Kgshop commited on
Commit
7e271e3
·
verified ·
1 Parent(s): ce3c49f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +579 -301
app.py CHANGED
@@ -1,390 +1,668 @@
1
- from flask import Flask, request, jsonify, Response
 
2
  import json
 
 
3
  from pywebpush import webpush, WebPushException
4
- import os
5
- from cryptography.hazmat.primitives import serialization
6
- from cryptography.hazmat.primitives.asymmetric import ec
7
- from base64 import urlsafe_b64encode
8
 
9
- app = Flask(__name__)
10
- PORT = 7860
11
-
12
- # Путь к файлу для хранения новостей
13
- NEWS_FILE = 'news.json'
14
- # Путь к файлу для хранения подписок
15
- SUBSCRIPTIONS_FILE = 'subscriptions.json'
16
- # Путь к файлу для хранения VAPID ключей
17
- VAPID_KEYS_FILE = 'vapid_keys.json'
18
-
19
- # --- Генерация и загрузка VAPID ключей ---
20
-
21
- def generate_vapid_keys():
22
- """Генерирует VAPID ключи и возвращает их в формате base64."""
23
- private_key = ec.generate_private_key(ec.SECP256R1())
24
- public_key = private_key.public_key()
25
-
26
- private_pem = private_key.private_bytes(
27
- encoding=serialization.Encoding.PEM,
28
- format=serialization.PrivateFormat.PKCS8,
29
- encryption_algorithm=serialization.NoEncryption()
30
- )
31
- public_pem = public_key.public_bytes(
32
- encoding=serialization.Encoding.PEM,
33
- format=serialization.PublicFormat.SubjectPublicKeyInfo
34
- )
35
-
36
- private_b64 = urlsafe_b64encode(private_pem).decode('utf-8').rstrip('=')
37
- public_b64 = urlsafe_b64encode(public_pem).decode('utf-8').rstrip('=')
38
-
39
- return {"private_key": private_b64, "public_key": public_b64}
40
-
41
- def load_or_generate_vapid_keys():
42
- """Загружает VAPID ключи из файла или генерирует новые, если файла нет."""
43
- if os.path.exists(VAPID_KEYS_FILE):
44
- with open(VAPID_KEYS_FILE, 'r') as f:
45
- return json.load(f)
46
- else:
47
- keys = generate_vapid_keys()
48
- with open(VAPID_KEYS_FILE, 'w') as f:
49
- json.dump(keys, f, indent=4)
50
- print(f"Сгенерированы новые VAPID ключи и сохранены в {VAPID_KEYS_FILE}")
51
- return keys
52
-
53
- # Загружаем или генерируем ключи при старте приложения
54
- vapid_keys = load_or_generate_vapid_keys()
55
- VAPID_PUBLIC_KEY_BASE64 = vapid_keys["public_key"]
56
- VAPID_PRIVATE_KEY_BASE64 = vapid_keys["private_key"]
57
- VAPID_CLAIMS = {"subject": "mailto:your-email@example.com"} # Замените на свой email
58
 
59
- # --- Вспомогательные функции ---
 
 
60
 
61
- def load_news():
62
- """Загружает новости из JSON файла."""
 
63
  try:
64
- with open(NEWS_FILE, 'r', encoding='utf-8') as f:
65
- return json.load(f)
66
- except (FileNotFoundError, json.JSONDecodeError):
67
- return []
68
-
69
- def save_news(news_list):
70
- """Сохраняет новости в JSON файл."""
71
- with open(NEWS_FILE, 'w', encoding='utf-8') as f:
72
- json.dump(news_list, f, ensure_ascii=False, indent=4)
73
-
74
- def load_subscriptions():
75
- """Загружает подписки из JSON файла."""
76
  try:
77
- with open(SUBSCRIPTIONS_FILE, 'r') as f:
78
- return json.load(f)
79
- except (FileNotFoundError, json.JSONDecodeError):
80
- return []
 
 
 
 
 
 
 
 
 
 
81
 
82
- def save_subscriptions(subscriptions):
83
- """Сохраняет подписки в JSON файл."""
84
- with open(SUBSCRIPTIONS_FILE, 'w') as f:
85
- json.dump(subscriptions, f, indent=4)
86
 
87
- def send_push_notification(subscription, message_body):
88
- """Отправляет push уведомление одному подписчику."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  try:
90
- print(f"Отправка уведомления на: {subscription['endpoint']}")
91
  webpush(
92
- subscription_info=subscription,
93
- data=message_body,
94
- vapid_private_key=VAPID_PRIVATE_KEY_BASE64,
95
- vapid_public_key=VAPID_PUBLIC_KEY_BASE64,
96
- vapid_claims=VAPID_CLAIMS
97
  )
98
- print(f"Уведомление успешно отправлено на: {subscription['endpoint']}")
99
  return True
100
- except WebPushException as e:
101
- print(f"Ошибка отправки уведомления на: {subscription['endpoint']}. Ошибка: {e}")
 
 
 
 
 
 
 
 
 
 
102
  return False
103
 
104
- def send_push_to_all(message_body):
105
- """Отправляет push уведомление всем подписчикам."""
106
- subscriptions = load_subscriptions()
107
- valid_subscriptions = []
108
- for sub in subscriptions:
109
- if send_push_notification(sub, message_body):
110
- valid_subscriptions.append(sub)
111
- else:
112
- print(f"Подписка удалена из-за ошибки: {sub['endpoint']}")
113
- if valid_subscriptions != subscriptions:
114
- save_subscriptions(valid_subscriptions)
115
- return len(valid_subscriptions) > 0
116
 
117
- # --- HTML для главной страницы ---
118
- HTML_INDEX = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  <!DOCTYPE html>
120
  <html lang="ru">
121
  <head>
122
  <meta charset="UTF-8">
123
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
124
- <title>PWA Новости</title>
 
125
  <link rel="manifest" href="/manifest.json">
126
- <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
127
  <style>
128
- body { padding-top: 20px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  </style>
130
  </head>
131
  <body>
132
  <div class="container">
133
  <h1>Новости</h1>
134
 
135
- <ul class="list-group" id="news-list">
136
- </ul>
137
-
138
- <hr>
139
 
140
  <h2>Добавить новость</h2>
141
- <form id="add-news-form">
142
- <div class="form-group">
143
- <textarea class="form-control" id="news-text" rows="3" placeholder="Введите текст новости"></textarea>
 
 
 
 
 
144
  </div>
145
- <button type="submit" class="btn btn-primary">Добавить новость</button>
146
  </form>
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
 
150
  <script>
151
- const publicKey = '%PUBLIC_KEY%'; // Заменится на реальный публичный ключ
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- document.addEventListener('DOMContentLoaded', () => {
154
- requestNotificationPermission();
155
- registerServiceWorker();
156
- loadNews();
157
 
158
- const addNewsForm = document.getElementById('add-news-form');
159
- addNewsForm.addEventListener('submit', handleAddNews);
160
- });
 
 
161
 
162
- function requestNotificationPermission() {
163
- if (!("Notification" in window)) {
164
- console.log("Браузер не поддерживает уведомления.");
165
- } else if (Notification.permission === "default") {
166
- Notification.requestPermission().then(permission => {
167
- if (permission === "granted") {
168
- console.log("Разрешение на уведомления получено.");
169
- subscribeUserToPush();
170
- } else {
171
- console.log("Разрешение на уведомления отклонено.");
172
- }
173
- });
174
- } else if (Notification.permission === "granted") {
175
- console.log("Разрешение на уведомления уже есть.");
176
- subscribeUserToPush();
177
  }
 
178
  }
179
 
180
- function registerServiceWorker() {
181
- if ('serviceWorker' in navigator) {
182
- navigator.serviceWorker.register('/service-worker.js')
183
- .then(registration => {
184
- console.log('Service Worker зарегистрирован:', registration);
185
- })
186
- .catch(error => {
187
- console.error('Ошибка регистрации Service Worker:', error);
188
- });
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
  }
191
 
192
- function subscribeUserToPush() {
193
- navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
194
- serviceWorkerRegistration.pushManager.subscribe({
195
- userVisibleOnly: true,
196
- applicationServerKey: urlBase64ToUint8Array(publicKey)
197
- })
198
- .then(subscription => {
199
- console.log('Успешная подписка на Push API:', subscription);
200
- sendSubscriptionToServer(subscription);
201
- })
202
- .catch(error => {
203
- console.error('Ошибка подписки на Push API:', error);
 
204
  });
205
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  }
207
 
208
- function sendSubscriptionToServer(subscription) {
209
- fetch('/save_subscription', {
210
- method: 'POST',
211
- headers: {
212
- 'Content-Type': 'application/json'
213
- },
214
- body: JSON.stringify(subscription)
215
- })
216
- .then(response => {
217
- if (!response.ok) {
218
- throw new Error('Ошибка сохранения подписки на сервере.');
 
 
 
 
 
 
 
 
 
 
219
  }
220
- return response.json();
221
- })
222
- .then(data => {
223
- console.log('Подписка успешно сохранена:', data.message);
224
- })
225
- .catch(error => {
226
- console.error('Ошибка при сохранении подписки:', error);
227
- });
228
  }
229
 
230
- function loadNews() {
231
- fetch('/get_news')
232
- .then(response => response.json())
233
- .then(news => {
234
- const newsList = document.getElementById('news-list');
235
- newsList.innerHTML = '';
236
- news.forEach(item => {
237
- const li = document.createElement('li');
238
- li.className = 'list-group-item';
239
- li.textContent = item.text;
240
- newsList.appendChild(li);
241
- });
242
- })
243
- .catch(error => {
244
- console.error('Ошибка загрузки новостей:', error);
245
- });
246
- }
247
 
248
- function handleAddNews(event) {
249
- event.preventDefault();
250
- const newsText = document.getElementById('news-text').value;
251
- if (newsText.trim() !== '') {
252
- fetch('/add_news', {
253
  method: 'POST',
 
254
  headers: {
255
- 'Content-Type': 'application/x-www-form-urlencoded',
256
- },
257
- body: `news_text=${encodeURIComponent(newsText)}`
258
- })
259
- .then(response => response.json())
260
- .then(data => {
261
- console.log(data.message);
262
- document.getElementById('news-text').value = '';
263
- loadNews();
264
- })
265
- .catch(error => {
266
- console.error('Ошибка добавления новости:', error);
267
  });
 
 
 
 
 
 
 
 
 
268
  }
269
  }
270
 
271
- function urlBase64ToUint8Array(base64String) {
272
- const padding = '='.repeat((4 - base64String.length % 4) % 4);
273
- const base64 = (base64String + padding)
274
- .replace(/-/g, '+')
275
- .replace(/_/g, '/');
276
- const rawData = window.atob(base64);
277
- const outputArray = new Uint8Array(rawData.length);
278
- for (let i = 0; i < rawData.length; ++i) {
279
- outputArray[i] = rawData.charCodeAt(i);
280
- }
281
- return outputArray;
282
- }
 
283
 
284
- navigator.serviceWorker.addEventListener('message', event => {
285
- if (event.data && event.data.type === 'push-received') {
286
- console.log('Push уведомление в foreground:', event.data.message);
287
- loadNews();
288
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  });
 
 
 
 
 
 
 
290
  </script>
291
  </body>
292
  </html>
293
  """
294
 
295
- # --- Service Worker код ---
296
  SERVICE_WORKER_JS = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  self.addEventListener('push', event => {
298
- const message = event.data.text();
299
- const title = 'Новая новость!';
300
- const options = {
301
- body: message,
302
- icon: '/no-icon.png' // Убедитесь, что иконка существует, или удалите эту строку
303
- };
304
-
305
- event.waitUntil(self.registration.showNotification(title, options));
306
-
307
- self.clients.matchAll({ type: 'window' }).then(clients => {
308
- clients.forEach(client => {
309
- client.postMessage({ type: 'push-received', message: message });
310
- });
311
- });
 
 
 
 
 
 
 
 
 
 
312
  });
313
 
 
314
  self.addEventListener('notificationclick', event => {
315
- event.notification.close();
316
- event.waitUntil(clients.openWindow('/'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  });
 
318
  """
319
 
320
  # --- Manifest JSON ---
321
- MANIFEST_JSON = """
322
- {
323
- "name": "PWA Новости",
324
- "short_name": "Новости",
325
  "start_url": "/",
326
- "display": "standalone",
327
- "background_color": "#fff",
328
- "theme_color": "#007bff"
 
 
 
 
 
 
 
 
 
 
 
329
  }
330
- """
331
 
332
- # --- Flask маршруты ---
 
333
 
334
  @app.route('/')
335
  def index():
336
- """Главная страница."""
337
- news = load_news()
338
- public_key = VAPID_PUBLIC_KEY_BASE64
339
- html = HTML_INDEX.replace('%PUBLIC_KEY%', public_key)
340
- news_list_html = ''.join([f'<li class="list-group-item">{item["text"]}</li>' for item in news])
341
- html = html.replace('<ul class="list-group" id="news-list"></ul>', f'<ul class="list-group" id="news-list">{news_list_html}</ul>')
342
- return html
343
 
344
- @app.route('/add_news', methods=['POST'])
345
- def add_news():
346
- """API endpoint для добавления новости."""
347
- print("Маршрут /add_news вызван!")
348
- news_text = request.form.get('news_text')
349
- if news_text:
350
- news_list = load_news()
351
- news_list.append({"text": news_text})
352
- save_news(news_list)
353
- success = send_push_to_all(news_text)
354
- if success:
355
- return jsonify({"message": "Новость добавлена и уведомления отправлены"}), 200
356
- else:
357
- return jsonify({"message": "Новость добавлена, но уведомления не отправлены"}), 200
358
- return jsonify({"error": "Текст новости не предоставлен"}), 400
359
-
360
- @app.route('/get_news')
361
- def get_news():
362
- """API endpoint для получения новостей."""
363
- news = load_news()
364
- return jsonify(news)
365
-
366
- @app.route('/save_subscription', methods=['POST'])
367
- def save_subscription():
368
- """API endpoint для сохранения подписки."""
369
- subscription_data = request.get_json()
370
- if subscription_data:
371
- subscriptions = load_subscriptions()
372
- if not any(sub['endpoint'] == subscription_data['endpoint'] for sub in subscriptions):
373
- subscriptions.append(subscription_data)
374
- save_subscriptions(subscriptions)
375
- print(f"Подписка сохранена: {subscription_data['endpoint']}")
376
- return jsonify({"message": "Подписка сохранена"}), 201
377
- return jsonify({"error": "Неверные данные подписки"}), 400
378
 
379
  @app.route('/service-worker.js')
380
  def service_worker():
381
- """Маршрут для service-worker.js."""
382
  return Response(SERVICE_WORKER_JS, mimetype='application/javascript')
383
 
384
- @app.route('/manifest.json')
385
- def manifest():
386
- """Маршрут для manifest.json."""
387
- return Response(MANIFEST_JSON, mimetype='application/json')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
 
389
  if __name__ == '__main__':
390
- app.run(debug=True, port=PORT, host='0.0.0.0')
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import os
3
  import json
4
+ import time
5
+ from flask import Flask, request, jsonify, Response, render_template_string, redirect, url_for
6
  from pywebpush import webpush, WebPushException
7
+ from py_vapid import Vapid
 
 
 
8
 
9
+ # --- Константы и Настройки ---
10
+ APP_PORT = 7860
11
+ NEWS_FILE = "news.json"
12
+ SUBSCRIPTIONS_FILE = "subscriptions.json"
13
+ VAPID_KEYS_FILE = "vapid_keys.json"
14
+ VAPID_CONTACT_EMAIL = "mailto:your_email@example.com" # Замените на ваш email
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ # --- Инициализация Flask ---
17
+ app = Flask(__name__)
18
+ app.secret_key = os.urandom(24) # Для flash сообщений (хотя здесь не используются)
19
 
20
+ # --- Генерация/Загрузка VAPID ключей ---
21
+ vapid_keys = {}
22
+ if os.path.exists(VAPID_KEYS_FILE):
23
  try:
24
+ with open(VAPID_KEYS_FILE, "r") as f:
25
+ vapid_keys = json.load(f)
26
+ print(f"VAPID keys loaded from {VAPID_KEYS_FILE}")
27
+ except Exception as e:
28
+ print(f"Error loading VAPID keys: {e}. Generating new ones.")
29
+ vapid_keys = {}
30
+
31
+ if not vapid_keys or 'private_key' not in vapid_keys or 'public_key' not in vapid_keys:
32
+ print("Generating new VAPID keys...")
 
 
 
33
  try:
34
+ vapid = Vapid.generate()
35
+ vapid_keys = {
36
+ "private_key": vapid.private_key,
37
+ "public_key": vapid.public_key
38
+ }
39
+ with open(VAPID_KEYS_FILE, "w") as f:
40
+ json.dump(vapid_keys, f, indent=4)
41
+ print(f"VAPID keys generated and saved to {VAPID_KEYS_FILE}")
42
+ except Exception as e:
43
+ print(f"FATAL: Could not generate or save VAPID keys: {e}")
44
+ exit(1) # Не можем работать без ключей
45
+
46
+ VAPID_PRIVATE_KEY = vapid_keys['private_key']
47
+ VAPID_PUBLIC_KEY = vapid_keys['public_key']
48
 
49
+ # --- Вспомогательные функции ---
 
 
 
50
 
51
+ def load_data(filename):
52
+ """Загружает данные из JSON файла."""
53
+ if not os.path.exists(filename):
54
+ return []
55
+ try:
56
+ with open(filename, 'r', encoding='utf-8') as f:
57
+ # Handle empty file case
58
+ content = f.read()
59
+ if not content:
60
+ return []
61
+ return json.loads(content)
62
+ except (IOError, json.JSONDecodeError) as e:
63
+ print(f"Error loading {filename}: {e}")
64
+ return [] # Возвращаем пустой список при ошибке
65
+
66
+ def save_data(filename, data):
67
+ """Сохраняет данные в JSON файл."""
68
+ try:
69
+ with open(filename, 'w', encoding='utf-8') as f:
70
+ json.dump(data, f, ensure_ascii=False, indent=4)
71
+ except IOError as e:
72
+ print(f"Error saving {filename}: {e}")
73
+
74
+ def send_notification(subscription_info, title, body):
75
+ """Отправляет одно push уведомление."""
76
+ print(f"Attempting to send notification to: {subscription_info.get('endpoint')[:50]}...")
77
  try:
 
78
  webpush(
79
+ subscription_info=subscription_info,
80
+ data=json.dumps({"title": title, "body": body}),
81
+ vapid_private_key=VAPID_PRIVATE_KEY,
82
+ vapid_claims={"sub": VAPID_CONTACT_EMAIL}
 
83
  )
84
+ print("Notification sent successfully.")
85
  return True
86
+ except WebPushException as ex:
87
+ print(f"WebPushException: {ex}")
88
+ # Улавливаем специфичные ошибки, например, подписка недействительна
89
+ if ex.response and ex.response.status_code in [404, 410]:
90
+ print(f"Subscription {subscription_info.get('endpoint')[:50]}... seems invalid (Gone or Not Found). Consider removing it.")
91
+ # Здесь можно добавить логику удаления недействительной подписки
92
+ # remove_subscription(subscription_info) # Понадобится функция для удаления
93
+ else:
94
+ print("Notification sending failed for other reason.")
95
+ return False
96
+ except Exception as e:
97
+ print(f"An unexpected error occurred during push notification sending: {e}")
98
  return False
99
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
+ def notify_all(title, body):
102
+ """Отправляет уведомление всем подписчикам."""
103
+ subscriptions = load_data(SUBSCRIPTIONS_FILE)
104
+ if not subscriptions:
105
+ print("No subscriptions found to notify.")
106
+ return
107
+
108
+ print(f"Notifying {len(subscriptions)} subscribers about '{title}'...")
109
+ # Создаем копию списка для безопасной итерации, если будем удалять элементы
110
+ # (Хотя в текущей реализации удаление не происходит внутри цикла)
111
+ for sub in list(subscriptions):
112
+ send_notification(sub, title, body)
113
+ # Небольшая пауза, чтобы не перегружать серверы push-уведомлений (опционально)
114
+ time.sleep(0.1)
115
+
116
+
117
+ # --- HTML Шаблон ---
118
+ HTML_TEMPLATE = """
119
  <!DOCTYPE html>
120
  <html lang="ru">
121
  <head>
122
  <meta charset="UTF-8">
123
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
124
+ <title>Новостное PWA</title>
125
+ <meta name="theme-color" content="#3367D6"/>
126
  <link rel="manifest" href="/manifest.json">
 
127
  <style>
128
+ body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; }
129
+ .container { max-width: 800px; margin: auto; background: #fff; padding: 20px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
130
+ h1, h2 { color: #333; }
131
+ .news-item { border-bottom: 1px solid #eee; padding-bottom: 15px; margin-bottom: 15px; }
132
+ .news-item:last-child { border-bottom: none; }
133
+ .news-item h3 { margin: 0 0 5px 0; }
134
+ .news-item p { margin: 0; color: #555; }
135
+ form label { display: block; margin-bottom: 5px; font-weight: bold;}
136
+ form input[type="text"], form textarea {
137
+ width: calc(100% - 22px); /* Учитываем padding и border */
138
+ padding: 10px;
139
+ margin-bottom: 10px;
140
+ border: 1px solid #ddd;
141
+ border-radius: 4px;
142
+ }
143
+ form textarea { min-height: 80px; resize: vertical; }
144
+ form button, .action-button {
145
+ background-color: #3367D6;
146
+ color: white;
147
+ padding: 10px 15px;
148
+ border: none;
149
+ border-radius: 4px;
150
+ cursor: pointer;
151
+ font-size: 1em;
152
+ }
153
+ form button:hover, .action-button:hover { background-color: #254A9E; }
154
+ #notifications-status { margin-top: 15px; font-style: italic; color: #666; }
155
+ .hidden { display: none; }
156
  </style>
157
  </head>
158
  <body>
159
  <div class="container">
160
  <h1>Новости</h1>
161
 
162
+ <!-- Кнопка подписки -->
163
+ <button id="subscribe-button" class="action-button">Подписаться на уведомления</button>
164
+ <button id="unsubscribe-button" class="action-button hidden">Отписаться от уведомлений</button>
165
+ <p id="notifications-status"></p>
166
 
167
  <h2>Добавить новость</h2>
168
+ <form action="/add_news" method="post">
169
+ <div>
170
+ <label for="title">Заголовок:</label>
171
+ <input type="text" id="title" name="title" required>
172
+ </div>
173
+ <div>
174
+ <label for="content">Содержание:</label>
175
+ <textarea id="content" name="content" required></textarea>
176
  </div>
177
+ <button type="submit">Добавить</button>
178
  </form>
179
 
180
+ <h2>Лента новостей</h2>
181
+ <div id="news-list">
182
+ {% if news %}
183
+ {% for item in news|reverse %}
184
+ <div class="news-item">
185
+ <h3>{{ item.title }}</h3>
186
+ <p>{{ item.content }}</p>
187
+ {% if item.timestamp %}
188
+ <small>Опубликовано: {{ item.timestamp }}</small>
189
+ {% endif %}
190
+ </div>
191
+ {% endfor %}
192
+ {% else %}
193
+ <p>Новостей пока нет.</p>
194
+ {% endif %}
195
+ </div>
196
  </div>
197
 
198
  <script>
199
+ const VAPID_PUBLIC_KEY = '{{ vapid_public_key }}'; // Получаем ключ из Flask
200
+ const subscribeButton = document.getElementById('subscribe-button');
201
+ const unsubscribeButton = document.getElementById('unsubscribe-button');
202
+ const statusElement = document.getElementById('notifications-status');
203
+
204
+ // --- Service Worker Регистрация ---
205
+ if ('serviceWorker' in navigator && 'PushManager' in window) {
206
+ console.log('Service Worker and Push is supported');
207
+
208
+ navigator.serviceWorker.register('/service-worker.js')
209
+ .then(function(swReg) {
210
+ console.log('Service Worker is registered', swReg);
211
+ window.swRegistration = swReg; // Сохраняем регистрацию для дальнейшего использования
212
+ checkSubscription(); // Проверяем статус подписки при загрузке
213
+ })
214
+ .catch(function(error) {
215
+ console.error('Service Worker Error', error);
216
+ statusElement.textContent = 'Ошибка регистрации Service Worker.';
217
+ });
218
+ } else {
219
+ console.warn('Push messaging is not supported');
220
+ subscribeButton.disabled = true; // Отключаем кнопку, если Push не поддерживае��ся
221
+ statusElement.textContent = 'Push-уведомления не поддерживаются в этом браузере.';
222
+ }
223
 
224
+ // --- Функции для работы с подпиской ---
 
 
 
225
 
226
+ function urlBase64ToUint8Array(base64String) {
227
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
228
+ const base64 = (base64String + padding)
229
+ .replace(/\\-/g, '+')
230
+ .replace(/_/g, '/');
231
 
232
+ const rawData = window.atob(base64);
233
+ const outputArray = new Uint8Array(rawData.length);
234
+
235
+ for (let i = 0; i < rawData.length; ++i) {
236
+ outputArray[i] = rawData.charCodeAt(i);
 
 
 
 
 
 
 
 
 
 
237
  }
238
+ return outputArray;
239
  }
240
 
241
+ async function checkSubscription() {
242
+ if (!window.swRegistration) {
243
+ console.log("Service worker not ready yet.");
244
+ return;
245
+ }
246
+ try {
247
+ const subscription = await window.swRegistration.pushManager.getSubscription();
248
+ if (subscription) {
249
+ console.log('User IS subscribed.');
250
+ statusElement.textContent = 'Вы подписаны на уведомления.';
251
+ subscribeButton.classList.add('hidden');
252
+ unsubscribeButton.classList.remove('hidden');
253
+ } else {
254
+ console.log('User is NOT subscribed.');
255
+ statusElement.textContent = 'Вы не подписаны на уведомления.';
256
+ subscribeButton.classList.remove('hidden');
257
+ unsubscribeButton.classList.add('hidden');
258
+ }
259
+ } catch (error) {
260
+ console.error('Error checking subscription:', error);
261
+ statusElement.textContent = 'Не удалось проверить статус подписки.';
262
  }
263
  }
264
 
265
+
266
+ async function subscribeUser() {
267
+ if (!window.swRegistration) {
268
+ console.error("Service worker registration not found.");
269
+ statusElement.textContent = 'Ошибка: Service Worker не зарегистрирован.';
270
+ return;
271
+ }
272
+
273
+ const applicationServerKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY);
274
+ try {
275
+ const subscription = await window.swRegistration.pushManager.subscribe({
276
+ userVisibleOnly: true, // Требование для большинства браузеров
277
+ applicationServerKey: applicationServerKey
278
  });
279
+ console.log('User is subscribed:', subscription);
280
+ statusElement.textContent = 'Подписка оформлена!';
281
+ subscribeButton.classList.add('hidden');
282
+ unsubscribeButton.classList.remove('hidden');
283
+
284
+ // Отправляем подписку на сервер
285
+ await sendSubscriptionToServer(subscription);
286
+
287
+ } catch (err) {
288
+ console.error('Failed to subscribe the user: ', err);
289
+ if (Notification.permission === 'denied') {
290
+ statusElement.textContent = 'Разрешение на уведомления заблокировано. Измените настройки браузера.';
291
+ } else {
292
+ statusElement.textContent = 'Не удалось оформить подписку.';
293
+ }
294
+ subscribeButton.classList.remove('hidden'); // Показать кнопку снова, если не удалось
295
+ unsubscribeButton.classList.add('hidden');
296
+ }
297
  }
298
 
299
+ async function unsubscribeUser() {
300
+ if (!window.swRegistration) {
301
+ console.error("Service worker registration not found.");
302
+ statusElement.textContent = 'Ошибка: Service Worker не зарегистрирован.';
303
+ return;
304
+ }
305
+ try {
306
+ const subscription = await window.swRegistration.pushManager.getSubscription();
307
+ if (subscription) {
308
+ const successful = await subscription.unsubscribe();
309
+ if(successful) {
310
+ console.log('User is unsubscribed.');
311
+ statusElement.textContent = 'Вы отписались от уведомлений.';
312
+ subscribeButton.classList.remove('hidden');
313
+ unsubscribeButton.classList.add('hidden');
314
+ // TODO: Опционально: отправить запрос на сервер для удаления подписки
315
+ // await removeSubscriptionFromServer(subscription);
316
+ } else {
317
+ console.error('Unsubscription failed.');
318
+ statusElement.textContent = 'Не удалось отписаться.';
319
+ }
320
  }
321
+ } catch (error) {
322
+ console.error('Error unsubscribing', error);
323
+ statusElement.textContent = 'Ошибка при отписке.';
324
+ }
 
 
 
 
325
  }
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
 
328
+ async function sendSubscriptionToServer(subscription) {
329
+ try {
330
+ const response = await fetch('/subscribe', {
 
 
331
  method: 'POST',
332
+ body: JSON.stringify(subscription),
333
  headers: {
334
+ 'Content-Type': 'application/json'
335
+ }
 
 
 
 
 
 
 
 
 
 
336
  });
337
+ if (!response.ok) {
338
+ throw new Error('Server responded with an error.');
339
+ }
340
+ const responseData = await response.json();
341
+ console.log('Subscription sent to server:', responseData);
342
+ } catch (error) {
343
+ console.error('Could not send subscription to server: ', error);
344
+ // Возможно, стоит откатить UI или попробовать позже
345
+ statusElement.textContent += ' (Не удалось сохранить подписку на сервере)';
346
  }
347
  }
348
 
349
+ // --- Обработчики событий ---
350
+ subscribeButton.addEventListener('click', () => {
351
+ // Запрашиваем разрешение, если его еще нет, затем подписываем
352
+ Notification.requestPermission().then(permission => {
353
+ if (permission === 'granted') {
354
+ console.log("Notification permission granted.");
355
+ subscribeUser();
356
+ } else {
357
+ console.log("Unable to get permission to notify.");
358
+ statusElement.textContent = 'Вы не разрешили показ уведомлений.';
359
+ }
360
+ });
361
+ });
362
 
363
+ unsubscribeButton.addEventListener('click', () => {
364
+ unsubscribeUser();
365
+ });
366
+
367
+
368
+ // --- PWA Install Prompt ---
369
+ let deferredPrompt;
370
+ const installButtonPlaceholder = document.createElement('div'); // Невидимый элемент для кнопки установки
371
+ // Браузер сам покажет кнопку/опцию установки, если критерии выполнены.
372
+ // Мы можем перехватить событие, чтобы показать свою кнопку, но для простоты оставим стандартное поведение.
373
+
374
+ window.addEventListener('beforeinstallprompt', (e) => {
375
+ // Prevent the mini-infobar from appearing on mobile
376
+ e.preventDefault();
377
+ // Stash the event so it can be triggered later.
378
+ deferredPrompt = e;
379
+ // Update UI notify the user they can install the PWA
380
+ console.log('`beforeinstallprompt` event was fired.');
381
+ // Можно показать свою кнопку установки здесь, если нужно:
382
+ // installButtonPlaceholder.innerHTML = '<button id="custom-install-button" class="action-button">Установить приложение</button>';
383
+ // document.body.appendChild(installButtonPlaceholder);
384
+ // document.getElementById('custom-install-button').addEventListener('click', async () => {
385
+ // deferredPrompt.prompt(); // Show the install prompt
386
+ // const { outcome } = await deferredPrompt.userChoice;
387
+ // console.log(`User response to the install prompt: ${outcome}`);
388
+ // deferredPrompt = null; // Prompt can only be used once
389
+ // installButtonPlaceholder.innerHTML = ''; // Hide button after use
390
+ // });
391
  });
392
+
393
+ window.addEventListener('appinstalled', (evt) => {
394
+ console.log('PWA was installed');
395
+ // Hide the install button if it was shown
396
+ installButtonPlaceholder.innerHTML = '';
397
+ });
398
+
399
  </script>
400
  </body>
401
  </html>
402
  """
403
 
404
+ # --- Service Worker JavaScript ---
405
  SERVICE_WORKER_JS = """
406
+ // service-worker.js
407
+
408
+ // Уникальное имя кэша (можно добавить версию)
409
+ const CACHE_NAME = 'news-pwa-cache-v1';
410
+ // Ресурсы для кэширования при установке
411
+ const urlsToCache = [
412
+ '/', // Кэшируем главную страницу
413
+ // Можно добавить другие статические ресурсы, если они есть (CSS, JS файлы, изображения)
414
+ // '/static/style.css',
415
+ // '/static/logo.png'
416
+ ];
417
+
418
+ // Установка Service Worker: кэшируем основные ресурсы
419
+ self.addEventListener('install', event => {
420
+ console.log('Service Worker: Installing...');
421
+ event.waitUntil(
422
+ caches.open(CACHE_NAME)
423
+ .then(cache => {
424
+ console.log('Service Worker: Caching app shell');
425
+ return cache.addAll(urlsToCache);
426
+ })
427
+ .then(() => {
428
+ console.log('Service Worker: Install completed');
429
+ // Принудительная активация нового SW сразу после установки (не рекомендуется для продакшена без тщательного тестирования)
430
+ // return self.skipWaiting();
431
+ })
432
+ .catch(error => {
433
+ console.error('Service Worker: Installation failed', error);
434
+ })
435
+ );
436
+ });
437
+
438
+ // Активация Service Worker: очищаем старые кэши
439
+ self.addEventListener('activate', event => {
440
+ console.log('Service Worker: Activating...');
441
+ event.waitUntil(
442
+ caches.keys().then(cacheNames => {
443
+ return Promise.all(
444
+ cacheNames.map(cacheName => {
445
+ // Удаляем все кэши, кроме текущего активного
446
+ if (cacheName !== CACHE_NAME) {
447
+ console.log('Service Worker: Clearing old cache:', cacheName);
448
+ return caches.delete(cacheName);
449
+ }
450
+ })
451
+ );
452
+ }).then(() => {
453
+ console.log('Service Worker: Activation completed');
454
+ // Захватываем контроль над открытыми страницами немедленно
455
+ return self.clients.claim();
456
+ })
457
+ );
458
+ });
459
+
460
+
461
+ // Обработка запросов (Fetch): стратегия Cache First для закэшированных ресурсов
462
+ self.addEventListener('fetch', event => {
463
+ // Мы отвечаем только на GET запросы
464
+ if (event.request.method !== 'GET') {
465
+ return;
466
+ }
467
+
468
+ // Для запросов навигации (HTML страниц) используем стратегию Network Falling Back to Cache
469
+ if (event.request.mode === 'navigate') {
470
+ event.respondWith(
471
+ fetch(event.request)
472
+ .catch(() => {
473
+ // Если сеть недоступна, пробуем достать из кэша главную страницу
474
+ return caches.match('/');
475
+ })
476
+ );
477
+ return;
478
+ }
479
+
480
+ // Для остальных запросов (CSS, JS, картинки и т.д.) используем Cache First
481
+ event.respondWith(
482
+ caches.match(event.request)
483
+ .then(cachedResponse => {
484
+ // Если ресурс есть в кэше, возвращаем его
485
+ if (cachedResponse) {
486
+ // console.log('Service Worker: Serving from cache:', event.request.url);
487
+ return cachedResponse;
488
+ }
489
+
490
+ // Если ресурса нет в кэше, запрашиваем его из сети
491
+ // console.log('Service Worker: Fetching from network:', event.request.url);
492
+ return fetch(event.request).then(
493
+ networkResponse => {
494
+ // Опционально: можно кэшировать новые запросы динамически
495
+ // if (networkResponse.ok) {
496
+ // const responseToCache = networkResponse.clone();
497
+ // caches.open(CACHE_NAME)
498
+ // .then(cache => {
499
+ // cache.put(event.request, responseToCache);
500
+ // });
501
+ // }
502
+ return networkResponse;
503
+ }
504
+ ).catch(error => {
505
+ console.error('Service Worker: Fetch failed; returning offline fallback or error.', error);
506
+ // Можно вернуть запасной контент (например, оффлайн-страницу), если он был закэширован
507
+ // return caches.match('/offline.html');
508
+ });
509
+ })
510
+ );
511
+ });
512
+
513
+
514
+ // Обработка Push-уведомлений
515
  self.addEventListener('push', event => {
516
+ console.log('[Service Worker] Push Received.');
517
+ console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);
518
+
519
+ let title = 'Новая новость!';
520
+ let options = {
521
+ body: 'Проверьте обновления на сайте.',
522
+ icon: null, // Иконки нет, как запрошено
523
+ badge: null // Значок для Android
524
+ // tag: 'news-notification' // Тег для группировки или замены уведомлений
525
+ };
526
+
527
+ try {
528
+ const data = event.data.json();
529
+ title = data.title || title;
530
+ options.body = data.body || options.body;
531
+ if (data.icon) options.icon = data.icon; // Если вдруг передали иконку
532
+ } catch (e) {
533
+ console.log("Push data was not JSON, using default message.");
534
+ options.body = event.data.text(); // Используем текст как тело, если не JSON
535
+ }
536
+
537
+ event.waitUntil(
538
+ self.registration.showNotification(title, options)
539
+ );
540
  });
541
 
542
+ // Обработка клика по уведомлению (опционально)
543
  self.addEventListener('notificationclick', event => {
544
+ console.log('[Service Worker] Notification click Received.');
545
+
546
+ event.notification.close(); // Закрываем уведомление
547
+
548
+ // Открываем или фокусируем окно приложения при клике
549
+ event.waitUntil(
550
+ clients.matchAll({ type: "window" })
551
+ .then(clientList => {
552
+ // Проверяем, открыта ли уже вкладка с этим URL
553
+ for (const client of clientList) {
554
+ // '/' - URL вашего приложения
555
+ if (client.url === '/' && 'focus' in client) {
556
+ return client.focus(); // Фокусируемся на существующей вкладке
557
+ }
558
+ }
559
+ // Если вкладка не найдена, открываем новую
560
+ if (clients.openWindow) {
561
+ return clients.openWindow('/'); // Открываем главную страницу
562
+ }
563
+ })
564
+ );
565
  });
566
+
567
  """
568
 
569
  # --- Manifest JSON ---
570
+ MANIFEST_JSON = {
571
+ "name": "Новостное PWA Приложение",
572
+ "short_name": "НовостиPWA",
573
+ "description": "Простое PWA для просмотра и добавления новостей с push-уведомлениями.",
574
  "start_url": "/",
575
+ "display": "standalone", # Или 'minimal-ui', 'fullscreen', 'browser'
576
+ "background_color": "#ffffff",
577
+ "theme_color": "#3367D6",
578
+ # "icons": [] # Пустой массив, т.к. иконка не нужна по запросу
579
+ # Если браузер требует иконку для установки, можно добавить минимальную:
580
+ # "icons": [
581
+ # {
582
+ # "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", # 1x1 transparent pixel
583
+ # "sizes": "1x1",
584
+ # "type": "image/png"
585
+ # }
586
+ # ]
587
+ # ПРИМЕЧАНИЕ: Отсутствие иконок может помешать установке PWA в некоторых браузерах.
588
+ # Лучше предоставить хотя бы одну иконку. Оставим пока без иконок согласно запросу.
589
  }
 
590
 
591
+
592
+ # --- Маршруты Flask ---
593
 
594
  @app.route('/')
595
  def index():
596
+ """Главная страница, отображает новости и форму добавления."""
597
+ news_list = load_data(NEWS_FILE)
598
+ return render_template_string(HTML_TEMPLATE, news=news_list, vapid_public_key=VAPID_PUBLIC_KEY)
 
 
 
 
599
 
600
+ @app.route('/manifest.json')
601
+ def manifest():
602
+ """Сервирует manifest.json."""
603
+ return jsonify(MANIFEST_JSON)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
 
605
  @app.route('/service-worker.js')
606
  def service_worker():
607
+ """Сервирует файл service-worker.js."""
608
  return Response(SERVICE_WORKER_JS, mimetype='application/javascript')
609
 
610
+ @app.route('/add_news', methods=['POST'])
611
+ def add_news():
612
+ """Добавляет новую новость и уведомляет подписчиков."""
613
+ title = request.form.get('title')
614
+ content = request.form.get('content')
615
+
616
+ if not title or not content:
617
+ # Можно добавить flash сообщение об ошибке, если используется render_template
618
+ return "Ошибка: Заголовок и содержание не могут быть пустыми.", 400
619
+
620
+ news_list = load_data(NEWS_FILE)
621
+ new_entry = {
622
+ 'id': int(time.time() * 1000), # Простой ID на основе времени
623
+ 'title': title,
624
+ 'content': content,
625
+ 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
626
+ }
627
+ news_list.append(new_entry)
628
+ save_data(NEWS_FILE, news_list)
629
+
630
+ # Отправляем уведомления подписчикам
631
+ print(f"Sending notification for new news: '{title}'")
632
+ notify_all(title=f"Новая новость: {title}", body=content[:100] + ('...' if len(content) > 100 else '')) # Ограничим длину тела уведомления
633
+
634
+ return redirect(url_for('index')) # Перенаправляем на главную страницу
635
+
636
+
637
+ @app.route('/subscribe', methods=['POST'])
638
+ def subscribe():
639
+ """Сохраняет информацию о подписке пользователя."""
640
+ subscription_info = request.json
641
+ if not subscription_info or 'endpoint' not in subscription_info:
642
+ return jsonify({"error": "Invalid subscription object"}), 400
643
+
644
+ print("Received subscription:")
645
+ print(json.dumps(subscription_info, indent=2))
646
+
647
+ subscriptions = load_data(SUBSCRIPTIONS_FILE)
648
+
649
+ # Проверяем, нет ли уже такой подписки (по endpoint)
650
+ exists = any(sub.get('endpoint') == subscription_info.get('endpoint') for sub in subscriptions)
651
+
652
+ if not exists:
653
+ subscriptions.append(subscription_info)
654
+ save_data(SUBSCRIPTIONS_FILE, subscriptions)
655
+ print(f"Subscription added. Total subscriptions: {len(subscriptions)}")
656
+ return jsonify({"message": "Subscription added successfully."}), 201
657
+ else:
658
+ print("Subscription already exists.")
659
+ return jsonify({"message": "Subscription already exists."}), 200
660
 
661
+ # --- Запуск приложения ---
662
  if __name__ == '__main__':
663
+ print(f"Starting Flask app on http://127.0.0.1:{APP_PORT}")
664
+ print(f"Make sure to access via localhost or 127.0.0.1 for PWA/Push features.")
665
+ # Используем host='0.0.0.0' чтобы быть доступным извне, но PWA может не работать без HTTPS
666
+ # Для локального тестирования лучше использовать host='127.0.0.1'
667
+ app.run(host='0.0.0.0', port=APP_PORT, debug=True)
668
+ # Важно: debug=True не рекомендуется для продакшена!