From: Chris Fulljames Date: Sat, 17 May 2025 18:11:45 +0000 (-0400) Subject: Implement basic push notifications X-Git-Url: https://littlesong.place/gitweb/?a=commitdiff_plain;h=2ef7d1e83406784bbbce554167b9b50b62c2cebc;p=littlesongplace.git Implement basic push notifications --- diff --git a/pyproject.toml b/pyproject.toml index d7ccbe2..51b879a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "flask", "gunicorn", "pillow", + "pywebpush", "yt-dlp", ] requires-python = ">=3.11" diff --git a/src/littlesongplace/__init__.py b/src/littlesongplace/__init__.py index 5754392..69afb4a 100644 --- a/src/littlesongplace/__init__.py +++ b/src/littlesongplace/__init__.py @@ -7,12 +7,13 @@ from logging.handlers import RotatingFileHandler from pathlib import Path import click +import pywebpush from flask import Flask, render_template, request, redirect, g, session, abort, \ send_from_directory, flash, get_flashed_messages from werkzeug.middleware.proxy_fix import ProxyFix from . import activity, auth, colors, comments, datadir, db, jams, playlists, \ - profiles, songs, users + profiles, push_notifications, songs, users from .logutils import flash_and_log # Logging @@ -35,6 +36,7 @@ app.register_blueprint(comments.bp) app.register_blueprint(jams.bp) app.register_blueprint(playlists.bp) app.register_blueprint(profiles.bp) +app.register_blueprint(push_notifications.bp) app.register_blueprint(songs.bp) db.init_app(app) @@ -91,6 +93,10 @@ def site_news(): def about(): return render_template("about.html") +@app.get("/service.js") +def service_worker(): + return send_from_directory("static", "service.js") + def get_gif_data(): # Convert all .gifs to base64 strings and embed them as dataset entries # in
s. This is used by nav.js:customImage() - it replaces specific diff --git a/src/littlesongplace/comments.py b/src/littlesongplace/comments.py index 69320a6..3e41bdc 100644 --- a/src/littlesongplace/comments.py +++ b/src/littlesongplace/comments.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from flask import abort, Blueprint, redirect, render_template, request, session -from . import db, songs +from . import auth, db, push_notifications, songs from .sanitize import sanitize_user_text bp = Blueprint("comments", __name__) @@ -55,10 +55,8 @@ def for_thread(threadid): return song_comments @bp.route("/comment", methods=["GET", "POST"]) +@auth.requires_login def comment(): - if not "userid" in session: - return redirect("/login") - if not "threadid" in request.args: abort(400) # Must have threadid @@ -178,7 +176,7 @@ def comment(): if userid in notification_targets: notification_targets.remove(userid) - # Create notifications + # Create notifications in database for target in notification_targets: db.query( """ @@ -188,6 +186,10 @@ def comment(): """, [commentid, ObjectType.COMMENT, target, timestamp]) + # Send push notifications + push_notifications.notify( + notification_targets, f"Comment from {g.username}", content) + db.commit() return redirect_to_previous_page() diff --git a/src/littlesongplace/db.py b/src/littlesongplace/db.py index ec1ebad..0fd94c9 100644 --- a/src/littlesongplace/db.py +++ b/src/littlesongplace/db.py @@ -6,7 +6,7 @@ from flask import abort, g, current_app from . import datadir -DB_VERSION = 5 +DB_VERSION = 6 def get(): db = getattr(g, '_database', None) diff --git a/src/littlesongplace/push_notifications.py b/src/littlesongplace/push_notifications.py new file mode 100644 index 0000000..7445965 --- /dev/null +++ b/src/littlesongplace/push_notifications.py @@ -0,0 +1,73 @@ +import json +import threading + +import pywebpush +from flask import Blueprint, current_app, g, request + +from . import auth, db + +bp = Blueprint("push-notifications", __name__, url_prefix="/push-notifications") + +@bp.post("/subscribe") +@auth.requires_login +def subscribe(): + if not request.json: + # Request must contain valid subscription JSON + abort(400) + + db.query( + """ + INSERT INTO users_push_subscriptions (userid, subscription) + VALUES (?, ?) + """, + [g.userid, json.dumps(request.json)]) + db.commit() + + current_app.logger.info(f"{g.username} registered push subscription") + + return {"status": "success"} + +def get_user_subscriptions(userid): + rows = db.query( + """ + SELECT * FROM users_push_subscriptions + WHERE userid = ? + """, + [userid]) + str_subs = (r["subscription"] for r in rows) + subs = [] + for s in str_subs: + try: + subs.append(json.loads(s)) + except json.decoder.JSONDecodeError: + current_app.logger.error(f"Invalid subscription: {s}") + return subs + +def notify_all(title, body, _except=None): + # Notify all users (who have notifications enabled) + rows = db.query("SELECT * FROM users") + userids = [r["userid"] for r in rows] + if _except in userids: + userids.remove(_except) + notify(userids, title, body) + +def notify(userids, title, body): + # Send push notifications in background thread (could take a while) + thread = threading.Thread( + target=_do_push, + args=(current_app._get_current_object(), userids, title, body)) + thread.start() + +def _do_push(app, userids, title, body): + data = {"title": title, "body": body} + data_str = json.dumps(data) + for userid in userids: + with app.app_context(): + subs = get_user_subscriptions(userid) + for sub in subs: + try: + # TODO: Use VAPID keys + pywebpush.webpush(sub, data_str) + except pywebpush.WebPushException as ex: + current_app.logger.error(f"Failed to send push: {ex}") + diff --git a/src/littlesongplace/songs.py b/src/littlesongplace/songs.py index a8c25f8..0521782 100644 --- a/src/littlesongplace/songs.py +++ b/src/littlesongplace/songs.py @@ -8,12 +8,12 @@ from datetime import datetime, timezone from dataclasses import dataclass from typing import Optional -from flask import Blueprint, current_app, render_template, request, redirect, \ +from flask import Blueprint, current_app, g, render_template, request, redirect, \ session, abort, send_from_directory from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError -from . import comments, colors, datadir, db, users +from . import auth, comments, colors, datadir, db, push_notifications, users from .sanitize import sanitize_user_text from .logutils import flash_and_log @@ -223,10 +223,8 @@ def _from_db(query, args=()): return songs @bp.get("/edit-song") +@auth.requires_login def edit_song(): - if not "userid" in session: - return redirect("/login") # Must be logged in to edit - song = None song_colors = users.get_user_colors(session["userid"]) @@ -260,10 +258,8 @@ def edit_song(): return render_template("edit-song.html", song=song, **song_colors, eventid=eventid) @bp.post("/upload-song") +@auth.requires_login def upload_song(): - if not "userid" in session: - return redirect("/login") # Must be logged in to edit - userid = session["userid"] error = validate_song_form() @@ -457,7 +453,12 @@ def create_song(): db.commit() flash_and_log(f"Successfully uploaded '{title}'", "success") - return False + + # Send push notifications to all other users + push_notifications.notify_all( + f"New song from {g.username}", title, _except=g.userid) + + return False # No error def convert_song(tmp_file, request_file, yt_url): if request_file: @@ -512,6 +513,7 @@ def yt_import(tmp_file, yt_url): ydl.download([yt_url]) @bp.get("/delete-song/") +@auth.requires_login def delete_song(songid): song_data = db.query( "select * from songs where songid = ?", [songid], one=True) diff --git a/src/littlesongplace/sql/schema.sql b/src/littlesongplace/sql/schema.sql index 51057b6..a2c8bde 100644 --- a/src/littlesongplace/sql/schema.sql +++ b/src/littlesongplace/sql/schema.sql @@ -21,9 +21,12 @@ CREATE TABLE songs ( title TEXT NOT NULL, description TEXT, threadid INTEGER, - FOREIGN KEY(userid) REFERENCES users(userid) + eventid INTEGER, + FOREIGN KEY(userid) REFERENCES users(userid), + FOREIGN KEY(eventid) REFERENCES jam_events(eventid) ); CREATE INDEX idx_songs_by_user ON songs(userid); +CREATE INDEX idx_songs_by_eventid ON songs(eventid); DROP TABLE IF EXISTS song_collaborators; CREATE TABLE song_collaborators ( @@ -41,34 +44,6 @@ CREATE TABLE song_tags ( PRIMARY KEY(songid, tag) ); CREATE INDEX idx_song_tags_tag ON song_tags(tag); - --- Old comment system (superceded by comments/comment_threads/comment_notifications --- --- DROP TABLE IF EXISTS song_comments; --- CREATE TABLE song_comments ( --- commentid INTEGER PRIMARY KEY, --- songid INTEGER NOT NULL, --- userid INTEGER NOT NULL, --- replytoid INTEGER, --- created TEXT NOT NULL, --- content TEXT NOT NULL, --- FOREIGN KEY(songid) REFERENCES songs(songid) ON DELETE CASCADE, --- FOREIGN KEY(userid) REFERENCES users(userid) ON DELETE CASCADE --- ); --- CREATE INDEX idx_comments_by_song ON song_comments(songid); --- CREATE INDEX idx_comments_by_user ON song_comments(userid); --- CREATE INDEX idx_comments_by_replyto ON song_comments(replytoid); --- CREATE INDEX idx_comments_by_time ON song_comments(created); --- --- DROP TABLE IF EXISTS song_comment_notifications; --- CREATE TABLE song_comment_notifications ( --- notificationid INTEGER PRIMARY KEY, --- commentid INTEGER NOT NULL, --- targetuserid INTEGER NOT NULL, --- FOREIGN KEY(commentid) REFERENCES song_comments(commentid) ON DELETE CASCADE, --- FOREIGN KEY(targetuserid) REFERENCES users(userid) ON DELETE CASCADE --- ); --- CREATE INDEX idx_song_comment_notifications_by_target ON song_comment_notifications(targetuserid); DROP TABLE IF EXISTS playlists; CREATE TABLE playlists ( @@ -159,5 +134,29 @@ BEGIN DELETE FROM notifications WHERE objectid = OLD.commentid AND objecttype = 0; END; -PRAGMA user_version = 4; +DROP TABLE IF EXISTS jams; +CREATE TABLE jams ( + jamid INTEGER PRIMARY KEY, + ownerid INTEGER NOT NULL, + created TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + FOREIGN KEY(ownerid) REFERENCES users(userid) +); + +DROP TABLE IF EXISTS jam_events; +CREATE TABLE jam_events( + eventid INTEGER PRIMARY KEY, + jamid INTEGER NOT NULL, + threadid INTEGER NOT NULL, + created TEXT NOT NULL, + title TEXT NOT NULL, -- Hidden until startdate + startdate TEXT, + enddate TEXT, + description TEXT, -- Hidden until startdate + FOREIGN KEY(jamid) REFERENCES jams(jamid), + FOREIGN KEY(threadid) REFERENCES comment_threads(threadid) +); + +PRAGMA user_version = 5; diff --git a/src/littlesongplace/sql/schema_revert.sql b/src/littlesongplace/sql/schema_revert.sql index a6cd883..f92e9af 100644 --- a/src/littlesongplace/sql/schema_revert.sql +++ b/src/littlesongplace/sql/schema_revert.sql @@ -1,7 +1,4 @@ -DROP INDEX idx_songs_by_eventid; -ALTER TABLE songs DROP COLUMN eventid; -DROP TABLE IF EXISTS jams; -DROP TABLE IF EXISTS jam_events; +DROP TABLE users_push_subscriptions; -PRAGMA user_version = 4; +PRAGMA user_version = 5; diff --git a/src/littlesongplace/sql/schema_update.sql b/src/littlesongplace/sql/schema_update.sql index ffb9b60..14208a2 100644 --- a/src/littlesongplace/sql/schema_update.sql +++ b/src/littlesongplace/sql/schema_update.sql @@ -1,29 +1,8 @@ ---DROP TABLE IF EXISTS jams; -CREATE TABLE jams ( - jamid INTEGER PRIMARY KEY, - ownerid INTEGER NOT NULL, - created TEXT NOT NULL, - title TEXT NOT NULL, - description TEXT, - FOREIGN KEY(ownerid) REFERENCES users(userid) +-- DROP TABLE IF EXISTS users_push_subscriptions +CREATE TABLE users_push_subscriptions ( + userid INTEGER NOT NULL, + subscription TEXT NOT NULL, + FOREIGN KEY(userid) REFERENCES users(userid) ON DELETE CASCADE ); ---DROP TABLE IF EXISTS jam_events; -CREATE TABLE jam_events( - eventid INTEGER PRIMARY KEY, - jamid INTEGER NOT NULL, - threadid INTEGER NOT NULL, - created TEXT NOT NULL, - title TEXT NOT NULL, -- Hidden until startdate - startdate TEXT, - enddate TEXT, - description TEXT, -- Hidden until startdate - FOREIGN KEY(jamid) REFERENCES jams(jamid), - FOREIGN KEY(threadid) REFERENCES comment_threads(threadid) -); - -ALTER TABLE songs ADD COLUMN eventid INTEGER REFERENCES jam_events(eventid); -CREATE INDEX idx_songs_by_eventid ON songs(eventid); - -PRAGMA user_version = 5; - +PRAGMA user_version = 6; diff --git a/src/littlesongplace/static/manifest.json b/src/littlesongplace/static/manifest.json index 7fa4d16..e46cd28 100644 --- a/src/littlesongplace/static/manifest.json +++ b/src/littlesongplace/static/manifest.json @@ -6,7 +6,7 @@ "src": "/static/lsp_notes.png", "sizes": "96x96", "type": "image/png" - }, + } ], "start_url": ".", "display": "standalone", diff --git a/src/littlesongplace/static/nav.js b/src/littlesongplace/static/nav.js index b369974..dc89f3e 100644 --- a/src/littlesongplace/static/nav.js +++ b/src/littlesongplace/static/nav.js @@ -1,4 +1,4 @@ -document.addEventListener("DOMContentLoaded", (e) => { +document.addEventListener("DOMContentLoaded", async (e) => { // Handle link clicks with AJAX document.querySelectorAll("a").forEach((anchor) => { @@ -41,6 +41,17 @@ document.addEventListener("DOMContentLoaded", (e) => { }); }); +async function requestNotificationPermission() { + const permission = await window.Notification.requestPermission(); + if (permission === "granted") { + // Register service worker + navigator.serviceWorker.register("service.js"); + } + else { + console.log("Did not get permission to send notifications:", permission); + } +} + function onLinkClick(event) { if (event.defaultPrevented) { return; diff --git a/src/littlesongplace/static/service.js b/src/littlesongplace/static/service.js new file mode 100644 index 0000000..91456af --- /dev/null +++ b/src/littlesongplace/static/service.js @@ -0,0 +1,30 @@ +self.addEventListener("activate", async () => { + console.log("hello?"); + try { + // TODO: Use VAPID key + const options = {}; + const subscription = await self.registration.pushManager.subscribe(options); + console.log(JSON.stringify(subscription)); + const response = await fetch( + "/push-notifications/subscribe", { + method: "post", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(subscription) + } + ); + console.log(response); + } + catch (err) { + console.log("Error", err); + } + +}); + +self.addEventListener("push", (event) => { + if (event.data) { + const data = event.data.json(); + self.registration.showNotification(data.title, {body: data.body}); + } +}); + +// TODO: handle notificationclick event diff --git a/src/littlesongplace/templates/index.html b/src/littlesongplace/templates/index.html index d627aa8..92824dd 100644 --- a/src/littlesongplace/templates/index.html +++ b/src/littlesongplace/templates/index.html @@ -4,6 +4,8 @@ {% block body %} + +

hello!

🎶