"flask",
"gunicorn",
"pillow",
+ "pywebpush",
"yt-dlp",
]
requires-python = ">=3.11"
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
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)
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 <div>s. This is used by nav.js:customImage() - it replaces specific
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__)
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
if userid in notification_targets:
notification_targets.remove(userid)
- # Create notifications
+ # Create notifications in database
for target in notification_targets:
db.query(
"""
""",
[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()
from . import datadir
-DB_VERSION = 5
+DB_VERSION = 6
def get():
db = getattr(g, '_database', None)
--- /dev/null
+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}")
+
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
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"])
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()
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:
ydl.download([yt_url])
@bp.get("/delete-song/<int:songid>")
+@auth.requires_login
def delete_song(songid):
song_data = db.query(
"select * from songs where songid = ?", [songid], one=True)
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 (
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 (
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;
-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;
---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;
"src": "/static/lsp_notes.png",
"sizes": "96x96",
"type": "image/png"
- },
+ }
],
"start_url": ".",
"display": "standalone",
-document.addEventListener("DOMContentLoaded", (e) => {
+document.addEventListener("DOMContentLoaded", async (e) => {
// Handle link clicks with AJAX
document.querySelectorAll("a").forEach((anchor) => {
});
});
+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;
--- /dev/null
+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
{% block body %}
+<button onclick="requestNotificationPermission()" class="button">Enable Notifications</button>
+
<h2>hello!</h2>
<div style="display: flex; flex-direction: row; justify-content: center; gap: 10px; align-items: center;">
<div>🎶</div>