# -*- coding: utf-8 -*- 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" # Замените на ваш email # --- Инициализация Flask --- app = Flask(__name__) app.secret_key = os.urandom(24) # Для flash сообщений (хотя здесь не используются) # --- Генерация/Загрузка VAPID ключей --- 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: # Handle empty file case 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.") # Здесь можно добавить логику удаления недействительной подписки # remove_subscription(subscription_info) # Понадобится функция для удаления 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) # Небольшая пауза, чтобы не перегружать серверы push-уведомлений (опционально) time.sleep(0.1) # --- HTML Шаблон --- HTML_TEMPLATE = """ Новостное PWA

Новости

Добавить новость

Лента новостей

{% if news %} {% for item in news|reverse %}

{{ item.title }}

{{ item.content }}

{% if item.timestamp %} Опубликовано: {{ item.timestamp }} {% endif %}
{% endfor %} {% else %}

Новостей пока нет.

{% endif %}
""" # --- Service Worker JavaScript --- 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 --- MANIFEST_JSON = { "name": "Новостное PWA Приложение", "short_name": "НовостиPWA", "description": "Простое PWA для просмотра и добавления новостей с push-уведомлениями.", "start_url": "/", "display": "standalone", # Или 'minimal-ui', 'fullscreen', 'browser' "background_color": "#ffffff", "theme_color": "#3367D6", # "icons": [] # Пустой массив, т.к. иконка не нужна по запросу # Если браузер требует иконку для установки, можно добавить минимальную: # "icons": [ # { # "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", # 1x1 transparent pixel # "sizes": "1x1", # "type": "image/png" # } # ] # ПРИМЕЧАНИЕ: Отсутствие иконок может помешать установке PWA в некоторых браузерах. # Лучше предоставить хотя бы одну иконку. Оставим пока без иконок согласно запросу. } # --- Маршруты Flask --- @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: # Можно добавить flash сообщение об ошибке, если используется render_template return "Ошибка: Заголовок и содержание не могут быть пустыми.", 400 news_list = load_data(NEWS_FILE) new_entry = { 'id': int(time.time() * 1000), # Простой ID на основе времени '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) # Проверяем, нет ли уже такой подписки (по endpoint) 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.") # Используем host='0.0.0.0' чтобы быть доступным извне, но PWA может не работать без HTTPS # Для локального тестирования лучше использовать host='127.0.0.1' app.run(host='0.0.0.0', port=APP_PORT, debug=True) # Важно: debug=True не рекомендуется для продакшена!