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 |
| 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 |
| 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
@@ -85,17 +85,31 @@ users
id, username, email, password_hash, is_bot, is_site_admin, created_at
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_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
id, room_id, invited_by, token, target_user_id or target_email,
expires_at, status (pending | accepted | revoked)
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
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
```
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
- **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
members over WebSocket.
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
+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
rsync/S3 examples) — decide where those need to go and fill it in.
That script covers Postgres only. Uploaded chat images and file attachments
both live on the **app** server's disk (`/srv/ds-chat/uploads`, created in
§3a) — a separate machine from the data server this script runs on — and
currently have no backup mechanism at all. Whatever off-box destination you
pick above, include `/srv/ds-chat/uploads` in it too (e.g. a second `rsync`
That script covers Postgres only. Uploaded chat images, file/video
attachments, avatars, and custom emoji all live on the **app** server's
disk (`/srv/ds-chat/uploads`, created in §3a) — a separate machine from
the data server this script runs on — and currently have no backup
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).
**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):
- No rate limiting on human or bot API traffic.
- 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
re-validated per delivery (DNS-rebinding gap).
- Backup off-box shipping is a placeholder — decide a destination and fill
in `deploy/backup-postgres.sh`.
- Uploaded chat images and file attachments (`/srv/ds-chat/uploads` on the
app server) have no backup coverage at all yet, on-box or off — see §7.
- Uploaded chat images, file/video attachments, and custom emoji
(`/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
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
+18 -13
View File
@@ -18,21 +18,26 @@ build plan.
## Features
- **Auth & accounts** — session-based auth, invite-only signup (admin-issued
site invites or room invites, both delivered by email), password reset,
per-user light/dark/midnight/sunset presets plus a live theme builder for
fully custom, named, savable color themes.
- **Rooms** — open and private rooms, owner/admin/member roles, invites,
room browsing/search, file/image galleries per room.
- **Auth & accounts** — server-side, revocable sessions (see every device
you're logged in from and sign one out remotely), invite-only signup
(admin-issued site invites or room invites, both delivered by email),
password reset, per-user light/dark/midnight/sunset presets plus a live
theme builder for fully custom, named, savable color themes.
- **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
and backoff, Markdown rendering, @mentions with autocomplete and inline
highlighting, emoji reactions, message editing, image and file
attachments (drag-and-drop, paste, or picker) with inline previews for
images/PDFs/text/Markdown, unread indicators, and presence (online/away/
and backoff, Markdown rendering (headings with custom anchors, sub/
superscript, tables, and more), @mentions and #room-reference links with
autocomplete and inline highlighting, emoji reactions and shortcodes plus
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).
- **Notifications** — Web Push for offline/backgrounded members, with
per-type opt-in/out (mentions vs. all messages), plus in-app unread
badges.
- **Notifications** — Web Push and native desktop notifications for
offline/backgrounded members, plus email: always-on for direct messages,
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
dedicated offline banner), with automatic update detection that prompts
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)
- [Rooms](#rooms)
- [Direct messages](#direct-messages)
- [Sending messages](#sending-messages)
- [Formatting](#formatting)
- [Mentions and room links](#mentions-and-room-links)
- [Attachments](#attachments)
- [Reactions](#reactions)
- [Editing a message](#editing-a-message)
- [Reactions and custom emoji](#reactions-and-custom-emoji)
- [Editing and deleting a message](#editing-and-deleting-a-message)
- [Presence and notifications](#presence-and-notifications)
- [Your profile](#your-profile)
- [Room details](#room-details)
- [Active sessions](#active-sessions)
- [Staying up to date](#staying-up-to-date)
## 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
name — a plain dot for unread messages, a highlighted dot if you were
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
@@ -61,7 +78,9 @@ Messages support Markdown:
- `-` or `1.` for bulleted/numbered lists
- `[link text](https://example.com)` — or just paste a bare URL and it
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
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
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
@@ -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
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
filename and size; `.txt`, `.md`, and `.pdf` files open in a preview
without leaving the room, everything else downloads when clicked.
to view it full-size. Common video formats (MP4, WebM, Ogg) play inline
too, with a button to expand to a larger view; other files show as a
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,
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
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
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
changes, then press **Enter** to save or **Escape** to cancel. Clicking
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
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
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
Open your account menu and choose **Profile settings** to:
@@ -135,12 +175,22 @@ Open your account menu and choose **Profile settings** to:
live)
- 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
Click the info icon in a room's header to open its details panel, where
you can:
- 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,
without scrolling back through history
- 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
CRUD (open and private), room roles (owner/admin/member) and direct
membership management, a WebSocket chat endpoint that fans out across
multiple app-server instances via Redis pub/sub, Web Push notifications for
offline room members, a site-admin portal (user/room/bot management + an
audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
access, incoming and outgoing webhooks, message editing), image uploads and
generic file attachments in chat messages, emoji reactions on messages,
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.
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth
(server-side, revocable sessions — see Active sessions below), room CRUD
(open and private), room roles (owner/admin/member) and direct membership
management, direct messages, a WebSocket chat endpoint that fans out across
multiple app-server instances via Redis pub/sub, Web Push and email
notifications for offline room members (plus a native desktop-notification
bridge for DS Chat Desktop), a site-admin portal (user/room/bot management +
an audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
access, incoming and outgoing webhooks, message editing), image uploads,
inline-playable video attachments, and generic file attachments in chat
messages, message deletion, emoji reactions (built-in Unicode plus
site-wide custom/uploaded emoji, both usable in reactions and inline in
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.
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
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
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,
push_subscriptions, admin_audit_log, api_tokens,
webhooks_incoming, event_subscriptions)
upload_settings, push_subscriptions,
admin_audit_log, api_tokens, webhooks_incoming,
event_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, users, signup, push, admin,
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,
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)
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
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
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.
**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
The 8 MB image/file/avatar cap is no longer hardcoded — it's an
@@ -462,14 +553,17 @@ returning `None`.
## Emoji & reactions
An emoji picker in the frontend composer is purely client-side (a static
curated unicode list, no backend involvement). Message **reactions** are
full-stack: `message_reactions` (`app/models/message_reaction.py`) has
The built-in emoji picker in the frontend composer is purely client-side (a
static curated unicode list, no backend involvement). Message **reactions**
are full-stack: `message_reactions` (`app/models/message_reaction.py`) has
`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
backing toggle semantics — the same user reacting with the same emoji on
the same message twice removes it (Slack/Mattermost convention).
`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
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`
in `webhook_service.py` is unchanged — same restraint as image uploads), no
reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
only, curated client-side list in `frontend/src/lib/emoji.ts`).
reaction-count limit or rate limiting.
## 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
@@ -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.
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
`send_email(db, to, subject, body)` is the fire-and-forget path used by
invite flows — if `SmtpSettings` isn't configured yet it logs at debug and
returns (same "silently skip if unconfigured" UX push notifications already
use for a missing VAPID key), and it never raises on delivery failure (an
SMTP outage must not block an invite/membership action that already
succeeded in the database). `send_test_email(db, to)` 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. Plain-text bodies only, no
HTML templates, matching this codebase's existing minimalism.
`send_email(db, to, subject, paragraphs, *, cta_label=None, cta_url=None,
theme_user=None)` is the fire-and-forget path used by invite/notification
flows — if `SmtpSettings` isn't configured yet it logs (at `.warning`, not
`.debug` — this app has no logging config lowering the root level below
Python's own `WARNING` default, so anything below that is silently
invisible in production) and returns, and it never raises on delivery
failure (an SMTP outage must not block an invite/membership/notification
action that already succeeded in the database). `send_test_email(db, to)`
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
`/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,
unauthenticated endpoint in this app that creates a `User` row — it calls
the existing `auth_service.register_user` directly for identical
hashing/uniqueness handling, and logs the new user in immediately (same
session-cookie line `auth.py`'s `login()` uses) so they land in the app
already signed in. No new rate limiting on it — the unguessable, single-use,
expiring token is the actual protection, inheriting the same "no rate
limiting on human/bot traffic" gap already documented below, not a new one.
hashing/uniqueness handling, then `session_service.start_session` (see
Active sessions below) so they land in the app already signed in. No new
rate limiting on it — the unguessable, single-use, expiring token is the
actual protection, inheriting the same "no rate limiting on human/bot
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
the target user after creating the `RoomMembership`, using the live
request's `base_url` for the link — no new "public URL" config needed.
Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers
it), no HTML email templates.
uploads/reactions).
## 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
`security.verify_password` before setting `password_hash =
hash_password(new_password)`. Same self-service shape as `PATCH /api/auth/me`
(profile update): mutate `current_user`, commit, done. No session
invalidation elsewhere (there's no server-side session table to invalidate
against — see Notes below), so other logged-in sessions for that account
stay valid until they expire naturally.
(profile update): mutate `current_user`, commit, done. Doesn't proactively
revoke any other logged-in session for that account — a session table now
exists (see Active sessions below), but changing your password doesn't
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`,
`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
show a "this link is invalid" state before rendering the password form.
`POST /api/auth/reset-password` completes it and — like signup — logs the
user in immediately (`request.session["user_id"]`), since they've just proven
they control the account's email.
user in immediately (`session_service.start_session`, see Active sessions
below), since they've just proven they control the account's email.
Scope cuts: no rate limiting on `/forgot-password` (inherits the same
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
`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
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
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
membership.
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
through a WebSocket handshake). This means there's currently no way to force-
revoke a session server-side; that needs a real session table later.
- Sessions are backed by a real server-side table (`app/models/session.py`,
see Active sessions above) — the signed cookie (Starlette
`SessionMiddleware`) now only ever carries an opaque session id, resolved
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
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
- Deleting a room explicitly deletes its messages/memberships first
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ds-chat"
version = "1.0.0"
version = "1.1.0"
description = "DS Chat backend service"
license = { text = "AGPL-3.0-or-later" }
requires-python = ">=3.11"
+43 -23
View File
@@ -1,13 +1,15 @@
# DS Chat frontend
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
(Markdown, @mentions, reactions, image/file attachments with previews,
message editing), unread indicators and presence, per-user theming
(presets plus a custom theme builder), Web Push notifications, 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
invite-based signup, room CRUD with roles/invites, direct messages,
real-time WebSocket chat (Markdown, @mentions, reactions, built-in and
custom emoji, image/video/file attachments with previews, message editing
and deletion), unread indicators and presence, per-user theming (presets
plus a custom theme builder), Web Push/email notifications and a desktop-
notification bridge, an active-sessions view for managing where you're
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.
## Dev
@@ -39,20 +41,24 @@ worker.
```
src/
main.tsx, App.tsx routes: /login, /signup, /forgot-password, /reset-password,
/rooms, /rooms/:roomId, /admin (AdminRoute-gated); mounts
UpdateBanner globally and ChatSocketProvider once authed
/rooms, /rooms/:roomId, /admin (AdminRoute-gated), /help;
mounts UpdateBanner globally, ChatSocketProvider and
CustomEmojiProvider once authed
types.ts shared request/response/WS-envelope types, mirroring the
backend's Pydantic schemas
api/ fetch wrappers, one file per backend resource: client
(base fetch/error handling), auth, signup, rooms, users,
bots, webhooks, push, admin, customThemes, uploads
(base fetch/error handling), auth (incl. active sessions),
signup, rooms (incl. DMs), users, bots, webhooks, push,
admin, customThemes, customEmoji, uploads
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
context/
AuthContext.tsx current-user state, hydrated via GET /api/auth/me
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/
avatar.ts deterministic accent-color cycling for avatars
@@ -61,7 +67,9 @@ src/
fileSize.ts human-readable byte formatting
lastUser.ts cached "who was I last logged in as" for offline shell render
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
theme.ts applies preset/custom themes as CSS custom properties
@@ -73,24 +81,36 @@ src/
components/
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,
message rendering, composer/attach/send
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering + @mention highlighting/autocomplete
ImageLightbox.tsx, FilePreviewModal.tsx attachment viewers (image/PDF/text/Markdown)
EmojiPicker.tsx reaction/composer emoji picker
RoomInfoPanel.tsx room details/members/roles panel
NewRoomModal.tsx, BrowseRoomsModal.tsx, UserPicker.tsx room creation/discovery, member picking
message rendering (incl. deleted-message
tombstones), composer/attach/send
MessageContent.tsx, MentionAutocomplete.tsx Markdown rendering (mentions, room
links, custom emoji `:shortcode:`,
heading ids, sub/superscript) +
@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
profile settings + the custom theme editor
(opened in its own wide dialog) with a live,
profile settings (incl. active-sessions
list) + the custom theme editor (opened
in its own wide dialog) with a live,
hoverable mockup of the real UI
RoomAvatar.tsx, UserAvatar.tsx avatar rendering (incl. presence dot)
OfflineBanner.tsx, UpdateBanner.tsx connectivity state / new-version-available prompt
pages/
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.)
sw.ts custom service worker (injectManifest): app-shell
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {