Private
Public Access
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319b7097c2 | ||
|
|
0065b4ced3 | ||
|
|
b52db0ebe0 | ||
|
|
dda85a2b78 | ||
|
|
7ff7f2ec0e | ||
|
|
62b5eec2fb | ||
|
|
eb30dc1753 | ||
|
|
6975ed4dc0 | ||
|
|
c54c1bf97d | ||
|
|
3349cadea2 | ||
|
|
047f47d524 | ||
|
|
850d9ec0fa | ||
|
|
86584660fb | ||
|
|
6f67f36578 | ||
|
|
b1c9a85ab3 | ||
|
|
62a25c6278 | ||
|
|
7a84d2f09f | ||
|
|
d74527a29c | ||
|
|
f0e4c76ffd | ||
|
|
3be8d9d731 | ||
|
|
30e63ffa83 | ||
|
|
019e10ac5c | ||
|
|
6e889b8ea4 | ||
|
|
b4a104f8c6 | ||
|
|
03cc16f236 | ||
|
|
520b971247 | ||
|
|
cd6296d079 | ||
|
|
f6c71753b5 | ||
|
|
3e5f842df3 | ||
|
|
ad745c734e | ||
|
|
092f1de585 | ||
|
|
2e84ca42b7 | ||
|
|
b26643527d | ||
|
|
278f8bb995 | ||
|
|
250bb862f6 | ||
|
|
3dabf0022c | ||
|
|
08a36248a0 | ||
|
|
5ec79e652f | ||
|
|
89d609f584 | ||
|
|
51d0092bd3 | ||
|
|
66e9c80422 | ||
|
|
aeaaef96ec | ||
|
|
1a05cf5515 | ||
|
|
7b44ce325c | ||
|
|
ef615e1ef4 | ||
|
|
157f1e30ac | ||
|
|
072405eb2d | ||
|
|
2fd055f7d6 | ||
|
|
4cc3823adf | ||
|
|
d42bf114dd | ||
|
|
ed88eb0205 | ||
|
|
1d9fe25410 | ||
|
|
766883c992 | ||
|
|
1d322d9516 | ||
|
|
f3f59ad822 | ||
|
|
8a461ebb13 | ||
|
|
222ca49355 |
No files matched your search
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "frontend-preview",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "frontend", "run", "preview"],
|
||||
"port": 4173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ dist-ssr/
|
||||
## Editors / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.DS_Store
|
||||
|
||||
## Test / coverage
|
||||
|
||||
+32
-5
@@ -28,7 +28,7 @@ database and Redis; the other runs the application and serves the frontend.
|
||||
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contributing to DS Chat
|
||||
|
||||
Thanks for considering a contribution.
|
||||
|
||||
## Reporting bugs and requesting features
|
||||
|
||||
Open an issue on this project's issue tracker. Include steps to reproduce
|
||||
for a bug, or the problem you're trying to solve for a feature request —
|
||||
that's usually more useful than a proposed solution.
|
||||
|
||||
## Development setup
|
||||
|
||||
See the root [README.md](README.md)'s Quickstart, plus
|
||||
[backend/README.md](backend/README.md) and
|
||||
[frontend/README.md](frontend/README.md) for the full local dev setup
|
||||
(Postgres, Redis, Python venv, migrations, the Vite dev server).
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md) covers the overall system design if
|
||||
you're orienting yourself before a larger change.
|
||||
|
||||
## Before opening a pull request
|
||||
|
||||
- **Tests**: run `pytest` in `backend/` for any backend change, and add
|
||||
tests for new behavior rather than just the happy path — see
|
||||
`backend/README.md`'s "Run tests" section. For frontend changes, run
|
||||
`npx tsc -b` in `frontend/` and confirm `npm run build` succeeds.
|
||||
- **Style**: match the conventions already in the file you're editing
|
||||
rather than introducing a new pattern — this codebase doesn't have a
|
||||
separate style guide beyond "look at what's already there."
|
||||
- **Scope**: smaller, focused PRs are easier to review than large ones
|
||||
that mix unrelated changes.
|
||||
|
||||
## Contributor terms
|
||||
|
||||
By submitting a contribution (a pull request, patch, or similar), you
|
||||
agree that:
|
||||
|
||||
1. Your contribution is licensed under the project's own license,
|
||||
AGPL-3.0-or-later ([LICENSE](LICENSE)), and
|
||||
2. You grant the project's maintainer(s) a perpetual, worldwide,
|
||||
non-exclusive right to also relicense your contribution under different
|
||||
terms — for example, as part of a separately-licensed commercial
|
||||
offering built on this project.
|
||||
|
||||
This keeps the option of a future dual-licensed (open-source +
|
||||
commercial) version of the project available, without requiring a
|
||||
separate signed agreement for every contribution.
|
||||
|
||||
*This is a lightweight starting point, not a substitute for legal advice —
|
||||
if you're contributing something substantial, or maintaining a fork with
|
||||
your own commercial plans, it's worth having this reviewed by a lawyer
|
||||
rather than relying on the paragraph above alone.*
|
||||
+46
-23
@@ -132,35 +132,45 @@ sudo -u ds-chat ssh-keygen -t ed25519 -f /srv/ds-chat/.ssh/id_ed25519 -N ""
|
||||
sudo cat /srv/ds-chat/.ssh/id_ed25519.pub
|
||||
```
|
||||
|
||||
Add that public key as a **read-only deploy key** on the Gitea repo
|
||||
(Settings → Deploy Keys), then:
|
||||
This project is hosted at
|
||||
**[github.com/ds-ksmith/DS-Chat](https://github.com/ds-ksmith/DS-Chat)**.
|
||||
Add that public key there as a **read-only deploy key** (Settings → Deploy
|
||||
Keys on the repo), then:
|
||||
|
||||
```bash
|
||||
sudo -u ds-chat ssh-keyscan git.darksingularity.org >> /srv/ds-chat/.ssh/known_hosts
|
||||
sudo -u ds-chat git clone git@git.darksingularity.org:DarkSingularity/ds-chat.git /srv/ds-chat
|
||||
sudo -u ds-chat ssh-keyscan github.com >> /srv/ds-chat/.ssh/known_hosts
|
||||
sudo -u ds-chat git clone git@github.com:ds-ksmith/DS-Chat.git /srv/ds-chat
|
||||
```
|
||||
|
||||
(If your Gitea's SSH is on a non-default port, adjust the clone URL and
|
||||
`ssh-keyscan -p <port>` accordingly.)
|
||||
(Deploying from your own fork instead? Substitute its clone URL — the same
|
||||
deploy-key/access-token steps work the same way on GitHub, GitLab, Gitea,
|
||||
and most other git hosts.)
|
||||
|
||||
**Alternative: a personal/deployment-user access token instead of a deploy
|
||||
key** — skip the `.ssh`/`ssh-keygen`/`ssh-keyscan` commands above entirely
|
||||
and clone over HTTPS with the token embedded in the URL:
|
||||
|
||||
```bash
|
||||
sudo -u ds-chat git clone https://<TOKEN>@git.darksingularity.org/DarkSingularity/ds-chat.git /srv/ds-chat
|
||||
sudo -u ds-chat git clone https://<TOKEN>@github.com/ds-ksmith/DS-Chat.git /srv/ds-chat
|
||||
```
|
||||
|
||||
The token then lives in plaintext in `/srv/ds-chat/.git/config` (`git
|
||||
remote -v` shows it) — readable by root and the `ds-chat` user, not by
|
||||
anyone else under normal file permissions. `deploy/upgrade.sh`'s later
|
||||
`git pull`s reuse this same authenticated URL automatically, no extra
|
||||
`git fetch`es reuse this same authenticated URL automatically, no extra
|
||||
setup needed. Fine as long as the token is scoped to read-only access on
|
||||
just this repo.
|
||||
|
||||
Either way, now that the repo is cloned:
|
||||
Either way, now that the repo is cloned, check out the latest release tag
|
||||
rather than deploying whatever the default branch's tip happens to be —
|
||||
`deploy/upgrade.sh` follows the same rule on every later upgrade (see §6),
|
||||
so this keeps the very first deploy consistent with all the ones after it:
|
||||
|
||||
```bash
|
||||
cd /srv/ds-chat
|
||||
sudo -u ds-chat git fetch --tags
|
||||
LATEST_TAG="$(sudo -u ds-chat git tag --sort=-creatordate | head -n1)"
|
||||
sudo -u ds-chat git checkout --detach "$LATEST_TAG"
|
||||
sudo -u ds-chat mkdir -p /srv/ds-chat/uploads
|
||||
```
|
||||
|
||||
@@ -232,7 +242,16 @@ admin sets it up.
|
||||
`/ws`) whenever that directory exists — that's what lets Nginx Proxy
|
||||
Manager forward the whole domain to one port with no custom path routing.
|
||||
|
||||
Before building, copy `frontend/.env.example` to `frontend/.env.production`
|
||||
and set `VITE_SOURCE_URL` to wherever *your* copy of the repo lives — see
|
||||
that file's own comment for why this matters (AGPL-3.0 source-availability
|
||||
compliance). Vite bakes this in at build time, so it needs to be in place
|
||||
before `npm run build` runs, and needs re-running after any future change
|
||||
to it.
|
||||
|
||||
```bash
|
||||
sudo -u ds-chat cp /srv/ds-chat/frontend/.env.example /srv/ds-chat/frontend/.env.production
|
||||
sudo -u ds-chat nano /srv/ds-chat/frontend/.env.production # set VITE_SOURCE_URL
|
||||
sudo -u ds-chat bash -c 'cd /srv/ds-chat/frontend && npm ci && npm run build'
|
||||
```
|
||||
|
||||
@@ -320,12 +339,15 @@ This is config in NPM's own UI/database, not a file this repo ships:
|
||||
sudo -u ds-chat /srv/ds-chat/deploy/upgrade.sh
|
||||
```
|
||||
|
||||
Pulls latest `main`, reinstalls backend deps, runs `alembic upgrade head`,
|
||||
rebuilds the frontend, restarts `ds-chat`, and curls `/api/health` to
|
||||
confirm it came back up. Fails loudly (`set -euo pipefail`) and stops
|
||||
before restarting anything if an earlier step — most importantly a failed
|
||||
migration — errors out, so a bad deploy doesn't take down the previously
|
||||
working one.
|
||||
Fetches tags and checks out whichever one sorts newest (`git tag
|
||||
--sort=-creatordate`) — deliberately not the default branch's tip, so
|
||||
running this between releases is a safe no-op rather than pulling in
|
||||
whatever's mid-flight on `main`. Then reinstalls backend deps, runs
|
||||
`alembic upgrade head`, rebuilds the frontend, restarts `ds-chat`, and
|
||||
curls `/api/health` to confirm it came back up. Fails loudly
|
||||
(`set -euo pipefail`) and stops before restarting anything if an earlier
|
||||
step — most importantly a failed migration — errors out, so a bad deploy
|
||||
doesn't take down the previously working one.
|
||||
|
||||
Active users get disconnected for a few seconds during the restart and
|
||||
reconnect automatically (same reconnect logic as §4's NPM-timeout note) —
|
||||
@@ -345,11 +367,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 +412,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
|
||||
|
||||
@@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
DS Chat, a self-hosted, real-time team chat service.
|
||||
Copyright (C) 2026 Keith Smith
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
|
||||
@@ -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.
|
||||
|
||||
+74
-11
@@ -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,15 +47,41 @@ 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
|
||||
|
||||
Type in the message box at the bottom of a room and press **Enter** to
|
||||
send. Use **Shift+Enter** to add a line break without sending.
|
||||
|
||||
Each message shows when it was sent, in your local time. Messages from today
|
||||
show just the time; older ones add the day — "Yesterday 4:04 PM", a weekday
|
||||
like "Mon 4:04 PM" for the past week, then the date ("Oct 2, 4:04 PM", with
|
||||
the year if it's not this one). Hover a time to see the full date.
|
||||
|
||||
## Formatting
|
||||
|
||||
Messages support Markdown:
|
||||
Messages support Markdown. Prefer buttons to typing the syntax? Click the
|
||||
**Aa** button next to the emoji button in the composer to open a formatting
|
||||
toolbar — it inserts the markdown below into your message for you (select some
|
||||
text first to wrap it, or click a button again to take the formatting back
|
||||
off). It stays collapsed until you open it, and remembers your choice.
|
||||
|
||||
Or type it yourself:
|
||||
|
||||
- `**bold**`, `*italic*`, `~~strikethrough~~`
|
||||
- `` `inline code` `` and fenced code blocks (three backticks)
|
||||
@@ -61,7 +89,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 +99,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 +119,41 @@ 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.
|
||||
reaction to it. Hovering a pill shows who reacted. Once a message has
|
||||
reactions, a small **+🙂** button at the end of the row of pills lets you
|
||||
add a different emoji without going back to the action row.
|
||||
|
||||
## 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 +171,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 +188,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
|
||||
|
||||
+264
-64
@@ -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
|
||||
@@ -281,17 +289,26 @@ instance and running many behave identically:
|
||||
to `room:*`; each message it receives is handed to its own local
|
||||
`ConnectionManager.broadcast()`. One instance just talks to itself
|
||||
through Redis, so there's no separate single-instance code path.
|
||||
- `app/ws/presence.py` (`Presence`) — a Redis hash per room
|
||||
(`presence:{room_id}`, field = user ID, value = connection refcount) is
|
||||
the cross-instance answer to "is this member connected *anywhere* right
|
||||
now," which is what the Phase 4 offline-push check uses instead of the
|
||||
local `ConnectionManager`. Refcounted so a user connected from two tabs
|
||||
(or two instances) isn't marked offline until every connection closes.
|
||||
- `app/ws/presence.py` (`Presence`) — a Redis sorted set per room
|
||||
(`presence:v2:room:{id}`, one member per *connection*, `<user_id>:<conn_id>`,
|
||||
score = expiry time) is the cross-instance answer to "is this member
|
||||
connected *anywhere* right now," which is what the Phase 4 offline-push
|
||||
check uses instead of the local `ConnectionManager`. A user is connected
|
||||
while any of their entries is unexpired, so two tabs (or two instances)
|
||||
don't mark them offline until every connection is gone. `GlobalPresence`
|
||||
(`presence:v2:global`) and `FocusPresence` (`presence:v2:unfocused`) use the
|
||||
same store, `app/ws/expiring_presence.py` (`ExpiringPresenceSet`).
|
||||
|
||||
Known limitation: `Presence` has no heartbeat/TTL, so a hard process crash
|
||||
(not a clean disconnect) leaks that connection's increment forever — same
|
||||
category of simplification as the "no server-side session revocation" note
|
||||
below.
|
||||
Presence entries expire (#77) so nothing can be stuck online. A connection
|
||||
renews its entries (90s TTL) whenever the client sends a frame — the frontend
|
||||
answers the server's 30s `ping` with a `pong`, which is exactly that — and
|
||||
the server closes a human client's socket (code 4408) after 90s of silence.
|
||||
Bot (API-token) connections don't speak the heartbeat, so the server renews
|
||||
their entries itself while their handler is alive. An expiry has no
|
||||
disconnect event to announce it, so `app/ws/presence_sweeper.py` runs on every
|
||||
worker (a Redis lock makes it one sweep per 15s overall), prunes expired
|
||||
entries, and broadcasts the offline transition. The pre-#77 `presence:*`
|
||||
refcount hashes are deleted at startup.
|
||||
|
||||
## Push notifications (Phase 4)
|
||||
|
||||
@@ -345,6 +362,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 +412,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 +520,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 +562,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 +585,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 +667,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 +701,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 +730,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 +747,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 +756,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
|
||||
@@ -637,10 +831,13 @@ preview card fetched from that page's Open Graph tags (`og:title`,
|
||||
`Content-Type` is `text/html`.
|
||||
- **Cached by URL, not by message** (`link_previews` table, unique on
|
||||
`url`) — a URL posted by five different people in five different rooms
|
||||
fetches once. A row also gets written on a *failed* fetch
|
||||
(`fetch_failed=True`) so a URL that genuinely doesn't unfurl (SSRF
|
||||
rejection, timeout, no usable title) isn't re-attempted on every message
|
||||
that references it; both kinds expire after 7 days (`_CACHE_TTL`).
|
||||
within the same short window fetches once. A row also gets written on a
|
||||
*failed* fetch (`fetch_failed=True`) so a URL that genuinely doesn't
|
||||
unfurl (SSRF rejection, timeout, no usable title) isn't re-attempted on
|
||||
every message that references it; both kinds expire after 5 minutes
|
||||
(`_CACHE_TTL` — #70: was 7 days, confirmed live as far too long, a
|
||||
re-posted URL whose title/content had genuinely changed kept showing
|
||||
the stale first-fetch preview for up to a week).
|
||||
- Parsed with stdlib `html.parser.HTMLParser`, not a new dependency — only
|
||||
meta-tag scraping is needed, not general HTML parsing.
|
||||
- Editing a message re-extracts the URL; if it changed or was removed, the
|
||||
@@ -656,13 +853,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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""room email notifications opt-in
|
||||
|
||||
Revision ID: 05b28b0a2261
|
||||
Revises: e2fc4d65f93e
|
||||
Create Date: 2026-08-28 19:34:23.507200
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '05b28b0a2261'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e2fc4d65f93e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('room_memberships', sa.Column('email_notifications', sa.Boolean(), server_default='false', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('room_memberships', 'email_notifications')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add sessions table for active-sessions feature
|
||||
|
||||
Revision ID: 319c30e24cd9
|
||||
Revises: 05b28b0a2261
|
||||
Create Date: 2026-08-28 19:59:51.475384
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '319c30e24cd9'
|
||||
down_revision: Union[str, Sequence[str], None] = '05b28b0a2261'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('sessions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('last_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add user text_scale preference
|
||||
|
||||
Revision ID: 339b78011a4f
|
||||
Revises: a318850726ee
|
||||
Create Date: 2026-08-30 18:03:16.159924
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '339b78011a4f'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a318850726ee'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('text_scale', sa.String(length=20), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'text_scale')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,35 @@
|
||||
"""room is_dm flag for direct messages
|
||||
|
||||
Revision ID: 9ca717f837c2
|
||||
Revises: 9484fbd1cb3a
|
||||
Create Date: 2026-08-19 15:50:25.781735
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9ca717f837c2'
|
||||
down_revision: Union[str, Sequence[str], None] = '9484fbd1cb3a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# server_default backfills existing rows; dropped right after since the
|
||||
# model itself only sets a Python-side default (see is_archived above).
|
||||
op.add_column(
|
||||
'rooms', sa.Column('is_dm', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
op.alter_column('rooms', 'is_dm', server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('rooms', 'is_dm')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add custom emoji
|
||||
|
||||
Revision ID: a318850726ee
|
||||
Revises: 319c30e24cd9
|
||||
Create Date: 2026-08-28 20:34:27.293658
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a318850726ee'
|
||||
down_revision: Union[str, Sequence[str], None] = '319c30e24cd9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('custom_emoji',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('shortcode', sa.String(length=30), nullable=False),
|
||||
sa.Column('storage_filename', sa.String(length=64), nullable=False),
|
||||
sa.Column('content_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('uploaded_by', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_custom_emoji_shortcode'), 'custom_emoji', ['shortcode'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_custom_emoji_shortcode'), table_name='custom_emoji')
|
||||
op.drop_table('custom_emoji')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,39 @@
|
||||
"""allow deleted messages to clear content and attachments
|
||||
|
||||
Revision ID: e2fc4d65f93e
|
||||
Revises: f3f255da9c96
|
||||
Create Date: 2026-08-28 16:20:02.114630
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e2fc4d65f93e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f3f255da9c96'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.drop_constraint('messages_content_or_attachment_required', 'messages', type_='check')
|
||||
op.create_check_constraint(
|
||||
'messages_content_or_attachment_required',
|
||||
'messages',
|
||||
'content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL '
|
||||
'OR deleted_at IS NOT NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_constraint('messages_content_or_attachment_required', 'messages', type_='check')
|
||||
op.create_check_constraint(
|
||||
'messages_content_or_attachment_required',
|
||||
'messages',
|
||||
'content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL',
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add user emoji_scale preference
|
||||
|
||||
Revision ID: e81c9bcc82b9
|
||||
Revises: 339b78011a4f
|
||||
Create Date: 2026-08-30 18:14:26.045186
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e81c9bcc82b9'
|
||||
down_revision: Union[str, Sequence[str], None] = '339b78011a4f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('emoji_scale', sa.String(length=20), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'emoji_scale')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""hide DM conversations per-participant
|
||||
|
||||
Revision ID: f3f255da9c96
|
||||
Revises: 9ca717f837c2
|
||||
Create Date: 2026-08-19 16:36:28.332581
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f3f255da9c96'
|
||||
down_revision: Union[str, Sequence[str], None] = '9ca717f837c2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('room_memberships', sa.Column('hidden_at', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('room_memberships', 'hidden_at')
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, RoomRole, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.session_service import resolve_session
|
||||
|
||||
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
|
||||
@@ -29,16 +30,26 @@ async def get_current_user(
|
||||
request.state.api_token = token
|
||||
return user
|
||||
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
session_id = request.session.get("session_id")
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# #69: a session row, not a bare user_id -- resolve_session is also
|
||||
# where a revoked session (this endpoint's own DELETE, or another
|
||||
# device's "sign out") actually takes effect, since there's no other
|
||||
# per-request check of that state.
|
||||
session = await resolve_session(db, uuid.UUID(session_id))
|
||||
if session is None:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
request.state.session_id = session.id
|
||||
|
||||
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
|
||||
# be read without a MissingGreenlet -- selectinload skips the second
|
||||
# query entirely when active_custom_theme_id is null (the common case),
|
||||
# so this costs nothing for users who've never set a custom theme.
|
||||
user = await db.get(
|
||||
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
|
||||
User, session.user_id, options=[selectinload(User.active_custom_theme)]
|
||||
)
|
||||
if user is None or not user.is_active:
|
||||
request.session.clear()
|
||||
|
||||
+30
-4
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import pathlib
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -11,10 +12,12 @@ from redis.asyncio import Redis
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session_factory
|
||||
from app.routers import (
|
||||
admin,
|
||||
auth,
|
||||
bots,
|
||||
custom_emoji,
|
||||
custom_themes,
|
||||
health,
|
||||
push,
|
||||
@@ -27,8 +30,12 @@ from app.routers import (
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
from app.ws.focus_presence import FocusPresence
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
from app.ws.presence import Presence
|
||||
from app.ws.presence_sweeper import purge_legacy_presence, run_presence_sweeper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# backend/app/main.py -> backend/ -> repo root -- matches both the local
|
||||
# monorepo layout and the production layout (/srv/ds-chat/backend,
|
||||
@@ -56,14 +63,30 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# ASGITransport, used by the plain REST test fixtures -- only
|
||||
# TestClient's websocket_connect-based tests actually run lifespan).
|
||||
# The background listener task genuinely needs a running event loop
|
||||
# though, so that part stays here.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.listen())
|
||||
# though, so that part stays here. run_forever() (not listen() directly)
|
||||
# -- #76: a bare listen() here died permanently and silently the moment
|
||||
# its Redis connection ever dropped (e.g. Redis itself restarting),
|
||||
# with nothing to notice or reconnect; run_forever() supervises it and
|
||||
# restarts on failure instead.
|
||||
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
|
||||
|
||||
# #77: presence entries now expire, and an expiry has no disconnect event
|
||||
# to announce it -- this loop is what notices. The purge clears the old
|
||||
# refcount hashes (and the counts that had leaked into them); it's
|
||||
# best-effort, since Redis being down at startup shouldn't stop the app.
|
||||
try:
|
||||
await purge_legacy_presence(app.state.redis)
|
||||
except Exception:
|
||||
logger.warning("Could not purge legacy presence keys", exc_info=True)
|
||||
sweeper_task = asyncio.create_task(run_presence_sweeper(app))
|
||||
|
||||
yield
|
||||
|
||||
listener_task.cancel()
|
||||
for task in (sweeper_task, listener_task):
|
||||
task.cancel()
|
||||
for task in (sweeper_task, listener_task):
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await listener_task
|
||||
await task
|
||||
await app.state.redis.aclose()
|
||||
|
||||
|
||||
@@ -79,9 +102,11 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
app.state.connection_manager = ConnectionManager()
|
||||
app.state.session_factory = async_session_factory
|
||||
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
app.state.presence = Presence(app.state.redis)
|
||||
app.state.global_presence = GlobalPresence(app.state.redis)
|
||||
app.state.focus_presence = FocusPresence(app.state.redis)
|
||||
app.state.broadcaster = Broadcaster(app.state.redis, app.state.connection_manager)
|
||||
|
||||
app.include_router(health.router)
|
||||
@@ -91,6 +116,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(users.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(custom_themes.router)
|
||||
app.include_router(custom_emoji.router)
|
||||
app.include_router(uploads.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(bots.router)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.models.admin_audit_log import AdminAuditLog
|
||||
from app.models.api_token import ApiToken
|
||||
from app.models.base import Base
|
||||
from app.models.custom_emoji import CustomEmoji
|
||||
from app.models.custom_theme import CustomTheme
|
||||
from app.models.event_subscription import EventSubscription
|
||||
from app.models.invite import InviteStatus
|
||||
@@ -15,6 +16,7 @@ from app.models.message_room_reference import MessageRoomReference
|
||||
from app.models.password_reset import PasswordReset
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.session import Session
|
||||
from app.models.site_invite import SiteInvite
|
||||
from app.models.smtp_settings import SmtpSettings
|
||||
from app.models.upload_settings import UploadSettings
|
||||
@@ -35,6 +37,7 @@ __all__ = [
|
||||
"MessageRoomReference",
|
||||
"InviteStatus",
|
||||
"PasswordReset",
|
||||
"Session",
|
||||
"SiteInvite",
|
||||
"SmtpSettings",
|
||||
"UploadSettings",
|
||||
@@ -44,5 +47,6 @@ __all__ = [
|
||||
"WebhookIncoming",
|
||||
"EventSubscription",
|
||||
"CustomTheme",
|
||||
"CustomEmoji",
|
||||
"LinkPreview",
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class CustomEmoji(Base):
|
||||
__tablename__ = "custom_emoji"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
# #18: site-wide, not room-scoped -- kept globally unique so a bare
|
||||
# `:shortcode:` in any message/reaction is unambiguous without also
|
||||
# knowing which room it was posted in. 30 chars, not 32 -- the stored
|
||||
# *reference* in MessageReaction.emoji (String(32)) is the shortcode
|
||||
# wrapped in colons, so this is sized to leave room for both without
|
||||
# widening that column.
|
||||
shortcode: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False)
|
||||
storage_filename: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
content_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
uploader = relationship("User")
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
@@ -33,6 +33,23 @@ class RoomMembership(Base):
|
||||
last_read_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# #52 follow-up: lets a DM be hidden from one participant's own sidebar
|
||||
# without touching the other participant's copy or deleting anything --
|
||||
# a DM has no sensible "leave" (it would corrupt find_or_create_dm's
|
||||
# exactly-two-members assumption), so this is deliberately a per-viewer
|
||||
# display flag on their own membership row, not a membership deletion.
|
||||
# Cleared automatically (see message_events.py) whenever a new message
|
||||
# arrives in the room, or when find_or_create_dm resolves back to it --
|
||||
# both count as the conversation being active again.
|
||||
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# #67: opt-in, per-member -- email while offline on the room's first
|
||||
# unread message, plus every mention regardless of that debounce (see
|
||||
# message_events.py's _maybe_email_room_notifications). Deliberately
|
||||
# separate from #66's DM emails (always-on, no toggle) rather than a
|
||||
# shared flag, since DMs are explicitly out of scope for this setting.
|
||||
email_notifications: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
|
||||
room = relationship("Room", back_populates="memberships")
|
||||
user = relationship("User")
|
||||
@@ -10,8 +10,12 @@ from app.models.base import Base
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
__table_args__ = (
|
||||
# #53: a deleted message clears content/image_id/file_id entirely
|
||||
# (see message_service.delete_message) -- the "must have something"
|
||||
# rule only applies while the message is actually live.
|
||||
CheckConstraint(
|
||||
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL",
|
||||
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL "
|
||||
"OR deleted_at IS NOT NULL",
|
||||
name="messages_content_or_attachment_required",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,10 @@ class Room(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# #52: a DM is a Room whose `name` is an internal, never-displayed
|
||||
# deterministic token (see room_service.dm_room_name) rather than a
|
||||
# user-chosen name -- see that function's docstring for the scheme.
|
||||
is_dm: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# #69: the row's own id doubles as the opaque value stored in the
|
||||
# signed session cookie (see app/dependencies.py) -- no separate
|
||||
# generate_token()/hash_token() pair like ApiToken needs. A bearer API
|
||||
# token has to be looked up *by itself* from a plaintext string a bot
|
||||
# pastes into an Authorization header (real leak risk, hence hashing
|
||||
# it at rest); this id only ever travels inside itsdangerous's signed,
|
||||
# tamper-proof cookie payload, so a plain UUID primary key carries the
|
||||
# same security properties the stateless cookie already had before
|
||||
# this table existed.
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
# 45 chars fits the longest possible IPv6 text representation.
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# Bumped (throttled, not on every request -- see session_service.py) so
|
||||
# "active sessions" can be sorted/labeled by actual recent use, not just
|
||||
# login time -- a session opened once a week ago and used constantly
|
||||
# since should not look identical to one opened once and abandoned.
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# Null while active. Set on explicit logout or a deliberate "sign out
|
||||
# this device" from another session -- never deleted outright, so a
|
||||
# revoked row still means something if anyone ever needs to ask "was
|
||||
# this session valid at time X."
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user = relationship("User")
|
||||
@@ -19,6 +19,17 @@ class User(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(50))
|
||||
theme: Mapped[str | None] = mapped_column(String(20))
|
||||
# #71: null means "normal" (the pre-existing default before this
|
||||
# setting existed) -- a preset name, not a raw scale factor, so it's
|
||||
# validated/enumerable the same way `theme` already is rather than
|
||||
# accepting an arbitrary float.
|
||||
text_scale: Mapped[str | None] = mapped_column(String(20))
|
||||
# #71: independent of text_scale above -- scales emoji rendered in
|
||||
# message text specifically, not the whole UI (see
|
||||
# frontend/src/components/MessageContent.tsx's --emoji-scale, scoped
|
||||
# to message content only so it can't also inflate the emoji picker's
|
||||
# grid or reaction pills).
|
||||
emoji_scale: Mapped[str | None] = mapped_column(String(20))
|
||||
# Only meaningful when theme == "custom" -- which of this user's saved
|
||||
# CustomTheme rows (app/models/custom_theme.py) is currently active.
|
||||
# Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.services.site_invite_service import (
|
||||
SiteInviteNotPendingError,
|
||||
create_site_invite,
|
||||
list_site_invites,
|
||||
resend_site_invite,
|
||||
revoke_site_invite,
|
||||
)
|
||||
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings
|
||||
@@ -322,6 +323,22 @@ async def revoke_site_invite_endpoint(
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/resend", response_model=SiteInviteRead)
|
||||
async def resend_site_invite_endpoint(
|
||||
invite_id: uuid.UUID,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
return await resend_site_invite(db, current_user, str(request.base_url), invite_id)
|
||||
except SiteInviteNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
except SiteInviteNotPendingError:
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
|
||||
async def get_smtp_settings_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -376,7 +393,7 @@ async def test_smtp_settings_endpoint(
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
await send_test_email(db, current_user.email)
|
||||
await send_test_email(db, current_user.email, theme_user=current_user)
|
||||
except SmtpNotConfiguredError:
|
||||
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
|
||||
from app.schemas.session import SessionRead
|
||||
from app.schemas.user import ProfileUpdate, UserRead
|
||||
from app.services.auth_service import (
|
||||
AccountDeactivatedError,
|
||||
@@ -22,7 +25,15 @@ from app.services.password_service import (
|
||||
request_password_reset,
|
||||
validate_reset_token,
|
||||
)
|
||||
from app.services.session_service import (
|
||||
SessionNotFoundError,
|
||||
list_sessions,
|
||||
revoke_session,
|
||||
revoke_session_unchecked,
|
||||
start_session,
|
||||
)
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.services.user_agent_service import describe_user_agent
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
InvalidImageError,
|
||||
@@ -55,12 +66,15 @@ async def login(
|
||||
except AccountDeactivatedError:
|
||||
raise HTTPException(status_code=401, detail="Account is deactivated")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(request: Request) -> Response:
|
||||
async def logout(request: Request, db: AsyncSession = Depends(get_db)) -> Response:
|
||||
session_id = request.session.get("session_id")
|
||||
if session_id:
|
||||
await revoke_session_unchecked(db, uuid.UUID(session_id))
|
||||
request.session.clear()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -86,6 +100,10 @@ async def update_profile(
|
||||
current_user.display_name = display_name or None
|
||||
if "theme" in updates:
|
||||
current_user.theme = updates["theme"]
|
||||
if "text_scale" in updates:
|
||||
current_user.text_scale = updates["text_scale"]
|
||||
if "emoji_scale" in updates:
|
||||
current_user.emoji_scale = updates["emoji_scale"]
|
||||
if "appear_offline" in updates:
|
||||
current_user.appear_offline = updates["appear_offline"]
|
||||
await db.commit()
|
||||
@@ -218,5 +236,37 @@ async def complete_reset_password_endpoint(
|
||||
except PasswordResetInvalidError:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionRead])
|
||||
async def list_sessions_endpoint(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sessions = await list_sessions(db, current_user.id)
|
||||
return [
|
||||
SessionRead(
|
||||
id=s.id,
|
||||
ip_address=s.ip_address,
|
||||
device_label=describe_user_agent(s.user_agent),
|
||||
created_at=s.created_at,
|
||||
last_seen_at=s.last_seen_at,
|
||||
is_current=s.id == request.state.session_id,
|
||||
)
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
async def revoke_session_endpoint(
|
||||
session_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await revoke_session(db, current_user.id, session_id)
|
||||
except SessionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.custom_emoji import CustomEmojiRead
|
||||
from app.services.custom_emoji_service import (
|
||||
SHORTCODE_PATTERN,
|
||||
CustomEmojiNotFoundError,
|
||||
DuplicateShortcodeError,
|
||||
InvalidShortcodeError,
|
||||
NotEmojiOwnerError,
|
||||
create_custom_emoji,
|
||||
delete_custom_emoji,
|
||||
get_custom_emoji_by_shortcode,
|
||||
list_custom_emoji,
|
||||
)
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
UPLOADS_DIR,
|
||||
InvalidImageError,
|
||||
UploadTooLargeError,
|
||||
process_image,
|
||||
read_capped,
|
||||
save_file,
|
||||
)
|
||||
|
||||
# Small and square -- these render inline in message text/reaction pills at
|
||||
# roughly text size, nowhere near message-image or avatar dimensions.
|
||||
CUSTOM_EMOJI_MAX_DIMENSION = 128
|
||||
|
||||
router = APIRouter(prefix="/api/custom-emoji", tags=["custom-emoji"])
|
||||
|
||||
|
||||
@router.post("", response_model=CustomEmojiRead, status_code=201)
|
||||
async def upload_custom_emoji_endpoint(
|
||||
shortcode: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
shortcode = shortcode.strip().lower()
|
||||
# Checked here, before any file processing/saving, so the common
|
||||
# rejection cases (bad format, name taken) never leave an orphaned
|
||||
# file on disk -- create_custom_emoji below still re-checks both
|
||||
# (the actual source of truth, and the only thing that closes the
|
||||
# TOCTOU race on the uniqueness check).
|
||||
if not SHORTCODE_PATTERN.match(shortcode):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Shortcode must be 2-30 characters: lowercase letters, numbers, hyphens, underscores",
|
||||
)
|
||||
if await get_custom_emoji_by_shortcode(db, shortcode) is not None:
|
||||
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
|
||||
|
||||
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Unsupported image type")
|
||||
|
||||
upload_settings = await get_upload_settings(db)
|
||||
try:
|
||||
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
|
||||
except UploadTooLargeError:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
|
||||
)
|
||||
|
||||
try:
|
||||
data, ext = process_image(
|
||||
data, file.content_type, square=True, max_dimension=CUSTOM_EMOJI_MAX_DIMENSION
|
||||
)
|
||||
except InvalidImageError:
|
||||
raise HTTPException(status_code=400, detail="File is not a valid image")
|
||||
|
||||
storage_filename = save_file(data, ext)
|
||||
try:
|
||||
return await create_custom_emoji(
|
||||
db, current_user.id, shortcode, storage_filename, file.content_type
|
||||
)
|
||||
except (InvalidShortcodeError, DuplicateShortcodeError):
|
||||
# Already checked above -- only reachable via the uniqueness
|
||||
# check's TOCTOU race (two uploads of the same new shortcode at
|
||||
# once), not the common case.
|
||||
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomEmojiRead])
|
||||
async def list_custom_emoji_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_custom_emoji(db)
|
||||
|
||||
|
||||
@router.delete("/{emoji_id}", status_code=204)
|
||||
async def delete_custom_emoji_endpoint(
|
||||
emoji_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await delete_custom_emoji(db, emoji_id, current_user)
|
||||
except CustomEmojiNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Custom emoji not found")
|
||||
except NotEmojiOwnerError:
|
||||
raise HTTPException(status_code=403, detail="Only the uploader or a site admin can remove this")
|
||||
|
||||
|
||||
@router.get("/{shortcode}/image")
|
||||
async def get_custom_emoji_image_endpoint(
|
||||
shortcode: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
emoji = await get_custom_emoji_by_shortcode(db, shortcode)
|
||||
if emoji is None:
|
||||
raise HTTPException(status_code=404, detail="Custom emoji not found")
|
||||
return FileResponse(
|
||||
UPLOADS_DIR / emoji.storage_filename,
|
||||
media_type=emoji.content_type,
|
||||
# #18 follow-up: `max-age=300` (the avatar endpoint's own
|
||||
# convention) meant a browser that had already fetched this
|
||||
# shortcode's image kept serving it from cache for up to 5 minutes
|
||||
# after a delete-and-reupload under the same name swapped in a
|
||||
# genuinely different file underneath the same URL -- confirmed
|
||||
# live, re-adding an emoji with a just-deleted shortcode showed the
|
||||
# old image. `no-cache` (despite the name, still cacheable) forces
|
||||
# a revalidation round trip on every use instead of trusting a
|
||||
# timed cache -- FileResponse already sets ETag/Last-Modified from
|
||||
# the file's own mtime+size (see Starlette's set_stat_headers), so
|
||||
# an unchanged file still gets served as a cheap 304 and only an
|
||||
# actually-different one (any re-upload) returns fresh bytes.
|
||||
headers={"Cache-Control": "private, no-cache"},
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from app.schemas.message import LinkPreviewInfo, MessageFileInfo, MessageRead
|
||||
from app.schemas.message_file import MessageFileCreated
|
||||
from app.schemas.message_image import MessageImageCreated
|
||||
from app.schemas.room import (
|
||||
DmPartnerInfo,
|
||||
MyRoomItem,
|
||||
RoomAttachmentRead,
|
||||
RoomCreate,
|
||||
@@ -24,8 +25,10 @@ from app.schemas.room import (
|
||||
RoomMemberAdd,
|
||||
RoomMemberRead,
|
||||
RoomMemberRoleUpdate,
|
||||
RoomNotificationSettingsUpdate,
|
||||
RoomRead,
|
||||
RoomUpdate,
|
||||
StartDmRequest,
|
||||
TransferOwnershipRequest,
|
||||
)
|
||||
from app.schemas.webhook import (
|
||||
@@ -36,19 +39,24 @@ from app.schemas.webhook import (
|
||||
WebhookIncomingRead,
|
||||
)
|
||||
from app.services.link_preview_service import get_link_previews_for_urls
|
||||
from app.services.message_events import broadcast_room_added
|
||||
from app.services.message_events import broadcast_new_message, broadcast_room_added
|
||||
from app.services.message_service import (
|
||||
create_message,
|
||||
get_reactions_for_messages,
|
||||
list_recent_messages,
|
||||
list_room_attachments,
|
||||
)
|
||||
from app.services.system_user_service import get_or_create_system_user
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.services.room_service import (
|
||||
AlreadyMemberError,
|
||||
CannotDmSelfError,
|
||||
CannotModifyDmError,
|
||||
CannotRemoveOwnerError,
|
||||
DuplicateRoomError,
|
||||
InsufficientRoleError,
|
||||
MembershipNotFoundError,
|
||||
NotADmError,
|
||||
OwnerMustTransferError,
|
||||
RoomIsPrivateError,
|
||||
RoomNotFoundError,
|
||||
@@ -57,7 +65,9 @@ from app.services.room_service import (
|
||||
change_member_role,
|
||||
create_room,
|
||||
delete_room,
|
||||
find_or_create_dm,
|
||||
get_room,
|
||||
hide_dm,
|
||||
join_room,
|
||||
leave_room,
|
||||
list_member_rooms,
|
||||
@@ -65,6 +75,7 @@ from app.services.room_service import (
|
||||
list_room_members,
|
||||
mark_room_read,
|
||||
remove_member,
|
||||
set_room_email_notifications,
|
||||
transfer_ownership,
|
||||
update_room,
|
||||
)
|
||||
@@ -82,6 +93,7 @@ from app.services.webhook_service import (
|
||||
from app.services.ssrf import UnsafeUrlError
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
INLINE_SAFE_VIDEO_CONTENT_TYPES,
|
||||
UPLOADS_DIR,
|
||||
InvalidImageError,
|
||||
UploadTooLargeError,
|
||||
@@ -105,6 +117,28 @@ async def create_room_endpoint(
|
||||
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
||||
|
||||
|
||||
@router.post("/dm", response_model=RoomRead, status_code=201)
|
||||
async def start_dm_endpoint(
|
||||
data: StartDmRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
room = await find_or_create_dm(db, current_user.id, data.other_user_id)
|
||||
except CannotDmSelfError:
|
||||
raise HTTPException(status_code=400, detail="Cannot start a DM with yourself")
|
||||
except TargetUserNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="No user with that ID")
|
||||
# Same signal add_member sends -- without it, the other participant's
|
||||
# already-open client has no way to know this DM exists until they
|
||||
# reload: GET /rooms/mine is only fetched once at app mount. Sent
|
||||
# unconditionally (not just on genuine creation) since re-finding an
|
||||
# existing DM and refreshing their room list again is harmless.
|
||||
await broadcast_room_added(request.app.state.broadcaster, data.other_user_id, room)
|
||||
return room
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoomListItem])
|
||||
async def list_rooms_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -117,6 +151,8 @@ async def list_rooms_endpoint(
|
||||
name=room.name,
|
||||
description=room.description,
|
||||
is_private=room.is_private,
|
||||
is_dm=room.is_dm,
|
||||
is_archived=room.is_archived,
|
||||
owner_id=room.owner_id,
|
||||
created_at=room.created_at,
|
||||
is_member=is_member,
|
||||
@@ -127,23 +163,40 @@ async def list_rooms_endpoint(
|
||||
|
||||
@router.get("/mine", response_model=list[MyRoomItem])
|
||||
async def list_my_rooms_endpoint(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
rooms = await list_member_rooms(db, current_user.id)
|
||||
partner_ids = [partner.id for *_, partner in rooms if partner is not None]
|
||||
online_ids = await request.app.state.global_presence.online_user_ids(partner_ids)
|
||||
return [
|
||||
MyRoomItem(
|
||||
id=room.id,
|
||||
name=room.name,
|
||||
description=room.description,
|
||||
is_private=room.is_private,
|
||||
is_dm=room.is_dm,
|
||||
is_archived=room.is_archived,
|
||||
owner_id=room.owner_id,
|
||||
created_at=room.created_at,
|
||||
role=role,
|
||||
has_unread=has_unread,
|
||||
has_mention=has_mention,
|
||||
email_notifications=email_notifications,
|
||||
dm_partner=(
|
||||
DmPartnerInfo(
|
||||
user_id=partner.id,
|
||||
username=partner.username,
|
||||
display_name=partner.display_name,
|
||||
avatar_filename=partner.avatar_filename,
|
||||
status=_member_status(partner, online_ids),
|
||||
)
|
||||
for room, role, has_unread, has_mention in rooms
|
||||
if partner is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
for room, role, has_unread, has_mention, email_notifications, partner in rooms
|
||||
]
|
||||
|
||||
|
||||
@@ -167,6 +220,8 @@ async def update_room_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
except DuplicateRoomError:
|
||||
raise HTTPException(status_code=409, detail="A room with this name already exists")
|
||||
except CannotModifyDmError:
|
||||
raise HTTPException(status_code=400, detail="DMs can't be edited")
|
||||
|
||||
|
||||
@router.delete("/{room_id}", status_code=204)
|
||||
@@ -215,6 +270,24 @@ async def leave_room_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Not a member of this room")
|
||||
|
||||
|
||||
@router.post("/{room_id}/hide", status_code=204)
|
||||
async def hide_dm_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
try:
|
||||
room = await get_room(db, room_id)
|
||||
await hide_dm(db, room, current_user.id)
|
||||
except RoomNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
except NotADmError:
|
||||
raise HTTPException(status_code=400, detail="Only DMs can be hidden")
|
||||
except MembershipNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Not a member of this room")
|
||||
|
||||
|
||||
@router.post("/{room_id}/read", status_code=204)
|
||||
async def mark_room_read_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
@@ -225,6 +298,20 @@ async def mark_room_read_endpoint(
|
||||
await mark_room_read(db, room_id, current_user.id)
|
||||
|
||||
|
||||
@router.patch("/{room_id}/notifications", status_code=204)
|
||||
async def update_room_notifications_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: RoomNotificationSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_member(room_id, current_user, db)
|
||||
try:
|
||||
await set_room_email_notifications(db, room_id, current_user.id, data.email_notifications)
|
||||
except CannotModifyDmError:
|
||||
raise HTTPException(status_code=400, detail="Email notifications aren't available for DMs")
|
||||
|
||||
|
||||
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
||||
# appear_offline always wins, regardless of actual connection -- that's
|
||||
# the whole point of the override (lurking in a room undetected).
|
||||
@@ -366,6 +453,7 @@ async def get_room_messages_endpoint(
|
||||
reactions=reactions_by_message.get(m.id, []),
|
||||
created_at=m.created_at,
|
||||
edited_at=m.edited_at,
|
||||
deleted_at=m.deleted_at,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
@@ -480,12 +568,25 @@ async def get_room_file_endpoint(
|
||||
message_file = await db.get(MessageFile, file_id)
|
||||
if message_file is None or message_file.room_id != room_id:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
# #65: a browser-playable video is served inline (no filename=) so a
|
||||
# <video> tag can actually play it instead of triggering a download --
|
||||
# gated to a strict allowlist (INLINE_SAFE_VIDEO_CONTENT_TYPES), the
|
||||
# same reasoning MessageImage's own endpoint already relies on: these
|
||||
# are content types a browser only ever interprets as media, never as
|
||||
# something that could execute script, so the attachment-disposition
|
||||
# mitigation below doesn't need to apply to them.
|
||||
if message_file.content_type in INLINE_SAFE_VIDEO_CONTENT_TYPES:
|
||||
return FileResponse(
|
||||
UPLOADS_DIR / message_file.storage_filename,
|
||||
media_type=message_file.content_type,
|
||||
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||
)
|
||||
# `filename=` makes Starlette set Content-Disposition: attachment,
|
||||
# forcing a download instead of an inline render regardless of
|
||||
# content-type -- the mitigation for a same-origin-served, user-
|
||||
# uploaded file (e.g. .html/.svg) executing script in this app's own
|
||||
# origin if opened directly. No content-type allowlist needed on top
|
||||
# of this; see backend/README.md.
|
||||
# of this beyond the video carve-out above; see backend/README.md.
|
||||
return FileResponse(
|
||||
UPLOADS_DIR / message_file.storage_filename,
|
||||
media_type=message_file.content_type,
|
||||
@@ -556,7 +657,37 @@ async def add_member_endpoint(
|
||||
raise HTTPException(status_code=404, detail="No user with that ID")
|
||||
except AlreadyMemberError:
|
||||
raise HTTPException(status_code=409, detail="That user is already a member")
|
||||
|
||||
# room_added first -- the new member's client needs to know this room
|
||||
# exists before it can make sense of an unread_update for it, which the
|
||||
# welcome message below would otherwise trigger out of order.
|
||||
await broadcast_room_added(request.app.state.broadcaster, data.user_id, room)
|
||||
|
||||
# #74: posted as the auto-provisioned System account, not the admin who
|
||||
# did the adding -- "Welcome, bob!" reads as coming from the room/app
|
||||
# itself, not as something the admin personally typed.
|
||||
system_user = await get_or_create_system_user(db)
|
||||
welcome_name = membership.user.display_name or membership.user.username
|
||||
welcome_message = await create_message(
|
||||
db, room.id, system_user.id, f"Welcome to #{room.name}, {welcome_name}!"
|
||||
)
|
||||
# Same "sending implies having seen the room" reasoning as ws/chat.py's
|
||||
# own live-message path -- the admin is the one who caused this message,
|
||||
# and is presumably already looking at this room's member management, so
|
||||
# without this their own client would show it as unread regardless.
|
||||
await mark_room_read(db, room.id, current_user.id)
|
||||
await broadcast_new_message(
|
||||
db,
|
||||
request.app.state.broadcaster,
|
||||
request.app.state.presence,
|
||||
request.app.state.focus_presence,
|
||||
request.app.state.global_presence,
|
||||
str(request.base_url),
|
||||
room.id,
|
||||
welcome_message,
|
||||
system_user,
|
||||
)
|
||||
|
||||
online_ids = await request.app.state.global_presence.online_user_ids([membership.user_id])
|
||||
return RoomMemberRead(
|
||||
user_id=membership.user_id,
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.database import get_db
|
||||
from app.schemas.site_invite import SignupComplete, SignupValidateRead
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.auth_service import DuplicateUserError
|
||||
from app.services.session_service import start_session
|
||||
from app.services.site_invite_service import (
|
||||
SiteInviteInvalidError,
|
||||
complete_signup,
|
||||
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
|
||||
except DuplicateUserError:
|
||||
raise HTTPException(status_code=409, detail="That username or email is already taken")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.schemas.webhook import IncomingWebhookPost
|
||||
from app.services.message_events import broadcast_new_message
|
||||
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
|
||||
from app.services.webhook_service import RoomArchivedError, WebhookNotFoundError, post_via_webhook
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
@@ -22,7 +22,21 @@ async def incoming_webhook_endpoint(
|
||||
message, room, sender = await post_via_webhook(db, token, data.content)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Unknown webhook")
|
||||
except RoomArchivedError:
|
||||
raise HTTPException(status_code=403, detail="This room has been archived and is read-only")
|
||||
|
||||
broadcaster = request.app.state.broadcaster
|
||||
presence = request.app.state.presence
|
||||
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
|
||||
focus_presence = request.app.state.focus_presence
|
||||
global_presence = request.app.state.global_presence
|
||||
await broadcast_new_message(
|
||||
db,
|
||||
broadcaster,
|
||||
presence,
|
||||
focus_presence,
|
||||
global_presence,
|
||||
str(request.base_url),
|
||||
room.id,
|
||||
message,
|
||||
sender,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CustomEmojiRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
shortcode: str
|
||||
uploaded_by: uuid.UUID
|
||||
created_at: datetime
|
||||
@@ -44,3 +44,9 @@ class MessageRead(BaseModel):
|
||||
reactions: list[ReactionSummary]
|
||||
created_at: datetime
|
||||
edited_at: datetime | None
|
||||
# #53: null for a live message; set once deleted, at which point
|
||||
# content/image_id/file/link_preview are all already cleared
|
||||
# server-side (see message_service.delete_message). `reactions` isn't
|
||||
# cleared server-side -- the frontend just doesn't render them once
|
||||
# deleted_at is set, same as it doesn't render the rest of a tombstone.
|
||||
deleted_at: datetime | None
|
||||
@@ -26,6 +26,12 @@ class RoomRead(BaseModel):
|
||||
name: str
|
||||
description: str | None
|
||||
is_private: bool
|
||||
is_dm: bool
|
||||
# #57: previously only exposed on the admin-only AdminRoom schema, so a
|
||||
# member of an archived room had no way to even know it was archived --
|
||||
# the flag was set server-side but had no effect on their own view of
|
||||
# the room at all.
|
||||
is_archived: bool
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
@@ -34,6 +40,14 @@ class RoomListItem(RoomRead):
|
||||
is_member: bool
|
||||
|
||||
|
||||
class DmPartnerInfo(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
username: str
|
||||
display_name: str | None
|
||||
avatar_filename: str | None
|
||||
status: Literal["online", "offline"]
|
||||
|
||||
|
||||
class MyRoomItem(RoomRead):
|
||||
role: RoomRole
|
||||
# Whether this room has a message newer than the caller's last_read_at --
|
||||
@@ -44,6 +58,25 @@ class MyRoomItem(RoomRead):
|
||||
# over has_unread in the sidebar (see RoomRow.tsx), not shown alongside
|
||||
# it.
|
||||
has_mention: bool
|
||||
# #52: populated only when is_dm is true -- the *other* participant,
|
||||
# precomputed here so the sidebar can render a DM row (their name +
|
||||
# avatar, not this room's internal `name`) without a second fetch per
|
||||
# row. None for a regular room.
|
||||
dm_partner: DmPartnerInfo | None = None
|
||||
# #67: this viewer's own opt-in for email while offline -- the room's
|
||||
# first unread message plus every mention (see message_events.py's
|
||||
# _maybe_email_room_notifications for the exact debounce shape). Always
|
||||
# false for a DM, deliberately out of scope -- DMs already get #66's
|
||||
# automatic offline email.
|
||||
email_notifications: bool = False
|
||||
|
||||
|
||||
class StartDmRequest(BaseModel):
|
||||
other_user_id: uuid.UUID
|
||||
|
||||
|
||||
class RoomNotificationSettingsUpdate(BaseModel):
|
||||
email_notifications: bool
|
||||
|
||||
|
||||
class RoomMemberRead(BaseModel):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
ip_address: str | None
|
||||
# Parsed from the stored user_agent by the router (see
|
||||
# user_agent_service.describe_user_agent) -- not a stored column, so a
|
||||
# future improvement to the parser applies retroactively to old rows
|
||||
# too.
|
||||
device_label: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
# Whether this is the session the request making this call is itself
|
||||
# authenticated with -- lets the UI mark "this device" and treat
|
||||
# revoking it as a self-logout rather than just another row.
|
||||
is_current: bool
|
||||
@@ -23,6 +23,8 @@ class UserRead(BaseModel):
|
||||
is_site_admin: bool
|
||||
display_name: str | None
|
||||
theme: str | None
|
||||
text_scale: str | None
|
||||
emoji_scale: str | None
|
||||
# Resolved, not just an id -- the frontend needs the actual palette to
|
||||
# paint on load without a second round trip (see lib/theme.ts).
|
||||
active_custom_theme: CustomThemeRead | None
|
||||
@@ -57,6 +59,10 @@ class ProfileUpdate(BaseModel):
|
||||
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
|
||||
# bare theme name with nothing to point it at.
|
||||
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
|
||||
# #71: kept in sync with frontend/src/lib/theme.ts's TEXT_SCALE_PERCENT map.
|
||||
text_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
|
||||
# #71: kept in sync with MessageContent.tsx's EMOJI_SCALE_MULTIPLIER map.
|
||||
emoji_scale: Literal["small", "normal", "large", "xlarge"] | None = Field(default=None)
|
||||
appear_offline: bool | None = Field(default=None)
|
||||
|
||||
|
||||
|
||||
@@ -93,9 +93,14 @@ async def set_user_site_admin(
|
||||
|
||||
|
||||
async def list_rooms_admin(db: AsyncSession) -> list[tuple[Room, int]]:
|
||||
# #52: DMs are fully private, not just unlisted -- excluded here rather
|
||||
# than merely omitted from the response, so there's no admin-portal
|
||||
# surface (this list, or the audit log via anything that touches this
|
||||
# query) that reveals a DM even exists between two users.
|
||||
result = await db.execute(
|
||||
select(Room, func.count(RoomMembership.user_id))
|
||||
.outerjoin(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(Room.is_dm.is_(False))
|
||||
.group_by(Room.id)
|
||||
.order_by(Room.created_at)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import CustomEmoji, User
|
||||
from app.storage import delete_file
|
||||
|
||||
# Deliberately stricter than the built-in Unicode shortcode set's charset
|
||||
# (see frontend/src/lib/emojiShortcodes.ts, which also allows '+') -- this
|
||||
# is validating a *new name being chosen*, not matching against an
|
||||
# existing fixed list, so there's no reason to allow anything a person
|
||||
# wouldn't naturally type. Max 30 chars matches CustomEmoji.shortcode's
|
||||
# column width exactly (see that model's comment for why).
|
||||
SHORTCODE_PATTERN = re.compile(r"^[a-z0-9_-]{2,30}$")
|
||||
|
||||
|
||||
class InvalidShortcodeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DuplicateShortcodeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CustomEmojiNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotEmojiOwnerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_custom_emoji(
|
||||
db: AsyncSession,
|
||||
uploaded_by: uuid.UUID,
|
||||
shortcode: str,
|
||||
storage_filename: str,
|
||||
content_type: str,
|
||||
) -> CustomEmoji:
|
||||
if not SHORTCODE_PATTERN.match(shortcode):
|
||||
raise InvalidShortcodeError()
|
||||
|
||||
emoji = CustomEmoji(
|
||||
shortcode=shortcode,
|
||||
storage_filename=storage_filename,
|
||||
content_type=content_type,
|
||||
uploaded_by=uploaded_by,
|
||||
)
|
||||
db.add(emoji)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise DuplicateShortcodeError() from exc
|
||||
await db.refresh(emoji)
|
||||
return emoji
|
||||
|
||||
|
||||
async def list_custom_emoji(db: AsyncSession) -> list[CustomEmoji]:
|
||||
result = await db.execute(select(CustomEmoji).order_by(CustomEmoji.shortcode))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_custom_emoji_by_shortcode(db: AsyncSession, shortcode: str) -> CustomEmoji | None:
|
||||
result = await db.execute(select(CustomEmoji).where(CustomEmoji.shortcode == shortcode))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_custom_emoji(db: AsyncSession, emoji_id: uuid.UUID, current_user: User) -> None:
|
||||
emoji = await db.get(CustomEmoji, emoji_id)
|
||||
if emoji is None:
|
||||
raise CustomEmojiNotFoundError()
|
||||
if emoji.uploaded_by != current_user.id and not current_user.is_site_admin:
|
||||
raise NotEmojiOwnerError()
|
||||
delete_file(emoji.storage_filename)
|
||||
await db.delete(emoji)
|
||||
await db.commit()
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
@@ -5,7 +6,7 @@ import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crypto import decrypt
|
||||
from app.models import SmtpSettings
|
||||
from app.models import CustomTheme, SmtpSettings, User
|
||||
from app.services.smtp_settings_service import get_smtp_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
|
||||
# #68: the 6 tokens actually used by the email template below, out of the
|
||||
# full ~12-token palette themes.css defines per preset -- an email has no
|
||||
# equivalent of --ds-surface-2/--ds-void-2/--ds-highlight/--ds-danger, it's
|
||||
# one card on one background with one accent. Kept in sync by hand with
|
||||
# frontend/src/styles/tokens.css (the default) and themes.css (the other
|
||||
# three presets) -- there's no way to share the source of truth across the
|
||||
# Python/CSS boundary, so if either changes the other needs updating too.
|
||||
DEFAULT_PALETTE = {
|
||||
"void": "#07080f",
|
||||
"surface": "#101030",
|
||||
"border": "#242478",
|
||||
"text": "#fce4fc",
|
||||
"muted": "#c0ccd8",
|
||||
"accent": "#60d8fc",
|
||||
}
|
||||
_PRESET_PALETTES: dict[str, dict[str, str]] = {
|
||||
"dark": DEFAULT_PALETTE,
|
||||
"light": {
|
||||
"void": "#f5f3fb",
|
||||
"surface": "#ffffff",
|
||||
"border": "#d8d2ee",
|
||||
"text": "#1a1030",
|
||||
"muted": "#675f80",
|
||||
"accent": "#0891b2",
|
||||
},
|
||||
"midnight": {
|
||||
"void": "#000000",
|
||||
"surface": "#0a0a14",
|
||||
"border": "#262640",
|
||||
"text": "#ffffff",
|
||||
"muted": "#a8b0c0",
|
||||
"accent": "#00f0ff",
|
||||
},
|
||||
"sunset": {
|
||||
"void": "#120a07",
|
||||
"surface": "#241408",
|
||||
"border": "#4a2c14",
|
||||
"text": "#fce8d8",
|
||||
"muted": "#c8b0a0",
|
||||
"accent": "#fca050",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_palette(db: AsyncSession, theme_user: User | None) -> dict[str, str]:
|
||||
"""#68: an email addressed to an existing user is styled with *their*
|
||||
selected theme (mirroring the app itself), not a fixed look -- but
|
||||
there's no such thing as "their theme" for someone who doesn't have an
|
||||
account yet (site invites), so theme_user is None there and this falls
|
||||
back to the default DarkSingularity palette, same as a logged-out page.
|
||||
"""
|
||||
if theme_user is None or theme_user.theme is None:
|
||||
return DEFAULT_PALETTE
|
||||
if theme_user.theme == "custom":
|
||||
if theme_user.active_custom_theme_id is not None:
|
||||
# A fresh PK fetch, not `theme_user.active_custom_theme` --
|
||||
# that relationship is essentially never eager-loaded by
|
||||
# whatever query got this User row in the first place, and
|
||||
# touching it lazily here would raise MissingGreenlet in
|
||||
# async SQLAlchemy.
|
||||
custom = await db.get(CustomTheme, theme_user.active_custom_theme_id)
|
||||
if custom is not None:
|
||||
colors = custom.colors
|
||||
return {key: colors[key] for key in DEFAULT_PALETTE}
|
||||
return DEFAULT_PALETTE
|
||||
return _PRESET_PALETTES.get(theme_user.theme, DEFAULT_PALETTE)
|
||||
|
||||
|
||||
def _render_text(paragraphs: list[str], cta_label: str | None, cta_url: str | None) -> str:
|
||||
body = "\n\n".join(paragraphs)
|
||||
if cta_label and cta_url:
|
||||
body += f"\n\n{cta_label}: {cta_url}"
|
||||
return body
|
||||
|
||||
|
||||
def _render_html(
|
||||
palette: dict[str, str],
|
||||
subject: str,
|
||||
paragraphs: list[str],
|
||||
cta_label: str | None,
|
||||
cta_url: str | None,
|
||||
) -> str:
|
||||
# Table-based layout with everything inlined -- not the app's own CSS
|
||||
# custom properties (email clients strip <style> blocks and don't
|
||||
# support :root variables), just their resolved hex values baked in
|
||||
# per send. Deliberately plain: one card, one accent color, no imagery
|
||||
# that could get blocked by a client's "show images" gate and leave
|
||||
# the email looking broken instead of just plain.
|
||||
paragraphs_html = "".join(
|
||||
f'<p style="margin:0 0 16px;color:{palette["muted"]};font-size:15px;'
|
||||
f'line-height:1.6;">{html.escape(p)}</p>'
|
||||
for p in paragraphs
|
||||
)
|
||||
cta_html = ""
|
||||
if cta_label and cta_url:
|
||||
cta_html = f"""
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">
|
||||
<tr>
|
||||
<td style="border-radius:8px;background:{palette["accent"]};">
|
||||
<a href="{html.escape(cta_url)}" style="display:inline-block;padding:12px 22px;
|
||||
font-size:15px;font-weight:700;color:{palette["void"]};text-decoration:none;
|
||||
border-radius:8px;">{html.escape(cta_label)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:{palette["void"]};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background:{palette["void"]};padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0"
|
||||
style="max-width:480px;width:100%;background:{palette["surface"]};
|
||||
border:1px solid {palette["border"]};border-radius:12px;padding:32px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size:13px;font-weight:800;letter-spacing:0.06em;
|
||||
text-transform:uppercase;color:{palette["accent"]};margin:0 0 20px;">DS Chat</div>
|
||||
<h1 style="margin:0 0 16px;font-size:20px;font-weight:800;
|
||||
color:{palette["text"]};">{html.escape(subject)}</h1>
|
||||
{paragraphs_html}
|
||||
{cta_html}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
async def _deliver(
|
||||
cfg: SmtpSettings, to_address: str, subject: str, html_body: str, text_body: str
|
||||
) -> None:
|
||||
"""Raises on failure -- internal helper only. Callers decide whether to
|
||||
swallow (send_email) or surface (send_test_email) the error."""
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg.from_address
|
||||
message["To"] = to_address
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
# Plain-text part first, HTML as the alternative -- standard
|
||||
# multipart/alternative ordering (least to most preferred), so a
|
||||
# client with no HTML support (or a spam filter) still gets a normal
|
||||
# readable email instead of raw markup.
|
||||
message.set_content(text_body)
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
|
||||
|
||||
@@ -55,29 +198,56 @@ async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str)
|
||||
)
|
||||
|
||||
|
||||
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
|
||||
async def send_email(
|
||||
db: AsyncSession,
|
||||
to_address: str,
|
||||
subject: str,
|
||||
paragraphs: list[str],
|
||||
*,
|
||||
cta_label: str | None = None,
|
||||
cta_url: str | None = None,
|
||||
theme_user: User | None = None,
|
||||
) -> None:
|
||||
"""Best-effort -- used by invite/notification flows. Never raises: an
|
||||
SMTP outage or missing configuration must never block an action (an
|
||||
invite, a room membership) that already succeeded in the database."""
|
||||
invite, a room membership) that already succeeded in the database.
|
||||
|
||||
`paragraphs` replaces the old flat `body: str` (#68) -- each entry
|
||||
renders as its own paragraph in both the HTML and plain-text parts,
|
||||
which a single pre-formatted string can't cleanly become HTML from
|
||||
without re-parsing it. `theme_user`, when given, styles the email with
|
||||
that user's own selected theme (default palette if they haven't picked
|
||||
one, or don't have an account at all -- see _resolve_palette).
|
||||
"""
|
||||
cfg = await get_smtp_settings(db)
|
||||
if cfg is None:
|
||||
logger.debug("SMTP not configured; skipping email to %s", to_address)
|
||||
# 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 (confirmed live:
|
||||
# a real "no emails arriving" report produced nothing in the logs
|
||||
# at all, this line included, even though it was relevant).
|
||||
logger.warning("SMTP not configured; skipping email to %s", to_address)
|
||||
return
|
||||
palette = await _resolve_palette(db, theme_user)
|
||||
html_body = _render_html(palette, subject, paragraphs, cta_label, cta_url)
|
||||
text_body = _render_text(paragraphs, cta_label, cta_url)
|
||||
try:
|
||||
await _deliver(cfg, to_address, subject, body)
|
||||
await _deliver(cfg, to_address, subject, html_body, text_body)
|
||||
except Exception:
|
||||
logger.warning("Failed to send email to %s", to_address, exc_info=True)
|
||||
|
||||
|
||||
async def send_test_email(db: AsyncSession, to_address: str) -> None:
|
||||
async def send_test_email(db: AsyncSession, to_address: str, theme_user: User | None = None) -> None:
|
||||
"""Used only by the admin 'send test email' button -- raises so the
|
||||
admin UI can show why it failed instead of a silent no-op."""
|
||||
admin UI can show why it failed instead of a silent no-op. theme_user
|
||||
is the admin themselves (see routers/admin.py) -- the preview shows
|
||||
them their own emails' real look, not a generic default."""
|
||||
cfg = await get_smtp_settings(db)
|
||||
if cfg is None:
|
||||
raise SmtpNotConfiguredError()
|
||||
await _deliver(
|
||||
cfg,
|
||||
to_address,
|
||||
"DS Chat test email",
|
||||
"This is a test email from DS Chat to confirm your SMTP settings are working.",
|
||||
)
|
||||
subject = "DS Chat test email"
|
||||
paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
|
||||
palette = await _resolve_palette(db, theme_user)
|
||||
html_body = _render_html(palette, subject, paragraphs, None, None)
|
||||
text_body = _render_text(paragraphs, None, None)
|
||||
await _deliver(cfg, to_address, subject, html_body, text_body)
|
||||
@@ -27,8 +27,18 @@ _TRAILING_PUNCTUATION = ".,;:!?)'\">"
|
||||
_FETCH_TIMEOUT_SECONDS = 5.0
|
||||
_MAX_BYTES = 512 * 1024
|
||||
_MAX_REDIRECTS = 3
|
||||
# #70: was 7 days -- confirmed live as too long for how this app actually
|
||||
# gets used: re-posting a URL whose title/content had genuinely changed
|
||||
# kept showing the stale first-fetch preview for up to a week. Short
|
||||
# enough that it's effectively "always fresh" for any realistic human
|
||||
# posting cadence, while still doing the one thing a cache here is
|
||||
# actually for -- collapsing a burst of near-simultaneous fetches of the
|
||||
# same URL (several people pasting the same link within moments of each
|
||||
# other, or the same person's message history being loaded repeatedly)
|
||||
# into one, and not hammering a URL that just failed on every message
|
||||
# that references it.
|
||||
_USER_AGENT = "ds-chat-link-preview/1.0"
|
||||
_CACHE_TTL = timedelta(days=7)
|
||||
_CACHE_TTL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def extract_first_url(content: str | None) -> str | None:
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.email_service import send_email
|
||||
from app.services.link_preview_service import fetch_and_broadcast_link_preview
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.room_service import list_dm_partner_ids
|
||||
from app.services.webhook_service import dispatch_event
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
from app.ws.focus_presence import FocusPresence
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
from app.ws.presence import Presence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _notify_offline_members(
|
||||
db: AsyncSession,
|
||||
broadcaster: Broadcaster,
|
||||
presence: Presence,
|
||||
focus_presence: FocusPresence,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
@@ -25,12 +33,28 @@ async def _notify_offline_members(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
|
||||
)
|
||||
member_ids = {row[0] for row in result.all()}
|
||||
connected_ids = await presence.connected_user_ids(room_id)
|
||||
# Subtract the sender explicitly rather than relying on them being
|
||||
# "connected" (true for the WS path, since they just sent this over an
|
||||
# active connection -- not true for the incoming-webhook REST path,
|
||||
# which has no WS connection for the attributed sender at all).
|
||||
offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
|
||||
if not offline_ids:
|
||||
offline_ids = member_ids - connected_ids - {sender.id}
|
||||
|
||||
# #59: a desktop-mode member can be *connected* to this room's channel
|
||||
# (it's open on screen, live messages are rendering) while their window
|
||||
# sits unfocused behind something else -- still exactly the situation a
|
||||
# desktop notification should fire for, same as #49's original intent.
|
||||
# This used to be handled by the client faking "offline" (leaving the
|
||||
# room's channel on blur), which also silently stopped live delivery to
|
||||
# that room; FocusPresence is a separate signal so notification
|
||||
# eligibility no longer has to ride on room-connection state at all.
|
||||
connected_but_unfocused_ids = {
|
||||
user_id
|
||||
for user_id in connected_ids - {sender.id}
|
||||
if await focus_presence.is_unfocused(user_id)
|
||||
}
|
||||
notify_ids = offline_ids | connected_but_unfocused_ids
|
||||
if not notify_ids:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
@@ -38,12 +62,10 @@ async def _notify_offline_members(
|
||||
)
|
||||
mentioned_ids = {row[0] for row in result.all()}
|
||||
|
||||
# This is also exactly the right audience for "give this room an unread
|
||||
# dot": presence.connected_user_ids(room_id) means "has this room's
|
||||
# channel joined right now" -- which the client only does while the tab
|
||||
# is genuinely foregrounded (see useChatSocket.ts's visibility-gated
|
||||
# join/leave), so a backgrounded-but-open room correctly lands here too,
|
||||
# not just rooms that aren't open at all.
|
||||
# Unread-dot audience stays exactly offline_ids, not notify_ids: a
|
||||
# connected-but-unfocused member still has the room open and rendering
|
||||
# on screen right now, so it isn't actually "unread" for them the way a
|
||||
# room they haven't got open at all is.
|
||||
for user_id in offline_ids:
|
||||
await broadcaster.publish_to_user(
|
||||
user_id,
|
||||
@@ -56,7 +78,7 @@ async def _notify_offline_members(
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
title = f"#{room.name}" if room else "New message"
|
||||
for user_id in offline_ids:
|
||||
for user_id in notify_ids:
|
||||
mentioned = user_id in mentioned_ids
|
||||
if message.content:
|
||||
prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: "
|
||||
@@ -117,6 +139,10 @@ async def _message_payload(db: AsyncSession, message: Message, username: str) ->
|
||||
"reactions": [],
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
# Always null here -- a message just being created can't already be
|
||||
# deleted -- but included for wire-format parity with MessageRead
|
||||
# and message_deleted (#53).
|
||||
"deleted_at": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +153,199 @@ def _maybe_fetch_link_preview(broadcaster: Broadcaster, room_id: uuid.UUID, mess
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_email_dm_notification(
|
||||
db: AsyncSession,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
) -> None:
|
||||
"""#66: DMs only, deliberately narrower than _notify_offline_members'
|
||||
own "offline" -- that one means "not connected to this room's channel
|
||||
right now," which fires on every message and is fine for a lightweight
|
||||
channel (push/desktop). Email is heavier-weight and a DM's other
|
||||
participant could easily be actively using the app in a different room,
|
||||
so this uses GlobalPresence (genuinely no open connection anywhere)
|
||||
instead -- the same "is this user actually offline" logic as
|
||||
rooms.py's private _member_status (not importable from here), just
|
||||
re-derived.
|
||||
"""
|
||||
# #68 follow-up: this whole function used to have zero logging on any
|
||||
# of its early-return paths, which made "why didn't an email go out"
|
||||
# completely undiagnosable from the outside -- confirmed live, a real
|
||||
# report of "no emails" produced nothing in the logs at all, not even
|
||||
# at the level that turned out to be the actual cause. logger.warning
|
||||
# (not .info/.debug) is deliberate: this app has no logging config
|
||||
# setting the root level below Python's own WARNING default, so
|
||||
# anything logged lower than that is silently invisible in production
|
||||
# regardless of what it's actually about -- these aren't really
|
||||
# warnings, they're the only level guaranteed to reach journalctl
|
||||
# today.
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None or not room.is_dm:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id != sender.id
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if membership is None:
|
||||
logger.warning("DM email skipped for room %s: no other participant found", room_id)
|
||||
return
|
||||
recipient = await db.get(User, membership.user_id)
|
||||
if recipient is None:
|
||||
logger.warning(
|
||||
"DM email skipped for room %s: recipient user %s not found", room_id, membership.user_id
|
||||
)
|
||||
return
|
||||
# appear_offline is a manual "always look offline" override -- treated
|
||||
# the same as genuinely offline here, same as everywhere else it's
|
||||
# checked in this codebase.
|
||||
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
|
||||
logger.warning(
|
||||
"DM email skipped for room %s: recipient %s is online", room_id, recipient.id
|
||||
)
|
||||
return
|
||||
|
||||
# Debounced to the first unread message in this conversation, not
|
||||
# every single one -- a burst of DMs while someone's asleep should be
|
||||
# one email, not one per message.
|
||||
already_unread = await db.execute(
|
||||
select(Message.id)
|
||||
.where(
|
||||
Message.room_id == room_id,
|
||||
Message.id != message.id,
|
||||
Message.created_at > membership.last_read_at,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if already_unread.scalar_one_or_none() is not None:
|
||||
logger.warning(
|
||||
"DM email skipped for room %s: recipient %s already has unread messages",
|
||||
room_id,
|
||||
recipient.id,
|
||||
)
|
||||
return
|
||||
|
||||
if message.content:
|
||||
body_line = f"{sender.username}: {message.content[:200]}"
|
||||
elif message.file_id:
|
||||
body_line = f"{sender.username} sent a file"
|
||||
else:
|
||||
body_line = f"{sender.username} sent an image"
|
||||
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
|
||||
logger.warning("Sending DM email to %s for room %s", recipient.email, room_id)
|
||||
await send_email(
|
||||
db,
|
||||
recipient.email,
|
||||
f"New message from {sender.username}",
|
||||
[body_line],
|
||||
cta_label="Open conversation",
|
||||
cta_url=link,
|
||||
theme_user=recipient,
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_email_room_notifications(
|
||||
db: AsyncSession,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
sender: User,
|
||||
message: Message,
|
||||
) -> None:
|
||||
"""#67: opt-in, per-room email -- deliberately scoped to regular rooms
|
||||
only (see set_room_email_notifications: DMs already get #66's
|
||||
always-on offline email, no separate toggle).
|
||||
|
||||
Two triggers, not one: the room's first unread message debounces the
|
||||
same way #66 does (one email per unread burst, not one per message),
|
||||
but a mention always emails regardless of that debounce -- a mention
|
||||
is a stronger, individually-addressed signal that shouldn't get
|
||||
silently swallowed just because an earlier plain message in the same
|
||||
burst already used up the "first unread" email.
|
||||
"""
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None or room.is_dm:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
|
||||
)
|
||||
mentioned_ids = {row[0] for row in result.all()}
|
||||
|
||||
result = await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id,
|
||||
RoomMembership.user_id != sender.id,
|
||||
RoomMembership.email_notifications.is_(True),
|
||||
)
|
||||
)
|
||||
subscribed_memberships = result.scalars().all()
|
||||
if not subscribed_memberships:
|
||||
return
|
||||
|
||||
for membership in subscribed_memberships:
|
||||
user_id = membership.user_id
|
||||
recipient = await db.get(User, user_id)
|
||||
if recipient is None:
|
||||
continue
|
||||
# appear_offline always wins here too, same as _maybe_email_dm_notification.
|
||||
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
|
||||
continue
|
||||
|
||||
mentioned = user_id in mentioned_ids
|
||||
if not mentioned:
|
||||
already_unread = await db.execute(
|
||||
select(Message.id)
|
||||
.where(
|
||||
Message.room_id == room_id,
|
||||
Message.id != message.id,
|
||||
Message.created_at > membership.last_read_at,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if already_unread.scalar_one_or_none() is not None:
|
||||
continue
|
||||
|
||||
if mentioned:
|
||||
subject = f"New mention in #{room.name}"
|
||||
body_line = (
|
||||
f"{sender.username} mentioned you: {message.content[:200]}"
|
||||
if message.content
|
||||
else f"{sender.username} mentioned you"
|
||||
)
|
||||
else:
|
||||
subject = f"New message in #{room.name}"
|
||||
if message.content:
|
||||
body_line = f"{sender.username}: {message.content[:200]}"
|
||||
elif message.file_id:
|
||||
body_line = f"{sender.username} sent a file"
|
||||
else:
|
||||
body_line = f"{sender.username} sent an image"
|
||||
|
||||
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
|
||||
await send_email(
|
||||
db,
|
||||
recipient.email,
|
||||
subject,
|
||||
[body_line],
|
||||
cta_label="Open room",
|
||||
cta_url=link,
|
||||
theme_user=recipient,
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_new_message(
|
||||
db: AsyncSession,
|
||||
broadcaster: Broadcaster,
|
||||
presence: Presence,
|
||||
focus_presence: FocusPresence,
|
||||
global_presence: GlobalPresence,
|
||||
base_url: str,
|
||||
room_id: uuid.UUID,
|
||||
message: Message,
|
||||
sender: User,
|
||||
@@ -140,7 +355,30 @@ async def broadcast_new_message(
|
||||
trigger identical fan-out/push/event behavior."""
|
||||
payload = await _message_payload(db, message, sender.username)
|
||||
await broadcaster.publish(room_id, payload)
|
||||
await _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
|
||||
# A no-op for a regular room (hidden_at is only ever set on a DM's
|
||||
# membership row -- see RoomMembership.hidden_at) -- new activity
|
||||
# un-hiding a DM someone closed matches find_or_create_dm's own
|
||||
# un-hide-on-reopen behavior. `.returning` so we know exactly who was
|
||||
# un-hidden -- their client needs the same room_added signal a brand
|
||||
# new DM does (see broadcast_room_added's docstring): the room wasn't
|
||||
# in their already-loaded room list at all, so unread_update's plain
|
||||
# setRooms(prev => prev.map(...)) can't make it reappear -- there's
|
||||
# nothing in `prev` for it to match.
|
||||
unhidden_result = await db.execute(
|
||||
update(RoomMembership)
|
||||
.where(RoomMembership.room_id == room_id, RoomMembership.hidden_at.is_not(None))
|
||||
.values(hidden_at=None)
|
||||
.returning(RoomMembership.user_id)
|
||||
)
|
||||
unhidden_user_ids = list(unhidden_result.scalars().all())
|
||||
await db.commit()
|
||||
for unhidden_user_id in unhidden_user_ids:
|
||||
await broadcaster.publish_to_user(
|
||||
unhidden_user_id, {"type": "room_added", "room_id": str(room_id)}
|
||||
)
|
||||
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
|
||||
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
|
||||
await _maybe_email_room_notifications(db, global_presence, base_url, room_id, sender, message)
|
||||
await dispatch_event(db, "message.created", room_id, payload)
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
@@ -165,6 +403,16 @@ async def broadcast_message_update(
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
|
||||
async def broadcast_message_delete(broadcaster: Broadcaster, room_id: uuid.UUID, message_id: uuid.UUID) -> None:
|
||||
# #53: no dispatch_event() call, deliberately -- same scope cut as
|
||||
# broadcast_reaction_update's, and for the same reason (see
|
||||
# backend/README.md): message.deleted isn't an outgoing-webhook event
|
||||
# type here.
|
||||
await broadcaster.publish(
|
||||
room_id, {"type": "message_deleted", "id": str(message_id), "room_id": str(room_id)}
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_reaction_update(
|
||||
broadcaster: Broadcaster,
|
||||
room_id: uuid.UUID,
|
||||
@@ -199,6 +447,29 @@ async def broadcast_member_updated(db: AsyncSession, broadcaster: Broadcaster, u
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_dm_presence_update(
|
||||
db: AsyncSession, broadcaster: Broadcaster, user_id: uuid.UUID, online: bool
|
||||
) -> None:
|
||||
"""Tells every one of user_id's DM partners that their online/offline
|
||||
status just changed (#63) -- on each partner's own per-user channel,
|
||||
not the DM room's channel. The room channel alone doesn't reach the
|
||||
sidebar: Presence gates room-channel delivery on actually having that
|
||||
specific room's channel joined right now, which is only ever the one
|
||||
room currently open in the UI -- so a DM sitting unopened in the
|
||||
sidebar (which is the normal case; the sidebar shows every DM's status
|
||||
at once) never saw its partner's status change until something else
|
||||
forced a full room-list refetch."""
|
||||
for partner_id in await list_dm_partner_ids(db, user_id):
|
||||
await broadcaster.publish_to_user(
|
||||
partner_id,
|
||||
{
|
||||
"type": "dm_presence_update",
|
||||
"user_id": str(user_id),
|
||||
"status": "online" if online else "offline",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_room_added(broadcaster: Broadcaster, user_id: uuid.UUID, room: Room) -> None:
|
||||
"""The only signal a user's open client gets that they were just added
|
||||
to a room -- without it, GET /rooms/mine is only ever fetched once at
|
||||
|
||||
@@ -6,11 +6,19 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, MessageMention, MessageReaction, MessageRoomReference
|
||||
from app.models import (
|
||||
Message,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageMention,
|
||||
MessageReaction,
|
||||
MessageRoomReference,
|
||||
)
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.link_preview_service import extract_first_url
|
||||
from app.services.mention_service import extract_mentioned_user_ids
|
||||
from app.services.room_reference_service import extract_referenced_room_ids
|
||||
from app.storage import delete_file
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
@@ -56,7 +64,9 @@ async def edit_message(
|
||||
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None:
|
||||
# A deleted message might as well not exist for editing purposes --
|
||||
# same MessageNotFoundError a genuinely missing id would raise.
|
||||
if message is None or message.deleted_at is not None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != editor_id:
|
||||
raise NotMessageAuthorError()
|
||||
@@ -69,6 +79,50 @@ async def edit_message(
|
||||
return message
|
||||
|
||||
|
||||
async def delete_message(db: AsyncSession, message_id: uuid.UUID, deleter_id: uuid.UUID) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None or message.deleted_at is not None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != deleter_id:
|
||||
raise NotMessageAuthorError()
|
||||
|
||||
# Fetch the attachment's storage filename (if any) before clearing the
|
||||
# message's own FK to it -- the file is only unlinked from disk after a
|
||||
# successful commit below, mirroring delete_room's identical ordering:
|
||||
# a rolled-back transaction should never leave us having destroyed
|
||||
# something we couldn't get back.
|
||||
image_filename: str | None = None
|
||||
file_filename: str | None = None
|
||||
if message.image_id is not None:
|
||||
image = await db.get(MessageImage, message.image_id)
|
||||
if image is not None:
|
||||
image_filename = image.storage_filename
|
||||
await db.delete(image)
|
||||
if message.file_id is not None:
|
||||
message_file = await db.get(MessageFile, message.file_id)
|
||||
if message_file is not None:
|
||||
file_filename = message_file.storage_filename
|
||||
await db.delete(message_file)
|
||||
|
||||
# #53: a real delete, not just a UI hide -- content and any attachment
|
||||
# are actually gone, not merely unlinked-but-still-fetchable. Only
|
||||
# deleted_at (plus id/room_id/user_id/created_at, kept so the tombstone
|
||||
# still occupies its place in history) survives.
|
||||
message.content = None
|
||||
message.image_id = None
|
||||
message.file_id = None
|
||||
message.preview_url = None
|
||||
message.deleted_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
for filename in (image_filename, file_filename):
|
||||
if filename is not None:
|
||||
delete_file(filename)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||
) -> list[Message]:
|
||||
|
||||
@@ -46,10 +46,13 @@ async def request_password_reset(db: AsyncSession, email: str, base_url: str) ->
|
||||
db,
|
||||
email,
|
||||
"Reset your DS Chat password",
|
||||
f"Someone requested a password reset for this account.\n\n"
|
||||
f"Reset it here:\n{reset_link}\n\n"
|
||||
f"This link expires in 15 minutes. If you didn't request this, "
|
||||
f"you can ignore this email.",
|
||||
[
|
||||
"Someone requested a password reset for this account.",
|
||||
"This link expires in 15 minutes. If you didn't request this, you can ignore this email.",
|
||||
],
|
||||
cta_label="Reset password",
|
||||
cta_url=reset_link,
|
||||
theme_user=user,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlalchemy import delete, select
|
||||
@@ -53,7 +54,23 @@ async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> No
|
||||
await db.commit()
|
||||
|
||||
|
||||
# #56 correction: bumping to pywebpush's latest release (2.4.0) turned out
|
||||
# not to actually fix WNS -- checked the installed package's own source
|
||||
# directly and it has no WNS-specific code anywhere; the upstream
|
||||
# discussion (web-push-libs/pywebpush#162) apparently never shipped.
|
||||
# Worked around here instead, using the `headers` param webpush() already
|
||||
# exposes for exactly this: WNS (Windows/Edge push,
|
||||
# *.notify.windows.com) has required this header since April 2024, or it
|
||||
# 400s with no useful body -- "cache" for a non-zero TTL, "no-cache" for
|
||||
# zero (this app never sets a TTL, so always the latter).
|
||||
def _is_wns_endpoint(endpoint: str) -> bool:
|
||||
return urlparse(endpoint).hostname is not None and urlparse(endpoint).hostname.endswith(
|
||||
"notify.windows.com"
|
||||
)
|
||||
|
||||
|
||||
def _send_one(subscription: PushSubscription, payload: dict) -> None:
|
||||
extra_headers = {"X-WNS-Cache-Policy": "no-cache"} if _is_wns_endpoint(subscription.endpoint) else None
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": subscription.endpoint,
|
||||
@@ -62,6 +79,7 @@ def _send_one(subscription: PushSubscription, payload: dict) -> None:
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=settings.vapid_private_key,
|
||||
vapid_claims={"sub": settings.vapid_subject},
|
||||
headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
@@ -95,4 +113,17 @@ async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict)
|
||||
)
|
||||
await db.commit()
|
||||
else:
|
||||
logger.warning("Push delivery failed for %s: %s", subscription.id, exc)
|
||||
# #56: WNS's own 400s carry the actual reason in a response
|
||||
# *header* ("Ttl value conflicts with X-WNS-Cache-Policy"),
|
||||
# not the body -- pywebpush's own exception message only
|
||||
# ever surfaces the body, so that specific bug still would
|
||||
# have needed a full journalctl+DB-dump investigation to
|
||||
# diagnose even with a body-only log line. Logging headers
|
||||
# too is the difference between "something is broken" and
|
||||
# this log line alone being enough next time, for any push
|
||||
# provider's failure, not just WNS's.
|
||||
response = exc.response
|
||||
detail = ""
|
||||
if response is not None:
|
||||
detail = f" | response: {response.text!r} | headers: {dict(response.headers)!r}"
|
||||
logger.warning("Push delivery failed for %s: %s%s", subscription.id, exc, detail)
|
||||
@@ -1,13 +1,27 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, MessageMention, Room, RoomMembership, RoomRole, User
|
||||
from app.models import (
|
||||
EventSubscription,
|
||||
Message,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageMention,
|
||||
MessageReaction,
|
||||
MessageRoomReference,
|
||||
Room,
|
||||
RoomMembership,
|
||||
RoomRole,
|
||||
User,
|
||||
WebhookIncoming,
|
||||
)
|
||||
from app.schemas.room import RoomCreate, RoomUpdate
|
||||
from app.services.email_service import send_email
|
||||
from app.storage import delete_file
|
||||
|
||||
|
||||
class DuplicateRoomError(Exception):
|
||||
@@ -46,6 +60,50 @@ class AlreadyMemberError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotDmSelfError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CannotModifyDmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotADmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def dm_room_name(user_a_id: uuid.UUID, user_b_id: uuid.UUID) -> str:
|
||||
"""Deterministic, internal-only name for the DM room between these two
|
||||
users -- same canonical string regardless of argument order, so
|
||||
find_or_create_dm can look up an existing DM with a single indexed
|
||||
query (Room.name is already unique+indexed) instead of a membership-set
|
||||
join. Never shown to a user -- the frontend renders a DM's dm_partner
|
||||
info instead of its `name` (see MyRoomItem)."""
|
||||
ids = sorted((str(user_a_id), str(user_b_id)))
|
||||
return f"dm:{ids[0]}:{ids[1]}"
|
||||
|
||||
|
||||
async def _unhide(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
membership = (
|
||||
await db.execute(
|
||||
select(RoomMembership).where(
|
||||
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if membership is not None and membership.hidden_at is not None:
|
||||
membership.hidden_at = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def hide_dm(db: AsyncSession, room: Room, user_id: uuid.UUID) -> None:
|
||||
if not room.is_dm:
|
||||
raise NotADmError()
|
||||
membership = await _get_membership(db, room.id, user_id)
|
||||
membership.hidden_at = func.now()
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
|
||||
room = Room(
|
||||
name=data.name,
|
||||
@@ -66,10 +124,53 @@ async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -
|
||||
return room
|
||||
|
||||
|
||||
async def find_or_create_dm(db: AsyncSession, user_id: uuid.UUID, other_user_id: uuid.UUID) -> Room:
|
||||
if user_id == other_user_id:
|
||||
raise CannotDmSelfError()
|
||||
other = await db.get(User, other_user_id)
|
||||
if other is None:
|
||||
raise TargetUserNotFoundError()
|
||||
|
||||
name = dm_room_name(user_id, other_user_id)
|
||||
result = await db.execute(select(Room).where(Room.name == name))
|
||||
room = result.scalar_one_or_none()
|
||||
if room is not None:
|
||||
await _unhide(db, room.id, user_id)
|
||||
return room
|
||||
|
||||
# is_private=True is belt-and-suspenders here -- list_open_rooms also
|
||||
# excludes is_dm directly -- but it's also just semantically correct: a
|
||||
# DM genuinely is a private room. Both participants get the plain
|
||||
# `member` role (there's no meaningful owner/admin distinction for a
|
||||
# 1:1 DM); `owner_id` still has to be someone to satisfy the column,
|
||||
# but nothing reads it as meaningful for a DM.
|
||||
room = Room(name=name, is_private=True, is_dm=True, owner_id=user_id)
|
||||
db.add(room)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Lost a race with a concurrent find_or_create_dm for the same pair
|
||||
# (e.g. both people click "message" on each other at once) -- the
|
||||
# unique constraint on `name` is exactly what caught it, same
|
||||
# pattern as create_room's DuplicateRoomError. The row that won the
|
||||
# race is the room we actually want.
|
||||
await db.rollback()
|
||||
result = await db.execute(select(Room).where(Room.name == name))
|
||||
room = result.scalar_one()
|
||||
await _unhide(db, room.id, user_id)
|
||||
return room
|
||||
|
||||
db.add(RoomMembership(room_id=room.id, user_id=user_id, role=RoomRole.member))
|
||||
db.add(RoomMembership(room_id=room.id, user_id=other_user_id, role=RoomRole.member))
|
||||
await db.commit()
|
||||
await db.refresh(room)
|
||||
return room
|
||||
|
||||
|
||||
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
|
||||
result = await db.execute(
|
||||
select(Room)
|
||||
.where(Room.is_private.is_(False), Room.is_archived.is_(False))
|
||||
.where(Room.is_private.is_(False), Room.is_archived.is_(False), Room.is_dm.is_(False))
|
||||
.options(selectinload(Room.memberships))
|
||||
.order_by(Room.created_at, Room.id)
|
||||
)
|
||||
@@ -81,7 +182,7 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
|
||||
|
||||
async def list_member_rooms(
|
||||
db: AsyncSession, user_id: uuid.UUID
|
||||
) -> list[tuple[Room, RoomRole, bool, bool]]:
|
||||
) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
|
||||
last_message_at = (
|
||||
select(func.max(Message.created_at))
|
||||
.where(Message.room_id == Room.id)
|
||||
@@ -104,10 +205,15 @@ async def list_member_rooms(
|
||||
)
|
||||
result = await db.execute(
|
||||
select(
|
||||
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
|
||||
Room,
|
||||
RoomMembership.role,
|
||||
RoomMembership.last_read_at,
|
||||
last_message_at,
|
||||
has_unread_mention,
|
||||
RoomMembership.email_notifications,
|
||||
)
|
||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(RoomMembership.user_id == user_id)
|
||||
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
|
||||
# A secondary key on the primary key -- without it, Postgres has no
|
||||
# obligation to return two same-instant rooms (a plausible tie:
|
||||
# bulk-created/migrated rooms, or just two created in quick
|
||||
@@ -116,12 +222,55 @@ async def list_member_rooms(
|
||||
# device's fetch and another's.
|
||||
.order_by(Room.created_at, Room.id)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# #52: one batched follow-up query for every DM room's *other*
|
||||
# participant, rather than a fetch per row -- a DM only ever has
|
||||
# exactly two members, so "the other one" is unambiguous.
|
||||
dm_room_ids = [room.id for room, *_ in rows if room.is_dm]
|
||||
partners_by_room: dict[uuid.UUID, User] = {}
|
||||
if dm_room_ids:
|
||||
partner_result = await db.execute(
|
||||
select(RoomMembership.room_id, User)
|
||||
.join(User, User.id == RoomMembership.user_id)
|
||||
.where(RoomMembership.room_id.in_(dm_room_ids), RoomMembership.user_id != user_id)
|
||||
)
|
||||
partners_by_room = {room_id: user for room_id, user in partner_result.all()}
|
||||
|
||||
return [
|
||||
(room, role, last_message_at is not None and last_message_at > last_read_at, has_mention)
|
||||
for room, role, last_read_at, last_message_at, has_mention in result.all()
|
||||
(
|
||||
room,
|
||||
role,
|
||||
last_message_at is not None and last_message_at > last_read_at,
|
||||
has_mention,
|
||||
email_notifications,
|
||||
partners_by_room.get(room.id),
|
||||
)
|
||||
for room, role, last_read_at, last_message_at, has_mention, email_notifications in rows
|
||||
]
|
||||
|
||||
|
||||
async def list_dm_partner_ids(db: AsyncSession, user_id: uuid.UUID) -> list[uuid.UUID]:
|
||||
"""Every user this user_id shares a DM with (#63) -- used to know who
|
||||
needs telling about a global online/offline transition, since Presence
|
||||
gates room-channel delivery on actually having that specific room
|
||||
joined right now (only ever the one room currently open in the UI), so
|
||||
a DM sitting unopened in the sidebar would otherwise never hear about
|
||||
its partner's status changing at all."""
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id)
|
||||
.join(Room, Room.id == RoomMembership.room_id)
|
||||
.where(
|
||||
Room.is_dm.is_(True),
|
||||
RoomMembership.user_id != user_id,
|
||||
RoomMembership.room_id.in_(
|
||||
select(RoomMembership.room_id).where(RoomMembership.user_id == user_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None:
|
||||
@@ -173,8 +322,10 @@ async def add_member(
|
||||
db,
|
||||
target.email,
|
||||
f"You've been added to #{room.name}",
|
||||
f"You've been added to the #{room.name} room on DS Chat.\n\n"
|
||||
f"Open the app: {base_url.rstrip('/')}",
|
||||
[f"You've been added to the #{room.name} room on DS Chat."],
|
||||
cta_label="Open DS Chat",
|
||||
cta_url=base_url.rstrip("/"),
|
||||
theme_user=target,
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
@@ -186,6 +337,14 @@ async def add_member(
|
||||
|
||||
|
||||
async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
||||
# A DM's `name` is an internal token find_or_create_dm's lookup depends
|
||||
# on being stable -- renaming it (even via the #48 site-admin bypass in
|
||||
# the router) would silently orphan that invariant, not just leak a
|
||||
# detail that's supposed to stay private. Blocked here, not just in the
|
||||
# UI, since it's a correctness issue for every caller, not a permission
|
||||
# one.
|
||||
if room.is_dm:
|
||||
raise CannotModifyDmError()
|
||||
if data.name is not None:
|
||||
room.name = data.name
|
||||
if data.description is not None:
|
||||
@@ -203,12 +362,60 @@ async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
|
||||
|
||||
async def delete_room(db: AsyncSession, room: Room) -> None:
|
||||
# Explicit deletes rather than relying on ORM cascade + eager-loading —
|
||||
# simpler and more predictable in async code.
|
||||
# simpler and more predictable in async code. None of these FKs are
|
||||
# declared ON DELETE CASCADE at the DB level (confirmed across every
|
||||
# migration that added one), so every table referencing this room --
|
||||
# directly, or indirectly via one of its messages -- has to be cleared
|
||||
# explicitly, in dependency order, or the final room delete 500s on
|
||||
# whichever one it happens to hit first (originally surfaced as a
|
||||
# message_room_references FK violation, but every table below has the
|
||||
# exact same gap).
|
||||
room_message_ids = select(Message.id).where(Message.room_id == room.id).scalar_subquery()
|
||||
|
||||
# Message-child tables first -- these reference message_id, so they'd
|
||||
# block deleting this room's own messages otherwise.
|
||||
await db.execute(delete(MessageMention).where(MessageMention.message_id.in_(room_message_ids)))
|
||||
await db.execute(delete(MessageReaction).where(MessageReaction.message_id.in_(room_message_ids)))
|
||||
# Both directions: a reference *from* one of this room's own messages,
|
||||
# and a reference *to* this room from a message in a completely
|
||||
# different room (the case that originally surfaced this bug).
|
||||
await db.execute(
|
||||
delete(MessageRoomReference).where(
|
||||
or_(
|
||||
MessageRoomReference.message_id.in_(room_message_ids),
|
||||
MessageRoomReference.room_id == room.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch attachment storage filenames before deleting their rows -- the
|
||||
# actual files are only unlinked after a successful commit below, so a
|
||||
# rolled-back transaction never leaves us having destroyed something we
|
||||
# couldn't get back.
|
||||
image_filenames = (
|
||||
await db.execute(select(MessageImage.storage_filename).where(MessageImage.room_id == room.id))
|
||||
).scalars().all()
|
||||
file_filenames = (
|
||||
await db.execute(select(MessageFile.storage_filename).where(MessageFile.room_id == room.id))
|
||||
).scalars().all()
|
||||
|
||||
# Messages themselves, now that nothing still references them.
|
||||
await db.execute(delete(Message).where(Message.room_id == room.id))
|
||||
|
||||
# Room-scoped attachments/integrations -- messages.image_id/file_id
|
||||
# reference these, so they must come after the message delete above.
|
||||
await db.execute(delete(MessageImage).where(MessageImage.room_id == room.id))
|
||||
await db.execute(delete(MessageFile).where(MessageFile.room_id == room.id))
|
||||
await db.execute(delete(WebhookIncoming).where(WebhookIncoming.room_id == room.id))
|
||||
await db.execute(delete(EventSubscription).where(EventSubscription.room_id == room.id))
|
||||
|
||||
await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
|
||||
await db.delete(room)
|
||||
await db.commit()
|
||||
|
||||
for filename in (*image_filenames, *file_filenames):
|
||||
delete_file(filename)
|
||||
|
||||
|
||||
async def list_room_members(db: AsyncSession, room_id: uuid.UUID) -> list[RoomMembership]:
|
||||
result = await db.execute(
|
||||
@@ -285,6 +492,22 @@ async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUI
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_room_email_notifications(
|
||||
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, enabled: bool
|
||||
) -> None:
|
||||
"""#67: DMs are deliberately excluded -- they already get #66's
|
||||
automatic offline email with no opt-in needed, and this setting only
|
||||
makes sense for a regular room's mention-based notifications."""
|
||||
room = await db.get(Room, room_id)
|
||||
if room is None:
|
||||
raise RoomNotFoundError()
|
||||
if room.is_dm:
|
||||
raise CannotModifyDmError()
|
||||
membership = await _get_membership(db, room_id, user_id)
|
||||
membership.email_notifications = enabled
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
membership = await _get_membership(db, room_id, user_id)
|
||||
if membership.role == RoomRole.owner:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Session
|
||||
|
||||
# #69: how stale last_seen_at has to be before a request bothers updating
|
||||
# it. get_current_user resolves a session on *every* authenticated
|
||||
# request (dozens per minute per active browser tab, between message
|
||||
# polling, presence, etc.) -- writing+committing on every single one would
|
||||
# turn a read into a write storm for no real benefit, since "active
|
||||
# sessions" only needs last-seen accurate to within a few minutes, not to
|
||||
# the second.
|
||||
LAST_SEEN_THROTTLE = timedelta(minutes=5)
|
||||
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_client_ip(request_or_websocket) -> str | None:
|
||||
# X-Forwarded-For's first entry is the original client -- everything
|
||||
# after it was appended by intermediate proxies. Production runs
|
||||
# behind Nginx Proxy Manager (see backend/README.md's "Admin portal"
|
||||
# section preamble), which sets this; local dev has nothing in front
|
||||
# of the app, so this falls back to the direct peer address.
|
||||
forwarded = request_or_websocket.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
client = request_or_websocket.client
|
||||
return client.host if client else None
|
||||
|
||||
|
||||
async def create_session(
|
||||
db: AsyncSession, user_id: uuid.UUID, ip_address: str | None, user_agent: str | None
|
||||
) -> Session:
|
||||
session = Session(user_id=user_id, ip_address=ip_address, user_agent=user_agent)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def start_session(request: Request, db: AsyncSession, user_id: uuid.UUID) -> Session:
|
||||
"""Every "log this browser in" call site (login, reset-password
|
||||
completion, signup completion) needs the exact same three steps --
|
||||
read the request's IP/UA, create the row, stash its id in the signed
|
||||
cookie -- so this is the one place that combination lives."""
|
||||
session = await create_session(db, user_id, get_client_ip(request), request.headers.get("user-agent"))
|
||||
request.session["session_id"] = str(session.id)
|
||||
return session
|
||||
|
||||
|
||||
async def resolve_session(db: AsyncSession, session_id: uuid.UUID) -> Session | None:
|
||||
"""Returns the session iff it exists and hasn't been revoked -- the
|
||||
single choke point get_current_user and the WS handshake both go
|
||||
through, so revoking a session (this endpoint or another device's
|
||||
"sign out") takes effect on that session's very next request rather
|
||||
than only once its signed cookie happens to expire."""
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.revoked_at is not None:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if now - session.last_seen_at > LAST_SEEN_THROTTLE:
|
||||
session.last_seen_at = now
|
||||
await db.commit()
|
||||
return session
|
||||
|
||||
|
||||
async def list_sessions(db: AsyncSession, user_id: uuid.UUID) -> list[Session]:
|
||||
result = await db.execute(
|
||||
select(Session)
|
||||
.where(Session.user_id == user_id, Session.revoked_at.is_(None))
|
||||
.order_by(Session.last_seen_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_session(db: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.user_id != user_id or session.revoked_at is not None:
|
||||
raise SessionNotFoundError()
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def revoke_session_unchecked(db: AsyncSession, session_id: uuid.UUID) -> None:
|
||||
"""Logout's own path -- no ownership check needed (a session can only
|
||||
ever log itself out) and silently does nothing for a session that's
|
||||
missing or already revoked, since "sign this browser out" should
|
||||
never itself fail."""
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.revoked_at is not None:
|
||||
return
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import InviteStatus, SiteInvite, User
|
||||
from app.models.site_invite import DEFAULT_SITE_INVITE_LIFETIME
|
||||
from app.schemas.user import UserCreate
|
||||
from app.security import hash_token
|
||||
from app.services.audit import record_audit_log
|
||||
@@ -26,6 +27,24 @@ class SiteInviteInvalidError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _send_invite_email(db: AsyncSession, inviter_username: str, base_url: str, email: str, raw_token: str) -> None:
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
# No theme_user -- the invitee doesn't have an account yet, so there's
|
||||
# no theme of theirs to use (#68). Default palette, same as any
|
||||
# logged-out page.
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join DS Chat",
|
||||
[
|
||||
f"You've been invited to join DS Chat by {inviter_username}.",
|
||||
"This link expires in 7 days.",
|
||||
],
|
||||
cta_label="Set up your account",
|
||||
cta_url=signup_link,
|
||||
)
|
||||
|
||||
|
||||
async def create_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, email: str
|
||||
) -> SiteInvite:
|
||||
@@ -39,21 +58,19 @@ async def create_site_invite(
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join DS Chat",
|
||||
f"You've been invited to join DS Chat by {actor.username}.\n\n"
|
||||
f"Set up your account here:\n{signup_link}\n\n"
|
||||
f"This link expires in 7 days.",
|
||||
)
|
||||
await _send_invite_email(db, actor.username, base_url, email, raw_token)
|
||||
return invite
|
||||
|
||||
|
||||
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]:
|
||||
# Pending only (#61) -- the admin UI's only consumer of this list labels
|
||||
# it "Pending invites" and had no way to drop a row once it was accepted
|
||||
# or revoked, since the backend returned every invite ever sent forever.
|
||||
# An accepted/revoked invite has nothing further to act on here; its
|
||||
# history already lives in the audit log ("user.invite"/"invite.revoke").
|
||||
result = await db.execute(
|
||||
select(SiteInvite)
|
||||
.where(SiteInvite.status == InviteStatus.pending)
|
||||
.options(selectinload(SiteInvite.inviter))
|
||||
.order_by(SiteInvite.created_at.desc())
|
||||
)
|
||||
@@ -74,6 +91,31 @@ async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID
|
||||
return invite
|
||||
|
||||
|
||||
async def resend_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, invite_id: uuid.UUID
|
||||
) -> SiteInvite:
|
||||
invite = await db.get(SiteInvite, invite_id)
|
||||
if invite is None:
|
||||
raise SiteInviteNotFoundError()
|
||||
if invite.status != InviteStatus.pending:
|
||||
raise SiteInviteNotPendingError()
|
||||
|
||||
# A fresh token and a reset 7-day expiry, not just re-sending the same
|
||||
# link -- the old link stops working the moment this runs (same
|
||||
# "rotate, don't just repeat" instinct as a password-reset resend), and
|
||||
# it means resending something close to expiring actually buys the
|
||||
# full week again instead of whatever was left.
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
invite.token_hash = hash_token(raw_token)
|
||||
invite.expires_at = datetime.now(timezone.utc) + DEFAULT_SITE_INVITE_LIFETIME
|
||||
record_audit_log(db, actor, "invite.resend", "invite", invite.id, {"email": invite.email})
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
await _send_invite_email(db, actor.username, base_url, invite.email, raw_token)
|
||||
return invite
|
||||
|
||||
|
||||
async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
|
||||
result = await db.execute(
|
||||
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User
|
||||
from app.security import generate_token, hash_password
|
||||
|
||||
# #74: one well-known, auto-provisioned bot account the app itself posts as
|
||||
# for automated first-party messages (the #72 welcome message, and whatever
|
||||
# comes next) -- distinct from bot_service.py's admin-created integration
|
||||
# bots, which each need a human actor and audit-log entry for creating them.
|
||||
# There's no actor here: this account is provisioned lazily, the first time
|
||||
# something needs to post as it.
|
||||
SYSTEM_USERNAME = "system"
|
||||
|
||||
|
||||
async def get_or_create_system_user(db: AsyncSession) -> User:
|
||||
result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
# Same placeholder-email/discarded-password shape as bot_service.create_bot
|
||||
# -- this account never logs in, email just satisfies the NOT NULL/unique
|
||||
# column.
|
||||
user = User(
|
||||
username=SYSTEM_USERNAME,
|
||||
email=f"{SYSTEM_USERNAME}@bots.example.com",
|
||||
password_hash=hash_password(generate_token()),
|
||||
is_bot=True,
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Two concurrent requests both found no existing row and raced to
|
||||
# create one -- the loser just reads back the winner's row instead
|
||||
# of erroring.
|
||||
await db.rollback()
|
||||
result = await db.execute(select(User).where(User.username == SYSTEM_USERNAME))
|
||||
return result.scalar_one()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -0,0 +1,46 @@
|
||||
def describe_user_agent(user_agent: str | None) -> str:
|
||||
"""A short, human-readable "Browser on OS" label for the sessions list
|
||||
-- no dependency pulled in for this (the app has stayed on a
|
||||
zero-subdependency-when-possible diet, see MessageContent.tsx's
|
||||
markdown-to-jsx choice), just substring checks against the handful of
|
||||
tokens that actually distinguish the browsers/platforms this app's
|
||||
users run. Order matters: Electron and Edge both also contain
|
||||
"Chrome/", and Chrome-on-iOS/Safari-on-iOS both contain "Safari/", so
|
||||
the more specific token has to be checked first.
|
||||
"""
|
||||
if not user_agent:
|
||||
return "Unknown device"
|
||||
|
||||
if "Electron/" in user_agent:
|
||||
browser = "DS Chat Desktop"
|
||||
elif "Edg/" in user_agent:
|
||||
browser = "Edge"
|
||||
elif "OPR/" in user_agent:
|
||||
browser = "Opera"
|
||||
elif "Firefox/" in user_agent:
|
||||
browser = "Firefox"
|
||||
elif "Chrome/" in user_agent:
|
||||
browser = "Chrome"
|
||||
elif "CriOS/" in user_agent:
|
||||
browser = "Chrome"
|
||||
elif "Safari/" in user_agent:
|
||||
browser = "Safari"
|
||||
else:
|
||||
browser = "Unknown browser"
|
||||
|
||||
if "Windows" in user_agent:
|
||||
os_name = "Windows"
|
||||
elif "Mac OS X" in user_agent and ("iPhone" in user_agent or "iPad" in user_agent):
|
||||
os_name = "iOS"
|
||||
elif "Mac OS X" in user_agent:
|
||||
os_name = "macOS"
|
||||
elif "Android" in user_agent:
|
||||
os_name = "Android"
|
||||
elif "Linux" in user_agent:
|
||||
os_name = "Linux"
|
||||
else:
|
||||
os_name = "Unknown OS"
|
||||
|
||||
if browser == "DS Chat Desktop":
|
||||
return f"{browser} ({os_name})"
|
||||
return f"{browser} on {os_name}"
|
||||
@@ -27,6 +27,10 @@ class SubscriptionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RoomArchivedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_incoming_webhook(
|
||||
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
|
||||
) -> WebhookIncoming:
|
||||
@@ -82,6 +86,11 @@ async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[
|
||||
webhook = result.scalar_one_or_none()
|
||||
if webhook is None:
|
||||
raise WebhookNotFoundError()
|
||||
# #57: same read-only rule as a human posting from the composer -- an
|
||||
# archived room shouldn't gain new messages through a bot integration
|
||||
# either.
|
||||
if webhook.room.is_archived:
|
||||
raise RoomArchivedError()
|
||||
|
||||
message = await create_message(db, webhook.room_id, webhook.created_by, content)
|
||||
return message, webhook.room, webhook.creator
|
||||
|
||||
@@ -25,6 +25,19 @@ ALLOWED_IMAGE_CONTENT_TYPES: dict[str, tuple[str, str]] = {
|
||||
"image/webp": (".webp", "WEBP"),
|
||||
}
|
||||
|
||||
# #65: browser-natively-playable video formats -- used to decide whether a
|
||||
# stored MessageFile gets served inline (a <video> tag can actually play
|
||||
# it) or forced to download like every other non-image attachment (see
|
||||
# rooms.py's file-serve endpoint). Deliberately a strict allowlist, not
|
||||
# "every video/* type": .mov (video/quicktime) has spotty <video> support
|
||||
# outside Safari, and more importantly this is the one thing standing
|
||||
# between "serve with the browser trusting our declared Content-Type" and
|
||||
# reopening the same-origin-script-execution risk Content-Disposition:
|
||||
# attachment exists to close off for arbitrary uploads -- it must only
|
||||
# ever contain types a <video> tag renders as media, never as something
|
||||
# that could execute script.
|
||||
INLINE_SAFE_VIDEO_CONTENT_TYPES = frozenset({"video/mp4", "video/webm", "video/ogg"})
|
||||
|
||||
|
||||
class UploadTooLargeError(Exception):
|
||||
pass
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOM_CHANNEL_PREFIX = "room:"
|
||||
USER_CHANNEL_PREFIX = "user:"
|
||||
|
||||
# #76: a run shorter than this before dying doesn't count as "stable" for
|
||||
# resetting the backoff in run_forever() below -- long enough that a real,
|
||||
# working connection has clearly settled in, short enough not to leave a
|
||||
# just-recovered listener exposed to a full backoff climb if Redis blips
|
||||
# again shortly after reconnecting.
|
||||
STABLE_RUN_SECONDS = 60
|
||||
MAX_BACKOFF_SECONDS = 30
|
||||
|
||||
|
||||
class Broadcaster:
|
||||
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
|
||||
@@ -36,6 +49,43 @@ class Broadcaster:
|
||||
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
"""Supervises listen(), restarting it if it ever dies.
|
||||
|
||||
#76's real root cause, found after the per-message try/except below
|
||||
turned out not to be enough: this is the *one* pubsub listener for
|
||||
the whole process (see main.py's lifespan), created once at startup
|
||||
with nothing watching over it afterward. A connection-level failure
|
||||
-- Redis restarting, a network blip between the app and Redis --
|
||||
raises from the `async for` iteration in listen() itself, outside
|
||||
that per-message guard, and previously killed this task permanently
|
||||
and silently: HTTP/REST and login kept working fine (neither
|
||||
touches this), but live message delivery was gone for every room
|
||||
and every user on this instance until the whole process was
|
||||
restarted. Confirmed live in production: Redis got restarted by an
|
||||
OS update, and `PUBSUB NUMPAT` against it afterward showed zero
|
||||
pattern subscriptions across all 4 gunicorn workers -- every one of
|
||||
them had silently lost its listener with nothing to bring it back.
|
||||
"""
|
||||
backoff_seconds = 1.0
|
||||
while True:
|
||||
started_at = asyncio.get_event_loop().time()
|
||||
try:
|
||||
await self.listen()
|
||||
logger.warning("Broadcaster.listen() exited without error -- restarting")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.error("Broadcaster.listen() failed -- restarting", exc_info=True)
|
||||
ran_for = asyncio.get_event_loop().time() - started_at
|
||||
# Sleep at the *current* backoff first, then grow it for next
|
||||
# time -- computing the next value before sleeping would skip
|
||||
# straight past the first (shortest) retry delay.
|
||||
await asyncio.sleep(backoff_seconds)
|
||||
backoff_seconds = (
|
||||
1.0 if ran_for > STABLE_RUN_SECONDS else min(backoff_seconds * 2, MAX_BACKOFF_SECONDS)
|
||||
)
|
||||
|
||||
async def listen(self) -> None:
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
@@ -43,6 +93,16 @@ class Broadcaster:
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] != "pmessage":
|
||||
continue
|
||||
# #76: this is the one pubsub listener for the whole process
|
||||
# (see main.py's lifespan) -- ConnectionManager.broadcast/
|
||||
# send_to_user already guard against one dead socket taking
|
||||
# the rest of a single fan-out down, but this belt-and-
|
||||
# suspenders catch is for anything else unexpected (a
|
||||
# malformed payload, e.g.) doing the same. An uncaught
|
||||
# exception here previously meant *this whole async for*
|
||||
# loop died silently -- no more live delivery to any room on
|
||||
# this instance, for anyone, until the process restarted.
|
||||
try:
|
||||
channel = message["channel"]
|
||||
payload = json.loads(message["data"])
|
||||
if channel.startswith(ROOM_CHANNEL_PREFIX):
|
||||
@@ -51,6 +111,19 @@ class Broadcaster:
|
||||
elif channel.startswith(USER_CHANNEL_PREFIX):
|
||||
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
|
||||
await self._manager.send_to_user(user_id, payload)
|
||||
except Exception:
|
||||
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
|
||||
finally:
|
||||
# #76: reached via a connection that's already dead just as
|
||||
# often as a clean shutdown (that's the whole failure mode
|
||||
# run_forever() now recovers from) -- punsubscribe/aclose over
|
||||
# a broken connection would themselves raise, masking whatever
|
||||
# actually killed the loop above and, since run_forever() only
|
||||
# starts backing off *after* listen() returns/raises, delaying
|
||||
# the reconnect for no benefit (a fresh pubsub object is about
|
||||
# to be created on retry regardless of whether this cleanup
|
||||
# succeeded).
|
||||
with contextlib.suppress(Exception):
|
||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
with contextlib.suppress(Exception):
|
||||
await pubsub.aclose()
|
||||
+250
-21
@@ -1,3 +1,7 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
@@ -6,10 +10,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
|
||||
from app.models import ApiToken, Message, MessageFile, MessageImage, Room, RoomMembership, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.message_events import (
|
||||
broadcast_dm_presence_update,
|
||||
broadcast_member_updated,
|
||||
broadcast_message_delete,
|
||||
broadcast_message_update,
|
||||
broadcast_new_message,
|
||||
broadcast_reaction_update,
|
||||
@@ -18,15 +24,47 @@ from app.services.message_service import (
|
||||
MessageNotFoundError,
|
||||
NotMessageAuthorError,
|
||||
create_message,
|
||||
delete_message,
|
||||
edit_message,
|
||||
toggle_reaction,
|
||||
)
|
||||
from app.services.room_service import mark_room_read
|
||||
from app.services.session_service import resolve_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
WS_UNAUTHENTICATED = 4401
|
||||
|
||||
# #76: comfortably under the ~60s idle-connection timeout common to reverse
|
||||
# proxies/load balancers sitting in front of this endpoint, and short enough
|
||||
# that the frontend's own zombie-detection watchdog (useChatSocket.ts) can
|
||||
# notice a dead connection and reconnect well within a session -- a socket
|
||||
# can go silently dead (no close frame at all, e.g. a laptop sleeping or a
|
||||
# NAT dropping an idle mapping) without this, and no live message reaches it
|
||||
# again until *something* else finally triggers a close, which used to be
|
||||
# "switch rooms and back" purely by accident (that remounts the message
|
||||
# list, which refetches history over plain REST -- unrelated to the socket
|
||||
# itself actually recovering).
|
||||
WS_PING_INTERVAL_SECONDS = 30
|
||||
|
||||
# #77: a human client that has sent nothing -- not even the "pong" to the ping
|
||||
# above -- for this long is treated as gone and disconnected, so its presence
|
||||
# stops being renewed and cleanup runs. Needed on top of the transport's own
|
||||
# keepalive: a browser answers *protocol-level* pings from its network layer
|
||||
# even while the page's JavaScript is frozen (a suspended or discarded tab,
|
||||
# a sleeping laptop that keeps its network up), so the connection looks
|
||||
# healthy forever while nobody is actually there. Only the page itself can
|
||||
# send the app-level "pong". The frontend reconnects on any non-4401 close.
|
||||
WS_DEAD_AFTER_SECONDS = 90
|
||||
WS_HEARTBEAT_TIMEOUT = 4408
|
||||
|
||||
# Presence entries (see ExpiringPresenceSet) are renewed on incoming frames,
|
||||
# at most this often -- a chatty client shouldn't turn every message into
|
||||
# several extra Redis writes.
|
||||
PRESENCE_REFRESH_MIN_INTERVAL_SECONDS = 10
|
||||
|
||||
|
||||
class ClientEnvelope(BaseModel):
|
||||
type: str
|
||||
@@ -36,6 +74,7 @@ class ClientEnvelope(BaseModel):
|
||||
file_id: uuid.UUID | None = None
|
||||
message_id: uuid.UUID | None = None
|
||||
emoji: str | None = None
|
||||
focused: bool | None = None
|
||||
|
||||
|
||||
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
@@ -47,10 +86,30 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _is_room_archived(db: AsyncSession, room_id: uuid.UUID) -> bool:
|
||||
room = await db.get(Room, room_id)
|
||||
return room is not None and room.is_archived
|
||||
|
||||
|
||||
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
|
||||
return api_token is not None and scope not in api_token.scopes
|
||||
|
||||
|
||||
async def _broadcast_offline(db: AsyncSession, broadcaster, user_id: uuid.UUID) -> None:
|
||||
await broadcast_member_updated(db, broadcaster, user_id)
|
||||
await broadcast_dm_presence_update(db, broadcaster, user_id, online=False)
|
||||
|
||||
|
||||
async def _best_effort(label: str, awaitable):
|
||||
# Disconnect cleanup is several independent Redis/DB steps; one failing
|
||||
# must not skip the ones after it.
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception:
|
||||
logger.warning("Presence cleanup step failed: %s", label, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
|
||||
api_token: ApiToken | None = None
|
||||
@@ -66,11 +125,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
return
|
||||
user, api_token = resolved
|
||||
else:
|
||||
user_id_raw = websocket.session.get("user_id")
|
||||
if not user_id_raw:
|
||||
session_id_raw = websocket.session.get("session_id")
|
||||
if not session_id_raw:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user = await db.get(User, uuid.UUID(user_id_raw))
|
||||
session = await resolve_session(db, uuid.UUID(session_id_raw))
|
||||
if session is None:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
user = await db.get(User, session.user_id)
|
||||
if user is None or not user.is_active:
|
||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||
return
|
||||
@@ -79,20 +142,89 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
manager = websocket.app.state.connection_manager
|
||||
presence = websocket.app.state.presence
|
||||
global_presence = websocket.app.state.global_presence
|
||||
focus_presence = websocket.app.state.focus_presence
|
||||
broadcaster = websocket.app.state.broadcaster
|
||||
joined_rooms: set[uuid.UUID] = set()
|
||||
# Tracks this connection's last-reported focus state (see the "focus"
|
||||
# envelope below) so the disconnect cleanup can release FocusPresence's
|
||||
# refcount if the socket closes while still blurred -- mirroring how
|
||||
# joined_rooms tracks per-connection room membership for its own cleanup.
|
||||
is_blurred = False
|
||||
# Identifies this connection's own presence entries (#77) -- see
|
||||
# ExpiringPresenceSet -- so one connection ending can't affect another
|
||||
# belonging to the same user, and a dead one can simply stop renewing.
|
||||
conn_id = uuid.uuid4().hex
|
||||
last_frame_at = time.monotonic()
|
||||
last_presence_refresh = last_frame_at
|
||||
manager.register_user(user.id, websocket)
|
||||
# Only broadcast on a genuine offline->online transition (this user's
|
||||
# first open connection), not for every extra tab -- broadcast_member_
|
||||
# updated tells every room this user's in to refresh, which would be
|
||||
# wasted churn on a transition that didn't actually change anything
|
||||
# visible.
|
||||
if await global_presence.connect(user.id):
|
||||
if await global_presence.connect(user.id, conn_id):
|
||||
await broadcast_member_updated(db, broadcaster, user.id)
|
||||
await broadcast_dm_presence_update(db, broadcaster, user.id, online=True)
|
||||
# This session is shared for the connection's entire lifetime (which can
|
||||
# be hours) -- SQLAlchemy opens a transaction implicitly on first use,
|
||||
# and every read above (the auth lookup, broadcast_member_updated's own
|
||||
# query) leaves it open with nothing to ever close it otherwise. Left
|
||||
# uncommitted, that transaction sits "idle in transaction" holding locks
|
||||
# for as long as the socket stays open -- confirmed in production
|
||||
# blocking unrelated schema migrations on the same tables for 30+
|
||||
# minutes. Committing here, and again after every frame below, means
|
||||
# the connection is never sitting on an open transaction while merely
|
||||
# waiting for the next one.
|
||||
await db.commit()
|
||||
|
||||
async def refresh_presence() -> None:
|
||||
# Renews every presence entry this connection holds. If the global
|
||||
# one had already lapsed (a long stall) this is also the moment the
|
||||
# user comes back online, and has to be announced like a fresh connect.
|
||||
if await global_presence.refresh(user.id, conn_id):
|
||||
await broadcast_member_updated(db, broadcaster, user.id)
|
||||
await broadcast_dm_presence_update(db, broadcaster, user.id, online=True)
|
||||
for room_id in joined_rooms:
|
||||
await presence.refresh(room_id, user.id, conn_id)
|
||||
if is_blurred:
|
||||
await focus_presence.refresh(user.id, conn_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_json()
|
||||
try:
|
||||
raw = await asyncio.wait_for(
|
||||
websocket.receive_json(), timeout=WS_PING_INTERVAL_SECONDS
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Nothing received in a while -- ping rather than assume the
|
||||
# connection's still good. A genuinely dead socket fails this
|
||||
# send (raising, caught below same as any other disconnect);
|
||||
# a merely quiet one just gets proded and the loop continues
|
||||
# waiting.
|
||||
if api_token is None and time.monotonic() - last_frame_at >= WS_DEAD_AFTER_SECONDS:
|
||||
# Gone (see WS_DEAD_AFTER_SECONDS). The socket may be too
|
||||
# dead to even take a close frame; either way, leaving the
|
||||
# loop runs the normal cleanup below.
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close(code=WS_HEARTBEAT_TIMEOUT)
|
||||
break
|
||||
if api_token is not None:
|
||||
# A bot isn't expected to speak our app-level heartbeat
|
||||
# (it may just ignore the ping), so it can't be dropped
|
||||
# for not answering and its presence can't depend on
|
||||
# answers. While the handler is alive the transport's own
|
||||
# keepalive is what vouches for it -- a dead bot socket
|
||||
# ends this handler, and cleanup does the rest.
|
||||
await refresh_presence()
|
||||
last_presence_refresh = time.monotonic()
|
||||
await db.commit()
|
||||
await websocket.send_json({"type": "ping"})
|
||||
continue
|
||||
last_frame_at = time.monotonic()
|
||||
try:
|
||||
if last_frame_at - last_presence_refresh >= PRESENCE_REFRESH_MIN_INTERVAL_SECONDS:
|
||||
await refresh_presence()
|
||||
last_presence_refresh = last_frame_at
|
||||
try:
|
||||
envelope = ClientEnvelope.model_validate(raw)
|
||||
except ValidationError:
|
||||
@@ -109,18 +241,45 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
)
|
||||
continue
|
||||
manager.join(envelope.room_id, websocket)
|
||||
await presence.join(envelope.room_id, user.id)
|
||||
await presence.join(envelope.room_id, user.id, conn_id)
|
||||
joined_rooms.add(envelope.room_id)
|
||||
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
|
||||
|
||||
elif envelope.type == "pong":
|
||||
# #76: the client's reply to our "ping" above -- nothing
|
||||
# to do with it. Its only purpose is resetting the
|
||||
# frontend's own liveness watchdog; the timeout on the
|
||||
# receive_json() above already gets reset by receiving
|
||||
# any frame at all, this one included.
|
||||
pass
|
||||
|
||||
elif envelope.type == "leave":
|
||||
if envelope.room_id is None:
|
||||
await websocket.send_json({"type": "error", "detail": "room_id required"})
|
||||
continue
|
||||
manager.leave(envelope.room_id, websocket)
|
||||
await presence.leave(envelope.room_id, user.id)
|
||||
await presence.leave(envelope.room_id, user.id, conn_id)
|
||||
joined_rooms.discard(envelope.room_id)
|
||||
|
||||
elif envelope.type == "focus":
|
||||
# Sent only by the desktop client (#59), independent of
|
||||
# room join/leave -- see FocusPresence's docstring for
|
||||
# why widening desktop_notification eligibility this
|
||||
# way no longer needs to touch live room delivery at
|
||||
# all, unlike the "leave the room's channel on blur"
|
||||
# approach this replaced.
|
||||
if envelope.focused is None:
|
||||
await websocket.send_json({"type": "error", "detail": "focused required"})
|
||||
continue
|
||||
if envelope.focused:
|
||||
if is_blurred:
|
||||
await focus_presence.mark_focused(user.id, conn_id)
|
||||
is_blurred = False
|
||||
else:
|
||||
if not is_blurred:
|
||||
await focus_presence.mark_blurred(user.id, conn_id)
|
||||
is_blurred = True
|
||||
|
||||
elif envelope.type == "message":
|
||||
if envelope.room_id is None or (
|
||||
not envelope.content
|
||||
@@ -146,6 +305,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
if await _is_room_archived(db, envelope.room_id):
|
||||
# #57: history stays fully readable (joining/reading
|
||||
# an archived room's channel is untouched above),
|
||||
# this is the one gate that actually makes archiving
|
||||
# do something for people who were already members.
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "This room has been archived and is read-only"}
|
||||
)
|
||||
continue
|
||||
image_id = None
|
||||
if envelope.image_id is not None:
|
||||
image = await db.get(MessageImage, envelope.image_id)
|
||||
@@ -163,16 +331,27 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
message = await create_message(
|
||||
db, envelope.room_id, user.id, envelope.content, image_id, file_id
|
||||
)
|
||||
# Sending implies having seen the room as of now -- without
|
||||
# this, GET /rooms/mine would show the sender's own room as
|
||||
# unread the instant they send into it (last_read_at isn't
|
||||
# otherwise bumped until the frontend's own message echo
|
||||
# triggers a mark-read call, which is a real but avoidable
|
||||
# race). Deliberately not done in create_message() itself:
|
||||
# the incoming-webhook path also calls it, and a webhook's
|
||||
# Sending implies having seen the room as of now --
|
||||
# without this, GET /rooms/mine would show the sender's
|
||||
# own room as unread the instant they send into it
|
||||
# (last_read_at isn't otherwise bumped until the
|
||||
# frontend's own message echo triggers a mark-read
|
||||
# call, which is a real but avoidable race).
|
||||
# Deliberately not done in create_message() itself: the
|
||||
# incoming-webhook path also calls it, and a webhook's
|
||||
# attributed sender may not actually be watching.
|
||||
await mark_room_read(db, envelope.room_id, user.id)
|
||||
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
|
||||
await broadcast_new_message(
|
||||
db,
|
||||
broadcaster,
|
||||
presence,
|
||||
focus_presence,
|
||||
global_presence,
|
||||
str(websocket.base_url),
|
||||
envelope.room_id,
|
||||
message,
|
||||
user,
|
||||
)
|
||||
|
||||
elif envelope.type == "edit":
|
||||
if envelope.room_id is None or envelope.message_id is None or not envelope.content:
|
||||
@@ -204,12 +383,46 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
continue
|
||||
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
|
||||
|
||||
elif envelope.type == "delete":
|
||||
if envelope.room_id is None or envelope.message_id is None:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id and message_id required"}
|
||||
)
|
||||
continue
|
||||
if _missing_scope(api_token, "write:messages"):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Token missing required scope: write:messages"}
|
||||
)
|
||||
continue
|
||||
if envelope.room_id not in joined_rooms or not await _is_room_member(
|
||||
db, envelope.room_id, user.id
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Not a member of this room"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
await delete_message(db, envelope.message_id, user.id)
|
||||
except MessageNotFoundError:
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
except NotMessageAuthorError:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "You can only delete your own messages"}
|
||||
)
|
||||
continue
|
||||
await broadcast_message_delete(broadcaster, envelope.room_id, envelope.message_id)
|
||||
|
||||
elif envelope.type == "reaction":
|
||||
if (
|
||||
envelope.room_id is None
|
||||
or envelope.message_id is None
|
||||
or not envelope.emoji
|
||||
or len(envelope.emoji) > 8
|
||||
# #18: a raw unicode glyph never gets close to this,
|
||||
# but a custom emoji reaction is stored as its
|
||||
# literal `:shortcode:` text (see MessageReaction.emoji's
|
||||
# String(32) column, which this matches exactly).
|
||||
or len(envelope.emoji) > 32
|
||||
):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "room_id, message_id, and emoji required"}
|
||||
@@ -228,7 +441,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
)
|
||||
continue
|
||||
target_message = await db.get(Message, envelope.message_id)
|
||||
if target_message is None or target_message.room_id != envelope.room_id:
|
||||
if (
|
||||
target_message is None
|
||||
or target_message.room_id != envelope.room_id
|
||||
or target_message.deleted_at is not None
|
||||
):
|
||||
await websocket.send_json({"type": "error", "detail": "Message not found"})
|
||||
continue
|
||||
reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)
|
||||
@@ -240,6 +457,13 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
|
||||
)
|
||||
finally:
|
||||
# See the comment on the pre-loop commit above -- guarantees
|
||||
# every single frame, on every exit path (including the
|
||||
# many `continue`s above, which still run a `finally`
|
||||
# before actually looping), leaves nothing open while this
|
||||
# blocks on the next receive_json().
|
||||
await db.commit()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
@@ -247,6 +471,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
manager.leave_all(websocket)
|
||||
manager.unregister_user(user.id, websocket)
|
||||
for room_id in joined_rooms:
|
||||
await presence.leave(room_id, user.id)
|
||||
if await global_presence.disconnect(user.id):
|
||||
await broadcast_member_updated(db, broadcaster, user.id)
|
||||
await _best_effort("room presence", presence.leave(room_id, user.id, conn_id))
|
||||
if is_blurred:
|
||||
await _best_effort("focus presence", focus_presence.mark_focused(user.id, conn_id))
|
||||
if await _best_effort("global presence", global_presence.disconnect(user.id, conn_id)):
|
||||
await _best_effort(
|
||||
"offline broadcast",
|
||||
_broadcast_offline(db, broadcaster, user.id),
|
||||
)
|
||||
@@ -1,8 +1,11 @@
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Local, single-process WebSocket socket registry.
|
||||
@@ -43,8 +46,22 @@ class ConnectionManager:
|
||||
|
||||
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||
for websocket in list(self._rooms.get(room_id, ())):
|
||||
# #76: one dead/racing socket (e.g. its own disconnect cleanup
|
||||
# hasn't finished removing it from _rooms yet) must not take the
|
||||
# rest of this broadcast down with it -- this runs inside
|
||||
# Broadcaster.listen()'s single, never-restarted per-process
|
||||
# pubsub loop, so an unhandled exception here previously meant
|
||||
# one bad socket silently killed *live delivery for every room
|
||||
# on this instance*, not just the one socket, until the process
|
||||
# was restarted.
|
||||
try:
|
||||
await websocket.send_json(payload)
|
||||
except Exception:
|
||||
logger.warning("Dropping dead socket during broadcast to room %s", room_id, exc_info=True)
|
||||
|
||||
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||
for websocket in list(self._users.get(user_id, ())):
|
||||
try:
|
||||
await websocket.send_json(payload)
|
||||
except Exception:
|
||||
logger.warning("Dropping dead socket sending to user %s", user_id, exc_info=True)
|
||||
@@ -0,0 +1,103 @@
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
# #77: an entry that isn't renewed for this long stops counting. Chosen as 3x
|
||||
# the app-level ping interval (chat.py's WS_PING_INTERVAL_SECONDS): a healthy
|
||||
# connection renews roughly every ping, so two missed beats are tolerated
|
||||
# before someone flips offline.
|
||||
PRESENCE_TTL_SECONDS = 90
|
||||
|
||||
|
||||
def _text(value: str | bytes) -> str:
|
||||
return value.decode() if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def _user_part(member: str) -> str:
|
||||
return member.split(":", 1)[0]
|
||||
|
||||
|
||||
class ExpiringPresenceSet:
|
||||
""""Which users have at least one live connection" for one Redis key,
|
||||
where every connection is its own entry with its own expiry.
|
||||
|
||||
This replaced per-user refcounts (#77): a refcount only goes down when a
|
||||
connection's cleanup code actually runs, so any connection that ended
|
||||
without it -- a server crash or reboot, a process killed mid-restart, a
|
||||
client that vanished without the server ever noticing -- left a +1 that
|
||||
nothing could ever subtract, showing that person online forever (and in
|
||||
the per-room case, suppressing their push notifications). Here a dead
|
||||
connection simply stops being renewed and ages out on its own, so there
|
||||
is no state that can leak.
|
||||
|
||||
One sorted set per key: member `<user_id>:<connection_id>`, score = the
|
||||
moment it expires. "Live" means score > now. Scans are O(connections) --
|
||||
fine for a self-hosted team chat; only the global key grows with the
|
||||
whole server rather than one room.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis: Redis,
|
||||
ttl_seconds: float = PRESENCE_TTL_SECONDS,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self._redis = redis
|
||||
self._ttl = ttl_seconds
|
||||
self._clock = clock
|
||||
|
||||
@staticmethod
|
||||
def _member(user_id: uuid.UUID, conn_id: str) -> str:
|
||||
return f"{user_id}:{conn_id}"
|
||||
|
||||
async def _live_members(self, key: str) -> list[str]:
|
||||
members = await self._redis.zrangebyscore(key, f"({self._clock()}", "+inf")
|
||||
return [_text(m) for m in members]
|
||||
|
||||
async def add(self, key: str, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Creates or renews this connection's entry. Returns True iff the
|
||||
user had no live entry beforehand -- a genuine offline->online
|
||||
transition worth telling anyone about. Renewing an entry that's still
|
||||
live (the common case, every heartbeat) is a single cheap write."""
|
||||
member = self._member(user_id, conn_id)
|
||||
now = self._clock()
|
||||
score = await self._redis.zscore(key, member)
|
||||
came_online = False
|
||||
if score is None or score <= now:
|
||||
came_online = not await self.is_live(key, user_id)
|
||||
await self._redis.zadd(key, {member: now + self._ttl})
|
||||
# Lets a key nobody renews any more (an idle room) disappear on its
|
||||
# own instead of sitting there holding only expired entries.
|
||||
await self._redis.expire(key, int(self._ttl * 2))
|
||||
return came_online
|
||||
|
||||
async def remove(self, key: str, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Drops this connection's entry. Returns True iff that left the user
|
||||
with no live entry -- a genuine online->offline transition. False if
|
||||
the entry was already gone: whoever removed it (see prune) already
|
||||
reported the transition, and reporting it twice would be noise."""
|
||||
removed = await self._redis.zrem(key, self._member(user_id, conn_id))
|
||||
if not removed:
|
||||
return False
|
||||
return not await self.is_live(key, user_id)
|
||||
|
||||
async def is_live(self, key: str, user_id: uuid.UUID) -> bool:
|
||||
prefix = f"{user_id}:"
|
||||
return any(m.startswith(prefix) for m in await self._live_members(key))
|
||||
|
||||
async def live_user_ids(self, key: str) -> set[uuid.UUID]:
|
||||
return {uuid.UUID(_user_part(m)) for m in await self._live_members(key)}
|
||||
|
||||
async def prune(self, key: str) -> set[uuid.UUID]:
|
||||
"""Deletes expired entries. Returns the users whose last live entry
|
||||
was among them -- the offline transitions nobody was told about,
|
||||
since an expiry has no disconnect event to hang a broadcast on."""
|
||||
now = self._clock()
|
||||
expired = [_text(m) for m in await self._redis.zrangebyscore(key, "-inf", now)]
|
||||
if not expired:
|
||||
return set()
|
||||
await self._redis.zremrangebyscore(key, "-inf", now)
|
||||
gone = {_user_part(m) for m in expired} - {_user_part(m) for m in await self._live_members(key)}
|
||||
return {uuid.UUID(u) for u in gone}
|
||||
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class FocusPresence:
|
||||
"""Cross-instance "is this desktop-mode user's window currently
|
||||
unfocused," used only to widen desktop_notification/push eligibility
|
||||
beyond plain room-connection state (#59). One expiring entry per
|
||||
currently-blurred desktop connection (see ExpiringPresenceSet),
|
||||
parallel to Presence/GlobalPresence.
|
||||
|
||||
Absence from the set is the default and means "focused." That default
|
||||
is also exactly right for every browser-tab connection: only the
|
||||
desktop client ever sends focus/blur frames at all (see chat.py's
|
||||
"focus" envelope handling), so a browser user never appears here --
|
||||
their attention is already fully captured by Presence's room-connection
|
||||
state, which stays visibility-gated with no separate focus signal.
|
||||
|
||||
Same known simplification as before: two desktop windows for one user,
|
||||
one focused and one blurred, count as "unfocused" (any live blurred
|
||||
entry) even though the user does have attention somewhere. That errs
|
||||
toward notifying rather than silently missing one, which is the safer
|
||||
failure mode for a notification. Entries expire (#77) -- a desktop
|
||||
window that died while blurred used to leave its user "unfocused"
|
||||
forever.
|
||||
"""
|
||||
|
||||
KEY = "presence:v2:unfocused"
|
||||
|
||||
def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
|
||||
self._store = store or ExpiringPresenceSet(redis)
|
||||
|
||||
async def mark_blurred(self, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def mark_focused(self, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.remove(self.KEY, user_id, conn_id)
|
||||
|
||||
async def refresh(self, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def is_unfocused(self, user_id: uuid.UUID) -> bool:
|
||||
return await self._store.is_live(self.KEY, user_id)
|
||||
@@ -2,50 +2,55 @@ import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class GlobalPresence:
|
||||
"""Cross-instance "does this user have the app open at all right now,"
|
||||
independent of which (if any) room they currently have open -- backing
|
||||
the online/offline presence dot shown wherever a user's avatar renders.
|
||||
A single Redis hash (field = user_id, value = connection refcount),
|
||||
parallel to but separate from Presence's per-room hashes.
|
||||
|
||||
Refcounted for the same reason as Presence: multiple tabs/instances for
|
||||
one user shouldn't flip them offline until the last connection closes.
|
||||
Each connection has its own expiring entry (see ExpiringPresenceSet), so
|
||||
a user with several tabs/devices stays online until the last one goes,
|
||||
and a connection that dies without cleaning up ages out instead of
|
||||
leaving them online forever (#77). The key is versioned (`v2`) because
|
||||
the old refcount hash lives at `presence:global` and its leaked counts
|
||||
must not be read as live entries.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
KEY = "presence:v2:global"
|
||||
|
||||
def _key(self) -> str:
|
||||
return "presence:global"
|
||||
def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
|
||||
self._store = store or ExpiringPresenceSet(redis)
|
||||
|
||||
async def connect(self, user_id: uuid.UUID) -> bool:
|
||||
"""Returns True iff this was the user's first open connection --
|
||||
a genuine offline->online transition worth telling anyone about."""
|
||||
count = await self._redis.hincrby(self._key(), str(user_id), 1)
|
||||
return count == 1
|
||||
async def connect(self, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Returns True iff this made the user newly online -- a genuine
|
||||
offline->online transition worth telling anyone about."""
|
||||
return await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def disconnect(self, user_id: uuid.UUID) -> bool:
|
||||
"""Returns True iff this was the user's last open connection -- a
|
||||
async def refresh(self, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Heartbeat. Same as connect() on purpose: a connection whose entry
|
||||
had already expired (a long stall) comes back online here, and the
|
||||
caller needs to announce that exactly like a fresh connect."""
|
||||
return await self._store.add(self.KEY, user_id, conn_id)
|
||||
|
||||
async def disconnect(self, user_id: uuid.UUID, conn_id: str) -> bool:
|
||||
"""Returns True iff that was the user's last live connection -- a
|
||||
genuine online->offline transition."""
|
||||
key = self._key()
|
||||
field = str(user_id)
|
||||
remaining = await self._redis.hincrby(key, field, -1)
|
||||
if remaining <= 0:
|
||||
await self._redis.hdel(key, field)
|
||||
return True
|
||||
return False
|
||||
return await self._store.remove(self.KEY, user_id, conn_id)
|
||||
|
||||
async def sweep(self) -> set[uuid.UUID]:
|
||||
"""Drops expired entries and returns the users that went offline as a
|
||||
result -- the transitions no disconnect ever reported."""
|
||||
return await self._store.prune(self.KEY)
|
||||
|
||||
async def is_online(self, user_id: uuid.UUID) -> bool:
|
||||
return await self._redis.hexists(self._key(), str(user_id))
|
||||
return await self._store.is_live(self.KEY, user_id)
|
||||
|
||||
async def online_user_ids(self, user_ids: list[uuid.UUID]) -> set[uuid.UUID]:
|
||||
if not user_ids:
|
||||
return set()
|
||||
values = await self._redis.hmget(self._key(), [str(u) for u in user_ids])
|
||||
return {uid for uid, v in zip(user_ids, values) if v is not None}
|
||||
return set(user_ids) & await self._store.live_user_ids(self.KEY)
|
||||
|
||||
async def all_online_user_ids(self) -> set[uuid.UUID]:
|
||||
fields = await self._redis.hkeys(self._key())
|
||||
return {uuid.UUID(f) for f in fields}
|
||||
return await self._store.live_user_ids(self.KEY)
|
||||
+26
-24
@@ -2,38 +2,40 @@ import uuid
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class Presence:
|
||||
"""Cross-instance "who's connected to this room," backed by a Redis hash
|
||||
per room (field = user_id, value = connection refcount).
|
||||
"""Cross-instance "who's connected to this room," one expiring set per
|
||||
room (see ExpiringPresenceSet).
|
||||
|
||||
Refcounted rather than a plain set so a user with two connections to the
|
||||
same room -- two tabs, or one per app instance -- doesn't get marked
|
||||
offline when only one of those connections closes.
|
||||
|
||||
Known limitation: a hard crash (not a clean disconnect) leaks that
|
||||
connection's increment forever, since there's no heartbeat/TTL here to
|
||||
reclaim it -- out of scope for this phase, same category of
|
||||
simplification as the "no server-side session revocation" note in the
|
||||
README.
|
||||
This is what the offline-push check reads, so a leaked entry used to mean
|
||||
a user who'd gone away was still treated as "watching the room" and never
|
||||
got a push notification (#77). Entries now expire unless the connection
|
||||
keeps renewing them, so that can't outlive a dead connection by more than
|
||||
the TTL. Keys are versioned (`v2`) so the old refcount hashes
|
||||
(`presence:{room_id}`), with whatever they leaked, are never read.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
def __init__(self, redis: Redis, store: ExpiringPresenceSet | None = None) -> None:
|
||||
self._store = store or ExpiringPresenceSet(redis)
|
||||
|
||||
def _key(self, room_id: uuid.UUID) -> str:
|
||||
return f"presence:{room_id}"
|
||||
return f"presence:v2:room:{room_id}"
|
||||
|
||||
async def join(self, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
await self._redis.hincrby(self._key(room_id), str(user_id), 1)
|
||||
|
||||
async def leave(self, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
async def join(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
key = self._key(room_id)
|
||||
field = str(user_id)
|
||||
remaining = await self._redis.hincrby(key, field, -1)
|
||||
if remaining <= 0:
|
||||
await self._redis.hdel(key, field)
|
||||
# Per-room offline transitions aren't broadcast anywhere, so unlike
|
||||
# the global key there's nothing to lose by pruning here -- it just
|
||||
# keeps a busy room's set from accumulating expired entries.
|
||||
await self._store.prune(key)
|
||||
await self._store.add(key, user_id, conn_id)
|
||||
|
||||
async def refresh(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.add(self._key(room_id), user_id, conn_id)
|
||||
|
||||
async def leave(self, room_id: uuid.UUID, user_id: uuid.UUID, conn_id: str) -> None:
|
||||
await self._store.remove(self._key(room_id), user_id, conn_id)
|
||||
|
||||
async def connected_user_ids(self, room_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
fields = await self._redis.hkeys(self._key(room_id))
|
||||
return {uuid.UUID(f) for f in fields}
|
||||
return await self._store.live_user_ids(self._key(room_id))
|
||||
@@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from redis.asyncio import Redis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.message_events import broadcast_dm_presence_update, broadcast_member_updated
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PRESENCE_SWEEP_INTERVAL_SECONDS = 15
|
||||
SWEEP_LOCK_KEY = "presence:v2:sweeper-lock"
|
||||
|
||||
|
||||
async def sweep_once(
|
||||
redis: Redis,
|
||||
global_presence: GlobalPresence,
|
||||
broadcaster: Broadcaster,
|
||||
session_factory: Callable[[], AsyncSession],
|
||||
lock_seconds: int | None = max(1, PRESENCE_SWEEP_INTERVAL_SECONDS - 1),
|
||||
) -> set[uuid.UUID]:
|
||||
"""Expires dead connections' presence entries and tells everyone about
|
||||
the users that went offline as a result.
|
||||
|
||||
A clean disconnect broadcasts its own offline transition (chat.py), but a
|
||||
connection that just stops being renewed has no such event -- this is the
|
||||
only thing that notices. Every app worker runs this loop; the Redis lock
|
||||
makes it one sweep per interval across the whole deployment rather than
|
||||
N workers each doing (and broadcasting) the same thing. `lock_seconds=None`
|
||||
skips the lock (tests).
|
||||
"""
|
||||
if lock_seconds is not None and not await redis.set(SWEEP_LOCK_KEY, "1", nx=True, ex=lock_seconds):
|
||||
return set()
|
||||
went_offline = await global_presence.sweep()
|
||||
for user_id in went_offline:
|
||||
# One user's failed broadcast must not cost everyone after them
|
||||
# theirs -- their entry is already gone, so this is the only chance.
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
await broadcast_member_updated(db, broadcaster, user_id)
|
||||
await broadcast_dm_presence_update(db, broadcaster, user_id, online=False)
|
||||
except Exception:
|
||||
logger.error("Failed to broadcast offline for %s", user_id, exc_info=True)
|
||||
return went_offline
|
||||
|
||||
|
||||
async def run_presence_sweeper(app, interval: float = PRESENCE_SWEEP_INTERVAL_SECONDS) -> None:
|
||||
# Same lesson as Broadcaster.run_forever (#76): a background task that
|
||||
# dies on its first error does so silently, and here that would quietly
|
||||
# bring the stuck-online bug back.
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await sweep_once(
|
||||
app.state.redis,
|
||||
app.state.global_presence,
|
||||
app.state.broadcaster,
|
||||
app.state.session_factory,
|
||||
lock_seconds=max(1, int(interval) - 1),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.error("Presence sweep failed", exc_info=True)
|
||||
|
||||
|
||||
async def purge_legacy_presence(redis: Redis) -> int:
|
||||
"""Deletes the old refcount hashes (`presence:global`, `presence:unfocused`,
|
||||
`presence:{room_id}`). Nothing reads them any more -- the live data is
|
||||
under `presence:v2:` -- and they hold whatever counts leaked before #77,
|
||||
so there's nothing worth keeping. Safe to run on every startup."""
|
||||
deleted = 0
|
||||
async for key in redis.scan_iter("presence:*"):
|
||||
name = key.decode() if isinstance(key, bytes) else key
|
||||
if name.startswith("presence:v2:"):
|
||||
continue
|
||||
await redis.delete(name)
|
||||
deleted += 1
|
||||
return deleted
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ds-chat"
|
||||
version = "1.0.0"
|
||||
version = "2026.10.22"
|
||||
description = "DS Chat backend service"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
requires-python = ">=3.11"
|
||||
@@ -15,7 +15,14 @@ dependencies = [
|
||||
"pydantic-settings>=2.6",
|
||||
"argon2-cffi>=23.1",
|
||||
"itsdangerous>=2.2",
|
||||
"pywebpush>=2.0",
|
||||
# #56: >=2.0 let the production venv sit on an old 2.0.x with no real
|
||||
# downside to bumping the floor -- worth keeping current regardless.
|
||||
# Doesn't by itself fix WNS (Windows/Edge push): despite
|
||||
# web-push-libs/pywebpush#162's discussion, even the latest release
|
||||
# (2.4.0) has no WNS-specific header handling in its own source. The
|
||||
# actual fix is app/services/push_service.py adding the required
|
||||
# X-WNS-Cache-Policy header itself via webpush()'s `headers` param.
|
||||
"pywebpush>=2.4.0",
|
||||
"redis>=5.0",
|
||||
"httpx>=0.27",
|
||||
"gunicorn>=23.0",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import uuid
|
||||
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _make_admin_ws(ws_client, user_id: str) -> None:
|
||||
async def _promote():
|
||||
async with ws_client.session_factory() as session:
|
||||
user = await session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await session.commit()
|
||||
|
||||
ws_client.portal.call(_promote)
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def test_archived_room_flag_reaches_existing_members(client, db_session):
|
||||
# #57: is_archived used to only ever reach the admin portal's own
|
||||
# AdminRoom schema -- a member's own view of the room (GET /rooms/mine)
|
||||
# had no way to know it was archived at all.
|
||||
admin = await register_and_login(client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
|
||||
resp = await client.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
assert resp.status_code == 200
|
||||
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert entry["is_archived"] is True
|
||||
|
||||
# History stays fully readable for an existing member.
|
||||
messages_resp = await client.get(f"/api/rooms/{room['id']}/messages")
|
||||
assert messages_resp.status_code == 200
|
||||
|
||||
resp = await client.post(f"/api/admin/rooms/{room['id']}/unarchive")
|
||||
assert resp.status_code == 200
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert entry["is_archived"] is False
|
||||
|
||||
|
||||
def test_ws_message_rejected_in_archived_room(ws_client_factory, db_session):
|
||||
admin_ws = ws_client_factory()
|
||||
admin = _register_ws(admin_ws, _unique("admin"))
|
||||
_make_admin_ws(admin_ws, admin["id"])
|
||||
room = admin_ws.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
archive_resp = admin_ws.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
assert archive_resp.status_code == 200
|
||||
|
||||
with admin_ws.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "should not send"})
|
||||
received = ws.receive_json()
|
||||
assert received["type"] == "error"
|
||||
assert "archived" in received["detail"].lower()
|
||||
|
||||
|
||||
def test_ws_message_allowed_again_after_unarchive(ws_client_factory):
|
||||
admin_ws = ws_client_factory()
|
||||
admin = _register_ws(admin_ws, _unique("admin"))
|
||||
_make_admin_ws(admin_ws, admin["id"])
|
||||
room = admin_ws.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
admin_ws.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
admin_ws.post(f"/api/admin/rooms/{room['id']}/unarchive")
|
||||
|
||||
with admin_ws.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "back online"})
|
||||
received = ws.receive_json()
|
||||
assert received["type"] == "message"
|
||||
assert received["content"] == "back online"
|
||||
|
||||
|
||||
async def test_incoming_webhook_rejected_in_archived_room(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
|
||||
|
||||
await client.post(f"/api/admin/rooms/{room['id']}/archive")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": "should not post"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "archived" in resp.json()["detail"].lower()
|
||||
@@ -8,21 +8,25 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "dm_presence_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
"""Reads the next frame, transparently discarding presence-change
|
||||
broadcasts (member_updated, and #63's dm_presence_update) -- another
|
||||
connection sharing a room or a DM going online/offline is real,
|
||||
expected noise these tests aren't about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
async def fake(db, to, subject, paragraphs, **kwargs):
|
||||
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||
return calls
|
||||
@@ -146,6 +150,65 @@ def test_add_member_notifies_target_user_via_websocket(ws_client_factory, monkey
|
||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_start_dm_notifies_other_participant_via_websocket(ws_client_factory):
|
||||
# A production report: bob had no idea a DM existed until he reloaded --
|
||||
# find_or_create_dm was creating the room/membership correctly but never
|
||||
# sending this signal, unlike every other "you're now in a room" path
|
||||
# (add_member, above). Same shape as that test: bob is only ever
|
||||
# "connected," never "joined," proving the signal alone is what tells
|
||||
# his client the room exists at all.
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
resp = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
room = resp.json()
|
||||
|
||||
received = bob_ws.receive_json()
|
||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_new_message_notifies_recipient_who_hid_the_dm_via_websocket(ws_client_factory):
|
||||
# A second production report on the same underlying gap: hiding a DM
|
||||
# correctly clears out of GET /rooms/mine, but when the other person
|
||||
# messages again, the *only* existing signal for that (unread_update)
|
||||
# does `setRooms(prev => prev.map(...))` -- a no-op for a room that
|
||||
# isn't in `prev` at all, which a hidden DM by definition isn't. Needs
|
||||
# the same room_added signal a brand new DM gets, not just a DB-level
|
||||
# un-hide.
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
room = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
resp = instance2.post(f"/api/rooms/{room['id']}/hide")
|
||||
assert resp.status_code == 204
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "you there?"})
|
||||
alice_ws.receive_json()
|
||||
# Sync barrier (see test_mentions.py's identical helper): the
|
||||
# message ack only proves the room-level broadcast happened,
|
||||
# not that broadcast_new_message's own continuation (which
|
||||
# un-hides the room and publishes room_added) has finished --
|
||||
# a second frame's own ack proves that before this connection
|
||||
# closes underneath it.
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
received = _recv(bob_ws)
|
||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_profile_update_notifies_room_members_via_websocket(ws_client_factory, monkeypatch):
|
||||
# Only reaches clients that have the room's own channel joined --
|
||||
# exactly the case where a stale avatar/display name would actually be
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ws.broadcaster import Broadcaster
|
||||
|
||||
|
||||
async def test_run_forever_restarts_after_listen_raises(monkeypatch):
|
||||
# #76: listen() dying (a dropped Redis connection, most commonly -- e.g.
|
||||
# Redis itself restarting) must not permanently kill live delivery for
|
||||
# the whole process. run_forever() has to notice and restart it, not
|
||||
# just let the background task disappear silently while everything else
|
||||
# (login, REST endpoints) keeps working fine.
|
||||
broadcaster = Broadcaster(redis=None, manager=None)
|
||||
calls = 0
|
||||
|
||||
async def fake_listen():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
raise ConnectionError("redis went away")
|
||||
# Stop the loop once the retry behavior below is proven, rather
|
||||
# than looping forever in a test.
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(broadcaster, "listen", fake_listen)
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
sleep_calls.append(seconds)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await broadcaster.run_forever()
|
||||
|
||||
assert calls == 3
|
||||
# Slept once after each of the first two failures (exponential:
|
||||
# 1s, then 2s) -- not after the third, which is the CancelledError
|
||||
# that stops the loop before reaching the sleep.
|
||||
assert sleep_calls == [1.0, 2.0]
|
||||
|
||||
|
||||
async def test_run_forever_does_not_swallow_cancellation(monkeypatch):
|
||||
# A genuine shutdown (the app's own lifespan cancels this task) must
|
||||
# propagate immediately, not get treated as just another failure to
|
||||
# retry after a backoff.
|
||||
broadcaster = Broadcaster(redis=None, manager=None)
|
||||
|
||||
async def fake_listen():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(broadcaster, "listen", fake_listen)
|
||||
|
||||
async def fail_if_called(seconds):
|
||||
raise AssertionError("should not sleep/retry on cancellation")
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fail_if_called)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await broadcaster.run_forever()
|
||||
@@ -0,0 +1,275 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.models import User
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _png_bytes(size: tuple[int, int] = (10, 10), color: tuple[int, int, int] = (255, 0, 0)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color=color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_upload_custom_emoji_succeeds(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "party-parrot"},
|
||||
files={"file": ("parrot.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["shortcode"] == "party-parrot"
|
||||
assert "id" in body
|
||||
assert "created_at" in body
|
||||
|
||||
|
||||
async def test_upload_normalizes_shortcode_case(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": " PartyParrot "},
|
||||
files={"file": ("parrot.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["shortcode"] == "partyparrot"
|
||||
|
||||
|
||||
async def test_upload_rejects_invalid_shortcode(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "a"}, # too short
|
||||
files={"file": ("x.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_upload_rejects_duplicate_shortcode(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
first = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "dupe-test"},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
|
||||
second = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "dupe-test"},
|
||||
files={"file": ("b.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
async def test_upload_rejects_non_image(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "not-an-image"},
|
||||
files={"file": ("x.txt", b"hello", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_upload_rejects_oversized(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
oversized = b"0" * (9 * 1024 * 1024)
|
||||
resp = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": "too-big"},
|
||||
files={"file": ("huge.png", oversized, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
async def test_list_custom_emoji(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("listed")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
resp = await client.get("/api/custom-emoji")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
|
||||
async def test_serve_custom_emoji_image_by_shortcode(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
shortcode = _unique("served")
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
|
||||
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
async def test_serve_custom_emoji_image_forces_revalidation(client, db_session):
|
||||
# A timed cache (the original `max-age=300`) meant a browser that had
|
||||
# already fetched a shortcode's image kept serving those bytes for up
|
||||
# to 5 minutes after a delete-and-reupload swapped in a different file
|
||||
# under the same URL -- confirmed live: re-adding an emoji under a
|
||||
# just-deleted shortcode showed the old image. `no-cache` forces
|
||||
# revalidation on every use instead (still cheap: FileResponse's own
|
||||
# ETag/Last-Modified make an actually-unchanged file a 304, not a full
|
||||
# re-transfer).
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
shortcode = _unique("revalidated")
|
||||
await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
|
||||
assert "no-cache" in resp.headers["cache-control"]
|
||||
assert "max-age" not in resp.headers["cache-control"]
|
||||
|
||||
|
||||
async def test_reuploading_a_deleted_shortcode_serves_the_new_image(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
shortcode = _unique("reused")
|
||||
first = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("red.png", _png_bytes(color=(255, 0, 0)), "image/png")},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
await client.delete(f"/api/custom-emoji/{first.json()['id']}")
|
||||
|
||||
second = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": shortcode},
|
||||
files={"file": ("blue.png", _png_bytes(color=(0, 0, 255)), "image/png")},
|
||||
)
|
||||
assert second.status_code == 201
|
||||
|
||||
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
|
||||
served = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
assert served.getpixel((0, 0)) == (0, 0, 255)
|
||||
|
||||
|
||||
async def test_serve_unknown_shortcode_404s(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
resp = await client.get("/api/custom-emoji/no-such-emoji/image")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_uploader_can_delete_own_emoji(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("deleteme")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
emoji_id = upload.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/custom-emoji/{emoji_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
listed = (await client.get("/api/custom-emoji")).json()
|
||||
assert emoji_id not in [e["id"] for e in listed]
|
||||
|
||||
|
||||
async def test_non_uploader_non_admin_cannot_delete(client, app, db_session):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("guarded")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
emoji_id = upload.json()["id"]
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
|
||||
await register_and_login(bob_client, db_session, username=_unique("bob"))
|
||||
resp = await bob_client.delete(f"/api/custom-emoji/{emoji_id}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
listed = (await client.get("/api/custom-emoji")).json()
|
||||
assert emoji_id in [e["id"] for e in listed]
|
||||
|
||||
|
||||
async def test_site_admin_can_delete_others_emoji(client, app, db_session):
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
alice = await register_and_login(client, db_session, username=_unique("alice"))
|
||||
upload = await client.post(
|
||||
"/api/custom-emoji",
|
||||
data={"shortcode": _unique("admin-deletable")},
|
||||
files={"file": ("a.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
emoji_id = upload.json()["id"]
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as admin_client:
|
||||
admin = await register_and_login(admin_client, db_session, username=_unique("admin"))
|
||||
await _make_admin(db_session, admin["id"])
|
||||
resp = await admin_client.delete(f"/api/custom-emoji/{emoji_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert alice["id"]
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_reaction_accepts_custom_emoji_shortcode_reference(ws_client_factory):
|
||||
instance = ws_client_factory()
|
||||
_register_ws(instance, _unique("alice"))
|
||||
room = instance.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with instance.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
# A custom emoji reaction is stored as its literal `:shortcode:`
|
||||
# text (14 chars here) -- well past the old 8-char cap that only
|
||||
# ever needed to fit a raw unicode glyph.
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "reaction",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"emoji": ":party-parrot:",
|
||||
}
|
||||
)
|
||||
reaction_update = ws.receive_json()
|
||||
assert reaction_update["type"] == "reaction_update"
|
||||
assert reaction_update["reactions"][0]["emoji"] == ":party-parrot:"
|
||||
@@ -128,6 +128,87 @@ def test_desktop_notification_not_sent_to_room_member_who_is_present(ws_client_f
|
||||
assert live_message["type"] == "message"
|
||||
|
||||
|
||||
def test_desktop_notification_sent_to_connected_but_blurred_member(ws_client_factory):
|
||||
# #59: bob keeps the room's channel joined (so live delivery to a room
|
||||
# actually open on screen never stops) but reports his desktop window
|
||||
# as unfocused via a "focus" frame -- the whole point of this fix is
|
||||
# that notification eligibility no longer needs the client to fake
|
||||
# "offline" by leaving the room's channel, which used to also break
|
||||
# live delivery until the room was manually left and rejoined.
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
_register_ws(instance2, _unique("bob"))
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
bob_ws.send_json({"type": "focus", "focused": False})
|
||||
# Sync barrier -- see test_mentions.py's identical pattern: a
|
||||
# second (idempotent) join only acks once the prior "focus"
|
||||
# frame's own handling (and commit) has completed.
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
message = _send_and_sync(alice_ws, room["id"], "you awake?")
|
||||
assert message["type"] == "message"
|
||||
|
||||
# Live delivery still works -- bob's room channel was never left.
|
||||
live_message = _recv(bob_ws)
|
||||
assert live_message["type"] == "message"
|
||||
assert live_message["content"] == "you awake?"
|
||||
|
||||
# ...and he's still notified, despite being "connected" to the room.
|
||||
desktop_note = _recv(bob_ws)
|
||||
assert desktop_note == {
|
||||
"type": "desktop_notification",
|
||||
"id": message["id"],
|
||||
"room_id": room["id"],
|
||||
"title": f"#{room['name']}",
|
||||
"body": f"{alice['username']}: you awake?",
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_notification_not_sent_after_refocus(ws_client_factory):
|
||||
# Proves the "focus" signal is a live toggle, not one-way -- blurring
|
||||
# and then refocusing before the message arrives must fully cancel out.
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
_register_ws(instance2, _unique("bob"))
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
bob_ws.send_json({"type": "focus", "focused": False})
|
||||
bob_ws.send_json({"type": "focus", "focused": True})
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], "hello again")
|
||||
|
||||
# Refocused before the message arrived -- only the live broadcast,
|
||||
# same as test_desktop_notification_not_sent_to_room_member_who_is_present.
|
||||
live_message = _recv(bob_ws)
|
||||
assert live_message["type"] == "message"
|
||||
|
||||
|
||||
def test_desktop_notification_not_sent_to_non_member(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Room, RoomMembership
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.room_service import dm_room_name
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def test_start_dm_creates_private_room_with_both_members(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
room = resp.json()
|
||||
assert room["is_dm"] is True
|
||||
assert room["is_private"] is True
|
||||
# The internal name is never meant to be shown, but its scheme is part
|
||||
# of the contract find_or_create_dm relies on -- pin it here so a
|
||||
# future refactor can't silently change it without this test noticing.
|
||||
assert room["name"] == dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"]))
|
||||
|
||||
result = await db_session.execute(
|
||||
select(RoomMembership.user_id).where(RoomMembership.room_id == uuid.UUID(room["id"]))
|
||||
)
|
||||
member_ids = {str(row[0]) for row in result.all()}
|
||||
assert member_ids == {alice["id"], bob["id"]}
|
||||
|
||||
|
||||
async def test_start_dm_is_idempotent_regardless_of_who_initiates(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
|
||||
resp1 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp1.status_code == 201
|
||||
room_id = resp1.json()["id"]
|
||||
|
||||
# bob -> alice again should return the same room, not create a second one.
|
||||
resp2 = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp2.status_code == 201
|
||||
assert resp2.json()["id"] == room_id
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
# alice -> bob (reversed direction) should also find the same room.
|
||||
resp3 = await client.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
assert resp3.status_code == 201
|
||||
assert resp3.json()["id"] == room_id
|
||||
|
||||
|
||||
async def test_start_dm_rejects_self(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_start_dm_404s_for_unknown_user(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": str(uuid.uuid4())})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_dm_excluded_from_browse_rooms(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
|
||||
# A third user should never see the DM in the open-rooms listing, even
|
||||
# though find_or_create_dm sets is_private=True (which alone would
|
||||
# already exclude it) -- confirms the belt-and-suspenders is_dm filter
|
||||
# in list_open_rooms is doing something, not just is_private.
|
||||
await client.post("/api/auth/logout")
|
||||
await register_and_login(client, db_session, username="carol")
|
||||
resp = await client.get("/api/rooms")
|
||||
assert resp.status_code == 200
|
||||
assert all(not r["is_dm"] for r in resp.json())
|
||||
|
||||
|
||||
async def test_dm_excluded_from_admin_room_list(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
admin = await register_and_login(client, db_session, username="dave")
|
||||
from app.models import User
|
||||
|
||||
user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/admin/rooms")
|
||||
assert resp.status_code == 200
|
||||
names = [r["name"] for r in resp.json()]
|
||||
assert dm_room_name(uuid.UUID(alice["id"]), uuid.UUID(bob["id"])) not in names
|
||||
|
||||
|
||||
async def test_dm_appears_in_mine_with_partner_info(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob", password="password123")
|
||||
# Give bob a display name so the partner payload's precedence is checked
|
||||
# for something other than the fallback username.
|
||||
await client.patch("/api/auth/me", json={"display_name": "Bobby"})
|
||||
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
dm_entry = next(r for r in mine if r["id"] == dm["id"])
|
||||
assert dm_entry["is_dm"] is True
|
||||
assert dm_entry["dm_partner"]["user_id"] == bob["id"]
|
||||
assert dm_entry["dm_partner"]["username"] == "bob"
|
||||
assert dm_entry["dm_partner"]["display_name"] == "Bobby"
|
||||
|
||||
# A regular room's dm_partner is always null.
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
room_entry = next(r for r in mine if r["id"] == room["id"])
|
||||
assert room_entry["is_dm"] is False
|
||||
assert room_entry["dm_partner"] is None
|
||||
|
||||
|
||||
async def test_dm_cannot_be_updated(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
# bob is a plain 'member' of the DM (no admin/owner role exists for a
|
||||
# DM), so this 403s on the ordinary role gate before ever reaching
|
||||
# update_room's own is_dm guard.
|
||||
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# A site admin bypasses that role gate (see #48) -- confirms the
|
||||
# explicit is_dm guard inside update_room itself is what stops this,
|
||||
# not just incidental role-based protection.
|
||||
await client.post("/api/auth/logout")
|
||||
admin = await register_and_login(client, db_session, username="carol")
|
||||
from app.models import User
|
||||
|
||||
user = await db_session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
resp = await client.patch(f"/api/rooms/{dm['id']}", json={"name": "renamed"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_dm_rejects_add_member_and_join(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
carol = await register_and_login(client, db_session, username="carol")
|
||||
|
||||
# Neither participant has admin/owner role in a DM, so adding a third
|
||||
# person 403s on the existing role gate.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "bob")
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/members", json={"user_id": carol["id"]})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# is_private=True on the DM already blocks the plain join endpoint too.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "carol")
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/join")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_hide_dm_removes_it_from_mine_for_that_user_only(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
resp = await client.post(f"/api/rooms/{dm['id']}/hide")
|
||||
assert resp.status_code == 204
|
||||
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert all(r["id"] != dm["id"] for r in mine)
|
||||
|
||||
# Alice never hid it -- still sees it, proving this is per-viewer, not
|
||||
# something that touched the room or bob's membership for everyone.
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert any(r["id"] == dm["id"] for r in mine)
|
||||
|
||||
|
||||
async def test_hide_dm_rejects_regular_rooms(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
resp = await client.post(f"/api/rooms/{room['id']}/hide")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_starting_a_dm_again_unhides_it(client, db_session):
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
dm = (await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})).json()
|
||||
|
||||
await client.post(f"/api/rooms/{dm['id']}/hide")
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert all(r["id"] != dm["id"] for r in mine)
|
||||
|
||||
# bob clicking alice in the People list again -- find_or_create_dm
|
||||
# resolves to the same room and un-hides it for him.
|
||||
resp = await client.post("/api/rooms/dm", json={"other_user_id": alice["id"]})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["id"] == dm["id"]
|
||||
|
||||
mine = (await client.get("/api/rooms/mine")).json()
|
||||
assert any(r["id"] == dm["id"] for r in mine)
|
||||
|
||||
|
||||
def test_new_message_unhides_dm_for_both_participants(ws_client):
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
bob = _register_ws(ws_client, _unique("bob")) # ws_client is now logged in as bob
|
||||
dm = ws_client.post("/api/rooms/dm", json={"other_user_id": alice["id"]}).json()
|
||||
|
||||
ws_client.post(f"/api/rooms/{dm['id']}/hide")
|
||||
assert all(r["id"] != dm["id"] for r in ws_client.get("/api/rooms/mine").json())
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": dm["id"], "content": "you there?"})
|
||||
ws.receive_json()
|
||||
# Sync barrier (see test_mentions.py's identical helper): the
|
||||
# message ack only proves the room-level broadcast happened, not
|
||||
# that broadcast_new_message's own continuation (which un-hides
|
||||
# the room) has finished -- a second frame's own ack proves that.
|
||||
ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
# bob never re-opened the DM himself -- alice's message alone unhid it.
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
assert any(r["id"] == dm["id"] for r in ws_client.get("/api/rooms/mine").json())
|
||||
|
||||
|
||||
def test_dm_partner_presence_update_delivered_without_room_open(ws_client_factory):
|
||||
# #63: alice never joins the DM's own room channel anywhere in this
|
||||
# test -- exactly the normal state for a DM sitting in the sidebar that
|
||||
# isn't the currently open room. member_updated's room-channel broadcast
|
||||
# would never reach her in that state (Presence gates it on having that
|
||||
# specific room joined); this signal has to arrive on her own per-user
|
||||
# channel instead, same as room_added.
|
||||
#
|
||||
# Only the connect ("online") side is exercised here, not disconnect --
|
||||
# see test_presence.py's module docstring for why the offline half
|
||||
# isn't reliably testable via a `with websocket_connect(...)` block
|
||||
# closing (TestClient cancels the server task rather than delivering a
|
||||
# real disconnect, which can interrupt a `finally` block's own awaits
|
||||
# in tests only, never in production).
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
# Sync barrier: alice's own websocket_connect() returning only
|
||||
# proves the handshake completed, not that chat.py's connection
|
||||
# setup (register_user, in particular -- required before bob's
|
||||
# connect can reach her per-user channel at all) has finished.
|
||||
# Any reply -- even an error -- proves the connection has reached
|
||||
# its main frame loop, which setup always completes before.
|
||||
alice_ws.send_json({"type": "__sync_barrier__"})
|
||||
assert alice_ws.receive_json()["type"] == "error"
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
online_update = alice_ws.receive_json()
|
||||
assert online_update == {
|
||||
"type": "dm_presence_update",
|
||||
"user_id": bob["id"],
|
||||
"status": "online",
|
||||
}
|
||||
|
||||
|
||||
def test_dm_presence_update_not_sent_to_non_partner(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
instance3 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
_register_ws(instance3, _unique("outsider"))
|
||||
instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]})
|
||||
|
||||
with instance3.websocket_connect("/ws/chat") as outsider_ws:
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
pass
|
||||
|
||||
# Nothing should ever arrive for an outsider who shares no DM with
|
||||
# bob. Prove the socket stayed quiet the same way
|
||||
# test_desktop_notifications.py's non-member test does: a harmless
|
||||
# self-targeted join, whose prompt "joined" ack proves nothing else
|
||||
# was already queued ahead of it.
|
||||
room = instance3.post("/api/rooms", json={"name": _unique("outsiders-room")}).json()
|
||||
outsider_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
joined = outsider_ws.receive_json()
|
||||
assert joined == {"type": "joined", "room_id": room["id"]}
|
||||
@@ -0,0 +1,207 @@
|
||||
import uuid
|
||||
|
||||
from app.models import SmtpSettings
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _fake_smtp(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append({"message": message, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
# Monkeypatches get_smtp_settings directly rather than configuring it
|
||||
# for real through the admin endpoint (like test_smtp_settings.py's own
|
||||
# _configure_smtp does) -- ws_client_factory-based tests commit for
|
||||
# real, no rollback, and SmtpSettings is a genuine single global row.
|
||||
# Configuring it for real here previously leaked into every later test
|
||||
# in the same run, breaking test_smtp_settings.py's "starts
|
||||
# unconfigured" assumption. This never touches the DB at all.
|
||||
fake_settings = SmtpSettings(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="bot",
|
||||
password_encrypted=None,
|
||||
from_address="noreply@example.com",
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
async def fake_get_smtp_settings(db):
|
||||
return fake_settings
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
|
||||
return calls
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _send_and_sync(ws, room_id: str, content: str) -> dict:
|
||||
"""Sync barrier -- see test_mentions.py's identical helper. The
|
||||
"message" ack fires the instant broadcaster.publish() runs, the very
|
||||
first line of broadcast_new_message -- it proves nothing about whether
|
||||
_maybe_email_dm_notification (awaited afterward, in the same handler)
|
||||
has finished. A second, idempotent join's own ack only arrives once
|
||||
the whole prior frame's handling -- including the email step -- is
|
||||
done, since one connection processes frames strictly sequentially."""
|
||||
ws.send_json({"type": "message", "room_id": room_id, "content": content})
|
||||
message = ws.receive_json()
|
||||
ws.send_json({"type": "join", "room_id": room_id})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
return message
|
||||
|
||||
|
||||
def test_dm_message_emails_globally_offline_recipient(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
# bob never connects via WS at all -- genuinely offline, not just
|
||||
# absent from this room's own channel.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "hey, you there?")
|
||||
|
||||
assert len(calls) == 1
|
||||
email = calls[0]["message"]
|
||||
assert email["To"] == bob["email"]
|
||||
assert f"New message from {alice['username']}" in email["Subject"]
|
||||
# #68: the email is now multipart/alternative (HTML + plain-text
|
||||
# fallback) -- get_body(preferencelist=...) reaches a specific part,
|
||||
# unlike get_content() which has no handler for the multipart itself.
|
||||
body = email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert f"{alice['username']}: hey, you there?" in body
|
||||
assert f"/rooms/{dm['id']}" in body
|
||||
|
||||
|
||||
def test_dm_message_does_not_email_online_recipient(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob has an open connection -- genuinely online -- even though he
|
||||
# never joins the DM's own room channel.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "hey")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_dm_message_emails_appear_offline_recipient_even_when_connected(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
resp = instance2.patch("/api/auth/me", json={"appear_offline": True})
|
||||
assert resp.status_code == 200
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob is connected (genuinely online) but lurking -- appear_offline
|
||||
# should still count as "email me," matching how it already
|
||||
# overrides the presence dot everywhere else.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "hey")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["message"]["To"] == bob["email"]
|
||||
|
||||
|
||||
def test_regular_room_message_does_not_email_offline_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
_register_ws(instance2, _unique("bob"))
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
# bob never connects -- genuinely offline, same as the DM case -- but
|
||||
# this isn't a DM, so #66's email notification is out of scope here.
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], "hello room")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_dm_email_debounced_to_first_unread_then_resets_after_read(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
_send_and_sync(alice_ws, dm["id"], "message one")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A second message while bob still hasn't read the first -- no
|
||||
# second email for the same burst.
|
||||
_send_and_sync(alice_ws, dm["id"], "message two")
|
||||
assert len(calls) == 1
|
||||
|
||||
# bob "reads" the conversation via REST -- he never has to have been
|
||||
# connected via WS for this to be meaningful, mark-read is independent
|
||||
# of live connection state.
|
||||
instance2.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
read_resp = instance2.post(f"/api/rooms/{dm['id']}/read")
|
||||
assert read_resp.status_code == 204
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, dm["id"], "message three")
|
||||
|
||||
assert len(calls) == 2
|
||||
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest_asyncio
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = time.time()
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def redis_client():
|
||||
client = Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
|
||||
yield client
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def setup(redis_client):
|
||||
clock = Clock()
|
||||
store = ExpiringPresenceSet(redis_client, ttl_seconds=90, clock=clock)
|
||||
key = f"presence:v2:test:{uuid.uuid4()}"
|
||||
yield store, clock, key
|
||||
await redis_client.delete(key)
|
||||
|
||||
|
||||
async def test_first_connection_is_a_transition_and_a_second_is_not(setup):
|
||||
store, _, key = setup
|
||||
user = uuid.uuid4()
|
||||
assert await store.add(key, user, "tab1") is True
|
||||
assert await store.add(key, user, "tab2") is False
|
||||
assert await store.live_user_ids(key) == {user}
|
||||
|
||||
|
||||
async def test_renewing_a_live_entry_is_not_a_transition(setup):
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
clock.advance(30)
|
||||
assert await store.add(key, user, "tab1") is False
|
||||
|
||||
|
||||
async def test_entry_stops_counting_after_the_ttl(setup):
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
clock.advance(89)
|
||||
assert await store.is_live(key, user) is True
|
||||
clock.advance(2)
|
||||
assert await store.is_live(key, user) is False
|
||||
assert await store.live_user_ids(key) == set()
|
||||
|
||||
|
||||
async def test_renewal_keeps_a_connection_alive_past_the_original_expiry(setup):
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
for _ in range(5):
|
||||
clock.advance(60)
|
||||
await store.add(key, user, "tab1")
|
||||
assert await store.is_live(key, user) is True
|
||||
|
||||
|
||||
async def test_coming_back_after_expiry_is_a_transition_again(setup):
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
clock.advance(200)
|
||||
assert await store.add(key, user, "tab1") is True
|
||||
|
||||
|
||||
async def test_one_live_connection_keeps_the_user_online_when_another_expires(setup):
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "old-tab")
|
||||
clock.advance(60)
|
||||
await store.add(key, user, "new-tab")
|
||||
clock.advance(60) # old-tab is now 120s stale, new-tab 60s
|
||||
assert await store.is_live(key, user) is True
|
||||
|
||||
|
||||
async def test_remove_reports_only_the_last_connection_leaving(setup):
|
||||
store, _, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
await store.add(key, user, "tab2")
|
||||
assert await store.remove(key, user, "tab1") is False
|
||||
assert await store.remove(key, user, "tab2") is True
|
||||
|
||||
|
||||
async def test_remove_of_an_entry_that_is_already_gone_reports_nothing(setup):
|
||||
# Whoever removed it first (e.g. prune) already reported the transition.
|
||||
store, _, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
await store.remove(key, user, "tab1")
|
||||
assert await store.remove(key, user, "tab1") is False
|
||||
|
||||
|
||||
async def test_remove_of_an_expired_but_unpruned_entry_still_reports_offline(setup):
|
||||
# Nobody has been told this user went offline yet (no prune has run), so
|
||||
# their eventual clean close is the one place left to report it.
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
clock.advance(200)
|
||||
assert await store.remove(key, user, "tab1") is True
|
||||
|
||||
|
||||
async def test_users_are_independent(setup):
|
||||
store, _, key = setup
|
||||
alice, bob = uuid.uuid4(), uuid.uuid4()
|
||||
await store.add(key, alice, "a")
|
||||
await store.add(key, bob, "b")
|
||||
await store.remove(key, alice, "a")
|
||||
assert await store.live_user_ids(key) == {bob}
|
||||
|
||||
|
||||
async def test_prune_reports_users_who_lost_their_last_live_entry(setup):
|
||||
store, clock, key = setup
|
||||
gone, still_here = uuid.uuid4(), uuid.uuid4()
|
||||
await store.add(key, gone, "tab1")
|
||||
await store.add(key, still_here, "old")
|
||||
clock.advance(60)
|
||||
await store.add(key, still_here, "new")
|
||||
clock.advance(40) # gone/tab1 and still_here/old are expired; still_here/new is not
|
||||
assert await store.prune(key) == {gone}
|
||||
|
||||
|
||||
async def test_prune_is_idempotent_and_removes_what_it_reports(setup):
|
||||
store, clock, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
clock.advance(200)
|
||||
assert await store.prune(key) == {user}
|
||||
assert await store.prune(key) == set()
|
||||
|
||||
|
||||
async def test_prune_with_nothing_expired_does_nothing(setup):
|
||||
store, _, key = setup
|
||||
user = uuid.uuid4()
|
||||
await store.add(key, user, "tab1")
|
||||
assert await store.prune(key) == set()
|
||||
assert await store.is_live(key, user) is True
|
||||
@@ -99,6 +99,43 @@ async def test_serve_file_forces_download(client, db_session):
|
||||
assert "notes.txt" in disposition
|
||||
|
||||
|
||||
async def test_serve_allowlisted_video_inline(client, db_session):
|
||||
# #65: a <video> tag can't play something the browser is forced to
|
||||
# download instead -- browser-playable video types are the one carve-
|
||||
# out from test_serve_file_forces_download's rule above.
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
upload = await client.post(
|
||||
f"/api/rooms/{room['id']}/files",
|
||||
files={"file": ("clip.mp4", b"not a real mp4", "video/mp4")},
|
||||
)
|
||||
file_id = upload.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
||||
assert resp.status_code == 200
|
||||
assert "content-disposition" not in resp.headers
|
||||
assert resp.headers["content-type"] == "video/mp4"
|
||||
|
||||
|
||||
async def test_serve_non_allowlisted_video_still_forces_download(client, db_session):
|
||||
# video/quicktime (.mov) has spotty <video> support outside Safari, and
|
||||
# more importantly this proves the carve-out is a strict allowlist, not
|
||||
# "every video/* content type" -- the security-relevant boundary from
|
||||
# test_serve_file_forces_download must still hold for anything not on
|
||||
# INLINE_SAFE_VIDEO_CONTENT_TYPES.
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
room = (await client.post("/api/rooms", json={"name": _unique("general")})).json()
|
||||
upload = await client.post(
|
||||
f"/api/rooms/{room['id']}/files",
|
||||
files={"file": ("clip.mov", b"not a real mov", "video/quicktime")},
|
||||
)
|
||||
file_id = upload.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/rooms/{room['id']}/files/{file_id}")
|
||||
assert resp.status_code == 200
|
||||
assert "attachment" in resp.headers["content-disposition"]
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.database import engine as _link_preview_engine
|
||||
from app.models import LinkPreview
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.link_preview_service import extract_first_url
|
||||
@@ -294,3 +298,66 @@ async def test_link_preview_reused_across_messages_with_same_url(client, db_sess
|
||||
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||
assert len(history) == 2
|
||||
assert all(m["link_preview"]["title"] == "Example Article" for m in history)
|
||||
|
||||
|
||||
async def test_link_preview_refetches_after_cache_expires(client, db_session, monkeypatch):
|
||||
# #70: a real report -- re-posting a URL whose title had genuinely
|
||||
# changed kept showing the stale first-fetch preview, because the
|
||||
# cache TTL used to be 7 days. Simulates that expiry directly (rather
|
||||
# than actually sleeping 5+ minutes) by backdating the cached row's
|
||||
# fetched_at past the TTL, then confirms a second post of the same URL
|
||||
# picks up new content instead of the stale cached title.
|
||||
captured_tasks: list[asyncio.Task] = []
|
||||
real_create_task = asyncio.create_task
|
||||
|
||||
def fake_create_task(coro):
|
||||
task = real_create_task(coro)
|
||||
captured_tasks.append(task)
|
||||
return task
|
||||
|
||||
monkeypatch.setattr("app.services.message_events.asyncio.create_task", fake_create_task)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.link_preview_service.httpx.AsyncClient",
|
||||
_fake_client_factory(call_log=calls),
|
||||
)
|
||||
url = _unique_url()
|
||||
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room = (await client.post("/api/rooms", json={"name": "general"})).json()
|
||||
webhook = (await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})).json()
|
||||
|
||||
resp1 = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"see {url}"}
|
||||
)
|
||||
assert resp1.status_code == 204
|
||||
await asyncio.gather(*captured_tasks)
|
||||
captured_tasks.clear()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
row = (
|
||||
await session.execute(select(LinkPreview).where(LinkPreview.url == url))
|
||||
).scalar_one()
|
||||
row.fetched_at = datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
await session.commit()
|
||||
|
||||
updated_html = _OG_HTML.replace(b"Example Article", b"Updated Article")
|
||||
monkeypatch.setattr(
|
||||
"app.services.link_preview_service.httpx.AsyncClient",
|
||||
_fake_client_factory(html=updated_html, call_log=calls),
|
||||
)
|
||||
|
||||
resp2 = await client.post(
|
||||
f"/api/webhooks/incoming/{webhook['token']}", json={"content": f"again: {url}"}
|
||||
)
|
||||
assert resp2.status_code == 204
|
||||
await asyncio.gather(*captured_tasks)
|
||||
|
||||
assert len(calls) == 2 # the expired cache forced a second real fetch
|
||||
|
||||
# Cached by URL, not by message (see link_preview_service.py) -- the
|
||||
# row was refreshed in place, so *both* messages referencing this URL
|
||||
# now show the new title on a history reload, not one each.
|
||||
history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
|
||||
assert len(history) == 2
|
||||
assert all(m["link_preview"]["title"] == "Updated Article" for m in history)
|
||||
@@ -0,0 +1,222 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding presence/offline-
|
||||
notify noise -- see test_message_edit.py's identical helper."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (10, 10), color=(255, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_ws_delete_clears_content_and_broadcasts(ws_client):
|
||||
username = _unique("alice")
|
||||
_register_ws(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
|
||||
deleted = ws.receive_json()
|
||||
assert deleted == {"type": "message_deleted", "id": message["id"], "room_id": room["id"]}
|
||||
|
||||
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||
history = resp.json()
|
||||
tombstone = next(m for m in history if m["id"] == message["id"])
|
||||
assert tombstone["content"] is None
|
||||
assert tombstone["deleted_at"] is not None
|
||||
assert tombstone["image_id"] is None
|
||||
assert tombstone["file"] is None
|
||||
|
||||
|
||||
def test_ws_delete_rejects_non_author(ws_client):
|
||||
alice = _register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, username=_unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = alice_ws.receive_json()
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": bob["username"], "password": "password123"},
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
bob_ws.send_json(
|
||||
{"type": "delete", "room_id": room["id"], "message_id": message["id"]}
|
||||
)
|
||||
resp = bob_ws.receive_json()
|
||||
assert resp["type"] == "error"
|
||||
assert "own messages" in resp["detail"]
|
||||
|
||||
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
||||
history = resp.json()
|
||||
still_there = next(m for m in history if m["id"] == message["id"])
|
||||
assert still_there["deleted_at"] is None
|
||||
assert still_there["content"] == "hello"
|
||||
|
||||
|
||||
def test_ws_delete_of_unknown_message_errors(ws_client):
|
||||
_register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json(
|
||||
{"type": "delete", "room_id": room["id"], "message_id": str(uuid.uuid4())}
|
||||
)
|
||||
resp = ws.receive_json()
|
||||
assert resp == {"type": "error", "detail": "Message not found"}
|
||||
|
||||
|
||||
def test_deleted_message_cannot_be_edited_or_reacted_to(ws_client):
|
||||
_register_ws(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
message = ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
|
||||
assert ws.receive_json()["type"] == "message_deleted"
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"content": "resurrected",
|
||||
}
|
||||
)
|
||||
assert ws.receive_json() == {"type": "error", "detail": "Message not found"}
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "reaction",
|
||||
"room_id": room["id"],
|
||||
"message_id": message["id"],
|
||||
"emoji": "👍",
|
||||
}
|
||||
)
|
||||
assert ws.receive_json() == {"type": "error", "detail": "Message not found"}
|
||||
|
||||
|
||||
def test_delete_fans_out_across_instances(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
||||
message = alice_ws.receive_json()
|
||||
assert _recv(bob_ws)["type"] == "message"
|
||||
|
||||
alice_ws.send_json(
|
||||
{"type": "delete", "room_id": room["id"], "message_id": message["id"]}
|
||||
)
|
||||
assert alice_ws.receive_json()["type"] == "message_deleted"
|
||||
|
||||
deleted = _recv(bob_ws)
|
||||
assert deleted == {"type": "message_deleted", "id": message["id"], "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_delete_removes_underlying_image_from_disk(ws_client):
|
||||
username = _unique("alice")
|
||||
_register_ws(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
upload = ws_client.post(
|
||||
f"/api/rooms/{room['id']}/images",
|
||||
files={"file": ("test.png", _png_bytes(), "image/png")},
|
||||
).json()
|
||||
image_id = upload["id"]
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "image_id": image_id})
|
||||
message = ws.receive_json()
|
||||
assert message["image_id"] == image_id
|
||||
|
||||
# Confirm the image actually serves before deleting, so a false
|
||||
# pass (it was never reachable to begin with) can't hide as a true
|
||||
# one below.
|
||||
get_resp = ws_client.get(f"/api/rooms/{room['id']}/images/{image_id}")
|
||||
assert get_resp.status_code == 200
|
||||
|
||||
ws.send_json({"type": "delete", "room_id": room["id"], "message_id": message["id"]})
|
||||
assert ws.receive_json()["type"] == "message_deleted"
|
||||
|
||||
# The image is gone -- both the DB row (via the now-404ing serve
|
||||
# endpoint) and, per #53's "delete the file too" choice, the file
|
||||
# actually unlinked from disk (not just detached and orphaned).
|
||||
get_resp = ws_client.get(f"/api/rooms/{room['id']}/images/{image_id}")
|
||||
assert get_resp.status_code == 404
|
||||
@@ -8,13 +8,19 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
"""Reads the next frame, transparently discarding presence/offline-
|
||||
notify noise -- another connection in the same room going online/
|
||||
offline, or a per-user-channel side effect of an earlier offline
|
||||
member's own message, can legitimately arrive right as a connection is
|
||||
established, before its own "joined" ack. Not what these tests are
|
||||
about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
@@ -90,7 +96,7 @@ def test_ws_edit_rejects_non_author(ws_client):
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
bob_ws.send_json(
|
||||
{
|
||||
"type": "edit",
|
||||
@@ -116,7 +122,7 @@ def test_edit_fans_out_across_instances(ws_client_factory):
|
||||
|
||||
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
|
||||
@@ -10,8 +10,17 @@ from tests.conftest import register_and_login
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
|
||||
calls.append(
|
||||
{
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"paragraphs": paragraphs,
|
||||
"cta_label": cta_label,
|
||||
"cta_url": cta_url,
|
||||
"theme_user": theme_user,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.services.password_service.send_email", fake)
|
||||
return calls
|
||||
@@ -91,7 +100,7 @@ async def test_reset_password_flow_end_to_end(client, db_session, monkeypatch):
|
||||
await client.post("/api/auth/logout")
|
||||
|
||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||
token = _extract_token(calls[0]["body"])
|
||||
token = _extract_token(calls[0]["cta_url"])
|
||||
|
||||
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
|
||||
assert validate.status_code == 204
|
||||
@@ -133,7 +142,7 @@ async def test_reset_password_expired_token_rejected(client, db_session, monkeyp
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||
token = _extract_token(calls[0]["body"])
|
||||
token = _extract_token(calls[0]["cta_url"])
|
||||
|
||||
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
|
||||
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
@@ -150,7 +159,7 @@ async def test_reset_password_used_token_cannot_be_reused(client, db_session, mo
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
await client.post("/api/auth/logout")
|
||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||
token = _extract_token(calls[0]["body"])
|
||||
token = _extract_token(calls[0]["cta_url"])
|
||||
|
||||
first = await client.post(
|
||||
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
|
||||
|
||||
@@ -130,3 +130,108 @@ def test_online_users_endpoint_respects_appear_offline(ws_client):
|
||||
|
||||
online = ws_client.get("/api/users/online").json()
|
||||
assert alice["id"] not in online
|
||||
|
||||
|
||||
# --- #77: liveness -----------------------------------------------------------
|
||||
# A connection that stops answering the app-level heartbeat is dropped by the
|
||||
# server, and a connection that does answer keeps its presence entry renewed.
|
||||
# Timings are shrunk so nothing here waits on the real 30s/90s values.
|
||||
|
||||
|
||||
def _fast_heartbeat(monkeypatch):
|
||||
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
|
||||
monkeypatch.setattr("app.ws.chat.WS_DEAD_AFTER_SECONDS", 0.2)
|
||||
monkeypatch.setattr("app.ws.chat.PRESENCE_REFRESH_MIN_INTERVAL_SECONDS", 0)
|
||||
|
||||
|
||||
def test_unresponsive_client_is_disconnected_by_the_server(ws_client, monkeypatch):
|
||||
# The frozen-page / sleeping-laptop case: the transport stays open (the
|
||||
# browser answers protocol pings itself) but the page never replies to
|
||||
# the app-level ping.
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
_fast_heartbeat(monkeypatch)
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
pings = 0
|
||||
try:
|
||||
while True:
|
||||
assert ws.receive_json() == {"type": "ping"}
|
||||
pings += 1
|
||||
assert pings < 100, "server never gave up on a silent client"
|
||||
except WebSocketDisconnect as exc:
|
||||
assert exc.code == 4408
|
||||
assert pings >= 2
|
||||
|
||||
|
||||
def test_client_that_answers_pings_is_not_disconnected(ws_client, monkeypatch):
|
||||
_fast_heartbeat(monkeypatch)
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
# Far longer than WS_DEAD_AFTER_SECONDS in total.
|
||||
for _ in range(12):
|
||||
assert ws.receive_json() == {"type": "ping"}
|
||||
ws.send_json({"type": "pong"})
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
while (msg := ws.receive_json())["type"] == "ping":
|
||||
pass
|
||||
assert msg == {"type": "joined", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_bot_connection_is_not_dropped_for_ignoring_pings(ws_client_factory, monkeypatch):
|
||||
# API-token clients aren't expected to speak the app heartbeat.
|
||||
_fast_heartbeat(monkeypatch)
|
||||
ws_client = ws_client_factory()
|
||||
admin = _register_ws(ws_client, _unique("admin"))
|
||||
|
||||
async def _promote():
|
||||
from app.models import User
|
||||
|
||||
async with ws_client.session_factory() as session:
|
||||
user = await session.get(User, uuid.UUID(admin["id"]))
|
||||
user.is_site_admin = True
|
||||
await session.commit()
|
||||
|
||||
ws_client.portal.call(_promote)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
|
||||
token = ws_client.post(
|
||||
f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages", "write:messages"]}
|
||||
).json()["token"]
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat", headers={"Authorization": f"Bearer {token}"}) as ws:
|
||||
for _ in range(8): # ~0.4s of silence, twice WS_DEAD_AFTER_SECONDS
|
||||
assert ws.receive_json() == {"type": "ping"}
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
while (msg := ws.receive_json())["type"] == "ping":
|
||||
pass
|
||||
assert msg["type"] == "joined"
|
||||
|
||||
|
||||
def test_presence_entry_is_renewed_while_the_client_answers(ws_client, monkeypatch):
|
||||
import time
|
||||
|
||||
_fast_heartbeat(monkeypatch)
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
|
||||
async def _scores():
|
||||
redis = ws_client.app.state.redis
|
||||
return await redis.zrange("presence:v2:global", 0, -1, withscores=True)
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
# Wait for the first ping so the connect-time entry exists.
|
||||
assert ws.receive_json() == {"type": "ping"}
|
||||
first = [s for m, s in ws_client.portal.call(_scores) if m.startswith(alice["id"])]
|
||||
assert len(first) == 1
|
||||
|
||||
time.sleep(0.1)
|
||||
ws.send_json({"type": "pong"})
|
||||
# Any later frame arriving back proves the pong was processed.
|
||||
while ws.receive_json()["type"] != "ping":
|
||||
pass
|
||||
second = [s for m, s in ws_client.portal.call(_scores) if m.startswith(alice["id"])]
|
||||
assert second and second[0] > first[0]
|
||||
@@ -0,0 +1,188 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest_asyncio
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.schemas.room import RoomCreate
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.room_service import create_room, find_or_create_dm
|
||||
from app.ws.expiring_presence import ExpiringPresenceSet
|
||||
from app.ws.global_presence import GlobalPresence
|
||||
from app.ws.presence_sweeper import SWEEP_LOCK_KEY, purge_legacy_presence, sweep_once
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = time.time()
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class FakeBroadcaster:
|
||||
def __init__(self) -> None:
|
||||
self.room_events: list[tuple[uuid.UUID, dict]] = []
|
||||
self.user_events: list[tuple[uuid.UUID, dict]] = []
|
||||
|
||||
async def publish(self, room_id, payload):
|
||||
self.room_events.append((room_id, payload))
|
||||
|
||||
async def publish_to_user(self, user_id, payload):
|
||||
self.user_events.append((user_id, payload))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def redis_client():
|
||||
client = Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
|
||||
await client.delete(GlobalPresence.KEY, SWEEP_LOCK_KEY)
|
||||
yield client
|
||||
await client.delete(GlobalPresence.KEY, SWEEP_LOCK_KEY)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world(redis_client, db_session):
|
||||
clock = Clock()
|
||||
presence = GlobalPresence(redis_client, ExpiringPresenceSet(redis_client, ttl_seconds=90, clock=clock))
|
||||
broadcaster = FakeBroadcaster()
|
||||
|
||||
class _Factory:
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return db_session
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def make_user(prefix: str):
|
||||
name = f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
return await register_user(
|
||||
db_session, UserCreate(username=name, email=f"{name}@example.com", password="password123")
|
||||
)
|
||||
|
||||
async def sweep(**kwargs):
|
||||
return await sweep_once(redis_client, presence, broadcaster, _Factory(), **kwargs)
|
||||
|
||||
return clock, presence, broadcaster, make_user, sweep
|
||||
|
||||
|
||||
async def test_sweep_announces_a_user_whose_connection_expired(world, db_session):
|
||||
clock, presence, broadcaster, make_user, sweep = world
|
||||
alice, bob = await make_user("alice"), await make_user("bob")
|
||||
room = await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
|
||||
dm = await find_or_create_dm(db_session, alice.id, bob.id)
|
||||
|
||||
await presence.connect(alice.id, "tab1")
|
||||
clock.advance(200)
|
||||
|
||||
assert await sweep(lock_seconds=None) == {alice.id}
|
||||
|
||||
room_ids = {rid for rid, p in broadcaster.room_events if p["type"] == "member_updated"}
|
||||
assert {room.id, dm.id} <= room_ids
|
||||
assert (bob.id, {"type": "dm_presence_update", "user_id": str(alice.id), "status": "offline"}) in (
|
||||
broadcaster.user_events
|
||||
)
|
||||
assert await presence.is_online(alice.id) is False
|
||||
|
||||
|
||||
async def test_sweep_announces_each_expiry_only_once(world, db_session):
|
||||
clock, presence, broadcaster, make_user, sweep = world
|
||||
alice = await make_user("alice")
|
||||
await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
|
||||
await presence.connect(alice.id, "tab1")
|
||||
clock.advance(200)
|
||||
|
||||
await sweep(lock_seconds=None)
|
||||
sent = len(broadcaster.room_events)
|
||||
assert sent > 0
|
||||
assert await sweep(lock_seconds=None) == set()
|
||||
assert len(broadcaster.room_events) == sent
|
||||
|
||||
|
||||
async def test_sweep_stays_quiet_while_the_user_has_another_live_connection(world, db_session):
|
||||
clock, presence, broadcaster, make_user, sweep = world
|
||||
alice = await make_user("alice")
|
||||
await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
|
||||
await presence.connect(alice.id, "old-tab")
|
||||
clock.advance(60)
|
||||
await presence.connect(alice.id, "new-tab")
|
||||
clock.advance(60)
|
||||
|
||||
assert await sweep(lock_seconds=None) == set()
|
||||
assert broadcaster.room_events == []
|
||||
assert await presence.is_online(alice.id) is True
|
||||
|
||||
|
||||
async def test_sweep_leaves_healthy_connections_alone(world, db_session):
|
||||
clock, presence, broadcaster, make_user, sweep = world
|
||||
alice = await make_user("alice")
|
||||
await presence.connect(alice.id, "tab1")
|
||||
clock.advance(30)
|
||||
assert await sweep(lock_seconds=None) == set()
|
||||
assert broadcaster.room_events == []
|
||||
|
||||
|
||||
async def test_only_one_sweeper_runs_per_interval(world, db_session, redis_client):
|
||||
clock, presence, broadcaster, make_user, sweep = world
|
||||
alice = await make_user("alice")
|
||||
await presence.connect(alice.id, "tab1")
|
||||
clock.advance(200)
|
||||
|
||||
# Another worker holds the lock: this one must not sweep (or broadcast).
|
||||
await redis_client.set(SWEEP_LOCK_KEY, "1", ex=14)
|
||||
assert await sweep(lock_seconds=14) == set()
|
||||
assert await redis_client.zcard(GlobalPresence.KEY) == 1
|
||||
|
||||
await redis_client.delete(SWEEP_LOCK_KEY)
|
||||
assert await sweep(lock_seconds=14) == {alice.id}
|
||||
# And the lock is now held, so an immediate second attempt does nothing.
|
||||
assert await sweep(lock_seconds=14) == set()
|
||||
|
||||
|
||||
async def test_one_failed_broadcast_does_not_stop_the_rest(world, db_session):
|
||||
clock, presence, broadcaster, make_user, sweep = world
|
||||
alice, bob = await make_user("alice"), await make_user("bob")
|
||||
room = await create_room(db_session, alice.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
|
||||
other = await create_room(db_session, bob.id, RoomCreate(name=f"r-{uuid.uuid4().hex[:8]}"))
|
||||
await presence.connect(alice.id, "a")
|
||||
await presence.connect(bob.id, "b")
|
||||
clock.advance(200)
|
||||
|
||||
real_publish = broadcaster.publish
|
||||
failed = []
|
||||
|
||||
async def flaky_publish(room_id, payload):
|
||||
if room_id == room.id:
|
||||
failed.append(room_id)
|
||||
raise RuntimeError("redis hiccup")
|
||||
await real_publish(room_id, payload)
|
||||
|
||||
broadcaster.publish = flaky_publish
|
||||
|
||||
assert await sweep(lock_seconds=None) == {alice.id, bob.id}
|
||||
assert failed == [room.id]
|
||||
assert other.id in {rid for rid, _ in broadcaster.room_events}
|
||||
|
||||
|
||||
async def test_purge_removes_legacy_keys_but_keeps_v2(redis_client):
|
||||
legacy = ["presence:global", "presence:unfocused", f"presence:{uuid.uuid4()}"]
|
||||
current = f"presence:v2:test:{uuid.uuid4()}"
|
||||
for key in legacy:
|
||||
await redis_client.hset(key, "someone", 3)
|
||||
await redis_client.zadd(current, {"x:y": time.time() + 90})
|
||||
|
||||
try:
|
||||
assert await purge_legacy_presence(redis_client) >= len(legacy)
|
||||
for key in legacy:
|
||||
assert await redis_client.exists(key) == 0
|
||||
assert await redis_client.exists(current) == 1
|
||||
finally:
|
||||
await redis_client.delete(current, *legacy)
|
||||
@@ -102,6 +102,62 @@ async def test_theme_custom_rejected_on_generic_profile_update(client, db_sessio
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_text_scale_persists(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"text_scale": "large"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["text_scale"] == "large"
|
||||
|
||||
me = await client.get("/api/auth/me")
|
||||
assert me.json()["text_scale"] == "large"
|
||||
|
||||
|
||||
async def test_invalid_text_scale_rejected(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"text_scale": "huge"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_updating_text_scale_does_not_clobber_theme(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await client.patch("/api/auth/me", json={"theme": "sunset"})
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"text_scale": "xlarge"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["theme"] == "sunset"
|
||||
assert resp.json()["text_scale"] == "xlarge"
|
||||
|
||||
|
||||
async def test_update_emoji_scale_persists(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"emoji_scale": "xlarge"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["emoji_scale"] == "xlarge"
|
||||
|
||||
me = await client.get("/api/auth/me")
|
||||
assert me.json()["emoji_scale"] == "xlarge"
|
||||
|
||||
|
||||
async def test_invalid_emoji_scale_rejected(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"emoji_scale": "huge"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_updating_emoji_scale_does_not_clobber_text_scale(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
await client.patch("/api/auth/me", json={"text_scale": "large"})
|
||||
|
||||
resp = await client.patch("/api/auth/me", json={"emoji_scale": "small"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["text_scale"] == "large"
|
||||
assert resp.json()["emoji_scale"] == "small"
|
||||
|
||||
|
||||
async def test_avatar_upload_succeeds_and_persists(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
|
||||
|
||||
@@ -218,3 +218,115 @@ def test_expired_subscription_is_cleaned_up(ws_client, monkeypatch):
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
assert _fetch_subscriptions(ws_client, bob["id"]) == []
|
||||
|
||||
|
||||
def test_non_gone_push_failure_logs_response_detail_and_keeps_subscription(ws_client, monkeypatch):
|
||||
# #56: a real WNS 400 carries its actual reason in a response *header*,
|
||||
# not the body -- str(WebPushException) alone (what used to be logged)
|
||||
# would have shown neither, which is exactly why that bug took a DB
|
||||
# dump + journalctl correlation to diagnose instead of one log line.
|
||||
class FakeResponse:
|
||||
status_code = 400
|
||||
text = "Bad Request"
|
||||
headers = {"X-WNS-Error-Description": "Ttl value conflicts with X-WNS-Cache-Policy"}
|
||||
|
||||
def fake_webpush(**kwargs):
|
||||
raise WebPushException("Push failed: 400 Bad Request", response=FakeResponse())
|
||||
|
||||
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
|
||||
|
||||
# caplog's handler capture isn't reliable here -- the actual push send
|
||||
# (and its logger.warning call) runs on ws_client_factory's background
|
||||
# portal thread (see that fixture's own docstring), not pytest's main
|
||||
# thread. Patching the logger call directly sidesteps that instead of
|
||||
# depending on cross-thread log propagation.
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.push_service.logger.warning",
|
||||
lambda msg, *args: calls.append(msg % args),
|
||||
)
|
||||
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
assert ws.receive_json()["type"] == "message"
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
# A 400 isn't "gone" (404/410) -- the subscription stays, unlike the
|
||||
# expired-subscription case above.
|
||||
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
|
||||
|
||||
assert len(calls) == 1
|
||||
assert "Bad Request" in calls[0]
|
||||
assert "Ttl value conflicts with X-WNS-Cache-Policy" in calls[0]
|
||||
|
||||
|
||||
def test_wns_endpoint_gets_cache_policy_header(ws_client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
ws_client.post(
|
||||
"/api/push/subscribe",
|
||||
json={
|
||||
"endpoint": f"https://wns2-by3p.notify.windows.com/w/{_unique('bob')}",
|
||||
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
|
||||
},
|
||||
)
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
assert ws.receive_json()["type"] == "message"
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["headers"] == {"X-WNS-Cache-Policy": "no-cache"}
|
||||
|
||||
|
||||
def test_non_wns_endpoint_gets_no_extra_headers(ws_client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
|
||||
|
||||
alice = _register_ws(ws_client, _unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
bob = _register_ws(ws_client, _unique("bob"))
|
||||
ws_client.post(f"/api/rooms/{room['id']}/join")
|
||||
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
||||
assert ws.receive_json()["type"] == "message"
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["headers"] is None
|
||||
@@ -9,13 +9,19 @@ def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
_NOISE_TYPES = {"member_updated", "desktop_notification", "unread_update"}
|
||||
|
||||
|
||||
def _recv(ws) -> dict:
|
||||
"""Reads the next frame, transparently discarding member_updated
|
||||
presence-change broadcasts -- another connection in the same room going
|
||||
online/offline is real, expected noise these tests aren't about."""
|
||||
"""Reads the next frame, transparently discarding presence/offline-
|
||||
notify noise -- another connection in the same room going online/
|
||||
offline, or a per-user-channel side effect of an earlier offline
|
||||
member's own message, can legitimately arrive right as a connection is
|
||||
established, before its own "joined" ack. Not what these tests are
|
||||
about."""
|
||||
while True:
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "member_updated":
|
||||
if msg.get("type") not in _NOISE_TYPES:
|
||||
return msg
|
||||
|
||||
|
||||
@@ -122,7 +128,7 @@ def test_reaction_broadcasts_to_other_room_members(ws_client):
|
||||
)
|
||||
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
||||
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert bob_ws.receive_json()["type"] == "joined"
|
||||
assert _recv(bob_ws)["type"] == "joined"
|
||||
|
||||
ws_client.post(
|
||||
"/api/auth/login",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import uuid
|
||||
|
||||
from app.models import SmtpSettings
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _fake_smtp(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append({"message": message, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
# See test_dm_email_notifications.py's identical helper for why this
|
||||
# monkeypatches get_smtp_settings directly instead of configuring a real
|
||||
# row through the admin endpoint.
|
||||
fake_settings = SmtpSettings(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="bot",
|
||||
password_encrypted=None,
|
||||
from_address="noreply@example.com",
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
async def fake_get_smtp_settings(db):
|
||||
return fake_settings
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
|
||||
return calls
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _send_and_sync(ws, room_id: str, content: str) -> dict:
|
||||
"""See test_dm_email_notifications.py's identical helper -- the
|
||||
"message" ack alone proves nothing about whether the email step
|
||||
(awaited afterward in the same handler) has finished; a second,
|
||||
idempotent join's ack only arrives once the whole frame is done."""
|
||||
ws.send_json({"type": "message", "room_id": room_id, "content": content})
|
||||
message = ws.receive_json()
|
||||
ws.send_json({"type": "join", "room_id": room_id})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
return message
|
||||
|
||||
|
||||
def _subscribe(ws_client, room_id: str, username: str, password: str = "password123") -> None:
|
||||
login = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": password}
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
resp = ws_client.patch(f"/api/rooms/{room_id}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_room_first_message_emails_offline_subscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
# bob never connects via WS -- genuinely offline.
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], "no mention here, just a plain message")
|
||||
|
||||
assert len(calls) == 1
|
||||
email = calls[0]["message"]
|
||||
assert email["To"] == bob["email"]
|
||||
assert f"New message in #{room['name']}" in email["Subject"]
|
||||
body = email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert alice["username"] in body
|
||||
assert f"/rooms/{room['id']}" in body
|
||||
|
||||
|
||||
def test_room_second_plain_message_does_not_reemail_before_read(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
_send_and_sync(alice_ws, room["id"], "message one")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A second plain message while bob still hasn't read the first --
|
||||
# no second email for the same unread burst.
|
||||
_send_and_sync(alice_ws, room["id"], "message two")
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_room_mention_always_emails_even_mid_unread_burst(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
alice = _register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
|
||||
# First unread message (plain) -- emails once, uses up the debounce.
|
||||
_send_and_sync(alice_ws, room["id"], "hey everyone")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A mention arriving while that first message is still unread --
|
||||
# must email anyway, unlike a second plain message.
|
||||
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} specifically you")
|
||||
assert len(calls) == 2
|
||||
|
||||
mention_email = calls[1]["message"]
|
||||
assert mention_email["To"] == bob["email"]
|
||||
assert f"New mention in #{room['name']}" in mention_email["Subject"]
|
||||
body = mention_email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert f"{alice['username']} mentioned you" in body
|
||||
|
||||
|
||||
def test_room_mention_does_not_email_unsubscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
# bob never opts in -- email_notifications defaults to False.
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_message_does_not_email_online_subscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob has an open connection -- genuinely online -- even though he
|
||||
# never joins this room's own channel.
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_notifications_reset_after_read(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], "message one")
|
||||
assert len(calls) == 1
|
||||
|
||||
instance2.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
read_resp = instance2.post(f"/api/rooms/{room['id']}/read")
|
||||
assert read_resp.status_code == 204
|
||||
|
||||
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
||||
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], "message two")
|
||||
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_enabling_notifications_rejected_for_dm(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
||||
|
||||
resp = instance1.patch(f"/api/rooms/{dm['id']}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_notifications_setting_reflected_in_my_rooms(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
rooms = instance1.get("/api/rooms/mine").json()
|
||||
mine = next(r for r in rooms if r["id"] == room["id"])
|
||||
assert mine["email_notifications"] is False
|
||||
|
||||
resp = instance1.patch(f"/api/rooms/{room['id']}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 204
|
||||
|
||||
rooms = instance1.get("/api/rooms/mine").json()
|
||||
mine = next(r for r in rooms if r["id"] == room["id"])
|
||||
assert mine["email_notifications"] is True
|
||||
+147
-3
@@ -1,11 +1,52 @@
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Room, RoomMembership, RoomRole, User
|
||||
from app.models import (
|
||||
EventSubscription,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageRoomReference,
|
||||
Room,
|
||||
RoomMembership,
|
||||
RoomRole,
|
||||
User,
|
||||
WebhookIncoming,
|
||||
)
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from app.storage import UPLOADS_DIR
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (10, 10), color=(255, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _register_ws(ws_client, username: str) -> dict:
|
||||
async def _seed():
|
||||
async with ws_client.session_factory() as session:
|
||||
await register_user(
|
||||
session,
|
||||
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
||||
)
|
||||
|
||||
ws_client.portal.call(_seed)
|
||||
resp = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def test_create_room_requires_auth(client):
|
||||
resp = await client.post("/api/rooms", json={"name": "general"})
|
||||
assert resp.status_code == 401
|
||||
@@ -210,6 +251,88 @@ async def test_delete_room_owner_only(client, db_session):
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
def test_delete_room_with_attachments_integrations_and_cross_room_reference(ws_client):
|
||||
# Reproduces #55: every table below has a room_id (or message_id, for a
|
||||
# room being deleted) foreign key with no ON DELETE CASCADE at the DB
|
||||
# level, so a room that's ever had an attachment, an integration, or
|
||||
# been #referenced from another room's message used to 500 on delete.
|
||||
# This sets up one of each and confirms delete_room cleans all of them
|
||||
# up, not just whichever one originally surfaced the bug.
|
||||
_register_ws(ws_client, _unique("alice"))
|
||||
target_name = _unique("target-room")
|
||||
other_name = _unique("other-room")
|
||||
target = ws_client.post("/api/rooms", json={"name": target_name}).json()
|
||||
other = ws_client.post("/api/rooms", json={"name": other_name}).json()
|
||||
|
||||
image_id = ws_client.post(
|
||||
f"/api/rooms/{target['id']}/images",
|
||||
files={"file": ("test.png", _png_bytes(), "image/png")},
|
||||
).json()["id"]
|
||||
file_upload = ws_client.post(
|
||||
f"/api/rooms/{target['id']}/files",
|
||||
files={"file": ("report.pdf", b"%PDF-1.4 not real", "application/pdf")},
|
||||
).json()
|
||||
file_id = file_upload["id"]
|
||||
|
||||
webhook = ws_client.post(f"/api/rooms/{target['id']}/webhooks/incoming", json={}).json()
|
||||
sub = ws_client.post(
|
||||
f"/api/rooms/{target['id']}/event-subscriptions",
|
||||
json={"event_types": ["message.created"], "target_url": "http://8.8.8.8/hook"},
|
||||
).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_json({"type": "join", "room_id": target["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
ws.send_json({"type": "message", "room_id": target["id"], "image_id": image_id})
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "message", "room_id": target["id"], "file_id": file_id})
|
||||
ws.receive_json()
|
||||
|
||||
ws.send_json({"type": "join", "room_id": other["id"]})
|
||||
assert ws.receive_json()["type"] == "joined"
|
||||
# References target-room from a message that belongs to a
|
||||
# *different* room -- the direction that originally 500'd, since
|
||||
# it's keyed by the referenced room's id, not the message's room.
|
||||
ws.send_json({"type": "message", "room_id": other["id"], "content": f"check out #{target_name}"})
|
||||
other_message = ws.receive_json()
|
||||
|
||||
async def _storage_filenames():
|
||||
async with ws_client.session_factory() as session:
|
||||
image = await session.get(MessageImage, uuid.UUID(image_id))
|
||||
file = await session.get(MessageFile, uuid.UUID(file_id))
|
||||
return image.storage_filename, file.storage_filename
|
||||
|
||||
image_filename, file_filename = ws_client.portal.call(_storage_filenames)
|
||||
assert (UPLOADS_DIR / image_filename).exists()
|
||||
assert (UPLOADS_DIR / file_filename).exists()
|
||||
|
||||
resp = ws_client.delete(f"/api/rooms/{target['id']}")
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
async def _assert_cleaned_up():
|
||||
async with ws_client.session_factory() as session:
|
||||
target_id = uuid.UUID(target["id"])
|
||||
for model in (MessageImage, MessageFile, WebhookIncoming, EventSubscription):
|
||||
result = await session.execute(select(model).where(model.room_id == target_id))
|
||||
assert result.scalar_one_or_none() is None, model.__name__
|
||||
result = await session.execute(
|
||||
select(MessageRoomReference).where(MessageRoomReference.room_id == target_id)
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
ws_client.portal.call(_assert_cleaned_up)
|
||||
|
||||
# The physical files were unlinked too, not just the DB rows.
|
||||
assert not (UPLOADS_DIR / image_filename).exists()
|
||||
assert not (UPLOADS_DIR / file_filename).exists()
|
||||
|
||||
# Sanity: deleting target-room didn't touch the unrelated other-room or
|
||||
# its message -- the cross-room reference cleanup is scoped correctly.
|
||||
history = ws_client.get(f"/api/rooms/{other['id']}/messages").json()
|
||||
assert any(m["id"] == other_message["id"] for m in history)
|
||||
assert webhook["id"] and sub["id"] # created successfully, not otherwise asserted above
|
||||
|
||||
|
||||
async def test_leave_room(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||
@@ -327,8 +450,8 @@ async def test_change_member_role_owner_only(client, db_session):
|
||||
def _fake_send_email(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake(db, to, subject, body):
|
||||
calls.append({"to": to, "subject": subject, "body": body})
|
||||
async def fake(db, to, subject, paragraphs, **kwargs):
|
||||
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||
return calls
|
||||
@@ -361,6 +484,27 @@ async def test_add_member_directly(client, db_session, monkeypatch):
|
||||
assert "added" in calls[0]["subject"].lower()
|
||||
|
||||
|
||||
async def test_add_member_posts_welcome_message(client, db_session, monkeypatch):
|
||||
# #74: posted as the auto-provisioned "system" account, not the admin
|
||||
# who added them -- mirrors test_add_member_directly's setup.
|
||||
_fake_send_email(monkeypatch)
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
bob = await register_and_login(client, db_session, username="bob")
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await login_as(client, "alice")
|
||||
resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
history = (await client.get(f"/api/rooms/{room_id}/messages")).json()
|
||||
welcome_messages = [m for m in history if m["username"] == "system"]
|
||||
assert len(welcome_messages) == 1
|
||||
assert welcome_messages[0]["content"] == "Welcome to #general, bob!"
|
||||
|
||||
|
||||
async def test_add_member_requires_admin_role(client, db_session, monkeypatch):
|
||||
_fake_send_email(monkeypatch)
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import uuid
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.services.auth_service import register_user
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
async def test_login_creates_a_session(client, db_session):
|
||||
username = _unique("alice")
|
||||
await register_user(
|
||||
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": username, "password": "password123"},
|
||||
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
sessions = (await client.get("/api/auth/sessions")).json()
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["is_current"] is True
|
||||
assert sessions[0]["device_label"] == "Chrome on Windows"
|
||||
assert sessions[0]["ip_address"]
|
||||
|
||||
|
||||
async def test_logout_revokes_the_session(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
# The important part is server-side: the session row itself is gone
|
||||
# from the active list (this request also 401s since this client's own
|
||||
# cookie was cleared, but that alone wouldn't prove the *row* is dead).
|
||||
resp = await client.get("/api/auth/sessions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_x_forwarded_for_takes_priority_over_direct_peer(client, db_session):
|
||||
username = _unique("alice")
|
||||
await register_user(
|
||||
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username_or_email": username, "password": "password123"},
|
||||
headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"},
|
||||
)
|
||||
|
||||
sessions = (await client.get("/api/auth/sessions")).json()
|
||||
assert sessions[0]["ip_address"] == "203.0.113.7"
|
||||
|
||||
|
||||
async def test_revoking_another_session_logs_it_out(client, app, db_session):
|
||||
username = _unique("alice")
|
||||
await register_and_login(client, db_session, username=username)
|
||||
|
||||
# A second "device" -- a separate client hitting the same app instance
|
||||
# (so it shares the DB-override wiring), its own independent cookie.
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other_device:
|
||||
await login_as(other_device, username)
|
||||
assert len((await other_device.get("/api/auth/sessions")).json()) == 2
|
||||
|
||||
mine = (await client.get("/api/auth/sessions")).json()
|
||||
assert len(mine) == 2
|
||||
not_current = next(s for s in mine if not s["is_current"])
|
||||
|
||||
revoke = await client.delete(f"/api/auth/sessions/{not_current['id']}")
|
||||
assert revoke.status_code == 204
|
||||
|
||||
# The other device's own cookie is now dead.
|
||||
resp = await other_device.get("/api/auth/sessions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# And the revoked session no longer shows up for the account at all.
|
||||
remaining = (await client.get("/api/auth/sessions")).json()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0]["is_current"] is True
|
||||
|
||||
|
||||
async def test_cannot_revoke_another_users_session(client, app, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
my_session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
|
||||
await register_and_login(bob_client, db_session, username=_unique("bob"))
|
||||
resp = await bob_client.delete(f"/api/auth/sessions/{my_session_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Untouched -- still exactly one active session for alice.
|
||||
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
||||
|
||||
|
||||
async def test_revoking_own_current_session_works_then_logs_this_client_out(client, db_session):
|
||||
await register_and_login(client, db_session, username=_unique("alice"))
|
||||
session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
||||
|
||||
# Revoking your own *current* session is allowed (a remote sign-out of
|
||||
# this same device is a legitimate, if odd, thing to do).
|
||||
first = await client.delete(f"/api/auth/sessions/{session_id}")
|
||||
assert first.status_code == 204
|
||||
|
||||
# A second call with the same (now-dead) cookie can't even reach the
|
||||
# revoke check -- get_current_user itself already 401s.
|
||||
second = await client.delete(f"/api/auth/sessions/{session_id}")
|
||||
assert second.status_code == 401
|
||||
@@ -43,6 +43,14 @@ def _extract_token(body: str) -> str:
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _plain_text(message) -> str:
|
||||
# #68: the email is now multipart/alternative (HTML + plain-text
|
||||
# fallback) -- .get_content() has no handler for a multipart message
|
||||
# itself, get_body(preferencelist=...) is the standard way to reach a
|
||||
# specific alternative part.
|
||||
return message.get_body(preferencelist=("plain",)).get_content()
|
||||
|
||||
|
||||
async def test_create_site_invite_requires_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
|
||||
@@ -62,7 +70,7 @@ async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
|
||||
assert invite["status"] == "pending"
|
||||
|
||||
assert len(calls) == 1
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
validate = await client.get(f"/api/signup/validate?token={token}")
|
||||
assert validate.status_code == 200
|
||||
@@ -87,7 +95,7 @@ async def test_signup_rejects_mismatched_password_confirmation(client, db_sessio
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "typo@example.com"})
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
@@ -127,7 +135,7 @@ async def test_expired_token_rejected(client, db_session, monkeypatch):
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
|
||||
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
@@ -147,7 +155,7 @@ async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "once@example.com"})
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
first = await client.post(
|
||||
"/api/signup",
|
||||
@@ -170,7 +178,7 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
@@ -183,6 +191,79 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
|
||||
assert complete.status_code == 400
|
||||
|
||||
|
||||
async def test_resend_site_invite_requires_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_resend_site_invite_unknown_id_404s(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_resend_site_invite_new_link_works_and_old_one_doesnt(client, db_session, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "resend-me@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
original_expires_at = datetime.fromisoformat(resp.json()["expires_at"])
|
||||
old_token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
|
||||
assert resend.status_code == 200, resend.text
|
||||
assert resend.json()["id"] == invite_id
|
||||
assert resend.json()["status"] == "pending"
|
||||
# A fresh 7-day window, not whatever was left on the original.
|
||||
new_expires_at = datetime.fromisoformat(resend.json()["expires_at"])
|
||||
assert new_expires_at > original_expires_at
|
||||
|
||||
assert len(calls) == 2
|
||||
new_token = _extract_token(_plain_text(calls[1]["message"]))
|
||||
assert new_token != old_token
|
||||
|
||||
# The old link is dead -- resending rotates the token, it doesn't just
|
||||
# repeat it.
|
||||
old_validate = await client.get(f"/api/signup/validate?token={old_token}")
|
||||
assert old_validate.status_code == 400
|
||||
|
||||
new_validate = await client.get(f"/api/signup/validate?token={new_token}")
|
||||
assert new_validate.status_code == 200
|
||||
assert new_validate.json()["email"] == "resend-me@example.com"
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={
|
||||
"token": new_token,
|
||||
"username": "resentuser",
|
||||
"password": "password123",
|
||||
"password_confirm": "password123",
|
||||
},
|
||||
)
|
||||
assert complete.status_code == 200, complete.text
|
||||
|
||||
|
||||
async def test_resend_site_invite_rejects_non_pending(client, db_session, monkeypatch):
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "already-revoked@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
|
||||
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
|
||||
assert resend.status_code == 400
|
||||
|
||||
|
||||
async def test_list_site_invites(client, db_session, monkeypatch):
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
@@ -194,3 +275,54 @@ async def test_list_site_invites(client, db_session, monkeypatch):
|
||||
assert resp.status_code == 200
|
||||
emails = [i["email"] for i in resp.json()]
|
||||
assert "listed@example.com" in emails
|
||||
|
||||
|
||||
async def test_list_site_invites_excludes_revoked_invite(client, db_session, monkeypatch):
|
||||
# #61: the admin UI labels this list "Pending invites" -- a revoked
|
||||
# invite has nothing left to act on and must actually drop out of it,
|
||||
# not just get relabeled in place.
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "revoked-from-list@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
|
||||
listed = await client.get("/api/admin/invites")
|
||||
emails = [i["email"] for i in listed.json()]
|
||||
assert "revoked-from-list@example.com" not in emails
|
||||
|
||||
|
||||
async def test_list_site_invites_excludes_accepted_invite(client, db_session, monkeypatch):
|
||||
# Same gap, the other trigger: completing signup accepts the invite
|
||||
# out-of-band from the admin's own session, but it must still be gone
|
||||
# from the pending list on the admin's next fetch.
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "accepted-from-list@example.com"})
|
||||
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={
|
||||
"token": token,
|
||||
"username": "acceptedfromlist",
|
||||
"password": "password123",
|
||||
"password_confirm": "password123",
|
||||
},
|
||||
)
|
||||
assert complete.status_code == 200, complete.text
|
||||
|
||||
# Signup logs the new user's session in on `client` -- switch back to
|
||||
# the admin to check the list the way the admin actually would.
|
||||
await login_as(client, "admin1")
|
||||
listed = await client.get("/api/admin/invites")
|
||||
emails = [i["email"] for i in listed.json()]
|
||||
assert "accepted-from-list@example.com" not in emails
|
||||
@@ -0,0 +1,13 @@
|
||||
from app.services.system_user_service import SYSTEM_USERNAME, get_or_create_system_user
|
||||
|
||||
|
||||
async def test_get_or_create_system_user_creates_bot_account(db_session):
|
||||
user = await get_or_create_system_user(db_session)
|
||||
assert user.username == SYSTEM_USERNAME
|
||||
assert user.is_bot is True
|
||||
|
||||
|
||||
async def test_get_or_create_system_user_is_idempotent(db_session):
|
||||
first = await get_or_create_system_user(db_session)
|
||||
second = await get_or_create_system_user(db_session)
|
||||
assert first.id == second.id
|
||||
@@ -109,6 +109,24 @@ def test_history_tiebreaks_identical_timestamps_by_id(ws_client):
|
||||
assert [m["content"] for m in history] == [m["content"] for m in expected_order]
|
||||
|
||||
|
||||
def test_ws_sends_heartbeat_ping(ws_client, monkeypatch):
|
||||
# #76: with nothing else sent on the connection, a "ping" must arrive
|
||||
# proactively once the idle timeout elapses -- shrunk here so the test
|
||||
# doesn't actually wait the real 30s. Replying "pong" (the frontend's
|
||||
# own behavior) must be accepted silently and leave the connection
|
||||
# fully usable afterward, not treated as an unknown message type.
|
||||
monkeypatch.setattr("app.ws.chat.WS_PING_INTERVAL_SECONDS", 0.05)
|
||||
username = _unique("alice")
|
||||
_register(ws_client, username=username)
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
with ws_client.websocket_connect("/ws/chat") as ws:
|
||||
assert ws.receive_json() == {"type": "ping"}
|
||||
ws.send_json({"type": "pong"})
|
||||
ws.send_json({"type": "join", "room_id": room["id"]})
|
||||
assert ws.receive_json() == {"type": "joined", "room_id": room["id"]}
|
||||
|
||||
|
||||
def test_ws_message_without_join_errors(ws_client):
|
||||
_register(ws_client, username=_unique("alice"))
|
||||
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
+11
-2
@@ -16,9 +16,18 @@ BACKEND_DIR="${REPO_DIR}/backend"
|
||||
FRONTEND_DIR="${REPO_DIR}/frontend"
|
||||
ENV_FILE="/etc/ds-chat/env"
|
||||
|
||||
echo "==> Pulling latest code"
|
||||
echo "==> Fetching latest release"
|
||||
cd "$REPO_DIR"
|
||||
git pull --ff-only
|
||||
git fetch --tags --force
|
||||
LATEST_TAG="$(git tag --sort=-creatordate | head -n1)"
|
||||
if [[ -z "$LATEST_TAG" ]]; then
|
||||
echo "No tags found -- nothing to deploy" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Deploying $LATEST_TAG"
|
||||
# Detached HEAD, not a branch checkout -- this directory only ever runs a
|
||||
# tagged release, never whatever the default branch's tip happens to be.
|
||||
git checkout --quiet --detach "$LATEST_TAG"
|
||||
|
||||
echo "==> Installing backend dependencies"
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Shown as the "Source code" link in the app's About screen -- required
|
||||
# for AGPL-3.0 section 13 compliance once you deploy this (a link so users
|
||||
# interacting with the app over the network can get the actual source,
|
||||
# including any modifications you've made). The default below points at
|
||||
# the upstream project -- fine if you're running it unmodified, but if
|
||||
# you've forked or patched the code, point this at *your* copy instead.
|
||||
VITE_SOURCE_URL=https://github.com/ds-ksmith/DS-Chat
|
||||
+44
-24
@@ -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,26 +81,38 @@ 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.)
|
||||
styles/tokens.css design tokens (default theme: colors, spacing, etc.)
|
||||
sw.ts custom service worker (injectManifest): app-shell
|
||||
precache + NetworkFirst runtime caching, push/notificationclick
|
||||
handlers, SKIP_WAITING messaging for the update-prompt flow
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DS Chat</title>
|
||||
</head>
|
||||
|
||||
Generated
+953
-11
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "2026.10.22",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
@@ -18,13 +19,16 @@
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"jsdom": "^30.1.1",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^5.0.2",
|
||||
"workbox-cacheable-response": "^7.4.1",
|
||||
"workbox-expiration": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { ChatSocketProvider } from './context/ChatSocketContext'
|
||||
import { CustomEmojiProvider } from './context/CustomEmojiContext'
|
||||
import { AdminRoute } from './components/AdminRoute'
|
||||
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
@@ -67,8 +68,10 @@ function AppRoutes() {
|
||||
if (!user) return routes
|
||||
return (
|
||||
<ChatSocketProvider key={user.id}>
|
||||
<CustomEmojiProvider>
|
||||
<DesktopNotificationBridge />
|
||||
{routes}
|
||||
</CustomEmojiProvider>
|
||||
</ChatSocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,13 @@ export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
|
||||
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// #60: same invite row, not a new one -- a fresh token/expiry, and the old
|
||||
// link stops working the moment this is called (see the backend's own
|
||||
// resend_site_invite for why).
|
||||
export function resendSiteInvite(inviteId: string): Promise<SiteInvite> {
|
||||
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}/resend`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSmtpSettings(): Promise<SmtpSettings | null> {
|
||||
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch, ApiError, NetworkError } from './client'
|
||||
import type { User } from '../types'
|
||||
import type { EmojiScale, TextScale, User, UserSession } from '../types'
|
||||
|
||||
// No register() here: this is an invite-only site. Accounts are created by
|
||||
// an operator via the backend CLI (`python -m app.cli create-user`), not
|
||||
@@ -16,6 +16,16 @@ export function logout(): Promise<void> {
|
||||
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
// #69: every device/browser currently logged into this account, newest
|
||||
// last-seen first -- see backend's app/schemas/session.py.
|
||||
export function listSessions(): Promise<UserSession[]> {
|
||||
return apiFetch<UserSession[]>('/api/auth/sessions')
|
||||
}
|
||||
|
||||
export function revokeSession(sessionId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function me(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me')
|
||||
}
|
||||
@@ -40,6 +50,23 @@ export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Pr
|
||||
})
|
||||
}
|
||||
|
||||
// #71: its own call, same reasoning as updateTheme above -- the backend
|
||||
// only applies fields actually present in the request body, so this can't
|
||||
// clobber theme (or vice versa).
|
||||
export function updateTextScale(textScale: TextScale): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ text_scale: textScale }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateEmojiScale(emojiScale: EmojiScale): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ emoji_scale: emojiScale }),
|
||||
})
|
||||
}
|
||||
|
||||
export function removeAvatar(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me/avatar', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiFetch, ApiError, NetworkError } from './client'
|
||||
import type { CustomEmoji } from '../types'
|
||||
|
||||
export function listCustomEmoji(): Promise<CustomEmoji[]> {
|
||||
return apiFetch<CustomEmoji[]>('/api/custom-emoji')
|
||||
}
|
||||
|
||||
export function getCustomEmojiUrl(shortcode: string): string {
|
||||
return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image`
|
||||
}
|
||||
|
||||
export function deleteCustomEmoji(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/custom-emoji/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Raw fetch, not apiFetch -- same multipart-boundary reason as
|
||||
// uploadAvatar/uploadRoomImage (a manually-set Content-Type header would
|
||||
// omit the boundary the browser generates for FormData).
|
||||
export async function uploadCustomEmoji(shortcode: string, file: File): Promise<CustomEmoji> {
|
||||
const formData = new FormData()
|
||||
formData.append('shortcode', shortcode)
|
||||
formData.append('file', file)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch('/api/custom-emoji', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
} catch {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
try {
|
||||
const body = await response.json()
|
||||
detail = body.detail ?? detail
|
||||
} catch {
|
||||
// response had no JSON body
|
||||
}
|
||||
throw new ApiError(response.status, detail)
|
||||
}
|
||||
|
||||
return (await response.json()) as CustomEmoji
|
||||
}
|
||||
@@ -29,6 +29,15 @@ export function createRoom(
|
||||
})
|
||||
}
|
||||
|
||||
// #52: find-or-create -- returns the existing DM with this person if one
|
||||
// already exists, rather than always creating a new room.
|
||||
export function startDm(otherUserId: string): Promise<Room> {
|
||||
return apiFetch<Room>('/api/rooms/dm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ other_user_id: otherUserId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRoom(
|
||||
roomId: string,
|
||||
data: { name?: string; description?: string; is_private?: boolean },
|
||||
@@ -51,6 +60,25 @@ export function leaveRoom(roomId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/leave`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// #52 follow-up: only for DMs -- hides it from this user's own sidebar
|
||||
// without touching the other participant's copy. Reversible: messaging
|
||||
// again (startDm, above) or a new message from the other person un-hides
|
||||
// it automatically.
|
||||
export function hideDm(roomId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// #67: per-viewer opt-in for email on this room's first unread message
|
||||
// and every mention. 400s for a DM (backend rejects it -- see
|
||||
// set_room_email_notifications), so callers should only expose the
|
||||
// toggle for a non-DM room.
|
||||
export function updateRoomNotifications(roomId: string, emailNotifications: boolean): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/notifications`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ email_notifications: emailNotifications }),
|
||||
})
|
||||
}
|
||||
|
||||
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ interface AboutModalProps {
|
||||
// users remotely through a computer network, you should also make sure
|
||||
// that it provides a way for users to get its source... its interface
|
||||
// could display a 'Source' link" -- this modal is that link, not just a
|
||||
// courtesy credits screen.
|
||||
const SOURCE_URL = 'https://git.darksingularity.org/DarkSingularity/ds-chat'
|
||||
// courtesy credits screen. Deliberately not a hardcoded URL: whoever
|
||||
// deploys this needs to point it at *their* copy of the repo (including
|
||||
// any modifications), not the upstream project -- see frontend/.env.example.
|
||||
const SOURCE_URL = import.meta.env.VITE_SOURCE_URL as string | undefined
|
||||
|
||||
export function AboutModal({ onClose }: AboutModalProps) {
|
||||
return (
|
||||
@@ -39,11 +41,13 @@ export function AboutModal({ onClose }: AboutModalProps) {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{SOURCE_URL && (
|
||||
<p className="about-modal-line">
|
||||
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
||||
Source code
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
|
||||
@@ -171,6 +171,21 @@ export function ChatPane({
|
||||
setLive((prev) =>
|
||||
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
|
||||
)
|
||||
} else if (envelope.type === 'message_deleted' && envelope.room_id === room.id) {
|
||||
// Mirrors what the server already did to the row (see
|
||||
// message_service.delete_message) -- content/image/file/preview
|
||||
// cleared, deleted_at set. `reactions` is left alone; MessageList
|
||||
// just doesn't render it once deleted_at is set, same as it
|
||||
// doesn't render anything else here.
|
||||
const tombstone = {
|
||||
content: null,
|
||||
image_id: null,
|
||||
file: null,
|
||||
link_preview: null,
|
||||
deleted_at: new Date().toISOString(),
|
||||
}
|
||||
setHistory((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
|
||||
setLive((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, ...tombstone } : m)))
|
||||
} else if (envelope.type === 'error') {
|
||||
setWsError(envelope.detail)
|
||||
}
|
||||
@@ -212,6 +227,10 @@ export function ChatPane({
|
||||
(messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji),
|
||||
[socket, room.id],
|
||||
)
|
||||
const sendDelete = useCallback(
|
||||
(messageId: string) => socket.sendDelete(room.id, messageId),
|
||||
[socket, room.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="chat-pane">
|
||||
@@ -224,8 +243,16 @@ export function ChatPane({
|
||||
</button>
|
||||
)}
|
||||
<div className="chat-pane-title-block">
|
||||
<div className="chat-pane-title">#{room.name}</div>
|
||||
<div className="chat-pane-subtitle">{members.length} member{members.length === 1 ? '' : 's'}</div>
|
||||
<div className="chat-pane-title">
|
||||
{room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : `#${room.name}`}
|
||||
</div>
|
||||
<div className="chat-pane-subtitle">
|
||||
{room.dm_partner
|
||||
? room.dm_partner.status === 'online'
|
||||
? 'Online'
|
||||
: 'Offline'
|
||||
: `${members.length} member${members.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -256,13 +283,16 @@ export function ChatPane({
|
||||
myRooms={myRooms}
|
||||
onEdit={sendEdit}
|
||||
onReact={sendReaction}
|
||||
onDelete={sendDelete}
|
||||
/>
|
||||
<Composer
|
||||
roomId={room.id}
|
||||
roomName={room.name}
|
||||
roomName={room.dm_partner ? room.dm_partner.display_name || room.dm_partner.username : room.name}
|
||||
isDm={room.is_dm}
|
||||
members={members}
|
||||
rooms={rooms}
|
||||
disabled={!connected}
|
||||
archived={room.is_archived}
|
||||
onSend={send}
|
||||
/>
|
||||
</section>
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user