"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()
--- /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)
PRIMARY KEY(songid, tag)
);
CREATE INDEX idx_song_tags_tag ON song_tags(tag);
-
DROP TABLE IF EXISTS playlists;
CREATE TABLE playlists (
playlistid INTEGER PRIMARY KEY,
FOREIGN KEY(threadid) REFERENCES comment_threads(threadid)
);
-PRAGMA user_version = 5;
+DROP VIEW IF EXISTS songs_view;
+CREATE VIEW songs_view AS
+ WITH
+ tags_agg AS (
+ SELECT songid, GROUP_CONCAT(tag) as tags
+ FROM song_tags
+ GROUP BY songid
+ ),
+ collaborators_agg AS (
+ SELECT songid, GROUP_CONCAT(name) as collaborators
+ FROM song_collaborators
+ GROUP BY songid
+ )
+ SELECT
+ songs.*,
+ users.username,
+ users.fgcolor,
+ users.bgcolor,
+ users.accolor,
+ jam_events.title AS event_title,
+ jam_events.jamid AS jamid,
+ jam_events.enddate AS event_enddate,
+ tags_agg.tags,
+ collaborators_agg.collaborators
+ FROM songs
+ INNER JOIN users ON songs.userid = users.userid
+ LEFT JOIN tags_agg ON tags_agg.songid = songs.songid
+ LEFT JOIN collaborators_agg ON collaborators_agg.songid = songs.songid
+ LEFT JOIN jam_events ON jam_events.eventid = songs.eventid;
+
+PRAGMA user_version = 6;
-DROP VIEW songs_view;
-PRAGMA user_version = 5;
+DROP TABLE users_push_subscriptions;
+
+PRAGMA user_version = 6;
-CREATE VIEW songs_view AS
- WITH
- tags_agg AS (
- SELECT songid, GROUP_CONCAT(tag) as tags
- FROM song_tags
- GROUP BY songid
- ),
- collaborators_agg AS (
- SELECT songid, GROUP_CONCAT(name) as collaborators
- FROM song_collaborators
- GROUP BY songid
- )
- SELECT
- songs.*,
- users.username,
- users.fgcolor,
- users.bgcolor,
- users.accolor,
- jam_events.title AS event_title,
- jam_events.jamid AS jamid,
- jam_events.enddate AS event_enddate,
- tags_agg.tags,
- collaborators_agg.collaborators
- FROM songs
- INNER JOIN users ON songs.userid = users.userid
- LEFT JOIN tags_agg ON tags_agg.songid = songs.songid
- LEFT JOIN collaborators_agg ON collaborators_agg.songid = songs.songid
- LEFT JOIN jam_events ON jam_events.eventid = songs.eventid;
-
-PRAGMA user_version = 6;
+-- 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
+);
+PRAGMA user_version = 7;
"src": "/static/lsp_notes.png",
"sizes": "96x96",
"type": "image/png"
- },
+ }
],
"start_url": ".",
"display": "standalone",
});
});
+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 class="mt0">hello!</h2>
<div style="display: flex; flex-direction: row; justify-content: center; gap: 10px; align-items: center;">
<div>🎶</div>