Push notifications: period soon/late reminders, test button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ce66b857db
commit
bb19bc0aeb
12 changed files with 872 additions and 21 deletions
21
README.md
21
README.md
|
|
@ -26,6 +26,25 @@ boundary.
|
||||||
|
|
||||||
The logic lives in `static/cycle.js` and is tested in `tests/test_cycle.js`.
|
The logic lives in `static/cycle.js` and is tested in `tests/test_cycle.js`.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
Web Push, sent by `notify.js` (Node, no dependencies: built-in crypto and
|
||||||
|
SQLite). It reuses `static/cycle.js`, so reminders always match the app.
|
||||||
|
|
||||||
|
- `flowtrack-notify daily` runs once a day. It sends "period expected soon"
|
||||||
|
once per cycle, `remindDays` (1-7, default 2) before the predicted start,
|
||||||
|
and "period is late" once per cycle after the expected day passes.
|
||||||
|
- The owner turns notifications on per device in Settings, where there is
|
||||||
|
also a test button. On iPhone this only works from the Home Screen app
|
||||||
|
(iOS 16.4 or later).
|
||||||
|
- The VAPID key pair is created on first start and stored in the database.
|
||||||
|
Replacing it invalidates every subscription.
|
||||||
|
- The server only accepts subscriptions on Apple, Google, Mozilla and
|
||||||
|
Microsoft push hosts, because it POSTs to whatever endpoint it stores.
|
||||||
|
|
||||||
|
Enable it with `--push-contact https://your.site` (the NixOS module does
|
||||||
|
this through `push.contact`).
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
|
|
||||||
Nginx must overwrite `Remote-User` with the Authelia username. Users listed
|
Nginx must overwrite `Remote-User` with the Authelia username. Users listed
|
||||||
|
|
@ -49,6 +68,8 @@ services.flowtrack = {
|
||||||
editors = [ "kayla" ];
|
editors = [ "kayla" ];
|
||||||
viewers = [ "fred" ];
|
viewers = [ "fred" ];
|
||||||
backup.directory = "/mnt/disk2/backups/flowtrack"; # nightly, keeps 30
|
backup.directory = "/mnt/disk2/backups/flowtrack"; # nightly, keeps 30
|
||||||
|
push.contact = "https://flowtrack.example.org";
|
||||||
|
push.time = "*-*-* 09:00:00 Europe/London";
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
181
app.py
181
app.py
|
|
@ -4,13 +4,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
@ -39,6 +43,10 @@ SYMPTOMS = (
|
||||||
("mood-swings", "Mood swings"),
|
("mood-swings", "Mood swings"),
|
||||||
)
|
)
|
||||||
SYMPTOM_IDS = frozenset(symptom_id for symptom_id, _ in SYMPTOMS)
|
SYMPTOM_IDS = frozenset(symptom_id for symptom_id, _ in SYMPTOMS)
|
||||||
|
# The server POSTs to whatever endpoint a browser hands it, so only the real
|
||||||
|
# push services are accepted (Apple, Google, Mozilla, Microsoft).
|
||||||
|
PUSH_HOSTS = ("push.apple.com", "fcm.googleapis.com", "push.services.mozilla.com", "notify.windows.com")
|
||||||
|
MAX_REMIND_DAYS = 7
|
||||||
|
|
||||||
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||||
|
|
||||||
|
|
@ -76,6 +84,32 @@ def clean_entry(value: object) -> dict | None:
|
||||||
return {"flow": flow, "symptoms": sorted(set(symptoms))}
|
return {"flow": flow, "symptoms": sorted(set(symptoms))}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_key(value: object, length: int) -> bool:
|
||||||
|
if not isinstance(value, str) or len(value) > 200:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return len(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))) == length
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_subscription(value: object) -> dict | None:
|
||||||
|
"""A browser PushSubscription.toJSON() pointing at a known push service."""
|
||||||
|
if not isinstance(value, dict) or not isinstance(value.get("keys"), dict):
|
||||||
|
return None
|
||||||
|
endpoint = value.get("endpoint")
|
||||||
|
if not isinstance(endpoint, str) or len(endpoint) > 1000:
|
||||||
|
return None
|
||||||
|
url = urlparse(endpoint)
|
||||||
|
host = url.hostname or ""
|
||||||
|
if url.scheme != "https" or not any(host == known or host.endswith("." + known) for known in PUSH_HOSTS):
|
||||||
|
return None
|
||||||
|
keys = value["keys"]
|
||||||
|
if not decode_key(keys.get("p256dh"), 65) or not decode_key(keys.get("auth"), 16):
|
||||||
|
return None
|
||||||
|
return {"endpoint": endpoint, "p256dh": keys["p256dh"], "auth": keys["auth"]}
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, path: str | Path):
|
def __init__(self, path: str | Path):
|
||||||
self.path = str(path)
|
self.path = str(path)
|
||||||
|
|
@ -102,6 +136,21 @@ class Store:
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('share_details', 'false');
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('share_details', 'false');
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('remind_days', '2');
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
endpoint TEXT PRIMARY KEY,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
-- One row per reminder kind per cycle, so each is sent once.
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications_sent (
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
cycle TEXT NOT NULL,
|
||||||
|
sent_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (kind, cycle)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -110,18 +159,42 @@ class Store:
|
||||||
rows = db.execute("SELECT day, flow, symptoms FROM days ORDER BY day").fetchall()
|
rows = db.execute("SELECT day, flow, symptoms FROM days ORDER BY day").fetchall()
|
||||||
return {row["day"]: {"flow": row["flow"], "symptoms": json.loads(row["symptoms"])} for row in rows}
|
return {row["day"]: {"flow": row["flow"], "symptoms": json.loads(row["symptoms"])} for row in rows}
|
||||||
|
|
||||||
def share_details(self) -> bool:
|
def setting(self, key: str) -> str | None:
|
||||||
with closing(self.connect()) as db, db:
|
with closing(self.connect()) as db, db:
|
||||||
row = db.execute("SELECT value FROM settings WHERE key = 'share_details'").fetchone()
|
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
return row is not None and row["value"] == "true"
|
return row["value"] if row else None
|
||||||
|
|
||||||
def set_share_details(self, enabled: bool) -> None:
|
def set_setting(self, key: str, value: str) -> None:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
db.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
||||||
|
|
||||||
|
def share_details(self) -> bool:
|
||||||
|
return self.setting("share_details") == "true"
|
||||||
|
|
||||||
|
def remind_days(self) -> int:
|
||||||
|
try:
|
||||||
|
return min(MAX_REMIND_DAYS, max(1, int(self.setting("remind_days") or 2)))
|
||||||
|
except ValueError:
|
||||||
|
return 2
|
||||||
|
|
||||||
|
def add_subscription(self, subscription: dict, user: str) -> None:
|
||||||
with closing(self.connect()) as db, db:
|
with closing(self.connect()) as db, db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE settings SET value = ? WHERE key = 'share_details'",
|
"""
|
||||||
("true" if enabled else "false",),
|
INSERT INTO push_subscriptions (endpoint, p256dh, auth, user, created_at) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth, user = excluded.user
|
||||||
|
""",
|
||||||
|
(subscription["endpoint"], subscription["p256dh"], subscription["auth"], user, utc_now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def remove_subscription(self, endpoint: str) -> None:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
db.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
|
||||||
|
|
||||||
|
def subscription_count(self) -> int:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
return db.execute("SELECT COUNT(*) FROM push_subscriptions").fetchone()[0]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _write(db: sqlite3.Connection, day: str, entry: dict, changed_at: str) -> None:
|
def _write(db: sqlite3.Connection, day: str, entry: dict, changed_at: str) -> None:
|
||||||
# An empty log is the same as no log, so the row goes away.
|
# An empty log is the same as no log, so the row goes away.
|
||||||
|
|
@ -173,12 +246,36 @@ class Store:
|
||||||
class TrackerServer(ThreadingHTTPServer):
|
class TrackerServer(ThreadingHTTPServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|
||||||
def __init__(self, address: tuple[str, int], store: Store, editors: set[str], viewers: set[str], dev_user: str | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
address: tuple[str, int],
|
||||||
|
store: Store,
|
||||||
|
editors: set[str],
|
||||||
|
viewers: set[str],
|
||||||
|
dev_user: str | None = None,
|
||||||
|
notify: str | None = None,
|
||||||
|
push_contact: str | None = None,
|
||||||
|
):
|
||||||
super().__init__(address, Handler)
|
super().__init__(address, Handler)
|
||||||
self.store = store
|
self.store = store
|
||||||
self.editors = editors
|
self.editors = editors
|
||||||
self.viewers = viewers
|
self.viewers = viewers
|
||||||
self.dev_user = dev_user
|
self.dev_user = dev_user
|
||||||
|
# Push is sent by the flowtrack-notify (Node) helper; without it, or
|
||||||
|
# without a VAPID contact, the app runs with push switched off.
|
||||||
|
self.notify = notify if notify and push_contact else None
|
||||||
|
self.push_contact = push_contact
|
||||||
|
|
||||||
|
def run_notify(self, command: str) -> dict:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[self.notify, command, "--database", self.store.path, "--contact", self.push_contact or ""],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RuntimeError(completed.stderr.strip() or f"flowtrack-notify {command} failed")
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -235,6 +332,46 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if entry["flow"] in PERIOD_FLOWS
|
if entry["flow"] in PERIOD_FLOWS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def push_state(self) -> dict:
|
||||||
|
store = self.server.store
|
||||||
|
return {
|
||||||
|
"available": self.server.notify is not None and store.setting("vapid_public") is not None,
|
||||||
|
"publicKey": store.setting("vapid_public"),
|
||||||
|
"remindDays": store.remind_days(),
|
||||||
|
"devices": store.subscription_count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_push(self, path: str) -> None:
|
||||||
|
if not self.push_state()["available"]:
|
||||||
|
self.error_json("Notifications are not set up on the server", HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
return
|
||||||
|
if path == "/api/push/test":
|
||||||
|
try:
|
||||||
|
result = self.server.run_notify("test")
|
||||||
|
except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError) as error:
|
||||||
|
logging.error("Test notification failed: %s", error)
|
||||||
|
self.error_json("Sending failed on the server", HTTPStatus.BAD_GATEWAY)
|
||||||
|
return
|
||||||
|
self.json_response(result)
|
||||||
|
return
|
||||||
|
payload = self.read_json()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
if path == "/api/push/subscribe":
|
||||||
|
subscription = clean_subscription(payload)
|
||||||
|
if subscription is None:
|
||||||
|
self.error_json("Not a supported push subscription", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self.server.store.add_subscription(subscription, self.role()[0])
|
||||||
|
self.json_response({"devices": self.server.store.subscription_count()})
|
||||||
|
return
|
||||||
|
endpoint = payload.get("endpoint") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(endpoint, str):
|
||||||
|
self.error_json("endpoint is required", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self.server.store.remove_subscription(endpoint)
|
||||||
|
self.json_response({"devices": self.server.store.subscription_count()})
|
||||||
|
|
||||||
def read_json(self) -> object:
|
def read_json(self) -> object:
|
||||||
if self.headers.get_content_type() != "application/json":
|
if self.headers.get_content_type() != "application/json":
|
||||||
self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
|
@ -272,6 +409,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
"shareDetails": self.server.store.share_details(),
|
"shareDetails": self.server.store.share_details(),
|
||||||
"symptoms": [{"id": symptom_id, "label": label} for symptom_id, label in SYMPTOMS],
|
"symptoms": [{"id": symptom_id, "label": label} for symptom_id, label in SYMPTOMS],
|
||||||
"days": self.visible_days(role),
|
"days": self.visible_days(role),
|
||||||
|
"push": self.push_state() if role == "editor" else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -308,12 +446,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
payload = self.read_json()
|
payload = self.read_json()
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return
|
return
|
||||||
share = payload.get("shareDetails") if isinstance(payload, dict) else None
|
if not isinstance(payload, dict) or not payload or set(payload) - {"shareDetails", "remindDays"}:
|
||||||
|
self.error_json("Expected shareDetails and/or remindDays", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
share = payload.get("shareDetails", False)
|
||||||
|
remind = payload.get("remindDays", 1)
|
||||||
if not isinstance(share, bool):
|
if not isinstance(share, bool):
|
||||||
self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST)
|
self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
self.server.store.set_share_details(share)
|
if not isinstance(remind, int) or isinstance(remind, bool) or not 1 <= remind <= MAX_REMIND_DAYS:
|
||||||
self.json_response({"shareDetails": share})
|
self.error_json(f"remindDays must be 1-{MAX_REMIND_DAYS}", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
if "shareDetails" in payload:
|
||||||
|
self.server.store.set_setting("share_details", "true" if share else "false")
|
||||||
|
if "remindDays" in payload:
|
||||||
|
self.server.store.set_setting("remind_days", str(remind))
|
||||||
|
self.json_response({"shareDetails": self.server.store.share_details(), "remindDays": self.server.store.remind_days()})
|
||||||
return
|
return
|
||||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
|
@ -323,6 +471,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if role != "editor":
|
if role != "editor":
|
||||||
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
|
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
|
||||||
return
|
return
|
||||||
|
if path in ("/api/push/subscribe", "/api/push/unsubscribe", "/api/push/test"):
|
||||||
|
self.handle_push(path)
|
||||||
|
return
|
||||||
if path != "/api/days":
|
if path != "/api/days":
|
||||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||||
return
|
return
|
||||||
|
|
@ -403,6 +554,8 @@ def main() -> None:
|
||||||
parser.add_argument("--dev-user", help="identity to assume when no Remote-User header is sent (local testing only)")
|
parser.add_argument("--dev-user", help="identity to assume when no Remote-User header is sent (local testing only)")
|
||||||
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
|
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
|
||||||
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
|
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
|
||||||
|
parser.add_argument("--push-contact", help="VAPID contact (https: or mailto: URL); enables push notifications")
|
||||||
|
parser.add_argument("--notify", default=os.environ.get("FLOWTRACK_NOTIFY"), help="path to flowtrack-notify")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
@ -412,7 +565,13 @@ def main() -> None:
|
||||||
if args.backup_to:
|
if args.backup_to:
|
||||||
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
|
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
|
||||||
return
|
return
|
||||||
server = TrackerServer((args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user)
|
server = TrackerServer(
|
||||||
|
(args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user, args.notify, args.push_contact
|
||||||
|
)
|
||||||
|
if server.notify:
|
||||||
|
# Creates the VAPID key pair on first start; the browser needs its
|
||||||
|
# public half before it can subscribe.
|
||||||
|
server.run_notify("init")
|
||||||
logging.info("Listening on http://%s:%s", args.host, args.port)
|
logging.info("Listening on http://%s:%s", args.host, args.port)
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@
|
||||||
python3 -m py_compile app.py
|
python3 -m py_compile app.py
|
||||||
node --check static/app.js
|
node --check static/app.js
|
||||||
node --check static/cycle.js
|
node --check static/cycle.js
|
||||||
|
node --check static/sw.js
|
||||||
|
node --check notify.js
|
||||||
node tests/test_cycle.js
|
node tests/test_cycle.js
|
||||||
|
node --disable-warning=ExperimentalWarning tests/test_push.js
|
||||||
touch $out
|
touch $out
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
34
module.nix
34
module.nix
|
|
@ -20,6 +20,7 @@ let
|
||||||
};
|
};
|
||||||
userFlags = lib.concatMapStringsSep " " (user: "--editor ${lib.escapeShellArg user}") cfg.editors
|
userFlags = lib.concatMapStringsSep " " (user: "--editor ${lib.escapeShellArg user}") cfg.editors
|
||||||
+ " " + lib.concatMapStringsSep " " (user: "--viewer ${lib.escapeShellArg user}") cfg.viewers;
|
+ " " + lib.concatMapStringsSep " " (user: "--viewer ${lib.escapeShellArg user}") cfg.viewers;
|
||||||
|
pushFlag = lib.optionalString (cfg.push.contact != null) " --push-contact ${lib.escapeShellArg cfg.push.contact}";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.flowtrack = {
|
options.services.flowtrack = {
|
||||||
|
|
@ -45,6 +46,18 @@ in
|
||||||
default = [ ];
|
default = [ ];
|
||||||
description = "Remote-User names with read-only access.";
|
description = "Remote-User names with read-only access.";
|
||||||
};
|
};
|
||||||
|
push.contact = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "https://flowtrack.example.org";
|
||||||
|
description = "VAPID contact URL (https: or mailto:) sent to push services; null disables push notifications.";
|
||||||
|
};
|
||||||
|
push.time = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "*-*-* 09:00:00";
|
||||||
|
example = "*-*-* 09:00:00 Europe/London";
|
||||||
|
description = "systemd OnCalendar expression for the daily reminder check.";
|
||||||
|
};
|
||||||
backup = {
|
backup = {
|
||||||
directory = lib.mkOption {
|
directory = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
|
@ -73,12 +86,31 @@ in
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
serviceConfig = hardening // {
|
serviceConfig = hardening // {
|
||||||
ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}";
|
ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}${pushFlag}";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 3;
|
RestartSec = 3;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
(lib.mkIf (cfg.push.contact != null) {
|
||||||
|
systemd.services.flowtrack-notify = {
|
||||||
|
description = "Send due Flowtrack reminders";
|
||||||
|
after = [ "network-online.target" "flowtrack.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
serviceConfig = hardening // {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${cfg.package}/bin/flowtrack-notify daily --database ${database} --contact ${lib.escapeShellArg cfg.push.contact}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.flowtrack-notify = {
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = cfg.push.time;
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
(lib.mkIf (cfg.backup.directory != null) {
|
(lib.mkIf (cfg.backup.directory != null) {
|
||||||
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 flowtrack flowtrack -" ];
|
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 flowtrack flowtrack -" ];
|
||||||
|
|
||||||
|
|
|
||||||
209
notify.js
Normal file
209
notify.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Web Push for Flowtrack: VAPID keys, payload encryption (RFC 8291) and the
|
||||||
|
// daily "period soon / late" check. Dependency-free: Node's own crypto and
|
||||||
|
// SQLite. The schema is owned by app.py, which always initializes it first.
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const path = require("node:path");
|
||||||
|
const Cycle = require(path.join(__dirname, "static", "cycle.js"));
|
||||||
|
|
||||||
|
const b64url = {
|
||||||
|
encode: bytes => Buffer.from(bytes).toString("base64url"),
|
||||||
|
decode: text => Buffer.from(text, "base64url")
|
||||||
|
};
|
||||||
|
const formatDay = number =>
|
||||||
|
new Date(number * 86400000).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" });
|
||||||
|
const localIso = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function openDatabase(file) {
|
||||||
|
const { DatabaseSync } = require("node:sqlite");
|
||||||
|
const db = new DatabaseSync(file);
|
||||||
|
db.exec("PRAGMA busy_timeout=10000");
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = (db, key) => {
|
||||||
|
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
||||||
|
return row ? row.value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generated once and kept in the database: browsers bind each subscription
|
||||||
|
// to this key, so replacing it would silently break every subscribed device.
|
||||||
|
function vapidKeys(db) {
|
||||||
|
if (!setting(db, "vapid_private")) {
|
||||||
|
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
||||||
|
const jwk = privateKey.export({ format: "jwk" });
|
||||||
|
const insert = db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
|
||||||
|
insert.run("vapid_private", JSON.stringify(jwk));
|
||||||
|
insert.run("vapid_public", b64url.encode(Buffer.concat([Buffer.from([4]), b64url.decode(jwk.x), b64url.decode(jwk.y)])));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
privateKey: crypto.createPrivateKey({ key: JSON.parse(setting(db, "vapid_private")), format: "jwk" }),
|
||||||
|
publicKey: setting(db, "vapid_public")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vapidAuthorization(endpoint, keys, contact, now = Date.now()) {
|
||||||
|
const header = b64url.encode(JSON.stringify({ typ: "JWT", alg: "ES256" }));
|
||||||
|
const claims = b64url.encode(JSON.stringify({
|
||||||
|
aud: new URL(endpoint).origin,
|
||||||
|
exp: Math.floor(now / 1000) + 12 * 3600,
|
||||||
|
sub: contact
|
||||||
|
}));
|
||||||
|
const signature = crypto.sign("sha256", Buffer.from(`${header}.${claims}`), { key: keys.privateKey, dsaEncoding: "ieee-p1363" });
|
||||||
|
return `vapid t=${header}.${claims}.${b64url.encode(signature)}, k=${keys.publicKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8291 "aes128gcm": one record holding the whole payload.
|
||||||
|
function encrypt(payload, subscriberKey, authSecret) {
|
||||||
|
const receiver = b64url.decode(subscriberKey);
|
||||||
|
const auth = b64url.decode(authSecret);
|
||||||
|
const sender = crypto.createECDH("prime256v1");
|
||||||
|
const senderKey = sender.generateKeys();
|
||||||
|
const shared = sender.computeSecret(receiver);
|
||||||
|
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), receiver, senderKey]);
|
||||||
|
const ikm = Buffer.from(crypto.hkdfSync("sha256", shared, auth, keyInfo, 32));
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
||||||
|
const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12));
|
||||||
|
const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce);
|
||||||
|
// 0x02 marks the last (and only) record.
|
||||||
|
const body = Buffer.concat([cipher.update(Buffer.concat([Buffer.from(payload), Buffer.from([2])])), cipher.final(), cipher.getAuthTag()]);
|
||||||
|
const header = Buffer.alloc(21);
|
||||||
|
salt.copy(header, 0);
|
||||||
|
header.writeUInt32BE(4096, 16);
|
||||||
|
header.writeUInt8(senderKey.length, 20);
|
||||||
|
return Buffer.concat([header, senderKey, body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAll(db, contact, message) {
|
||||||
|
const keys = vapidKeys(db);
|
||||||
|
const subscriptions = db.prepare("SELECT endpoint, p256dh, auth FROM push_subscriptions").all();
|
||||||
|
const result = { sent: 0, failed: 0, removed: 0, errors: [] };
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(subscription.endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: vapidAuthorization(subscription.endpoint, keys, contact),
|
||||||
|
"Content-Encoding": "aes128gcm",
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
TTL: String(message.ttl || 86400),
|
||||||
|
Urgency: "normal"
|
||||||
|
},
|
||||||
|
body: encrypt(payload, subscription.p256dh, subscription.auth),
|
||||||
|
signal: AbortSignal.timeout(20000)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
result.sent += 1;
|
||||||
|
} else if (response.status === 404 || response.status === 410) {
|
||||||
|
// The device unsubscribed or the app was removed from the home screen.
|
||||||
|
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(subscription.endpoint);
|
||||||
|
result.removed += 1;
|
||||||
|
} else {
|
||||||
|
result.failed += 1;
|
||||||
|
result.errors.push(`${new URL(subscription.endpoint).host}: ${response.status} ${(await response.text()).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.failed += 1;
|
||||||
|
result.errors.push(`${new URL(subscription.endpoint).host}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which reminder, if any, is due today. Each kind is sent at most once per
|
||||||
|
// cycle (keyed on the cycle's start date), so a missed timer run or a second
|
||||||
|
// run on the same day never repeats it.
|
||||||
|
function dueReminder(summary, remindDays) {
|
||||||
|
const { current, predictions, late, today } = summary;
|
||||||
|
if (!current || current.onPeriod) return null;
|
||||||
|
const cycle = Cycle.toIso(current.start);
|
||||||
|
if (late > 0) {
|
||||||
|
return {
|
||||||
|
kind: "late",
|
||||||
|
cycle,
|
||||||
|
title: "Period is late",
|
||||||
|
body: `It was expected on ${formatDay(today - late)}. Log it in Flowtrack when it starts.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const next = predictions[0];
|
||||||
|
const until = next.start - today;
|
||||||
|
if (until < 1 || until > remindDays) return null;
|
||||||
|
return {
|
||||||
|
kind: "soon",
|
||||||
|
cycle,
|
||||||
|
title: "Period expected soon",
|
||||||
|
body: until === 1
|
||||||
|
? `Your period is expected tomorrow, ${formatDay(next.start)}.`
|
||||||
|
: `Your period is expected in about ${until} days, around ${formatDay(next.start)}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function daily(db, contact, todayIso) {
|
||||||
|
const days = {};
|
||||||
|
for (const row of db.prepare("SELECT day, flow, symptoms FROM days").all()) {
|
||||||
|
days[row.day] = { flow: row.flow, symptoms: JSON.parse(row.symptoms) };
|
||||||
|
}
|
||||||
|
const remindDays = Number(setting(db, "remind_days")) || 2;
|
||||||
|
const reminder = dueReminder(Cycle.summarize(days, todayIso), remindDays);
|
||||||
|
if (!reminder) return { due: null };
|
||||||
|
if (db.prepare("SELECT 1 FROM notifications_sent WHERE kind = ? AND cycle = ?").get(reminder.kind, reminder.cycle)) {
|
||||||
|
return { due: reminder.kind, alreadySent: true };
|
||||||
|
}
|
||||||
|
const result = await sendAll(db, contact, { title: reminder.title, body: reminder.body, tag: reminder.kind, url: "/" });
|
||||||
|
// Only mark it sent once a device got it, so subscribing later the same
|
||||||
|
// day still delivers it on the next run.
|
||||||
|
if (result.sent) {
|
||||||
|
db.prepare("INSERT OR IGNORE INTO notifications_sent (kind, cycle, sent_at) VALUES (?, ?, ?)")
|
||||||
|
.run(reminder.kind, reminder.cycle, new Date().toISOString());
|
||||||
|
}
|
||||||
|
return { due: reminder.kind, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const [command, ...rest] = argv;
|
||||||
|
const options = { command };
|
||||||
|
for (let index = 0; index < rest.length; index += 2) {
|
||||||
|
if (!rest[index].startsWith("--")) throw new Error(`Unexpected argument ${rest[index]}`);
|
||||||
|
options[rest[index].slice(2)] = rest[index + 1];
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(argv) {
|
||||||
|
const options = parseArgs(argv);
|
||||||
|
if (!options.database) throw new Error("--database is required");
|
||||||
|
const db = openDatabase(options.database);
|
||||||
|
try {
|
||||||
|
if (options.command === "init") return { publicKey: vapidKeys(db).publicKey };
|
||||||
|
if (!options.contact) throw new Error("--contact is required");
|
||||||
|
if (options.command === "test") {
|
||||||
|
return await sendAll(db, options.contact, {
|
||||||
|
title: "Flowtrack",
|
||||||
|
body: "Test notification: reminders will arrive like this.",
|
||||||
|
tag: "test",
|
||||||
|
url: "/",
|
||||||
|
ttl: 600
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.command === "daily") return await daily(db, options.contact, options.today || localIso());
|
||||||
|
throw new Error("usage: flowtrack-notify init|test|daily --database DB [--contact URL] [--today YYYY-MM-DD]");
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main(process.argv.slice(2)).then(
|
||||||
|
result => { console.log(JSON.stringify(result)); },
|
||||||
|
error => { console.error(error.message); process.exitCode = 1; }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { b64url, encrypt, vapidKeys, vapidAuthorization, dueReminder, main };
|
||||||
12
package.nix
12
package.nix
|
|
@ -1,8 +1,8 @@
|
||||||
{ lib, stdenvNoCC, makeWrapper, python3 }:
|
{ lib, stdenvNoCC, makeWrapper, python3, nodejs-slim }:
|
||||||
|
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "flowtrack";
|
pname = "flowtrack";
|
||||||
version = "1.1.0";
|
version = "1.2.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
nativeBuildInputs = [ makeWrapper ];
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
@ -10,10 +10,14 @@ stdenvNoCC.mkDerivation {
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p $out/share/flowtrack $out/bin
|
mkdir -p $out/share/flowtrack $out/bin
|
||||||
cp app.py $out/share/flowtrack/app.py
|
cp app.py notify.js $out/share/flowtrack/
|
||||||
cp -r static $out/share/flowtrack/static
|
cp -r static $out/share/flowtrack/static
|
||||||
|
# node:sqlite still prints an ExperimentalWarning on every start.
|
||||||
|
makeWrapper ${nodejs-slim}/bin/node $out/bin/flowtrack-notify \
|
||||||
|
--add-flags "--disable-warning=ExperimentalWarning $out/share/flowtrack/notify.js"
|
||||||
makeWrapper ${python3}/bin/python3 $out/bin/flowtrack \
|
makeWrapper ${python3}/bin/python3 $out/bin/flowtrack \
|
||||||
--add-flags "$out/share/flowtrack/app.py"
|
--add-flags "$out/share/flowtrack/app.py" \
|
||||||
|
--set-default FLOWTRACK_NOTIFY $out/bin/flowtrack-notify
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
|
||||||
154
static/app.js
154
static/app.js
|
|
@ -14,7 +14,10 @@
|
||||||
days: {},
|
days: {},
|
||||||
view: "today",
|
view: "today",
|
||||||
month: null,
|
month: null,
|
||||||
sheetDay: null
|
sheetDay: null,
|
||||||
|
push: null,
|
||||||
|
// This device's PushSubscription: undefined until checked, then null or set.
|
||||||
|
device: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
@ -79,10 +82,12 @@
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
shareDetails: payload.shareDetails,
|
shareDetails: payload.shareDetails,
|
||||||
symptoms: payload.symptoms,
|
symptoms: payload.symptoms,
|
||||||
days: payload.days
|
days: payload.days,
|
||||||
|
push: payload.push
|
||||||
});
|
});
|
||||||
if (!$("banner").classList.contains("error")) showBanner("");
|
if (!$("banner").classList.contains("error")) showBanner("");
|
||||||
render();
|
render();
|
||||||
|
if (state.device === undefined) await checkDevice();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true);
|
showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true);
|
||||||
}
|
}
|
||||||
|
|
@ -507,7 +512,151 @@
|
||||||
|
|
||||||
// ---- Settings ------------------------------------------------------------
|
// ---- Settings ------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- Notifications -------------------------------------------------------
|
||||||
|
|
||||||
|
const isIos = () => /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
const installed = () => window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches;
|
||||||
|
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||||
|
const keyBytes = key => Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/")), char => char.charCodeAt(0));
|
||||||
|
const keyText = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
// serviceWorker.ready never settles if registration failed, so give up.
|
||||||
|
const workerReady = () => Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error("the notification worker did not start")), 8000))
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function checkDevice() {
|
||||||
|
if (!editable() || !state.push || !state.push.available || !pushSupported()) {
|
||||||
|
state.device = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const registration = await workerReady();
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
// A subscription made for a different server key can never be
|
||||||
|
// delivered to, so drop it rather than show notifications as on.
|
||||||
|
if (subscription && subscription.options.applicationServerKey &&
|
||||||
|
keyText(subscription.options.applicationServerKey) !== state.push.publicKey) {
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
subscription = null;
|
||||||
|
}
|
||||||
|
// Re-register on every launch so the server copy survives a restore.
|
||||||
|
if (subscription) await sendJson("POST", "/api/push/subscribe", subscription.toJSON());
|
||||||
|
state.device = subscription;
|
||||||
|
} catch {
|
||||||
|
state.device = null;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enablePush() {
|
||||||
|
try {
|
||||||
|
// iOS only shows the permission prompt straight from a tap, so this
|
||||||
|
// must be the first thing awaited in the click handler.
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
showBanner("Notifications were not allowed.", true);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const registration = await workerReady();
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: keyBytes(state.push.publicKey)
|
||||||
|
});
|
||||||
|
const { devices } = await sendJson("POST", "/api/push/subscribe", subscription.toJSON());
|
||||||
|
state.device = subscription;
|
||||||
|
state.push.devices = devices;
|
||||||
|
showBanner("Notifications are on. Send a test to check.");
|
||||||
|
} catch (error) {
|
||||||
|
showBanner(`Couldn't turn on notifications: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disablePush() {
|
||||||
|
try {
|
||||||
|
const { devices } = await sendJson("POST", "/api/push/unsubscribe", { endpoint: state.device.endpoint });
|
||||||
|
await state.device.unsubscribe();
|
||||||
|
state.device = null;
|
||||||
|
state.push.devices = devices;
|
||||||
|
showBanner("Notifications are off on this device.");
|
||||||
|
} catch (error) {
|
||||||
|
showBanner(`Couldn't turn off notifications: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPush(button) {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await api("/api/push/test", { method: "POST" });
|
||||||
|
if (result.sent) showBanner(`Test sent to ${plural(result.sent, "device")}. It should arrive within a few seconds.`);
|
||||||
|
else showBanner(result.errors[0] ? `Test failed: ${result.errors[0]}` : "No devices are signed up for notifications.", true);
|
||||||
|
} catch (error) {
|
||||||
|
showBanner(`Test failed: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRemindDays(remindDays) {
|
||||||
|
try {
|
||||||
|
await sendJson("PUT", "/api/settings", { remindDays });
|
||||||
|
state.push.remindDays = remindDays;
|
||||||
|
showBanner(`You'll be reminded ${plural(remindDays, "day")} before.`);
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
showBanner(`Couldn't save: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifications() {
|
||||||
|
const body = $("notify-body");
|
||||||
|
const note = text => h("p", { class: "notify-note" }, text);
|
||||||
|
if (!state.push || !state.push.available) {
|
||||||
|
body.replaceChildren(note("Notifications are not set up on the server yet."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pushSupported()) {
|
||||||
|
body.replaceChildren(note(isIos() && !installed()
|
||||||
|
? "On iPhone, notifications only work from the Home Screen app. In Safari tap Share, then Add to Home Screen, and open Flowtrack from there."
|
||||||
|
: "This browser does not support notifications."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Notification.permission === "denied") {
|
||||||
|
body.replaceChildren(note(isIos()
|
||||||
|
? "Notifications are blocked. Turn them on in the iPhone Settings app under Notifications, Flowtrack."
|
||||||
|
: "Notifications are blocked for this site in the browser settings."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.device === undefined) {
|
||||||
|
body.replaceChildren(note("Checking…"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const days = h("select", { "aria-label": "Days before" }, range(1, 7).map(count =>
|
||||||
|
h("option", { value: String(count), selected: count === state.push.remindDays }, String(count))
|
||||||
|
));
|
||||||
|
days.addEventListener("change", () => setRemindDays(Number(days.value)));
|
||||||
|
const remind = h("label", { class: "remind-row" }, "Remind me", days, state.push.remindDays === 1 ? "day before" : "days before");
|
||||||
|
if (!state.device) {
|
||||||
|
body.replaceChildren(
|
||||||
|
h("button", { type: "button", class: "button", onclick: enablePush }, "Turn on notifications"),
|
||||||
|
remind
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const test = h("button", { type: "button", class: "button" }, "Send test notification");
|
||||||
|
test.addEventListener("click", () => testPush(test));
|
||||||
|
body.replaceChildren(
|
||||||
|
h("p", { class: "notify-status" }, h("span", { class: "status-dot" }), "On for this device"),
|
||||||
|
remind,
|
||||||
|
h("div", { class: "button-row" }, test, h("button", { type: "button", class: "button secondary", onclick: disablePush }, "Turn off"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderSettings(summary) {
|
function renderSettings(summary) {
|
||||||
|
renderNotifications();
|
||||||
$("share-toggle").checked = state.shareDetails;
|
$("share-toggle").checked = state.shareDetails;
|
||||||
const hint = summary.periodSamples
|
const hint = summary.periodSamples
|
||||||
? `Leave "Ended" empty to use the average length so far, ${plural(summary.periodLength, "day")}.`
|
? `Leave "Ended" empty to use the average length so far, ${plural(summary.periodLength, "day")}.`
|
||||||
|
|
@ -572,6 +721,7 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
seedForm($("seed-form"));
|
seedForm($("seed-form"));
|
||||||
|
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
// The home-screen app is often resumed rather than reopened, so refresh
|
// The home-screen app is often resumed rather than reopened, so refresh
|
||||||
// whenever it comes back to the foreground (this also rolls "today" over).
|
// whenever it comes back to the foreground (this also rolls "today" over).
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
|
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/styles.css?v=1.1.0">
|
<link rel="stylesheet" href="/styles.css?v=1.2.0">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|
@ -74,6 +74,11 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
|
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
|
||||||
|
<div class="card" id="notify-card">
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
<p class="subtitle">A reminder before your period is due, and a note if it is late.</p>
|
||||||
|
<div id="notify-body" class="notify-body"></div>
|
||||||
|
</div>
|
||||||
<div class="card" id="share-card">
|
<div class="card" id="share-card">
|
||||||
<h2>Sharing</h2>
|
<h2>Sharing</h2>
|
||||||
<label class="switch-row">
|
<label class="switch-row">
|
||||||
|
|
@ -125,7 +130,7 @@
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="/cycle.js?v=1.1.0"></script>
|
<script src="/cycle.js?v=1.2.0"></script>
|
||||||
<script src="/app.js?v=1.1.0"></script>
|
<script src="/app.js?v=1.2.0"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,19 @@ main {
|
||||||
.switch-row small { color: var(--muted); font-size: 0.85rem; }
|
.switch-row small { color: var(--muted); font-size: 0.85rem; }
|
||||||
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
|
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
|
||||||
|
|
||||||
|
.notify-body { display: grid; gap: 14px; justify-items: start; }
|
||||||
|
.notify-note { margin: 0; color: var(--ink-2); font-size: 0.92rem; }
|
||||||
|
.notify-status { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 600; }
|
||||||
|
.status-dot { width: 10px; height: 10px; border-radius: 999px; background: var(--fertile); }
|
||||||
|
.remind-row { display: flex; align-items: center; gap: 8px; color: var(--ink-2); font-size: 0.92rem; }
|
||||||
|
.remind-row select {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
.seed-form { display: grid; gap: 12px; }
|
.seed-form { display: grid; gap: 12px; }
|
||||||
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
|
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
|
||||||
.seed-form input {
|
.seed-form input {
|
||||||
|
|
|
||||||
29
static/sw.js
Normal file
29
static/sw.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Service worker: only shows push notifications. It deliberately caches
|
||||||
|
// nothing, so the app always loads fresh from the server.
|
||||||
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
self.addEventListener("activate", event => event.waitUntil(self.clients.claim()));
|
||||||
|
|
||||||
|
self.addEventListener("push", event => {
|
||||||
|
let message = { title: "Flowtrack", body: "" };
|
||||||
|
try {
|
||||||
|
message = { ...message, ...event.data.json() };
|
||||||
|
} catch {
|
||||||
|
// An empty or unreadable push still has to show something on iOS.
|
||||||
|
}
|
||||||
|
event.waitUntil(self.registration.showNotification(message.title, {
|
||||||
|
body: message.body,
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
badge: "/icon-192.png",
|
||||||
|
tag: message.tag,
|
||||||
|
data: { url: message.url || "/" }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", event => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
if (windows.length) return windows[0].focus();
|
||||||
|
return self.clients.openWindow(event.notification.data.url);
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -138,6 +140,21 @@ class ServerTests(unittest.TestCase):
|
||||||
}})
|
}})
|
||||||
self.assertEqual((status, result), (200, {"added": 2}))
|
self.assertEqual((status, result), (200, {"added": 2}))
|
||||||
|
|
||||||
|
def test_push_is_off_without_a_helper(self):
|
||||||
|
_, state = self.request("GET", "/api/state", user="kayla")
|
||||||
|
self.assertFalse(state["push"]["available"])
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/test", user="kayla")[0], 503)
|
||||||
|
_, state = self.request("GET", "/api/state", user="fred")
|
||||||
|
self.assertIsNone(state["push"])
|
||||||
|
|
||||||
|
def test_remind_days_setting(self):
|
||||||
|
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": 3})[1], {"shareDetails": False, "remindDays": 3})
|
||||||
|
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": 9})[0], 400)
|
||||||
|
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": True})[0], 400)
|
||||||
|
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"other": 1})[0], 400)
|
||||||
|
_, state = self.request("GET", "/api/state", user="kayla")
|
||||||
|
self.assertEqual(state["push"]["remindDays"], 3)
|
||||||
|
|
||||||
def test_static_files(self):
|
def test_static_files(self):
|
||||||
status, body = self.request("GET", "/")
|
status, body = self.request("GET", "/")
|
||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
|
|
@ -146,5 +163,62 @@ class ServerTests(unittest.TestCase):
|
||||||
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
|
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
|
||||||
|
|
||||||
|
|
||||||
|
FAKE_NOTIFY = """#!{python}
|
||||||
|
import json, sqlite3, sys
|
||||||
|
command, database = sys.argv[1], sys.argv[3]
|
||||||
|
if command == "init":
|
||||||
|
with sqlite3.connect(database) as db:
|
||||||
|
db.execute("INSERT OR IGNORE INTO settings VALUES ('vapid_public', 'PUBLIC')")
|
||||||
|
print(json.dumps({{"publicKey": "PUBLIC"}}))
|
||||||
|
else:
|
||||||
|
print(json.dumps({{"sent": 1, "failed": 0, "removed": 0, "errors": [], "contact": sys.argv[5]}}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
APPLE_SUBSCRIPTION = {
|
||||||
|
"endpoint": "https://web.push.apple.com/QGuQyavXutnMH",
|
||||||
|
"keys": {"p256dh": base64.urlsafe_b64encode(b"\x04" + b"k" * 64).decode().rstrip("="), "auth": base64.urlsafe_b64encode(b"a" * 16).decode().rstrip("=")},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PushServerTests(ServerTests):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
script = Path(self.temp.name) / "notify"
|
||||||
|
script.write_text(FAKE_NOTIFY.format(python=sys.executable))
|
||||||
|
script.chmod(0o755)
|
||||||
|
self.server.notify = str(script)
|
||||||
|
self.server.push_contact = "https://flowtrack.example.org"
|
||||||
|
self.server.run_notify("init")
|
||||||
|
|
||||||
|
# Every other server test also runs again with push switched on.
|
||||||
|
test_push_is_off_without_a_helper = None
|
||||||
|
|
||||||
|
def test_push_state(self):
|
||||||
|
_, state = self.request("GET", "/api/state", user="kayla")
|
||||||
|
self.assertEqual(state["push"], {"available": True, "publicKey": "PUBLIC", "remindDays": 2, "devices": 0})
|
||||||
|
|
||||||
|
def test_subscribe_test_and_unsubscribe(self):
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=APPLE_SUBSCRIPTION), (200, {"devices": 1}))
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=APPLE_SUBSCRIPTION), (200, {"devices": 1}))
|
||||||
|
status, result = self.request("POST", "/api/push/test", user="kayla")
|
||||||
|
self.assertEqual((status, result["sent"], result["contact"]), (200, 1, "https://flowtrack.example.org"))
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/test", user="fred")[0], 403)
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/unsubscribe", user="kayla", body={"endpoint": APPLE_SUBSCRIPTION["endpoint"]}), (200, {"devices": 0}))
|
||||||
|
|
||||||
|
def test_only_real_push_services(self):
|
||||||
|
for endpoint in (
|
||||||
|
"https://127.0.0.1/push",
|
||||||
|
"http://web.push.apple.com/x",
|
||||||
|
"https://evilpush.apple.com.example.org/x",
|
||||||
|
"https://router.nordhammer.it/api",
|
||||||
|
):
|
||||||
|
body = {**APPLE_SUBSCRIPTION, "endpoint": endpoint}
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=body)[0], 400, endpoint)
|
||||||
|
bad_keys = {**APPLE_SUBSCRIPTION, "keys": {"p256dh": "short", "auth": APPLE_SUBSCRIPTION["keys"]["auth"]}}
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=bad_keys)[0], 400)
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/subscribe", user="fred", body=APPLE_SUBSCRIPTION)[0], 403)
|
||||||
|
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body={**APPLE_SUBSCRIPTION, "endpoint": "https://fcm.googleapis.com/fcm/send/abc"})[0], 200)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
152
tests/test_push.js
Normal file
152
tests/test_push.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const http = require("node:http");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const { DatabaseSync } = require("node:sqlite");
|
||||||
|
const push = require("../notify.js");
|
||||||
|
const cycle = require("../static/cycle.js");
|
||||||
|
|
||||||
|
const { b64url } = push;
|
||||||
|
|
||||||
|
// Decrypts like the receiving browser does (RFC 8291), using its private key.
|
||||||
|
function decrypt(body, receiver, auth) {
|
||||||
|
const salt = body.subarray(0, 16);
|
||||||
|
const keyLength = body.readUInt8(20);
|
||||||
|
const senderKey = body.subarray(21, 21 + keyLength);
|
||||||
|
const shared = receiver.computeSecret(senderKey);
|
||||||
|
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), receiver.getPublicKey(), senderKey]);
|
||||||
|
const ikm = Buffer.from(crypto.hkdfSync("sha256", shared, auth, keyInfo, 32));
|
||||||
|
const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
||||||
|
const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12));
|
||||||
|
const record = body.subarray(21 + keyLength);
|
||||||
|
const decipher = crypto.createDecipheriv("aes-128-gcm", cek, nonce);
|
||||||
|
decipher.setAuthTag(record.subarray(record.length - 16));
|
||||||
|
const plain = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
|
||||||
|
assert.equal(plain[plain.length - 1], 2, "last-record delimiter");
|
||||||
|
return plain.subarray(0, plain.length - 1).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function device() {
|
||||||
|
const receiver = crypto.createECDH("prime256v1");
|
||||||
|
receiver.generateKeys();
|
||||||
|
const auth = crypto.randomBytes(16);
|
||||||
|
return { receiver, auth, p256dh: b64url.encode(receiver.getPublicKey()), authText: b64url.encode(auth) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyVapid(authorization, publicKey, audience) {
|
||||||
|
const [, token, key] = authorization.match(/^vapid t=([^,]+), k=(.+)$/);
|
||||||
|
assert.equal(key, publicKey);
|
||||||
|
const [header, claims, signature] = token.split(".");
|
||||||
|
const raw = b64url.decode(publicKey);
|
||||||
|
const verifier = crypto.createPublicKey({
|
||||||
|
key: { kty: "EC", crv: "P-256", x: b64url.encode(raw.subarray(1, 33)), y: b64url.encode(raw.subarray(33)) },
|
||||||
|
format: "jwk"
|
||||||
|
});
|
||||||
|
assert.ok(crypto.verify("sha256", Buffer.from(`${header}.${claims}`), { key: verifier, dsaEncoding: "ieee-p1363" }, b64url.decode(signature)));
|
||||||
|
const parsed = JSON.parse(b64url.decode(claims));
|
||||||
|
assert.equal(parsed.aud, audience);
|
||||||
|
assert.ok(parsed.exp > Date.now() / 1000 && parsed.exp <= Date.now() / 1000 + 24 * 3600, "exp within 24h");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logPeriods(starts, length = 5) {
|
||||||
|
const days = {};
|
||||||
|
for (const start of starts) {
|
||||||
|
for (let offset = 0; offset < length; offset += 1) days[cycle.toIso(cycle.toDay(start) + offset)] = { flow: "medium", symptoms: [] };
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Encryption round trip, including a payload longer than one AES block.
|
||||||
|
{
|
||||||
|
const phone = device();
|
||||||
|
const message = JSON.stringify({ title: "Period expected soon", body: "x".repeat(300) });
|
||||||
|
const body = push.encrypt(message, phone.p256dh, phone.authText);
|
||||||
|
assert.equal(body.readUInt32BE(16), 4096);
|
||||||
|
assert.equal(decrypt(body, phone.receiver, phone.auth), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminder rules: regular 28-day cycles with the last one starting 1 Oct,
|
||||||
|
// so the next is expected 29 Oct.
|
||||||
|
{
|
||||||
|
const days = logPeriods(["2026-07-09", "2026-08-06", "2026-09-03", "2026-10-01"]);
|
||||||
|
const at = today => push.dueReminder(cycle.summarize(days, today), 2);
|
||||||
|
assert.equal(at("2026-10-26"), null);
|
||||||
|
assert.equal(at("2026-10-27").kind, "soon");
|
||||||
|
assert.match(at("2026-10-27").body, /in about 2 days, around Thu 29 Oct/);
|
||||||
|
assert.match(at("2026-10-28").body, /tomorrow/);
|
||||||
|
assert.equal(at("2026-10-29"), null, "due today is neither soon nor late");
|
||||||
|
assert.equal(at("2026-10-30").kind, "late");
|
||||||
|
assert.match(at("2026-10-30").body, /expected on Thu 29 Oct/);
|
||||||
|
assert.equal(at("2026-10-30").cycle, "2026-10-01");
|
||||||
|
assert.equal(push.dueReminder(cycle.summarize(days, "2026-10-27"), 1), null, "respects remind days");
|
||||||
|
assert.equal(push.dueReminder(cycle.summarize({}, "2026-10-27"), 2), null);
|
||||||
|
assert.equal(push.dueReminder(cycle.summarize(days, "2026-10-03"), 7), null, "nothing during a period");
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end: app.py creates the schema, `daily` sends one encrypted push
|
||||||
|
// to a fake push service, and a second run the same day sends nothing.
|
||||||
|
{
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "flowtrack-push-"));
|
||||||
|
const database = path.join(dir, "flowtrack.sqlite3");
|
||||||
|
execFileSync(process.env.PYTHON || "python3", ["-c", `import sys; sys.path.insert(0, ${JSON.stringify(path.join(__dirname, ".."))}); import app; app.Store(${JSON.stringify(database)}).initialize()`]);
|
||||||
|
|
||||||
|
const received = [];
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
const chunks = [];
|
||||||
|
request.on("data", chunk => chunks.push(chunk));
|
||||||
|
request.on("end", () => {
|
||||||
|
received.push({ url: request.url, headers: request.headers, body: Buffer.concat(chunks) });
|
||||||
|
response.writeHead(request.url.endsWith("/gone") ? 410 : 201).end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
|
||||||
|
const { publicKey } = await push.main(["init", "--database", database]);
|
||||||
|
assert.equal(b64url.decode(publicKey).length, 65);
|
||||||
|
assert.equal((await push.main(["init", "--database", database])).publicKey, publicKey, "keys are stable");
|
||||||
|
|
||||||
|
const phone = device();
|
||||||
|
const db = new DatabaseSync(database);
|
||||||
|
const subscribe = db.prepare("INSERT INTO push_subscriptions (endpoint, p256dh, auth, user, created_at) VALUES (?, ?, ?, 'kayla', 'now')");
|
||||||
|
subscribe.run(`${origin}/push/phone`, phone.p256dh, phone.authText);
|
||||||
|
subscribe.run(`${origin}/push/gone`, phone.p256dh, phone.authText);
|
||||||
|
const log = db.prepare("INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, 'medium', '[]', 'now')");
|
||||||
|
for (const day of Object.keys(logPeriods(["2026-08-06", "2026-09-03", "2026-10-01"]))) log.run(day);
|
||||||
|
|
||||||
|
const args = ["daily", "--database", database, "--contact", "https://flowtrack.example.org", "--today", "2026-10-27"];
|
||||||
|
const first = await push.main(args);
|
||||||
|
assert.deepEqual({ due: first.due, sent: first.sent, removed: first.removed }, { due: "soon", sent: 1, removed: 1 });
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 1, "410 drops the subscription");
|
||||||
|
|
||||||
|
const delivered = received.find(request => request.url === "/push/phone");
|
||||||
|
assert.equal(delivered.headers["content-encoding"], "aes128gcm");
|
||||||
|
assert.equal(delivered.headers.ttl, "86400");
|
||||||
|
const claims = verifyVapid(delivered.headers.authorization, publicKey, origin);
|
||||||
|
assert.equal(claims.sub, "https://flowtrack.example.org");
|
||||||
|
const payload = JSON.parse(decrypt(delivered.body, phone.receiver, phone.auth));
|
||||||
|
assert.equal(payload.title, "Period expected soon");
|
||||||
|
|
||||||
|
const again = await push.main(args);
|
||||||
|
assert.equal(again.alreadySent, true);
|
||||||
|
assert.equal(received.length, 2, "no second send");
|
||||||
|
|
||||||
|
const test = await push.main(["test", "--database", database, "--contact", "https://flowtrack.example.org"]);
|
||||||
|
assert.equal(test.sent, 1);
|
||||||
|
assert.equal(JSON.parse(decrypt(received[2].body, phone.receiver, phone.auth)).tag, "test");
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("push tests passed");
|
||||||
|
})().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue