Testpush / app.py
Kgshop's picture
Update app.py
7e271e3 verified
Raw
History Blame Contribute Delete
30.8 kB
# -*- 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 = """
<!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 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 не рекомендуется для продакшена!