Commit graph

414 commits

Author SHA1 Message Date
npeter83
63701f5344 refactor(auth): centralize token hashing, email validation & admin gating
- app/utils.py: shared valid_email() + now_utc() (one email regex, was duplicated
  in auth.py and admin.py with a looser "@"-in check elsewhere).
- security.hash_token(): one SHA-256 token hasher (was duplicated in auth.py + state.py).
- auth.admin_user dependency + count_admins() helper, replacing the inline role
  checks and the last-admin count query repeated across admin.py/me.py/tags.py.
2026-06-26 03:15:36 +02:00
npeter83
8ef748c7b8 fix(maintenance): drop dead status=="saved" scan
Watch later became a built-in playlist, so "saved" is no longer a VideoState
status — the scan matched nothing and under-counted users impacted by a video
removal. The playlist loop already covers Watch later, so impact is now correct.
Route the local _now through the shared utils.now_utc.
2026-06-26 03:15:36 +02:00
npeter83
7a97fef33c chore(release): 0.16.2 — access-requests UX + back-navigation fixes 2026-06-26 01:42:12 +02:00
npeter83
6621390cb1 Merge bug/access-review-tab — access-requests UX + general back-navigation fixes 2026-06-26 01:41:46 +02:00
npeter83
71f79fc73d fix(nav): Back steps through sub-views and closes modals before leaving a page
In-app history only tracked the top-level page, so Back from a module sub-view
(e.g. a Messages thread) or with a modal open jumped straight to the previous
module. Add two history primitives: useHistorySubview (a module's sub-view rides
in history.state, so Back returns to its root first) and useBackToClose (a
mounted overlay occupies one history entry; Back closes the topmost, nesting-safe
so a button-close doesn't trip the modals underneath). Apply to the Messages page
views and to PlayerModal + the shared Modal. setPage now pushes a clean entry so
each page starts at its root.
2026-06-26 01:37:43 +02:00
npeter83
ad77a1751e fix(notifications): access-requests nudge auto-clears on approval
The admin access-requests notice was issued from the on-user-load effect (keyed
on user id), so it only re-resolved on a fresh load — it lingered until F5 after
an approval dropped the pending count to 0. Move it to its own effect keyed on
the pending count, so approving/denying (which refetches /api/me) clears or
re-counts the notice live.
2026-06-26 01:19:01 +02:00
npeter83
de58ded923 fix(url): strip leftover query string once signed in
The pre-login ?access=requested (and ?verify/?reset) lingered in the address bar
after login. Strip the query string on auth (the logged-in app reads nothing from
the URL); Welcome still reads its params while logged out. stripUrlParams now
preserves history.state so it's safe to call after the in-app page stamp.
2026-06-26 01:13:26 +02:00
npeter83
04d3367c88 fix(notifications): Review link opens the Access requests tab, not the last-used one
The access-requests notice's Review link navigated to the Users page but the
persisted tab (often Demo) loaded instead. Pre-select the access tab before
navigating (focusAccessRequestsTab writes the persisted-tab key, which the page
reads on its fresh mount).
2026-06-26 01:05:14 +02:00
npeter83
6286dcc3c2 chore(release): 0.16.1 — notification context + YouTube API egress proxy 2026-06-26 00:36:46 +02:00
npeter83
ec1e8c6e4f Merge bug/notif-context-and-api-proxy — notification context + YouTube API egress proxy 2026-06-26 00:36:22 +02:00
npeter83
d047ed7e15 feat(youtube): optional egress proxy for YouTube API calls
New youtube_api_proxy setting (env fallback + admin Config UI, env YOUTUBE_API_PROXY):
when set, the YouTubeClient routes all its httpx traffic through that HTTP(S) proxy.
Lets a dynamic-IP host send API calls through a fixed-IP host (e.g. the server over
private tunnel) so an IP-restricted API key keeps working. Empty = direct.
2026-06-26 00:32:17 +02:00
npeter83
6c4c33f956 fix(notifications): name the entity in action toasts
Eight confirmations stated a bare fact without saying what they acted on. Pass
the entity through the mutation variables and interpolate it: channel
unsubscribe/reset name the channel, whitelist/demo-whitelist adds name the
email, tag delete names the tag, and playlist revert/push name the playlist.
EN/HU/DE.
2026-06-26 00:32:17 +02:00
npeter83
557f2cef9c chore(release): 0.16.0 — end-to-end encrypted real-time messaging 2026-06-26 00:01:54 +02:00
npeter83
da37eb6344 Merge feature/user-messaging — E2EE real-time direct messaging (epic 8 P2) 2026-06-26 00:01:17 +02:00
npeter83
70a117d0db fix(messages): make the dock new-message flash actually visible
The ring-based flash relied on ring-accent and a CSS transition that never
restarted on a repeat message, so it was effectively invisible. Replace it with
a keyed inset-glow overlay (animation replays on every flash counter bump, and
inset shadow isn't clipped by the window's overflow-hidden) — flashes whether
the window is expanded, freshly auto-opened, or minimised.
2026-06-26 00:00:21 +02:00
npeter83
963afa33a6 fix(messages): gate on server key state, not just local unlock
The Messages UI trusted only the per-device IndexedDB key, so if the server's
key record was gone (deleted, DB-restored, admin-reset) while a stale private
key lingered in the browser, the app looked 'unlocked' but no one could be
messaged and no setup was offered. Add useKeyState (server 'configured' AND
local unlock): show setup when the server has no key (setup overwrites the stale
local key), unlock when it has one this device hasn't opened, ready otherwise.
ChatThread is now self-contained. Also fix the header title on the Messages and
Notifications pages (was falling through to 'Channel manager').
2026-06-25 22:58:29 +02:00
npeter83
8392037e5f feat(messages): auto-open/flash dock on incoming + persist dock across reload
The live-message push now carries both parties, so an incoming message opens
that conversation's dock window if it's closed, or flashes it once if it's
already open (expanded or minimised, without disturbing the minimised state) —
only for messages from someone else, never your own echo.

Dock state (open windows + minimised state) is persisted per user, so a reload
restores exactly what was open, minimised, or closed.
2026-06-25 22:46:54 +02:00
npeter83
3fd71cd316 feat(messages): standalone Messages module + floating chat dock
Move messaging out of the notification center into its own left-nav module
(own page, own unread badge), so a reload returns to it and notifications stay
separate. Add a Messenger-style floating dock (bottom-right): pop a conversation
out from the page, keep chatting across navigation, minimise (rollup) or close.

- Messages is now a routed page; NotificationsPanel reverts to inbox-only and the
  nav badge no longer mixes in messages.
- Extract shared KeyGate + ChatThread (used by both the page and dock windows);
  e2ee exposes a shared unlock subscription so page and dock agree.
- ChatDock (always mounted for human users) owns the app-wide live-message
  subscription and per-device key restore. EN/HU/DE strings.
2026-06-25 22:34:24 +02:00
npeter83
2f0ca68e8a feat(messages): E2EE real-time messaging UI + system welcome
A Messages tab in the notification center (hidden for demo): set up secure
messaging with a passphrase (key generated + wrapped in-browser via WebCrypto,
stored non-extractable per device), unlock on other devices, then chat with
end-to-end encrypted, live-delivered messages. The server-authored Siftlode
welcome is readable before any key setup.

- lib/e2ee.ts: ECDH P-256 + HKDF + AES-GCM, PBKDF2-wrapped key, IndexedDB.
- lib/messagesSocket.ts: WebSocket client with backoff reconnect.
- Messages.tsx: key gate, conversation list with decrypted previews, directory,
  thread with client-side decrypt + encrypt-on-send. Unread feeds the nav badge.
  EN/HU/DE strings.
2026-06-25 22:05:35 +02:00
npeter83
002a79949b feat(messages): end-to-end encrypted real-time direct messaging backend
Private user-to-user messages are end-to-end encrypted: the server only ever
stores ciphertext + iv and acts as a key directory (public keys) plus an opaque
store for each user's private key, wrapped client-side with a passphrase the
server never sees — so not even an admin can read a conversation. A separate
kind=system message (plaintext, no sender) powers a server-authored Siftlode
welcome shown on first open, reusable later for announcements.

- models: rework Message (kind, nullable sender/body, ciphertext+iv) + MessageKey;
  migrations 0026 (table) + 0027 (E2EE rework).
- routes/messages.py: key directory/blob endpoints, ciphertext send, conversations
  + threads (system + user), lazy welcome, all gated by require_human.
- realtime.py: in-process WebSocket connection registry; /ws delivers sent
  messages to a user's open tabs instantly (sync-callable push, single-process).
2026-06-25 22:05:35 +02:00
npeter83
c84f5d5fe5 chore(release): 0.15.0 — feed virtualization + keyset pagination 2026-06-25 20:04:25 +02:00
npeter83
64a57ed29e Merge feature/feed-virtualization-keyset — feed virtualization + keyset pagination (epic 7) 2026-06-25 20:03:44 +02:00
npeter83
0058ba7ccf feat(feed): virtualize the feed list and consume the keyset cursor
Render only the visible rows via @tanstack/react-virtual (useVirtualizer
against the app's <main> scroll container, per-row dynamic measurement),
so the DOM no longer accumulates every loaded card. A ResizeObserver keeps
the responsive grid column count in sync and chunks cards into virtualized
rows; the list view virtualizes single-card rows. Infinite scroll now
triggers from the virtual range and pages via next_cursor instead of
offset; feed/count drops its unused paging args.
2026-06-25 19:54:40 +02:00
npeter83
424b19f3b6 feat(feed): keyset/cursor pagination replacing OFFSET/LIMIT
Replace deep-OFFSET paging (which scans-and-discards skipped rows on the
233k-row catalog and shifts/duplicates rows as the scheduler ingests mid-
scroll) with stable keyset pagination over an opaque cursor.

A single sort registry drives both the ORDER BY and a generic, NULL-aware
keyset WHERE so they can never drift, with Video.id as the unique final
tiebreaker. Covers every sort: newest/oldest, views, duration, title,
subscribers (joined), priority (coalesced multi-key) and the seeded
shuffle. The response now returns next_cursor instead of offset.
2026-06-25 19:54:40 +02:00
npeter83
d16505cdcd Merge feature/home-relocation into dev 2026-06-22 02:57:38 +02:00
npeter83
514c5d887b feat(ops): self-hosted deployment — app+db in a container behind a reverse proxy
Add docker-compose.home.yml: the app + Postgres run on strong, always-on home
hardware (nested Docker, apparmor:unconfined, Postgres tuned to keep the catalog
hot in cache), and the server reverse-proxies the hostname to it over a private
network link. This moves the heavy feed/count/facet scans off the server while
keeping it as the public TLS front door (the OAuth redirect URL is unchanged).

Retire the the server Forgejo Actions autodeploy (.forgejo/workflows/deploy.yml): the app
no longer runs on the server, so deploys go to the server from the dispatcher instead.
2026-06-22 02:57:38 +02:00
npeter83
231e0c708b chore: release 0.14.1 — finish subfeed→siftlode rename
Bump VERSION and add a release-notes entry for the rename cleanup so the
prod image is published under a fresh tag rather than overwriting 0.14.0.
2026-06-21 07:07:56 +02:00
npeter83
76a60af9db Merge branch 'chore/rename-subfeed-to-siftlode' into dev 2026-06-21 06:53:12 +02:00
npeter83
1ec89c8fbe chore: rename remaining subfeed references to siftlode
Replace the leftover 'subfeed' name across logger names + log_config,
frontend localStorage keys, Postgres user/db/volume defaults in the
compose files, .env.example, config.py, backup/restore scripts and the
README. Pure rename; no behavioural change. localStorage keys move from
subfeed.* to siftlode.* (one-time UI-state reset is acceptable).
2026-06-21 06:53:12 +02:00
npeter83
7cc54d02b7 Merge chore/remove-ci-workflow — drop the broken post-merge CI 2026-06-21 05:53:06 +02:00
npeter83
f932234892 ci: remove post-merge ci.yml workflow
It duplicated the developer-machine quality gate (tsc + npm build + compileall),
ran post-merge on main (not a pre-merge gate), and would put the heavy frontend
build on the server on every push — the very load deploy.yml deliberately
keeps on the dev box. The local pre-ship checks are the CI in this setup.
2026-06-21 05:53:06 +02:00
npeter83
0f5187a699 Merge feature/instant-deploy — event-driven prod deploy via Forgejo Actions 2026-06-21 05:26:24 +02:00
npeter83
73bffcbab6 ci(deploy): event-driven prod deploy via Forgejo Actions on push to main
On a push to main the server-hosted runner SSHes back to the host (forced-command
key, docker bridge gateway, port 2222) and runs deploy/deploy.sh — replacing the
2-minute systemd poller. Build/publish still happens on the dev machine before push.
2026-06-21 05:20:34 +02:00
npeter83
0f124dc4d3 chore(release): 0.14.0 2026-06-21 03:23:23 +02:00
npeter83
de79d0dcf2 Merge feature/install-wizard — first-run install wizard + self-host package (epic 6) 2026-06-21 03:20:59 +02:00
npeter83
c2a388e4ea fix(setup): hide YouTube/Google affordances when Google sign-in isn't configured
- /api/me exposes instance-wide google_enabled. The onboarding nudge no longer auto-opens, and
  Settings hides the YouTube-access section + the 'Connect Google' option, when the instance has
  no Google OAuth configured (e.g. an email+password-only self-host). A linked account still shows
  its 'Connected' status.
2026-06-21 02:06:37 +02:00
npeter83
97865c50cc feat(selfhost): one-command self-host package (epic 6d)
- docker-compose.selfhost.yml pulls the published image (forge.b1fr0st.eu/peter/siftlode) with a
  bundled Postgres — no source build on the operator's host.
- install.sh / install.ps1 generate the four required secrets (.env: POSTGRES_PASSWORD, SECRET_KEY,
  a valid Fernet TOKEN_ENCRYPTION_KEY, OAUTH_REDIRECT_URL), start the stack, and print the first-run
  setup-wizard URL. Everything else is configured in the web wizard.
- docs/self-hosting.md: the copy-paste playbook (get the files, run the installer, finish in the
  wizard, optional Google/SMTP, HTTPS/reverse-proxy, updates + backups).
- Verified end-to-end: a fresh install from the published image boots into setup mode and serves
  the wizard.
2026-06-21 01:42:18 +02:00
npeter83
6bbadbf32f feat(setup): install-wizard UI (epic 6c)
- Multi-step first-run wizard (intro → admin → Google → SMTP → finish), shown by App while the
  instance reports configured=false; the one-time token comes from the setup URL (?token=) and is
  sent as X-Setup-Token on every step. Google/SMTP steps appear only when secrets can be stored.
- App holds the 'me' query until setup status is known (so it doesn't 503 against the setup lock);
  a missing/invalid token shows a 'use the link from the logs' screen. EN/HU/DE.
2026-06-21 01:11:16 +02:00
npeter83
2689173208 feat(setup): install-wizard step endpoints (epic 6c, API)
- POST /api/setup/admin (create the first admin: active+verified+admin), /config (persist any
  registry-backed settings — Google creds, SMTP, quota…), /test-email, and /finish (mark
  configured + invalidate the token). All behind require_setup (valid token AND not configured).
- GET /api/setup/info reports secrets_manageable so the wizard hides the Google/SMTP secret steps
  on a box without TOKEN_ENCRYPTION_KEY.
2026-06-21 00:40:54 +02:00
npeter83
283c4c9a1e feat(setup): first-boot detection + setup-mode gating + one-time token (epic 6a)
- app_state gains configured + setup_token_hash (migration 0025); existing installs (any users)
  are marked configured so they never see the wizard, a fresh DB starts in setup mode.
- On first boot the app mints a one-time setup token and logs the wizard URL (only the hash is
  stored); the token gates the setup steps (added in 6c).
- A middleware locks all /api + /auth routes (except /api/setup, /api/version, /healthz, static)
  to 503 until configured, with a DB-free cached fast-path once setup completes.
- GET /api/setup/status tells the SPA whether to show the wizard.
2026-06-21 00:38:47 +02:00
npeter83
d7b7acb637 feat(auth): manage Google OAuth credentials from the database (epic 6b)
- Build the Google OAuth client lazily from sysconfig (DB override, env fallback) and re-register
  when the credentials change, so the install wizard / admin can set them without a restart.
- New 'google' config group (google_client_id/secret, encrypted) on the Configuration page; the
  token-refresh reads the creds the same way.
- Public GET /auth/config exposes google_enabled; the login page hides 'Continue with Google'
  when Google OAuth isn't configured (email+password still works).
2026-06-20 20:04:23 +02:00
npeter83
2d1d5c80ac chore(release): 0.13.0 2026-06-19 19:53:31 +02:00
npeter83
a9e1d31381 Merge feature/auth-5e-google-link — complete epic 5 (auth/account) + admin user management 2026-06-19 19:52:38 +02:00
npeter83
6db7014b08 feat(welcome): landing lightbox, prod-served screenshots & session-end UX
- Serve real files at the SPA root (e.g. /welcome/*.png, favicon) from the built app — the
  landing screenshots were only ever shown by the Vite dev server, never in production.
- Add the Channel-manager screenshot; the two secondary previews are smaller thumbnails that
  open in a custom full-size lightbox (fit-to-viewport, Esc/backdrop/✕ to close).
- Drop a suspended/deleted user to the login page on the next 401 (and poll the session only
  while signed in, so the public login page no longer flickers); 'suspended' and 'account
  deleted' confirmation banners; strip redirect query params for a clean address bar.
2026-06-19 19:52:29 +02:00
npeter83
3f9c395b17 feat(admin): user management — roles, suspend, delete + lifecycle emails
- New Users page tabs (Users & roles / Access requests / Demo) and a tab-ified Configuration
  page, both via a reusable Tabs component (persisted active tab).
- Admin can suspend/unsuspend (migration 0023 adds users.is_suspended) and delete accounts
  from the Users & roles tab, with guards (demo / self / last admin).
- Email notifications to the affected user: suspended (reactive, on a blocked sign-in),
  reinstated, role changed, and account deleted; the approval email now carries a clickable
  app link. The scheduled/manual demo reset also seeds sample channel subscriptions.
- Hide the 'unverified email' chip for Google accounts (Google attests the email).
2026-06-19 19:52:12 +02:00
npeter83
8c727dd99e feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing
- Link a Google account to a password account, and adopt the Google identity onto a matching
  email account instead of 500ing on a duplicate; set or change a password from Settings.
- Expose has_google/has_password on /api/me for the Sign-in methods UI.
- Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine
  login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin).
- Suspension login-gates (password + Google callback + current_user) with a rate-limited
  'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant
  revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
2026-06-19 19:52:02 +02:00
npeter83
72cd8e6d65 Merge fix/access-requests-notification
Correct the pending-access-requests nudge (points to Users now), add a Review jump
link, and keep it a singleton (no per-reload pile-up).
2026-06-19 16:10:08 +02:00
npeter83
ec78c48a17 fix(inbox): access-requests nudge points to Users + a jump link, no pile-up
The pending-access-requests notification still said "review in Settings -> Account",
but that moved to the new Users page in 5a. Update the text (EN/HU/DE), and give the
notification a durable inbox link ("Review" -> Users page) via a new access-requests
meta kind (persists across reload, unlike a live action callback). Also stop it
piling up: persisted notices reload as dismissed history, so the old dedupe never
matched and a fresh copy was added every load — now removeByMetaKind drops any prior
access-requests notice before re-issuing, keeping exactly one current nudge.
2026-06-19 16:10:08 +02:00
npeter83
105505f67e Merge feature/auth-5d-gdpr (GDPR account + data deletion)
Self-service DELETE /api/me/account (cascades all personal data; demo + last-admin
guarded) and a Danger zone in Settings -> Account.
2026-06-19 15:46:49 +02:00
npeter83
84b55c0567 feat(account): GDPR self-service account + data deletion (5d)
Add DELETE /api/me/account: permanently erases the signed-in account and all its
personal data — OAuth tokens, subscriptions, tags, video states, playlists,
notifications all cascade on the users row (FK ON DELETE CASCADE); quota-audit
events are kept but anonymised (SET NULL); the email's invite/whitelist row is
removed too. Guards: the shared demo account can't be deleted, and the last
remaining admin can't delete itself. Frontend: a Danger zone in Settings -> Account
(non-demo) with a danger-confirm; on success the session clears and the app lands
on the welcome page. EN/HU/DE. Verified via curl: self-delete + session clear +
demo 403.
2026-06-19 15:46:49 +02:00