| |
| import os |
| import json |
| import time |
| from flask import Flask, request, jsonify, Response, render_template_string, redirect, url_for |
| from pywebpush import webpush, WebPushException |
| from py_vapid import Vapid |
|
|
| |
| APP_PORT = 7860 |
| NEWS_FILE = "news.json" |
| SUBSCRIPTIONS_FILE = "subscriptions.json" |
| VAPID_KEYS_FILE = "vapid_keys.json" |
| VAPID_CONTACT_EMAIL = "mailto:your_email@example.com" |
|
|
| |
| app = Flask(__name__) |
| app.secret_key = os.urandom(24) |
|
|
| |
| vapid_keys = {} |
| if os.path.exists(VAPID_KEYS_FILE): |
| try: |
| with open(VAPID_KEYS_FILE, "r") as f: |
| vapid_keys = json.load(f) |
| print(f"VAPID keys loaded from {VAPID_KEYS_FILE}") |
| except Exception as e: |
| print(f"Error loading VAPID keys: {e}. Generating new ones.") |
| vapid_keys = {} |
|
|
| if not vapid_keys or 'private_key' not in vapid_keys or 'public_key' not in vapid_keys: |
| print("Generating new VAPID keys...") |
| try: |
| vapid = Vapid.generate() |
| vapid_keys = { |
| "private_key": vapid.private_key, |
| "public_key": vapid.public_key |
| } |
| with open(VAPID_KEYS_FILE, "w") as f: |
| json.dump(vapid_keys, f, indent=4) |
| print(f"VAPID keys generated and saved to {VAPID_KEYS_FILE}") |
| except Exception as e: |
| print(f"FATAL: Could not generate or save VAPID keys: {e}") |
| exit(1) |
|
|
| VAPID_PRIVATE_KEY = vapid_keys['private_key'] |
| VAPID_PUBLIC_KEY = vapid_keys['public_key'] |
|
|
| |
|
|
| def load_data(filename): |
| """Загружает данные из JSON файла.""" |
| if not os.path.exists(filename): |
| return [] |
| try: |
| with open(filename, 'r', encoding='utf-8') as f: |
| |
| content = f.read() |
| if not content: |
| return [] |
| return json.loads(content) |
| except (IOError, json.JSONDecodeError) as e: |
| print(f"Error loading {filename}: {e}") |
| return [] |
|
|
| def save_data(filename, data): |
| """Сохраняет данные в JSON файл.""" |
| try: |
| with open(filename, 'w', encoding='utf-8') as f: |
| json.dump(data, f, ensure_ascii=False, indent=4) |
| except IOError as e: |
| print(f"Error saving {filename}: {e}") |
|
|
| def send_notification(subscription_info, title, body): |
| """Отправляет одно push уведомление.""" |
| print(f"Attempting to send notification to: {subscription_info.get('endpoint')[:50]}...") |
| try: |
| webpush( |
| subscription_info=subscription_info, |
| data=json.dumps({"title": title, "body": body}), |
| vapid_private_key=VAPID_PRIVATE_KEY, |
| vapid_claims={"sub": VAPID_CONTACT_EMAIL} |
| ) |
| print("Notification sent successfully.") |
| return True |
| except WebPushException as ex: |
| print(f"WebPushException: {ex}") |
| |
| if ex.response and ex.response.status_code in [404, 410]: |
| print(f"Subscription {subscription_info.get('endpoint')[:50]}... seems invalid (Gone or Not Found). Consider removing it.") |
| |
| |
| else: |
| print("Notification sending failed for other reason.") |
| return False |
| except Exception as e: |
| print(f"An unexpected error occurred during push notification sending: {e}") |
| return False |
|
|
|
|
| def notify_all(title, body): |
| """Отправляет уведомление всем подписчикам.""" |
| subscriptions = load_data(SUBSCRIPTIONS_FILE) |
| if not subscriptions: |
| print("No subscriptions found to notify.") |
| return |
|
|
| print(f"Notifying {len(subscriptions)} subscribers about '{title}'...") |
| |
| |
| for sub in list(subscriptions): |
| send_notification(sub, title, body) |
| |
| time.sleep(0.1) |
|
|
|
|
| |
| HTML_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Новостное PWA</title> |
| <meta name="theme-color" content="#3367D6"/> |
| <link rel="manifest" href="/manifest.json"> |
| <style> |
| body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; } |
| .container { max-width: 800px; margin: auto; background: #fff; padding: 20px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } |
| h1, h2 { color: #333; } |
| .news-item { border-bottom: 1px solid #eee; padding-bottom: 15px; margin-bottom: 15px; } |
| .news-item:last-child { border-bottom: none; } |
| .news-item h3 { margin: 0 0 5px 0; } |
| .news-item p { margin: 0; color: #555; } |
| form label { display: block; margin-bottom: 5px; font-weight: bold;} |
| form input[type="text"], form textarea { |
| width: calc(100% - 22px); /* Учитываем padding и border */ |
| padding: 10px; |
| margin-bottom: 10px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| } |
| form textarea { min-height: 80px; resize: vertical; } |
| form button, .action-button { |
| background-color: #3367D6; |
| color: white; |
| padding: 10px 15px; |
| border: none; |
| border-radius: 4px; |
| cursor: pointer; |
| font-size: 1em; |
| } |
| form button:hover, .action-button:hover { background-color: #254A9E; } |
| #notifications-status { margin-top: 15px; font-style: italic; color: #666; } |
| .hidden { display: none; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>Новости</h1> |
| |
| <!-- Кнопка подписки --> |
| <button id="subscribe-button" class="action-button">Подписаться на уведомления</button> |
| <button id="unsubscribe-button" class="action-button hidden">Отписаться от уведомлений</button> |
| <p id="notifications-status"></p> |
| |
| <h2>Добавить новость</h2> |
| <form action="/add_news" method="post"> |
| <div> |
| <label for="title">Заголовок:</label> |
| <input type="text" id="title" name="title" required> |
| </div> |
| <div> |
| <label for="content">Содержание:</label> |
| <textarea id="content" name="content" required></textarea> |
| </div> |
| <button type="submit">Добавить</button> |
| </form> |
| |
| <h2>Лента новостей</h2> |
| <div id="news-list"> |
| {% if news %} |
| {% for item in news|reverse %} |
| <div class="news-item"> |
| <h3>{{ item.title }}</h3> |
| <p>{{ item.content }}</p> |
| {% if item.timestamp %} |
| <small>Опубликовано: {{ item.timestamp }}</small> |
| {% endif %} |
| </div> |
| {% endfor %} |
| {% else %} |
| <p>Новостей пока нет.</p> |
| {% endif %} |
| </div> |
| </div> |
| |
| <script> |
| const VAPID_PUBLIC_KEY = '{{ vapid_public_key }}'; // Получаем ключ из Flask |
| const subscribeButton = document.getElementById('subscribe-button'); |
| const unsubscribeButton = document.getElementById('unsubscribe-button'); |
| const statusElement = document.getElementById('notifications-status'); |
| |
| // --- Service Worker Регистрация --- |
| if ('serviceWorker' in navigator && 'PushManager' in window) { |
| console.log('Service Worker and Push is supported'); |
| |
| navigator.serviceWorker.register('/service-worker.js') |
| .then(function(swReg) { |
| console.log('Service Worker is registered', swReg); |
| window.swRegistration = swReg; // Сохраняем регистрацию для дальнейшего использования |
| checkSubscription(); // Проверяем статус подписки при загрузке |
| }) |
| .catch(function(error) { |
| console.error('Service Worker Error', error); |
| statusElement.textContent = 'Ошибка регистрации Service Worker.'; |
| }); |
| } else { |
| console.warn('Push messaging is not supported'); |
| subscribeButton.disabled = true; // Отключаем кнопку, если Push не поддерживается |
| statusElement.textContent = 'Push-уведомления не поддерживаются в этом браузере.'; |
| } |
| |
| // --- Функции для работы с подпиской --- |
| |
| function urlBase64ToUint8Array(base64String) { |
| const padding = '='.repeat((4 - base64String.length % 4) % 4); |
| const base64 = (base64String + padding) |
| .replace(/\\-/g, '+') |
| .replace(/_/g, '/'); |
| |
| const rawData = window.atob(base64); |
| const outputArray = new Uint8Array(rawData.length); |
| |
| for (let i = 0; i < rawData.length; ++i) { |
| outputArray[i] = rawData.charCodeAt(i); |
| } |
| return outputArray; |
| } |
| |
| async function checkSubscription() { |
| if (!window.swRegistration) { |
| console.log("Service worker not ready yet."); |
| return; |
| } |
| try { |
| const subscription = await window.swRegistration.pushManager.getSubscription(); |
| if (subscription) { |
| console.log('User IS subscribed.'); |
| statusElement.textContent = 'Вы подписаны на уведомления.'; |
| subscribeButton.classList.add('hidden'); |
| unsubscribeButton.classList.remove('hidden'); |
| } else { |
| console.log('User is NOT subscribed.'); |
| statusElement.textContent = 'Вы не подписаны на уведомления.'; |
| subscribeButton.classList.remove('hidden'); |
| unsubscribeButton.classList.add('hidden'); |
| } |
| } catch (error) { |
| console.error('Error checking subscription:', error); |
| statusElement.textContent = 'Не удалось проверить статус подписки.'; |
| } |
| } |
| |
| |
| async function subscribeUser() { |
| if (!window.swRegistration) { |
| console.error("Service worker registration not found."); |
| statusElement.textContent = 'Ошибка: Service Worker не зарегистрирован.'; |
| return; |
| } |
| |
| const applicationServerKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY); |
| try { |
| const subscription = await window.swRegistration.pushManager.subscribe({ |
| userVisibleOnly: true, // Требование для большинства браузеров |
| applicationServerKey: applicationServerKey |
| }); |
| console.log('User is subscribed:', subscription); |
| statusElement.textContent = 'Подписка оформлена!'; |
| subscribeButton.classList.add('hidden'); |
| unsubscribeButton.classList.remove('hidden'); |
| |
| // Отправляем подписку на сервер |
| await sendSubscriptionToServer(subscription); |
| |
| } catch (err) { |
| console.error('Failed to subscribe the user: ', err); |
| if (Notification.permission === 'denied') { |
| statusElement.textContent = 'Разрешение на уведомления заблокировано. Измените настройки браузера.'; |
| } else { |
| statusElement.textContent = 'Не удалось оформить подписку.'; |
| } |
| subscribeButton.classList.remove('hidden'); // Показать кнопку снова, если не удалось |
| unsubscribeButton.classList.add('hidden'); |
| } |
| } |
| |
| async function unsubscribeUser() { |
| if (!window.swRegistration) { |
| console.error("Service worker registration not found."); |
| statusElement.textContent = 'Ошибка: Service Worker не зарегистрирован.'; |
| return; |
| } |
| try { |
| const subscription = await window.swRegistration.pushManager.getSubscription(); |
| if (subscription) { |
| const successful = await subscription.unsubscribe(); |
| if(successful) { |
| console.log('User is unsubscribed.'); |
| statusElement.textContent = 'Вы отписались от уведомлений.'; |
| subscribeButton.classList.remove('hidden'); |
| unsubscribeButton.classList.add('hidden'); |
| // TODO: Опционально: отправить запрос на сервер для удаления подписки |
| // await removeSubscriptionFromServer(subscription); |
| } else { |
| console.error('Unsubscription failed.'); |
| statusElement.textContent = 'Не удалось отписаться.'; |
| } |
| } |
| } catch (error) { |
| console.error('Error unsubscribing', error); |
| statusElement.textContent = 'Ошибка при отписке.'; |
| } |
| } |
| |
| |
| async function sendSubscriptionToServer(subscription) { |
| try { |
| const response = await fetch('/subscribe', { |
| method: 'POST', |
| body: JSON.stringify(subscription), |
| headers: { |
| 'Content-Type': 'application/json' |
| } |
| }); |
| if (!response.ok) { |
| throw new Error('Server responded with an error.'); |
| } |
| const responseData = await response.json(); |
| console.log('Subscription sent to server:', responseData); |
| } catch (error) { |
| console.error('Could not send subscription to server: ', error); |
| // Возможно, стоит откатить UI или попробовать позже |
| statusElement.textContent += ' (Не удалось сохранить подписку на сервере)'; |
| } |
| } |
| |
| // --- Обработчики событий --- |
| subscribeButton.addEventListener('click', () => { |
| // Запрашиваем разрешение, если его еще нет, затем подписываем |
| Notification.requestPermission().then(permission => { |
| if (permission === 'granted') { |
| console.log("Notification permission granted."); |
| subscribeUser(); |
| } else { |
| console.log("Unable to get permission to notify."); |
| statusElement.textContent = 'Вы не разрешили показ уведомлений.'; |
| } |
| }); |
| }); |
| |
| unsubscribeButton.addEventListener('click', () => { |
| unsubscribeUser(); |
| }); |
| |
| |
| // --- PWA Install Prompt --- |
| let deferredPrompt; |
| const installButtonPlaceholder = document.createElement('div'); // Невидимый элемент для кнопки установки |
| // Браузер сам покажет кнопку/опцию установки, если критерии выполнены. |
| // Мы можем перехватить событие, чтобы показать свою кнопку, но для простоты оставим стандартное поведение. |
| |
| window.addEventListener('beforeinstallprompt', (e) => { |
| // Prevent the mini-infobar from appearing on mobile |
| e.preventDefault(); |
| // Stash the event so it can be triggered later. |
| deferredPrompt = e; |
| // Update UI notify the user they can install the PWA |
| console.log('`beforeinstallprompt` event was fired.'); |
| // Можно показать свою кнопку установки здесь, если нужно: |
| // installButtonPlaceholder.innerHTML = '<button id="custom-install-button" class="action-button">Установить приложение</button>'; |
| // document.body.appendChild(installButtonPlaceholder); |
| // document.getElementById('custom-install-button').addEventListener('click', async () => { |
| // deferredPrompt.prompt(); // Show the install prompt |
| // const { outcome } = await deferredPrompt.userChoice; |
| // console.log(`User response to the install prompt: ${outcome}`); |
| // deferredPrompt = null; // Prompt can only be used once |
| // installButtonPlaceholder.innerHTML = ''; // Hide button after use |
| // }); |
| }); |
| |
| window.addEventListener('appinstalled', (evt) => { |
| console.log('PWA was installed'); |
| // Hide the install button if it was shown |
| installButtonPlaceholder.innerHTML = ''; |
| }); |
| |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| |
| SERVICE_WORKER_JS = """ |
| // service-worker.js |
| |
| // Уникальное имя кэша (можно добавить версию) |
| const CACHE_NAME = 'news-pwa-cache-v1'; |
| // Ресурсы для кэширования при установке |
| const urlsToCache = [ |
| '/', // Кэшируем главную страницу |
| // Можно добавить другие статические ресурсы, если они есть (CSS, JS файлы, изображения) |
| // '/static/style.css', |
| // '/static/logo.png' |
| ]; |
| |
| // Установка Service Worker: кэшируем основные ресурсы |
| self.addEventListener('install', event => { |
| console.log('Service Worker: Installing...'); |
| event.waitUntil( |
| caches.open(CACHE_NAME) |
| .then(cache => { |
| console.log('Service Worker: Caching app shell'); |
| return cache.addAll(urlsToCache); |
| }) |
| .then(() => { |
| console.log('Service Worker: Install completed'); |
| // Принудительная активация нового SW сразу после установки (не рекомендуется для продакшена без тщательного тестирования) |
| // return self.skipWaiting(); |
| }) |
| .catch(error => { |
| console.error('Service Worker: Installation failed', error); |
| }) |
| ); |
| }); |
| |
| // Активация Service Worker: очищаем старые кэши |
| self.addEventListener('activate', event => { |
| console.log('Service Worker: Activating...'); |
| event.waitUntil( |
| caches.keys().then(cacheNames => { |
| return Promise.all( |
| cacheNames.map(cacheName => { |
| // Удаляем все кэши, кроме текущего активного |
| if (cacheName !== CACHE_NAME) { |
| console.log('Service Worker: Clearing old cache:', cacheName); |
| return caches.delete(cacheName); |
| } |
| }) |
| ); |
| }).then(() => { |
| console.log('Service Worker: Activation completed'); |
| // Захватываем контроль над открытыми страницами немедленно |
| return self.clients.claim(); |
| }) |
| ); |
| }); |
| |
| |
| // Обработка запросов (Fetch): стратегия Cache First для закэшированных ресурсов |
| self.addEventListener('fetch', event => { |
| // Мы отвечаем только на GET запросы |
| if (event.request.method !== 'GET') { |
| return; |
| } |
| |
| // Для запросов навигации (HTML страниц) используем стратегию Network Falling Back to Cache |
| if (event.request.mode === 'navigate') { |
| event.respondWith( |
| fetch(event.request) |
| .catch(() => { |
| // Если сеть недоступна, пробуем достать из кэша главную страницу |
| return caches.match('/'); |
| }) |
| ); |
| return; |
| } |
| |
| // Для остальных запросов (CSS, JS, картинки и т.д.) используем Cache First |
| event.respondWith( |
| caches.match(event.request) |
| .then(cachedResponse => { |
| // Если ресурс есть в кэше, возвращаем его |
| if (cachedResponse) { |
| // console.log('Service Worker: Serving from cache:', event.request.url); |
| return cachedResponse; |
| } |
| |
| // Если ресурса нет в кэше, запрашиваем его из сети |
| // console.log('Service Worker: Fetching from network:', event.request.url); |
| return fetch(event.request).then( |
| networkResponse => { |
| // Опционально: можно кэшировать новые запросы динамически |
| // if (networkResponse.ok) { |
| // const responseToCache = networkResponse.clone(); |
| // caches.open(CACHE_NAME) |
| // .then(cache => { |
| // cache.put(event.request, responseToCache); |
| // }); |
| // } |
| return networkResponse; |
| } |
| ).catch(error => { |
| console.error('Service Worker: Fetch failed; returning offline fallback or error.', error); |
| // Можно вернуть запасной контент (например, оффлайн-страницу), если он был закэширован |
| // return caches.match('/offline.html'); |
| }); |
| }) |
| ); |
| }); |
| |
| |
| // Обработка Push-уведомлений |
| self.addEventListener('push', event => { |
| console.log('[Service Worker] Push Received.'); |
| console.log(`[Service Worker] Push had this data: "${event.data.text()}"`); |
| |
| let title = 'Новая новость!'; |
| let options = { |
| body: 'Проверьте обновления на сайте.', |
| icon: null, // Иконки нет, как запрошено |
| badge: null // Значок для Android |
| // tag: 'news-notification' // Тег для группировки или замены уведомлений |
| }; |
| |
| try { |
| const data = event.data.json(); |
| title = data.title || title; |
| options.body = data.body || options.body; |
| if (data.icon) options.icon = data.icon; // Если вдруг передали иконку |
| } catch (e) { |
| console.log("Push data was not JSON, using default message."); |
| options.body = event.data.text(); // Используем текст как тело, если не JSON |
| } |
| |
| event.waitUntil( |
| self.registration.showNotification(title, options) |
| ); |
| }); |
| |
| // Обработка клика по уведомлению (опционально) |
| self.addEventListener('notificationclick', event => { |
| console.log('[Service Worker] Notification click Received.'); |
| |
| event.notification.close(); // Закрываем уведомление |
| |
| // Открываем или фокусируем окно приложения при клике |
| event.waitUntil( |
| clients.matchAll({ type: "window" }) |
| .then(clientList => { |
| // Проверяем, открыта ли уже вкладка с этим URL |
| for (const client of clientList) { |
| // '/' - URL вашего приложения |
| if (client.url === '/' && 'focus' in client) { |
| return client.focus(); // Фокусируемся на существующей вкладке |
| } |
| } |
| // Если вкладка не найдена, открываем новую |
| if (clients.openWindow) { |
| return clients.openWindow('/'); // Открываем главную страницу |
| } |
| }) |
| ); |
| }); |
| |
| """ |
|
|
| |
| MANIFEST_JSON = { |
| "name": "Новостное PWA Приложение", |
| "short_name": "НовостиPWA", |
| "description": "Простое PWA для просмотра и добавления новостей с push-уведомлениями.", |
| "start_url": "/", |
| "display": "standalone", |
| "background_color": "#ffffff", |
| "theme_color": "#3367D6", |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
|
|
|
|
| |
|
|
| @app.route('/') |
| def index(): |
| """Главная страница, отображает новости и форму добавления.""" |
| news_list = load_data(NEWS_FILE) |
| return render_template_string(HTML_TEMPLATE, news=news_list, vapid_public_key=VAPID_PUBLIC_KEY) |
|
|
| @app.route('/manifest.json') |
| def manifest(): |
| """Сервирует manifest.json.""" |
| return jsonify(MANIFEST_JSON) |
|
|
| @app.route('/service-worker.js') |
| def service_worker(): |
| """Сервирует файл service-worker.js.""" |
| return Response(SERVICE_WORKER_JS, mimetype='application/javascript') |
|
|
| @app.route('/add_news', methods=['POST']) |
| def add_news(): |
| """Добавляет новую новость и уведомляет подписчиков.""" |
| title = request.form.get('title') |
| content = request.form.get('content') |
|
|
| if not title or not content: |
| |
| return "Ошибка: Заголовок и содержание не могут быть пустыми.", 400 |
|
|
| news_list = load_data(NEWS_FILE) |
| new_entry = { |
| 'id': int(time.time() * 1000), |
| 'title': title, |
| 'content': content, |
| 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S') |
| } |
| news_list.append(new_entry) |
| save_data(NEWS_FILE, news_list) |
|
|
| |
| print(f"Sending notification for new news: '{title}'") |
| notify_all(title=f"Новая новость: {title}", body=content[:100] + ('...' if len(content) > 100 else '')) |
|
|
| return redirect(url_for('index')) |
|
|
|
|
| @app.route('/subscribe', methods=['POST']) |
| def subscribe(): |
| """Сохраняет информацию о подписке пользователя.""" |
| subscription_info = request.json |
| if not subscription_info or 'endpoint' not in subscription_info: |
| return jsonify({"error": "Invalid subscription object"}), 400 |
|
|
| print("Received subscription:") |
| print(json.dumps(subscription_info, indent=2)) |
|
|
| subscriptions = load_data(SUBSCRIPTIONS_FILE) |
|
|
| |
| exists = any(sub.get('endpoint') == subscription_info.get('endpoint') for sub in subscriptions) |
|
|
| if not exists: |
| subscriptions.append(subscription_info) |
| save_data(SUBSCRIPTIONS_FILE, subscriptions) |
| print(f"Subscription added. Total subscriptions: {len(subscriptions)}") |
| return jsonify({"message": "Subscription added successfully."}), 201 |
| else: |
| print("Subscription already exists.") |
| return jsonify({"message": "Subscription already exists."}), 200 |
|
|
| |
| if __name__ == '__main__': |
| print(f"Starting Flask app on http://127.0.0.1:{APP_PORT}") |
| print(f"Make sure to access via localhost or 127.0.0.1 for PWA/Push features.") |
| |
| |
| app.run(host='0.0.0.0', port=APP_PORT, debug=True) |
| |