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:
2026-08-28 21:27:52 -06:00
co-authored by Claude Sonnet 5
parent 3e5f842df3
commit f6c71753b5
8 changed files with 401 additions and 110 deletions
+32 -5
View File
@@ -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
View File
@@ -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 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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": {