Private
Public Access
Bump to v1.1.0 and bring all documentation current
Version bump in both pyproject.toml and package.json. Documentation update covers everything shipped since v1.0.0 (direct messages, message deletion, custom emoji, video attachments, DM/room email notifications, active sessions, and more), and corrects claims that had gone stale -- backend/README.md and DEPLOYMENT.md both still said "no server-side session revocation" and backend/README.md said "no custom/uploaded emoji," both now false. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+32
-5
@@ -28,7 +28,7 @@ database and Redis; the other runs the application and serves the frontend.
|
|||||||
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
|
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
|
||||||
| Reverse proxy / TLS | External Nginx Proxy Manager (pre-existing infra, not deployed by this project) | Terminates TLS, forwards REST + WebSocket traffic to the app server's one port. The app itself serves the built static frontend directly — no separate static-asset server needed |
|
| Reverse proxy / TLS | External Nginx Proxy Manager (pre-existing infra, not deployed by this project) | Terminates TLS, forwards REST + WebSocket traffic to the app server's one port. The app itself serves the built static frontend directly — no separate static-asset server needed |
|
||||||
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
|
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
|
||||||
| Auth | Session cookies (httpOnly, secure) | Simplest to carry through a WebSocket handshake automatically |
|
| Auth | Session cookies (httpOnly, secure), backed by a server-side session table | Cookie carries only an opaque session id — simplest way to carry auth through a WebSocket handshake automatically, while still allowing server-side revocation (see §4/§10) |
|
||||||
|
|
||||||
## 3. System architecture
|
## 3. System architecture
|
||||||
|
|
||||||
@@ -85,17 +85,31 @@ users
|
|||||||
id, username, email, password_hash, is_bot, is_site_admin, created_at
|
id, username, email, password_hash, is_bot, is_site_admin, created_at
|
||||||
|
|
||||||
rooms
|
rooms
|
||||||
id, name, description, is_private, owner_id, created_at
|
id, name, description, is_private, is_dm, is_archived, owner_id, created_at
|
||||||
|
|
||||||
room_memberships
|
room_memberships
|
||||||
room_id, user_id, role (owner | admin | member), joined_at
|
room_id, user_id, role (owner | admin | member), joined_at, last_read_at,
|
||||||
|
hidden_at (DM-only: hides it from one participant's sidebar),
|
||||||
|
email_notifications (opt-in, non-DM rooms only)
|
||||||
|
|
||||||
room_invites
|
room_invites
|
||||||
id, room_id, invited_by, token, target_user_id or target_email,
|
id, room_id, invited_by, token, target_user_id or target_email,
|
||||||
expires_at, status (pending | accepted | revoked)
|
expires_at, status (pending | accepted | revoked)
|
||||||
|
|
||||||
messages
|
messages
|
||||||
id, room_id, user_id, content, created_at, edited_at, deleted_at
|
id, room_id, user_id, content, image_id, file_id, created_at, edited_at,
|
||||||
|
deleted_at
|
||||||
|
|
||||||
|
message_reactions
|
||||||
|
id, message_id, user_id, emoji (unicode glyph or a custom emoji's
|
||||||
|
`:shortcode:`)
|
||||||
|
|
||||||
|
custom_emoji
|
||||||
|
id, shortcode (unique, site-wide), storage_filename, content_type,
|
||||||
|
uploaded_by, created_at
|
||||||
|
|
||||||
|
sessions
|
||||||
|
id, user_id, ip_address, user_agent, created_at, last_seen_at, revoked_at
|
||||||
|
|
||||||
push_subscriptions
|
push_subscriptions
|
||||||
id, user_id, endpoint, p256dh_key, auth_key, created_at
|
id, user_id, endpoint, p256dh_key, auth_key, created_at
|
||||||
@@ -114,6 +128,13 @@ admin_audit_log
|
|||||||
id, actor_id, action, target_type, target_id, metadata, created_at
|
id, actor_id, action, target_type, target_id, metadata, created_at
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Not shown above (present in the implementation, omitted here for brevity):
|
||||||
|
`message_images`, `message_files`, `message_mentions`,
|
||||||
|
`message_room_references`, `link_previews`, `custom_themes`,
|
||||||
|
`upload_settings`, `smtp_settings`, `site_invites`, `password_resets` — see
|
||||||
|
`backend/README.md` for the full, current model list and per-feature
|
||||||
|
detail on all of these.
|
||||||
|
|
||||||
## 5. Permission model
|
## 5. Permission model
|
||||||
|
|
||||||
- **Room visibility**: `open` (any authenticated user can find and join) or
|
- **Room visibility**: `open` (any authenticated user can find and join) or
|
||||||
@@ -133,7 +154,13 @@ admin_audit_log
|
|||||||
4. Every app instance subscribed to that channel forwards it to its own connected
|
4. Every app instance subscribed to that channel forwards it to its own connected
|
||||||
members over WebSocket.
|
members over WebSocket.
|
||||||
5. For members with no active connection, the server looks up
|
5. For members with no active connection, the server looks up
|
||||||
`push_subscriptions` and sends a Web Push notification via `pywebpush`.
|
`push_subscriptions` and sends a Web Push notification via `pywebpush`,
|
||||||
|
and a native desktop notification over the socket if the client is DS
|
||||||
|
Chat Desktop.
|
||||||
|
6. Separately, a genuinely-offline DM recipient always gets an email; a
|
||||||
|
regular room's member gets one too if they've opted in for that room
|
||||||
|
(first unread message, or any `@mention` regardless of debounce) — see
|
||||||
|
`backend/README.md`'s "Email notifications for missed messages".
|
||||||
|
|
||||||
## 7. Extension system: bots and AI agents
|
## 7. Extension system: bots and AI agents
|
||||||
|
|
||||||
|
|||||||
+9
-8
@@ -345,11 +345,12 @@ producing a gzipped `pg_dump` in `/var/backups/ds-chat/` with 14-day local
|
|||||||
rotation. Off-box shipping is a placeholder in that script (commented-out
|
rotation. Off-box shipping is a placeholder in that script (commented-out
|
||||||
rsync/S3 examples) — decide where those need to go and fill it in.
|
rsync/S3 examples) — decide where those need to go and fill it in.
|
||||||
|
|
||||||
That script covers Postgres only. Uploaded chat images and file attachments
|
That script covers Postgres only. Uploaded chat images, file/video
|
||||||
both live on the **app** server's disk (`/srv/ds-chat/uploads`, created in
|
attachments, avatars, and custom emoji all live on the **app** server's
|
||||||
§3a) — a separate machine from the data server this script runs on — and
|
disk (`/srv/ds-chat/uploads`, created in §3a) — a separate machine from
|
||||||
currently have no backup mechanism at all. Whatever off-box destination you
|
the data server this script runs on — and currently have no backup
|
||||||
pick above, include `/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
|
mechanism at all. Whatever off-box destination you pick above, include
|
||||||
|
`/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
|
||||||
line run from the app server).
|
line run from the app server).
|
||||||
|
|
||||||
**Test a restore** (against a scratch database, never directly onto
|
**Test a restore** (against a scratch database, never directly onto
|
||||||
@@ -389,13 +390,13 @@ Carried forward from earlier phases (see `backend/README.md`'s own "Notes /
|
|||||||
scope decisions" for the full detail on each):
|
scope decisions" for the full detail on each):
|
||||||
- No rate limiting on human or bot API traffic.
|
- No rate limiting on human or bot API traffic.
|
||||||
- No CSRF token (relies on `SameSite=Lax` cookies).
|
- No CSRF token (relies on `SameSite=Lax` cookies).
|
||||||
- No server-side session revocation (signed cookies only).
|
|
||||||
- SSRF protection on outgoing webhooks is creation-time only, not
|
- SSRF protection on outgoing webhooks is creation-time only, not
|
||||||
re-validated per delivery (DNS-rebinding gap).
|
re-validated per delivery (DNS-rebinding gap).
|
||||||
- Backup off-box shipping is a placeholder — decide a destination and fill
|
- Backup off-box shipping is a placeholder — decide a destination and fill
|
||||||
in `deploy/backup-postgres.sh`.
|
in `deploy/backup-postgres.sh`.
|
||||||
- Uploaded chat images and file attachments (`/srv/ds-chat/uploads` on the
|
- Uploaded chat images, file/video attachments, and custom emoji
|
||||||
app server) have no backup coverage at all yet, on-box or off — see §7.
|
(`/srv/ds-chat/uploads` on the app server) have no backup coverage at
|
||||||
|
all yet, on-box or off — see §7.
|
||||||
- Uploaded-but-never-sent images or files (a user attaches one, then never
|
- Uploaded-but-never-sent images or files (a user attaches one, then never
|
||||||
hits Send) leak an orphaned file on disk — no cleanup job for this yet.
|
hits Send) leak an orphaned file on disk — no cleanup job for this yet.
|
||||||
Not a security issue (still gated by room membership to view), just an
|
Not a security issue (still gated by room membership to view), just an
|
||||||
|
|||||||
@@ -18,21 +18,26 @@ build plan.
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Auth & accounts** — session-based auth, invite-only signup (admin-issued
|
- **Auth & accounts** — server-side, revocable sessions (see every device
|
||||||
site invites or room invites, both delivered by email), password reset,
|
you're logged in from and sign one out remotely), invite-only signup
|
||||||
per-user light/dark/midnight/sunset presets plus a live theme builder for
|
(admin-issued site invites or room invites, both delivered by email),
|
||||||
fully custom, named, savable color themes.
|
password reset, per-user light/dark/midnight/sunset presets plus a live
|
||||||
- **Rooms** — open and private rooms, owner/admin/member roles, invites,
|
theme builder for fully custom, named, savable color themes.
|
||||||
room browsing/search, file/image galleries per room.
|
- **Rooms & direct messages** — open and private rooms, owner/admin/member
|
||||||
|
roles, invites (with resend), room browsing/search, file/image galleries
|
||||||
|
per room, plus 1:1 direct messages with a collapsible sidebar section.
|
||||||
- **Real-time chat** — WebSocket-based messaging with automatic reconnect
|
- **Real-time chat** — WebSocket-based messaging with automatic reconnect
|
||||||
and backoff, Markdown rendering, @mentions with autocomplete and inline
|
and backoff, Markdown rendering (headings with custom anchors, sub/
|
||||||
highlighting, emoji reactions, message editing, image and file
|
superscript, tables, and more), @mentions and #room-reference links with
|
||||||
attachments (drag-and-drop, paste, or picker) with inline previews for
|
autocomplete and inline highlighting, emoji reactions and shortcodes plus
|
||||||
images/PDFs/text/Markdown, unread indicators, and presence (online/away/
|
site-wide custom/uploaded emoji, message editing and deletion, image/
|
||||||
|
video/file attachments (drag-and-drop, paste, or picker) with inline
|
||||||
|
previews and playback, unread indicators, and presence (online/away/
|
||||||
offline, with a manual "appear offline" override).
|
offline, with a manual "appear offline" override).
|
||||||
- **Notifications** — Web Push for offline/backgrounded members, with
|
- **Notifications** — Web Push and native desktop notifications for
|
||||||
per-type opt-in/out (mentions vs. all messages), plus in-app unread
|
offline/backgrounded members, plus email: always-on for direct messages,
|
||||||
badges.
|
opt-in per room (first unread message and every mention), all alongside
|
||||||
|
in-app unread badges.
|
||||||
- **PWA** — installable, offline-capable (cached room/message data, a
|
- **PWA** — installable, offline-capable (cached room/message data, a
|
||||||
dedicated offline banner), with automatic update detection that prompts
|
dedicated offline banner), with automatic update detection that prompts
|
||||||
a reload as soon as a new deploy goes live.
|
a reload as soon as a new deploy goes live.
|
||||||
|
|||||||
+59
-9
@@ -7,15 +7,17 @@ attachments, notifications, and personalizing your account.
|
|||||||
|
|
||||||
- [Getting started](#getting-started)
|
- [Getting started](#getting-started)
|
||||||
- [Rooms](#rooms)
|
- [Rooms](#rooms)
|
||||||
|
- [Direct messages](#direct-messages)
|
||||||
- [Sending messages](#sending-messages)
|
- [Sending messages](#sending-messages)
|
||||||
- [Formatting](#formatting)
|
- [Formatting](#formatting)
|
||||||
- [Mentions and room links](#mentions-and-room-links)
|
- [Mentions and room links](#mentions-and-room-links)
|
||||||
- [Attachments](#attachments)
|
- [Attachments](#attachments)
|
||||||
- [Reactions](#reactions)
|
- [Reactions and custom emoji](#reactions-and-custom-emoji)
|
||||||
- [Editing a message](#editing-a-message)
|
- [Editing and deleting a message](#editing-and-deleting-a-message)
|
||||||
- [Presence and notifications](#presence-and-notifications)
|
- [Presence and notifications](#presence-and-notifications)
|
||||||
- [Your profile](#your-profile)
|
- [Your profile](#your-profile)
|
||||||
- [Room details](#room-details)
|
- [Room details](#room-details)
|
||||||
|
- [Active sessions](#active-sessions)
|
||||||
- [Staying up to date](#staying-up-to-date)
|
- [Staying up to date](#staying-up-to-date)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
@@ -45,6 +47,21 @@ search box at the top to filter by name.
|
|||||||
- **Unread indicators**: a room with new activity shows a dot next to its
|
- **Unread indicators**: a room with new activity shows a dot next to its
|
||||||
name — a plain dot for unread messages, a highlighted dot if you were
|
name — a plain dot for unread messages, a highlighted dot if you were
|
||||||
specifically @mentioned.
|
specifically @mentioned.
|
||||||
|
- **Collapsible sections**: the **Direct Messages** and **Rooms** headers in
|
||||||
|
the sidebar can be collapsed to hide their contents — click the header to
|
||||||
|
toggle. Your choice is remembered.
|
||||||
|
|
||||||
|
## Direct messages
|
||||||
|
|
||||||
|
Click **People** to see everyone on the site and start a 1:1 conversation
|
||||||
|
with someone — it opens (or reopens, if you've messaged them before)
|
||||||
|
under **Direct Messages** in the sidebar. There's only ever one
|
||||||
|
conversation per pair of people, however many times you start it.
|
||||||
|
|
||||||
|
You can hide a conversation you're done with (its own menu, or from its
|
||||||
|
**Room details** panel) without deleting anything — it drops out of your
|
||||||
|
sidebar but reappears automatically the moment the other person sends a
|
||||||
|
new message, or if you message them again yourself.
|
||||||
|
|
||||||
## Sending messages
|
## Sending messages
|
||||||
|
|
||||||
@@ -61,7 +78,9 @@ Messages support Markdown:
|
|||||||
- `-` or `1.` for bulleted/numbered lists
|
- `-` or `1.` for bulleted/numbered lists
|
||||||
- `[link text](https://example.com)` — or just paste a bare URL and it
|
- `[link text](https://example.com)` — or just paste a bare URL and it
|
||||||
becomes clickable automatically
|
becomes clickable automatically
|
||||||
- `#`, `##`, `###` for headings
|
- `#`, `##`, `###` for headings — add `{#custom-id}` at the end of a
|
||||||
|
heading line to control its link anchor instead of the auto-generated one
|
||||||
|
- `~sub~` and `^sup^` for subscript and superscript
|
||||||
|
|
||||||
Pasting a link on its own often also generates a preview card underneath
|
Pasting a link on its own often also generates a preview card underneath
|
||||||
your message, pulled from that page's title/description/image, when the
|
your message, pulled from that page's title/description/image, when the
|
||||||
@@ -69,7 +88,9 @@ page provides one.
|
|||||||
|
|
||||||
Emoji: click the 🙂 button in the composer to open the emoji picker, or
|
Emoji: click the 🙂 button in the composer to open the emoji picker, or
|
||||||
type a shortcode like `:tada:` and it's converted automatically once you
|
type a shortcode like `:tada:` and it's converted automatically once you
|
||||||
send. The picker remembers your recently-used emoji and has a search box.
|
send. The picker remembers your recently-used emoji and has a search box —
|
||||||
|
see [Reactions and custom emoji](#reactions-and-custom-emoji) for uploading
|
||||||
|
your own.
|
||||||
|
|
||||||
## Mentions and room links
|
## Mentions and room links
|
||||||
|
|
||||||
@@ -87,26 +108,39 @@ can see the message (and is a member of that room) straight to it.
|
|||||||
|
|
||||||
Click the paperclip icon to attach a file, or just drag a file onto the
|
Click the paperclip icon to attach a file, or just drag a file onto the
|
||||||
message box and drop it. Images show as an inline thumbnail — click one
|
message box and drop it. Images show as an inline thumbnail — click one
|
||||||
to view it full-size. Other files show as a small card with the
|
to view it full-size. Common video formats (MP4, WebM, Ogg) play inline
|
||||||
filename and size; `.txt`, `.md`, and `.pdf` files open in a preview
|
too, with a button to expand to a larger view; other files show as a
|
||||||
without leaving the room, everything else downloads when clicked.
|
small card with the filename and size — `.txt`, `.md`, and `.pdf` files
|
||||||
|
open in a preview without leaving the room, everything else downloads
|
||||||
|
when clicked.
|
||||||
|
|
||||||
There's a server-configured maximum file size — if a file is too large,
|
There's a server-configured maximum file size — if a file is too large,
|
||||||
you'll see an error before it uploads.
|
you'll see an error before it uploads.
|
||||||
|
|
||||||
## Reactions
|
## Reactions and custom emoji
|
||||||
|
|
||||||
Hover over a message and click the 🙂 icon in its action row to react
|
Hover over a message and click the 🙂 icon in its action row to react
|
||||||
with an emoji. Reactions from everyone appear as small pills under the
|
with an emoji. Reactions from everyone appear as small pills under the
|
||||||
message with a count; click an existing pill to add or remove your own
|
message with a count; click an existing pill to add or remove your own
|
||||||
reaction to it. Hovering a pill shows who reacted.
|
reaction to it. Hovering a pill shows who reacted.
|
||||||
|
|
||||||
## Editing a message
|
Anyone can add a custom emoji: open the emoji picker (the 🙂 button, either
|
||||||
|
in the composer or on a message) and click **+ Add** in the **Custom**
|
||||||
|
section. Give it a short name and an image — it's then usable by everyone,
|
||||||
|
both as a reaction and inline in message text via `:your-name:`, right
|
||||||
|
alongside the built-in picker. You can remove a custom emoji you uploaded
|
||||||
|
(or any of them, if you're a site admin) from the same picker.
|
||||||
|
|
||||||
|
## Editing and deleting a message
|
||||||
|
|
||||||
You can edit any message you sent: hover it and click **Edit**, make your
|
You can edit any message you sent: hover it and click **Edit**, make your
|
||||||
changes, then press **Enter** to save or **Escape** to cancel. Clicking
|
changes, then press **Enter** to save or **Escape** to cancel. Clicking
|
||||||
away also saves. Edited messages are marked *(edited)*.
|
away also saves. Edited messages are marked *(edited)*.
|
||||||
|
|
||||||
|
To delete a message you sent, hover it and click **Delete**. It's replaced
|
||||||
|
with a "message deleted" placeholder rather than disappearing outright, so
|
||||||
|
the conversation doesn't visibly shift for anyone else reading it.
|
||||||
|
|
||||||
## Presence and notifications
|
## Presence and notifications
|
||||||
|
|
||||||
Everyone's avatar shows a small status dot — green for online, grey for
|
Everyone's avatar shows a small status dot — green for online, grey for
|
||||||
@@ -124,6 +158,12 @@ The same menu has a notifications toggle:
|
|||||||
get a notification whenever the app is minimized *or* simply not the
|
get a notification whenever the app is minimized *or* simply not the
|
||||||
focused window, even if it's still open somewhere on screen.
|
focused window, even if it's still open somewhere on screen.
|
||||||
|
|
||||||
|
You'll also get an email if someone messages you in a direct conversation
|
||||||
|
while you're genuinely offline — no setup needed. For regular rooms,
|
||||||
|
email is opt-in per room: open a room's **Room details** panel and turn
|
||||||
|
on **Email notifications** to get emailed on that room's first unread
|
||||||
|
message and on every `@mention`, while you're offline.
|
||||||
|
|
||||||
## Your profile
|
## Your profile
|
||||||
|
|
||||||
Open your account menu and choose **Profile settings** to:
|
Open your account menu and choose **Profile settings** to:
|
||||||
@@ -135,12 +175,22 @@ Open your account menu and choose **Profile settings** to:
|
|||||||
live)
|
live)
|
||||||
- Change your password
|
- Change your password
|
||||||
|
|
||||||
|
## Active sessions
|
||||||
|
|
||||||
|
Profile settings also lists **Active sessions** — every device/browser
|
||||||
|
currently logged into your account, with its approximate location (IP
|
||||||
|
address) and when it was last active. If you see one you don't
|
||||||
|
recognize, click **Revoke** to sign it out immediately. Revoking your own
|
||||||
|
current device signs you out too.
|
||||||
|
|
||||||
## Room details
|
## Room details
|
||||||
|
|
||||||
Click the info icon in a room's header to open its details panel, where
|
Click the info icon in a room's header to open its details panel, where
|
||||||
you can:
|
you can:
|
||||||
|
|
||||||
- See who else is in the room and their role (member/admin/owner)
|
- See who else is in the room and their role (member/admin/owner)
|
||||||
|
- Turn on **Email notifications** for that room (see
|
||||||
|
[Presence and notifications](#presence-and-notifications))
|
||||||
- Browse and re-download every file and image ever shared in the room,
|
- Browse and re-download every file and image ever shared in the room,
|
||||||
without scrolling back through history
|
without scrolling back through history
|
||||||
- Leave the room — unless you're the owner, in which case ownership has
|
- Leave the room — unless you're the owner, in which case ownership has
|
||||||
|
|||||||
+238
-50
@@ -1,18 +1,22 @@
|
|||||||
# DS Chat backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset)
|
# DS Chat backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset, direct messages, active sessions)
|
||||||
|
|
||||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth
|
||||||
CRUD (open and private), room roles (owner/admin/member) and direct
|
(server-side, revocable sessions — see Active sessions below), room CRUD
|
||||||
membership management, a WebSocket chat endpoint that fans out across
|
(open and private), room roles (owner/admin/member) and direct membership
|
||||||
multiple app-server instances via Redis pub/sub, Web Push notifications for
|
management, direct messages, a WebSocket chat endpoint that fans out across
|
||||||
offline room members, a site-admin portal (user/room/bot management + an
|
multiple app-server instances via Redis pub/sub, Web Push and email
|
||||||
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
|
notifications for offline room members (plus a native desktop-notification
|
||||||
access, incoming and outgoing webhooks, message editing), image uploads and
|
bridge for DS Chat Desktop), a site-admin portal (user/room/bot management +
|
||||||
generic file attachments in chat messages, emoji reactions on messages,
|
an audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
|
||||||
self-service user profiles
|
access, incoming and outgoing webhooks, message editing), image uploads,
|
||||||
(display name, avatar), self-service password change and a token-based
|
inline-playable video attachments, and generic file attachments in chat
|
||||||
forgot-password flow, and admin-issued email invites for new accounts
|
messages, message deletion, emoji reactions (built-in Unicode plus
|
||||||
plus email notifications when a user is added to a room. See
|
site-wide custom/uploaded emoji, both usable in reactions and inline in
|
||||||
`../ARCHITECTURE.md` for the full system design and the phased build plan.
|
message text), self-service user profiles (display name, avatar),
|
||||||
|
self-service password change and a token-based forgot-password flow, and
|
||||||
|
admin-issued email invites for new accounts plus email notifications when a
|
||||||
|
user is added to a room. See `../ARCHITECTURE.md` for the full system
|
||||||
|
design and the phased build plan.
|
||||||
|
|
||||||
This is an **invite-only site**: there is no public registration endpoint.
|
This is an **invite-only site**: there is no public registration endpoint.
|
||||||
Accounts are created by an operator on the app server — see step 4 below.
|
Accounts are created by an operator on the app server — see step 4 below.
|
||||||
@@ -129,10 +133,14 @@ app/
|
|||||||
on-disk save/read -- see Image uploads below
|
on-disk save/read -- see Image uploads below
|
||||||
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
|
||||||
models/ SQLAlchemy models (users, rooms, room_memberships,
|
models/ SQLAlchemy models (users, rooms, room_memberships,
|
||||||
messages, message_images, message_reactions,
|
messages, message_images, message_files,
|
||||||
|
message_reactions, message_mentions,
|
||||||
|
message_room_references, link_previews,
|
||||||
|
custom_emoji, custom_themes, sessions,
|
||||||
site_invites, password_resets, smtp_settings,
|
site_invites, password_resets, smtp_settings,
|
||||||
push_subscriptions, admin_audit_log, api_tokens,
|
upload_settings, push_subscriptions,
|
||||||
webhooks_incoming, event_subscriptions)
|
admin_audit_log, api_tokens, webhooks_incoming,
|
||||||
|
event_subscriptions)
|
||||||
schemas/ Pydantic request/response models
|
schemas/ Pydantic request/response models
|
||||||
routers/ auth, rooms, users, signup, push, admin,
|
routers/ auth, rooms, users, signup, push, admin,
|
||||||
bots, webhooks, health
|
bots, webhooks, health
|
||||||
@@ -345,6 +353,36 @@ No `User`/`PushSubscription` schema change was needed for this feature —
|
|||||||
the only backend change is the new `desktop_notification` envelope type,
|
the only backend change is the new `desktop_notification` envelope type,
|
||||||
covered by `backend/tests/test_desktop_notifications.py`.
|
covered by `backend/tests/test_desktop_notifications.py`.
|
||||||
|
|
||||||
|
## Email notifications for missed messages
|
||||||
|
|
||||||
|
Two related, separately-scoped email triggers layered on top of Web Push/
|
||||||
|
desktop notifications, both in `app/services/message_events.py`:
|
||||||
|
|
||||||
|
**DMs** (`_maybe_email_dm_notification`) — always on, no opt-in toggle.
|
||||||
|
Emails a DM's other participant when they're *genuinely* offline
|
||||||
|
(`GlobalPresence.is_online`, not just "not connected to this room's own
|
||||||
|
channel" the way the Web Push/desktop-notification audience is computed —
|
||||||
|
someone actively using the app in a different room shouldn't get emailed
|
||||||
|
for a DM) or have `appear_offline` set. Debounced to the first unread
|
||||||
|
message in the conversation, not one email per message in a burst, by
|
||||||
|
checking whether any other unread message already exists in the room
|
||||||
|
since the recipient's `last_read_at`.
|
||||||
|
|
||||||
|
**Regular rooms** (`_maybe_email_room_notifications`) — opt-in per
|
||||||
|
member, per room (`RoomMembership.email_notifications`, toggled via `PATCH
|
||||||
|
/api/rooms/{id}/notifications`; rejected for a DM — `CannotModifyDmError`
|
||||||
|
→ 400 — since DMs already get the always-on behavior above). Two triggers,
|
||||||
|
not one: the room's first unread message debounces the same way DMs do,
|
||||||
|
*but* a message that `@mentions` the subscriber always emails regardless
|
||||||
|
of that debounce — a mention is a stronger, individually-addressed signal
|
||||||
|
that shouldn't get silently absorbed by an earlier plain message in the
|
||||||
|
same burst already having used up the "first unread" email.
|
||||||
|
|
||||||
|
Both paths go through the same `email_service.send_email` used by
|
||||||
|
invites/room-membership notifications above, so they're silently skipped
|
||||||
|
if SMTP isn't configured and never block message delivery on an SMTP
|
||||||
|
outage.
|
||||||
|
|
||||||
## Room roles and membership (Phase 2)
|
## Room roles and membership (Phase 2)
|
||||||
|
|
||||||
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
|
Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or
|
||||||
@@ -365,6 +403,47 @@ since nothing meaningful was gained by making the target confirm first.
|
|||||||
`GET /api/rooms/mine` lists every room (open + private) the current user
|
`GET /api/rooms/mine` lists every room (open + private) the current user
|
||||||
belongs to, alongside their role.
|
belongs to, alongside their role.
|
||||||
|
|
||||||
|
## Direct messages
|
||||||
|
|
||||||
|
A DM is a `Room` with `is_dm=True` and a deterministic, never-shown
|
||||||
|
internal `name` (`dm_room_name(user_a, user_b)` — the two user ids sorted
|
||||||
|
and joined, so it's the same string regardless of who initiates), not a
|
||||||
|
separate model — `POST /api/rooms/dm` (`find_or_create_dm`) looks up an
|
||||||
|
existing DM by that name and creates one (`is_private=True`, both
|
||||||
|
participants as plain `member`) only if none exists yet, so starting a DM
|
||||||
|
with the same person twice always resolves to the one conversation. The
|
||||||
|
frontend never renders `Room.name` for a DM; `MyRoomItem.dm_partner`
|
||||||
|
(`DmPartnerInfo`: the *other* participant's id/username/display
|
||||||
|
name/avatar/status) is precomputed server-side instead, batched per
|
||||||
|
request rather than N+1.
|
||||||
|
|
||||||
|
**Hiding a DM**: `RoomMembership.hidden_at` lets one participant remove a
|
||||||
|
DM from their own sidebar without touching the other participant's copy or
|
||||||
|
deleting anything — a DM has no sensible "leave" (it would violate
|
||||||
|
`find_or_create_dm`'s exactly-two-members assumption). `POST
|
||||||
|
/api/rooms/{id}/hide` sets it; it's cleared automatically (un-hiding the
|
||||||
|
DM) whenever a new message arrives in it or `find_or_create_dm` resolves
|
||||||
|
back to an already-hidden one — both count as the conversation being
|
||||||
|
active again, matching how a re-opened DM in Slack/Discord reappears on
|
||||||
|
its own rather than needing an explicit "unhide."
|
||||||
|
|
||||||
|
## Message deletion
|
||||||
|
|
||||||
|
`DELETE`-shaped over WS (`{"type": "delete", "room_id", "message_id"}`,
|
||||||
|
`message_service.delete_message`) — author-only (`NotMessageAuthorError` →
|
||||||
|
error frame otherwise, no admin/moderator override yet). A real delete of
|
||||||
|
content, not a UI-only hide: `content`, `image_id`, `file_id`, and
|
||||||
|
`preview_url` are all cleared and any attached `MessageImage`/`MessageFile`
|
||||||
|
row (plus its on-disk file) is actually removed, only `deleted_at` (and
|
||||||
|
`id`/`room_id`/`user_id`/`created_at`, so the tombstone still occupies its
|
||||||
|
place in history) survives. The attachment's storage filename is read and
|
||||||
|
the DB row/file only unlinked *after* a successful commit — same ordering
|
||||||
|
`delete_room` already uses, so a rolled-back transaction never leaves an
|
||||||
|
already-destroyed file with no way back. Broadcasts
|
||||||
|
`{"type": "message_deleted", "id", "room_id"}`; the frontend renders a
|
||||||
|
"message deleted" placeholder rather than removing the row, so the
|
||||||
|
conversation doesn't visibly shift when someone deletes something above.
|
||||||
|
|
||||||
## Image uploads
|
## Image uploads
|
||||||
|
|
||||||
A message can carry an image (`Message.image_id`, nullable), a caption
|
A message can carry an image (`Message.image_id`, nullable), a caption
|
||||||
@@ -432,6 +511,18 @@ share them; `ImageTooLargeError` was likewise renamed to
|
|||||||
|
|
||||||
Same orphaned-upload disk-space caveat as images applies here too.
|
Same orphaned-upload disk-space caveat as images applies here too.
|
||||||
|
|
||||||
|
**Inline video playback**: a browser-natively-playable video attachment
|
||||||
|
(`INLINE_SAFE_VIDEO_CONTENT_TYPES` in `app/storage.py` — a strict allowlist,
|
||||||
|
`video/mp4`/`video/webm`/`video/ogg`, deliberately not "every `video/*`
|
||||||
|
type") is served *without* the `filename=` param above, so it plays inline
|
||||||
|
in a `<video>` tag instead of forcing a download — the same reasoning
|
||||||
|
`MessageImage`'s own always-inline endpoint already relies on: these are
|
||||||
|
content types a browser only ever interprets as media, never as something
|
||||||
|
that could execute script, so the `Content-Disposition: attachment`
|
||||||
|
mitigation doesn't need to apply to them. Anything outside that allowlist
|
||||||
|
(e.g. `.mov`/`video/quicktime`) still forces a download like any other
|
||||||
|
file.
|
||||||
|
|
||||||
## Upload size limits
|
## Upload size limits
|
||||||
|
|
||||||
The 8 MB image/file/avatar cap is no longer hardcoded — it's an
|
The 8 MB image/file/avatar cap is no longer hardcoded — it's an
|
||||||
@@ -462,14 +553,17 @@ returning `None`.
|
|||||||
|
|
||||||
## Emoji & reactions
|
## Emoji & reactions
|
||||||
|
|
||||||
An emoji picker in the frontend composer is purely client-side (a static
|
The built-in emoji picker in the frontend composer is purely client-side (a
|
||||||
curated unicode list, no backend involvement). Message **reactions** are
|
static curated unicode list, no backend involvement). Message **reactions**
|
||||||
full-stack: `message_reactions` (`app/models/message_reaction.py`) has
|
are full-stack: `message_reactions` (`app/models/message_reaction.py`) has
|
||||||
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
|
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
|
||||||
backing toggle semantics — the same user reacting with the same emoji on
|
backing toggle semantics — the same user reacting with the same emoji on
|
||||||
the same message twice removes it (Slack/Mattermost convention).
|
the same message twice removes it (Slack/Mattermost convention).
|
||||||
`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
|
`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
|
||||||
no upsert needed.
|
no upsert needed. `emoji` is `String(32)`, sized to hold either a raw
|
||||||
|
unicode glyph or a custom emoji's `:shortcode:` reference (see Custom emoji
|
||||||
|
below) — the WS reaction envelope's own length check matches this exactly,
|
||||||
|
not an arbitrary smaller cap.
|
||||||
|
|
||||||
WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
|
WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
|
||||||
reaction; the server broadcasts the message's **full recomputed** reaction
|
reaction; the server broadcasts the message's **full recomputed** reaction
|
||||||
@@ -482,8 +576,42 @@ reload doesn't lose reaction state that only ever arrived over WS.
|
|||||||
|
|
||||||
Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
|
Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
|
||||||
in `webhook_service.py` is unchanged — same restraint as image uploads), no
|
in `webhook_service.py` is unchanged — same restraint as image uploads), no
|
||||||
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
|
reaction-count limit or rate limiting.
|
||||||
only, curated client-side list in `frontend/src/lib/emoji.ts`).
|
|
||||||
|
## Custom emoji
|
||||||
|
|
||||||
|
Site-wide (not room-scoped), uploadable by any authenticated user —
|
||||||
|
distinct from the built-in Unicode picker above. `CustomEmoji`
|
||||||
|
(`app/models/custom_emoji.py`): `shortcode` (unique, 30 chars max — sized
|
||||||
|
so a `:shortcode:` reference fits `MessageReaction.emoji`'s column
|
||||||
|
alongside its own colons with zero width change), `storage_filename`,
|
||||||
|
`content_type`, `uploaded_by`.
|
||||||
|
|
||||||
|
- `POST /api/custom-emoji` (multipart: `shortcode` form field + `file`) —
|
||||||
|
reuses `app/storage.py`'s upload primitives (`read_capped`,
|
||||||
|
`process_image(..., square=True, max_dimension=128)`, `save_file`), same
|
||||||
|
pattern as avatars. Shortcode format (`^[a-z0-9_-]{2,30}$`) and
|
||||||
|
uniqueness are checked *before* processing/saving the image, so a
|
||||||
|
rejected upload never orphans a file on disk.
|
||||||
|
- `GET /api/custom-emoji` — full list, any authenticated user.
|
||||||
|
- `DELETE /api/custom-emoji/{id}` — the uploader or a site admin only
|
||||||
|
(`NotEmojiOwnerError` → 403 otherwise).
|
||||||
|
- `GET /api/custom-emoji/{shortcode}/image` — serves the file,
|
||||||
|
`Cache-Control: private, no-cache` (not `immutable`, and deliberately
|
||||||
|
*not* a long `max-age` either — a shortcode can be deleted and
|
||||||
|
re-uploaded with different image data under the same URL, and a timed
|
||||||
|
cache let a browser keep serving the old image for its full duration
|
||||||
|
after that happened; `no-cache` forces revalidation on every use, still
|
||||||
|
cheap since `FileResponse`'s own `ETag`/`Last-Modified` make an
|
||||||
|
unchanged file a 304, not a full re-transfer).
|
||||||
|
|
||||||
|
A `:shortcode:` reference is stored/sent as literal text everywhere (message
|
||||||
|
content, reaction values) and resolved to an image only at render time on
|
||||||
|
the frontend — the same convention the built-in Unicode shortcode
|
||||||
|
autocomplete already used for glyphs, extended to a case with no unicode
|
||||||
|
codepoint to substitute. No server-side collision check against the ~950
|
||||||
|
built-in shortcode names (that list only exists in the frontend); the
|
||||||
|
upload UI warns about a colliding name but doesn't hard-block it.
|
||||||
|
|
||||||
## User profiles
|
## User profiles
|
||||||
|
|
||||||
@@ -530,15 +658,21 @@ the Admin portal; being added directly to a room (see Room roles and
|
|||||||
membership above) sends a "you've been added" email too.
|
membership above) sends a "you've been added" email too.
|
||||||
|
|
||||||
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
|
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
|
||||||
`send_email(db, to, subject, body)` is the fire-and-forget path used by
|
`send_email(db, to, subject, paragraphs, *, cta_label=None, cta_url=None,
|
||||||
invite flows — if `SmtpSettings` isn't configured yet it logs at debug and
|
theme_user=None)` is the fire-and-forget path used by invite/notification
|
||||||
returns (same "silently skip if unconfigured" UX push notifications already
|
flows — if `SmtpSettings` isn't configured yet it logs (at `.warning`, not
|
||||||
use for a missing VAPID key), and it never raises on delivery failure (an
|
`.debug` — this app has no logging config lowering the root level below
|
||||||
SMTP outage must not block an invite/membership action that already
|
Python's own `WARNING` default, so anything below that is silently
|
||||||
succeeded in the database). `send_test_email(db, to)` is the one exception —
|
invisible in production) and returns, and it never raises on delivery
|
||||||
used only by the admin "send test email" button, it raises so the UI can
|
failure (an SMTP outage must not block an invite/membership/notification
|
||||||
show *why* it failed instead of a silent no-op. Plain-text bodies only, no
|
action that already succeeded in the database). `send_test_email(db, to)`
|
||||||
HTML templates, matching this codebase's existing minimalism.
|
is the one exception — used only by the admin "send test email" button, it
|
||||||
|
raises so the UI can show *why* it failed instead of a silent no-op.
|
||||||
|
`paragraphs` (a `list[str]`, not a flat `body: str`) renders both an HTML
|
||||||
|
email — styled with `theme_user`'s own selected theme palette when given,
|
||||||
|
falling back to the default palette — and a plain-text fallback part from
|
||||||
|
the same source, rather than a single pre-formatted string that can't
|
||||||
|
cleanly become HTML without re-parsing it.
|
||||||
|
|
||||||
**SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s
|
**SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s
|
||||||
`/settings/smtp` endpoints) lives in the database, not the env file — the
|
`/settings/smtp` endpoints) lives in the database, not the env file — the
|
||||||
@@ -558,19 +692,25 @@ tokens use — it's a bearer secret looked up by itself). `POST /api/signup`
|
|||||||
(`app/routers/signup.py`) is the first genuinely public,
|
(`app/routers/signup.py`) is the first genuinely public,
|
||||||
unauthenticated endpoint in this app that creates a `User` row — it calls
|
unauthenticated endpoint in this app that creates a `User` row — it calls
|
||||||
the existing `auth_service.register_user` directly for identical
|
the existing `auth_service.register_user` directly for identical
|
||||||
hashing/uniqueness handling, and logs the new user in immediately (same
|
hashing/uniqueness handling, then `session_service.start_session` (see
|
||||||
session-cookie line `auth.py`'s `login()` uses) so they land in the app
|
Active sessions below) so they land in the app already signed in. No new
|
||||||
already signed in. No new rate limiting on it — the unguessable, single-use,
|
rate limiting on it — the unguessable, single-use, expiring token is the
|
||||||
expiring token is the actual protection, inheriting the same "no rate
|
actual protection, inheriting the same "no rate limiting on human/bot
|
||||||
limiting on human/bot traffic" gap already documented below, not a new one.
|
traffic" gap already documented below, not a new one.
|
||||||
|
|
||||||
|
`POST /api/admin/invites/{id}/resend` (site-admin only,
|
||||||
|
`resend_site_invite`) issues a fresh token and resets the 7-day expiry
|
||||||
|
rather than re-sending the original link — the old link stops working the
|
||||||
|
moment this runs, and it means resending something close to expiring buys
|
||||||
|
the full week again, not just whatever was left. Only valid for a still-
|
||||||
|
`pending` invite (`SiteInviteNotPendingError` otherwise).
|
||||||
|
|
||||||
**Room-membership email**: `room_service.add_member` sends one email to
|
**Room-membership email**: `room_service.add_member` sends one email to
|
||||||
the target user after creating the `RoomMembership`, using the live
|
the target user after creating the `RoomMembership`, using the live
|
||||||
request's `base_url` for the link — no new "public URL" config needed.
|
request's `base_url` for the link — no new "public URL" config needed.
|
||||||
|
|
||||||
Scope cuts: no outgoing-webhook event type for these (matching image
|
Scope cuts: no outgoing-webhook event type for these (matching image
|
||||||
uploads/reactions), no resend for a site invite (revoke + re-invite covers
|
uploads/reactions).
|
||||||
it), no HTML email templates.
|
|
||||||
|
|
||||||
## Self-service password change and reset
|
## Self-service password change and reset
|
||||||
|
|
||||||
@@ -581,10 +721,11 @@ password, and a "forgot password" flow for someone locked out.
|
|||||||
`current_password` + `new_password`; verifies the current one with
|
`current_password` + `new_password`; verifies the current one with
|
||||||
`security.verify_password` before setting `password_hash =
|
`security.verify_password` before setting `password_hash =
|
||||||
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
|
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
|
||||||
(profile update): mutate `current_user`, commit, done. No session
|
(profile update): mutate `current_user`, commit, done. Doesn't proactively
|
||||||
invalidation elsewhere (there's no server-side session table to invalidate
|
revoke any other logged-in session for that account — a session table now
|
||||||
against — see Notes below), so other logged-in sessions for that account
|
exists (see Active sessions below), but changing your password doesn't
|
||||||
stay valid until they expire naturally.
|
walk it and revoke everything else; if you suspect a specific device, use
|
||||||
|
Active sessions to revoke it directly instead.
|
||||||
|
|
||||||
**Forgot password** (`app/models/password_reset.py`,
|
**Forgot password** (`app/models/password_reset.py`,
|
||||||
`app/services/password_service.py`) — same hashed-token-with-expiry shape as
|
`app/services/password_service.py`) — same hashed-token-with-expiry shape as
|
||||||
@@ -597,8 +738,8 @@ registered, so a miss is a silent no-op (no row created, no email sent) after
|
|||||||
a single `SELECT`. `GET /api/auth/reset-password/validate` lets the frontend
|
a single `SELECT`. `GET /api/auth/reset-password/validate` lets the frontend
|
||||||
show a "this link is invalid" state before rendering the password form.
|
show a "this link is invalid" state before rendering the password form.
|
||||||
`POST /api/auth/reset-password` completes it and — like signup — logs the
|
`POST /api/auth/reset-password` completes it and — like signup — logs the
|
||||||
user in immediately (`request.session["user_id"]`), since they've just proven
|
user in immediately (`session_service.start_session`, see Active sessions
|
||||||
they control the account's email.
|
below), since they've just proven they control the account's email.
|
||||||
|
|
||||||
Scope cuts: no rate limiting on `/forgot-password` (inherits the same
|
Scope cuts: no rate limiting on `/forgot-password` (inherits the same
|
||||||
documented gap as every other endpoint below, not a new one — the
|
documented gap as every other endpoint below, not a new one — the
|
||||||
@@ -606,6 +747,50 @@ unguessable expiring token is the actual protection once a request is made),
|
|||||||
no cleanup job for expired/used `password_resets` rows (same as
|
no cleanup job for expired/used `password_resets` rows (same as
|
||||||
`site_invites`, which has never had one either).
|
`site_invites`, which has never had one either).
|
||||||
|
|
||||||
|
## Active sessions
|
||||||
|
|
||||||
|
Replaces the previously-stateless signed cookie (a bare `user_id`) with a
|
||||||
|
real server-side `Session` table (`app/models/session.py`) — the cookie
|
||||||
|
now only ever carries an opaque session id, resolved against this table
|
||||||
|
via `session_service.resolve_session` on *every* request (`get_current_user`
|
||||||
|
in `app/dependencies.py`, and the WS handshake in `app/ws/chat.py`), which
|
||||||
|
is the single choke point that makes revocation actually take effect on a
|
||||||
|
session's very next request rather than only once its cookie happens to
|
||||||
|
expire.
|
||||||
|
|
||||||
|
Each row records `ip_address` (`X-Forwarded-For`'s first entry, since
|
||||||
|
production sits behind Nginx Proxy Manager — falls back to the direct peer
|
||||||
|
address with nothing in front locally), `user_agent`, `created_at`, and a
|
||||||
|
throttled `last_seen_at` (only bumped if stale by more than 5 minutes —
|
||||||
|
`get_current_user` resolves a session on every authenticated request, so
|
||||||
|
writing on every single one would turn a read into a write storm for no
|
||||||
|
real benefit). `session_service.start_session` is the one place every
|
||||||
|
"log this browser in" call site (login, signup completion, password-reset
|
||||||
|
completion) creates the row and stashes its id in the cookie.
|
||||||
|
|
||||||
|
- `GET /api/auth/sessions` — every non-revoked session for the current
|
||||||
|
user, newest-last-seen first, with a parsed "Browser on OS" label
|
||||||
|
(`app/services/user_agent_service.py` — plain substring checks against
|
||||||
|
the User-Agent header, no new dependency; special-cases an `Electron/`
|
||||||
|
token as "DS Chat Desktop" rather than the underlying Chromium version)
|
||||||
|
and `is_current` (compares against `request.state.session_id`, set by
|
||||||
|
`get_current_user`) so the frontend can label "this device" and treat
|
||||||
|
revoking it as a self-logout.
|
||||||
|
- `DELETE /api/auth/sessions/{id}` — any of the current user's own
|
||||||
|
sessions, including their own current one (a remote sign-out of the
|
||||||
|
same device is a legitimate thing to do); 404 if it belongs to someone
|
||||||
|
else or is already revoked.
|
||||||
|
- `POST /api/auth/logout` also revokes the session row, not just clears
|
||||||
|
the cookie (`revoke_session_unchecked` — no ownership check needed,
|
||||||
|
since a session can only ever log itself out, and never fails even if
|
||||||
|
the row is already gone).
|
||||||
|
|
||||||
|
Scope cuts: changing your password doesn't proactively revoke other
|
||||||
|
sessions (see Self-service password change and reset above) — this is a
|
||||||
|
deliberate scope boundary, not an oversight, since it's a meaningfully
|
||||||
|
different feature (auto-revoke-everywhere-on-password-change) from
|
||||||
|
"let a user see and manually revoke what's logged in."
|
||||||
|
|
||||||
## Link previews
|
## Link previews
|
||||||
|
|
||||||
Slack/Discord-style unfurling: the first `http(s)://` URL found in a
|
Slack/Discord-style unfurling: the first `http(s)://` URL found in a
|
||||||
@@ -656,13 +841,16 @@ preview card fetched from that page's Open Graph tags (`og:title`,
|
|||||||
|
|
||||||
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
|
||||||
provisioned with `python -m app.cli create-user` (see step 4 above), or via
|
provisioned with `python -m app.cli create-user` (see step 4 above), or via
|
||||||
a site invite (see Site invites & email below). This is separate from
|
a site invite (see Site invites & email above). This is separate from
|
||||||
adding an existing user to a private room — site accounts vs. room
|
adding an existing user to a private room — site accounts vs. room
|
||||||
membership.
|
membership.
|
||||||
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
|
- Sessions are backed by a real server-side table (`app/models/session.py`,
|
||||||
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
|
see Active sessions above) — the signed cookie (Starlette
|
||||||
through a WebSocket handshake). This means there's currently no way to force-
|
`SessionMiddleware`) now only ever carries an opaque session id, resolved
|
||||||
revoke a session server-side; that needs a real session table later.
|
against that table on every request, which is what makes revocation
|
||||||
|
possible. Carrying auth through the WebSocket handshake automatically is
|
||||||
|
still why it's cookie-based at all, per `ARCHITECTURE.md`'s original
|
||||||
|
rationale.
|
||||||
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
|
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
|
||||||
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
|
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
|
||||||
- Deleting a room explicitly deletes its messages/memberships first
|
- Deleting a room explicitly deletes its messages/memberships first
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ds-chat"
|
name = "ds-chat"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "DS Chat backend service"
|
description = "DS Chat backend service"
|
||||||
license = { text = "AGPL-3.0-or-later" }
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+43
-23
@@ -1,13 +1,15 @@
|
|||||||
# DS Chat frontend
|
# DS Chat frontend
|
||||||
|
|
||||||
React 19 + TypeScript + Vite PWA. The full client for DS Chat: auth and
|
React 19 + TypeScript + Vite PWA. The full client for DS Chat: auth and
|
||||||
invite-based signup, room CRUD with roles/invites, real-time WebSocket chat
|
invite-based signup, room CRUD with roles/invites, direct messages,
|
||||||
(Markdown, @mentions, reactions, image/file attachments with previews,
|
real-time WebSocket chat (Markdown, @mentions, reactions, built-in and
|
||||||
message editing), unread indicators and presence, per-user theming
|
custom emoji, image/video/file attachments with previews, message editing
|
||||||
(presets plus a custom theme builder), Web Push notifications, offline
|
and deletion), unread indicators and presence, per-user theming (presets
|
||||||
caching and an auto-update banner via a custom service worker, and a
|
plus a custom theme builder), Web Push/email notifications and a desktop-
|
||||||
site-admin portal — wired to the backend's REST API and `/ws/chat` WebSocket
|
notification bridge, an active-sessions view for managing where you're
|
||||||
endpoint. See [`../README.md`](../README.md) and
|
logged in, offline caching and an auto-update banner via a custom service
|
||||||
|
worker, and a site-admin portal — wired to the backend's REST API and
|
||||||
|
`/ws/chat` WebSocket endpoint. See [`../README.md`](../README.md) and
|
||||||
[`../backend/README.md`](../backend/README.md) for full local setup.
|
[`../backend/README.md`](../backend/README.md) for full local setup.
|
||||||
|
|
||||||
## Dev
|
## Dev
|
||||||
@@ -39,20 +41,24 @@ worker.
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password,
|
main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password,
|
||||||
/rooms, /rooms/:roomId, /admin (AdminRoute-gated); mounts
|
/rooms, /rooms/:roomId, /admin (AdminRoute-gated), /help;
|
||||||
UpdateBanner globally and ChatSocketProvider once authed
|
mounts UpdateBanner globally, ChatSocketProvider and
|
||||||
|
CustomEmojiProvider once authed
|
||||||
types.ts shared request/response/WS-envelope types, mirroring the
|
types.ts shared request/response/WS-envelope types, mirroring the
|
||||||
backend's Pydantic schemas
|
backend's Pydantic schemas
|
||||||
|
|
||||||
api/ fetch wrappers, one file per backend resource: client
|
api/ fetch wrappers, one file per backend resource: client
|
||||||
(base fetch/error handling), auth, signup, rooms, users,
|
(base fetch/error handling), auth (incl. active sessions),
|
||||||
bots, webhooks, push, admin, customThemes, uploads
|
signup, rooms (incl. DMs), users, bots, webhooks, push,
|
||||||
|
admin, customThemes, customEmoji, uploads
|
||||||
ws/useChatSocket.ts the WebSocket hook: connect/reconnect with backoff,
|
ws/useChatSocket.ts the WebSocket hook: connect/reconnect with backoff,
|
||||||
join/leave rooms, send/edit/react, visibility-gated
|
join/leave rooms, send/edit/delete/react, visibility-gated
|
||||||
presence, triggers an SW update check on reconnect
|
presence, triggers an SW update check on reconnect
|
||||||
context/
|
context/
|
||||||
AuthContext.tsx current-user state, hydrated via GET /api/auth/me
|
AuthContext.tsx current-user state, hydrated via GET /api/auth/me
|
||||||
ChatSocketContext.tsx shares one useChatSocket instance across the app
|
ChatSocketContext.tsx shares one useChatSocket instance across the app
|
||||||
|
CustomEmojiContext.tsx fetches the site's custom emoji once, exposes a
|
||||||
|
shortcode lookup + a refresh() called after upload/delete
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
avatar.ts deterministic accent-color cycling for avatars
|
avatar.ts deterministic accent-color cycling for avatars
|
||||||
@@ -61,7 +67,9 @@ src/
|
|||||||
fileSize.ts human-readable byte formatting
|
fileSize.ts human-readable byte formatting
|
||||||
lastUser.ts cached "who was I last logged in as" for offline shell render
|
lastUser.ts cached "who was I last logged in as" for offline shell render
|
||||||
messageGrouping.ts groups consecutive messages by sender/time, presence lookup
|
messageGrouping.ts groups consecutive messages by sender/time, presence lookup
|
||||||
push.ts PushManager subscribe/unsubscribe, VAPID key conversion
|
push.ts PushManager subscribe/unsubscribe, VAPID key conversion,
|
||||||
|
timeout-guarded so a browser that never settles the
|
||||||
|
permission prompt can't leave the UI stuck forever
|
||||||
swUpdate.ts bridges the SW registration to useChatSocket's reconnect hook
|
swUpdate.ts bridges the SW registration to useChatSocket's reconnect hook
|
||||||
theme.ts applies preset/custom themes as CSS custom properties
|
theme.ts applies preset/custom themes as CSS custom properties
|
||||||
|
|
||||||
@@ -73,24 +81,36 @@ src/
|
|||||||
|
|
||||||
components/
|
components/
|
||||||
ProtectedRoute.tsx, AdminRoute.tsx auth/site-admin route guards
|
ProtectedRoute.tsx, AdminRoute.tsx auth/site-admin route guards
|
||||||
TopBar.tsx, Sidebar.tsx, RoomRow.tsx room list chrome
|
TopBar.tsx, Sidebar.tsx, RoomRow.tsx room list chrome (DMs and Rooms as
|
||||||
|
independently collapsible sections)
|
||||||
ChatPane.tsx, MessageList.tsx, Composer.tsx chat view: history+live merge,
|
ChatPane.tsx, MessageList.tsx, Composer.tsx chat view: history+live merge,
|
||||||
message rendering, composer/attach/send
|
message rendering (incl. deleted-message
|
||||||
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering + @mention highlighting/autocomplete
|
tombstones), composer/attach/send
|
||||||
ImageLightbox.tsx, FilePreviewModal.tsx attachment viewers (image/PDF/text/Markdown)
|
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering (mentions, room
|
||||||
EmojiPicker.tsx reaction/composer emoji picker
|
links, custom emoji `:shortcode:`,
|
||||||
RoomInfoPanel.tsx room details/members/roles panel
|
heading ids, sub/superscript) +
|
||||||
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member picking
|
@mention highlighting/autocomplete
|
||||||
|
ImageLightbox.tsx, VideoLightbox.tsx,
|
||||||
|
FilePreviewModal.tsx attachment viewers (image/video/PDF/
|
||||||
|
text/Markdown)
|
||||||
|
EmojiPicker.tsx, CustomEmojiUploadModal.tsx reaction/composer emoji picker
|
||||||
|
(built-in + site's custom emoji) and
|
||||||
|
its upload dialog
|
||||||
|
RoomInfoPanel.tsx room details/members/roles/email-
|
||||||
|
notification-toggle panel
|
||||||
|
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member
|
||||||
|
picking (also how a DM starts)
|
||||||
ProfileModal.tsx, ThemeBuilderModal.tsx, CustomThemePreview.tsx
|
ProfileModal.tsx, ThemeBuilderModal.tsx, CustomThemePreview.tsx
|
||||||
profile settings + the custom theme editor
|
profile settings (incl. active-sessions
|
||||||
(opened in its own wide dialog) with a live,
|
list) + the custom theme editor (opened
|
||||||
|
in its own wide dialog) with a live,
|
||||||
hoverable mockup of the real UI
|
hoverable mockup of the real UI
|
||||||
RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot)
|
RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot)
|
||||||
OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt
|
OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt
|
||||||
|
|
||||||
pages/
|
pages/
|
||||||
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
LoginPage.tsx, SignupPage.tsx, ForgotPasswordPage.tsx, ResetPasswordPage.tsx
|
||||||
ChatShellPage.tsx, AdminPage.tsx
|
ChatShellPage.tsx, AdminPage.tsx, HelpPage.tsx
|
||||||
|
|
||||||
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.)
|
styles/tokens.css design tokens (DarkSingularity theme: colors, spacing, etc.)
|
||||||
sw.ts custom service worker (injectManifest): app-shell
|
sw.ts custom service worker (injectManifest): app-shell
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user