web,server: video attachments (Signal-style send-and-play, E2E) (#196)
Signal-style send-and-play video, E2E. Server: blob bytes move to disk files (<db>-blobs/) with a legacy column fallback, thread_id-tagged for per-thread GC + a byte quota, configurable upload cap, and a media-src blob: CSP token so decrypted video can play. Web: client-side poster/dims/duration extraction, two-blob upload with a progress bar, and a lazy video card that downloads with a radial ring then plays full-screen in the lightbox (download fallback for undecodable formats). docs/instance-migration.md notes the sibling blob dir must be copied. π€ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9 files changed,
+844
−54
changelog/2026-07-05-video-attachments.md+7 −0db.go+122 −8docs/instance-migration.md+18 −1handlers_test.go+83 −1hub.go+13 −0main.go+112 −17web/static/apps/chat.js+209 −12web/static/shell.js+162 −15web/static/style.css+118 −0
diff --git a/changelog/2026-07-05-video-attachments.md b/changelog/2026-07-05-video-attachments.md| new file mode 100644 |
| index 0000000..8365bb2 |
| --- /dev/null |
| +++ b/changelog/2026-07-05-video-attachments.md |
| @@ -0,0 +1,7 @@ |
| +You can now share videos in a chat, just like photos. Pick or drop a clip and it |
| +sends with a still preview and a play button; tap it and it plays full-screen (a |
| +ring shows the download as it loads). Like everything else here, videos are |
| +encrypted end-to-end: the server only ever holds scrambled bytes, so nobody but |
| +the people in your chat can watch them. Clips shot on an iPhone play best on |
| +Apple devices; if a video won't play for someone, they get a one-tap download so |
| +they can still open it. |
diff --git a/db.go b/db.go| index 6b202d6..374c7ae 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -7,6 +7,8 @@ import ( |
| "database/sql" |
| "encoding/base64" |
| "encoding/hex" |
| + "os" |
| + "path/filepath" |
| "strings" |
| "time" |
| @@ -80,7 +82,14 @@ func (db *DB) setUsersCanInvite(on bool) error { |
| // DB wraps the SQLite connection. Note: the server never stores message |
| // plaintext or any encryption key. Messages live here only as opaque |
| // ciphertext blobs produced by the browser. |
| -type DB struct{ sql *sql.DB } |
| +type DB struct { |
| + sql *sql.DB |
| + // blobDir holds attachment/photo bytes as files (see putBlob/getBlob). It sits |
| + // next to the DB file (<db>-blobs/) so it's per-instance and a data snapshot |
| + // captures both by copying the DB and its sibling dir. Large media never bloats |
| + // the SQLite file itself (keeping WAL/VACUUM/snapshots lean). |
| + blobDir string |
| +} |
| func openDB(path string) (*DB, error) { |
| d, err := sql.Open("sqlite", path) |
| @@ -99,7 +108,10 @@ func openDB(path string) (*DB, error) { |
| return nil, err |
| } |
| } |
| - db := &DB{sql: d} |
| + db := &DB{sql: d, blobDir: strings.TrimSuffix(path, filepath.Ext(path)) + "-blobs"} |
| + if err := os.MkdirAll(db.blobDir, 0o755); err != nil { |
| + return nil, err |
| + } |
| if err := db.migrate(); err != nil { |
| return nil, err |
| } |
| @@ -137,10 +149,15 @@ CREATE TABLE IF NOT EXISTS reactions ( |
| created_at INTEGER NOT NULL, |
| PRIMARY KEY (message_id, token, emoji) |
| ); |
| +-- Attachment/photo bytes. Two storage modes coexist so older DBs keep working: |
| +-- * disk-backed (new): bytes live at blobDir/id; the data column is empty, size>0. |
| +-- * legacy (pre-disk): bytes live in the data column; no file. getBlob falls back. |
| +-- thread_id ties a blob to its thread for GC + quota (NULL for legacy/branding). |
| CREATE TABLE IF NOT EXISTS blobs ( |
| id TEXT PRIMARY KEY, |
| data BLOB NOT NULL, |
| size INTEGER NOT NULL, |
| + thread_id TEXT, |
| created_at INTEGER NOT NULL |
| ); |
| CREATE TABLE IF NOT EXISTS users ( |
| @@ -338,6 +355,9 @@ CREATE TABLE IF NOT EXISTS connected_services ( |
| // '' = the shipped default; a literal '{name}' is replaced with the new |
| // member's name. Operator copy, like the persona β never message content. |
| "ALTER TABLE bot_configs ADD COLUMN welcome TEXT NOT NULL DEFAULT ''", |
| + // Blobs gain a thread tag (for thread-scoped GC + quota accounting). Older |
| + // blobs (branding, pre-disk photos) stay NULL and are never thread-GC'd. |
| + "ALTER TABLE blobs ADD COLUMN thread_id TEXT", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -1312,7 +1332,11 @@ func (db *DB) deleteThread(id string) error { |
| return err |
| } |
| } |
| - return tx.Commit() |
| + if err := tx.Commit(); err != nil { |
| + return err |
| + } |
| + // The thread's messages referenced these blobs; drop their bytes too. |
| + return db.deleteThreadBlobs(id) |
| } |
| // wrappedKeyFor returns a member's wrapped thread key (to unwrap client-side). |
| @@ -1770,16 +1794,46 @@ func (db *DB) messagesAfter(threadID string, after int64, limit int) ([]Message, |
| // the server stores only opaque bytes β no filename, type, or content. The |
| // referencing message (also encrypted) carries the metadata. |
| -func (db *DB) putBlob(id string, data []byte) error { |
| +// blobPath is the on-disk location of a disk-backed blob's bytes. Blob ids are |
| +// randToken (base64url), so they're always safe filename components β but the |
| +// HTTP handler still validates the id (validBlobID) before building this path. |
| +func (db *DB) blobPath(id string) string { return filepath.Join(db.blobDir, id) } |
| + |
| +// recordBlob inserts the metadata row for a disk-backed blob whose bytes the |
| +// caller has already written to blobPath(id). `data` is stored empty β the row |
| +// exists only for quota accounting and thread-scoped GC. threadID ties the blob |
| +// to its thread (empty β NULL, e.g. profile-avatar uploads that aren't |
| +// thread-scoped and are replaced rather than message-GC'd). |
| +func (db *DB) recordBlob(id, threadID string, size int64) error { |
| + var tid any |
| + if threadID != "" { |
| + tid = threadID |
| + } |
| _, err := db.sql.Exec( |
| - "INSERT INTO blobs(id, data, size, created_at) VALUES(?, ?, ?, ?)", |
| - id, data, len(data), time.Now().Unix(), |
| + "INSERT INTO blobs(id, data, size, thread_id, created_at) VALUES(?, X'', ?, ?, ?)", |
| + id, size, tid, time.Now().Unix(), |
| ) |
| return err |
| } |
| -// getBlob returns the stored bytes, or (nil, nil) if the id is unknown. |
| -func (db *DB) getBlob(id string) ([]byte, error) { |
| +// putBlob stores small in-memory blob bytes on disk + records the row. Kept for |
| +// callers that already hold the bytes (tests, any non-streaming path); the HTTP |
| +// upload path streams straight to disk instead (see uploadBlob). |
| +func (db *DB) putBlob(id, threadID string, data []byte) error { |
| + if err := os.WriteFile(db.blobPath(id), data, 0o644); err != nil { |
| + return err |
| + } |
| + if err := db.recordBlob(id, threadID, int64(len(data))); err != nil { |
| + _ = os.Remove(db.blobPath(id)) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// legacyBlob returns the bytes of a pre-disk (column-stored) blob, or nil if the |
| +// blob is disk-backed (empty column) or unknown. Only the HTTP handler's fallback |
| +// path calls this, after it fails to find the blob file. |
| +func (db *DB) legacyBlob(id string) ([]byte, error) { |
| var data []byte |
| err := db.sql.QueryRow("SELECT data FROM blobs WHERE id = ?", id).Scan(&data) |
| if err == sql.ErrNoRows { |
| @@ -1788,9 +1842,69 @@ func (db *DB) getBlob(id string) ([]byte, error) { |
| if err != nil { |
| return nil, err |
| } |
| + if len(data) == 0 { |
| + return nil, nil // disk-backed row β bytes live on disk, not here |
| + } |
| return data, nil |
| } |
| +// blobBytesTotal is the sum of all stored blob sizes β the figure the |
| +// per-instance storage quota is checked against. |
| +func (db *DB) blobBytesTotal() (int64, error) { |
| + var n sql.NullInt64 |
| + err := db.sql.QueryRow("SELECT COALESCE(SUM(size), 0) FROM blobs").Scan(&n) |
| + return n.Int64, err |
| +} |
| + |
| +// deleteBlobs removes the given blobs *scoped to one thread*: a row only goes if |
| +// its thread_id matches, so a client reporting ids to GC when it deletes its own |
| +// message can never drop another thread's (or another member's) blobs. On-disk |
| +// bytes are unlinked best-effort for each row actually removed. |
| +func (db *DB) deleteBlobs(threadID string, ids []string) error { |
| + for _, id := range ids { |
| + if !validBlobID(id) { |
| + continue |
| + } |
| + res, err := db.sql.Exec("DELETE FROM blobs WHERE id = ? AND thread_id = ?", id, threadID) |
| + if err != nil { |
| + return err |
| + } |
| + if n, _ := res.RowsAffected(); n > 0 { |
| + _ = os.Remove(db.blobPath(id)) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// deleteThreadBlobs unlinks + removes every blob tagged to a thread, called when |
| +// the whole thread is torn down (the messages referencing them are going too). |
| +func (db *DB) deleteThreadBlobs(threadID string) error { |
| + rows, err := db.sql.Query("SELECT id FROM blobs WHERE thread_id = ?", threadID) |
| + if err != nil { |
| + return err |
| + } |
| + var ids []string |
| + for rows.Next() { |
| + var id string |
| + if err := rows.Scan(&id); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + ids = append(ids, id) |
| + } |
| + rows.Close() |
| + if len(ids) == 0 { |
| + return nil |
| + } |
| + if _, err := db.sql.Exec("DELETE FROM blobs WHERE thread_id = ?", threadID); err != nil { |
| + return err |
| + } |
| + for _, id := range ids { |
| + _ = os.Remove(db.blobPath(id)) |
| + } |
| + return nil |
| +} |
| + |
| // ---- scheduled messages (mini-app "future messages") --------------------- |
| // |
| // A scheduled message is client-encrypted ciphertext the server holds until |
diff --git a/docs/instance-migration.md b/docs/instance-migration.md| index 887c744..394afc1 100644 |
| --- a/docs/instance-migration.md |
| +++ b/docs/instance-migration.md |
| @@ -40,10 +40,26 @@ If the source hides the DB behind an opaque store (many identically-named DB fil |
| identify the right one by **content** β e.g. grep the files for the instance's |
| `admin_token_hash` (`hex(sha256(admin token))`), which is unique per instance. |
| +`VACUUM INTO` snapshots the **DB only**. Attachment/photo/video bytes live in a |
| +**sibling directory** `<db>-blobs/` (e.g. `/data/<host>-blobs/`), not in the DB β |
| +so it must be copied separately (step 3), or migrated messages lose their images |
| +and videos. The files are immutable and content-addressed by a random id, so a |
| +live copy is safe. (An older instance that predates disk-backed blobs has no such |
| +directory β its blobs are still inside the DB and need nothing extra.) |
| + |
| ### 3. Copy the snapshot to the destination |
| Copy `/tmp/snapshot.db` to the destination as `/data/<host>.db` and verify it |
| landed intact (`integrity_check` + a couple of row counts against the source). |
| +**Also copy the blob directory** if the source has one: |
| + |
| +```sh |
| +rsync -a /data/<host>-blobs/ <dest>:/data/<host>-blobs/ |
| +``` |
| + |
| +These are the disk-backed attachment bytes the DB references by id; copy them at |
| +or after the DB snapshot (blobs uploaded to the source afterward are subject to |
| +the same gap check as messages β see step 5). |
| ### 4. Import on the destination (preserve token + config) |
| @@ -106,4 +122,5 @@ token). Plan the bot move as its own step. |
| - **Reversible**: the source is copied, never moved; DNS flip is reversible; the |
| cert issues automatically post-flip. |
| - **No key churn**: same origin β per-origin browser keys/passkeys untouched. |
| -- **Consistent snapshot**: `VACUUM INTO` captures a clean point-in-time DB. |
| +- **Consistent snapshot**: `VACUUM INTO` captures a clean point-in-time DB; the |
| + sibling `<db>-blobs/` dir (immutable, content-addressed files) copies alongside. |
diff --git a/handlers_test.go b/handlers_test.go| index a73227c..fdd2bb5 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| "strconv" |
| "strings" |
| "testing" |
| @@ -26,7 +27,8 @@ func newTestServer(t *testing.T) (*httptest.Server, *Server) { |
| } |
| hub := newHub() |
| go hub.run() |
| - s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv, homeOrigin: defaultHomeOrigin} |
| + s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv, homeOrigin: defaultHomeOrigin, |
| + maxUpload: defaultMaxUploadBytes, blobQuota: defaultBlobQuotaBytes} |
| ts := httptest.NewServer(s.handler()) |
| t.Cleanup(ts.Close) |
| return ts, s |
| @@ -430,6 +432,86 @@ func TestBlobUploadDownload(t *testing.T) { |
| if resp.StatusCode != http.StatusNotFound { |
| t.Fatalf("missing blob: want 404, got %d", resp.StatusCode) |
| } |
| + |
| + // The bytes live on disk (not bloating the SQLite file), and a malformed id |
| + // (path traversal / dots) is rejected before it can reach the filesystem. |
| + if _, err := os.Stat(srv.db.blobPath(up.ID)); err != nil { |
| + t.Fatalf("blob not written to disk: %v", err) |
| + } |
| + resp, _ = http.Get(ts.URL + "/api/blob/has.dot?token=" + tok) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("malformed id: want 404, got %d", resp.StatusCode) |
| + } |
| +} |
| + |
| +// A pre-disk blob (bytes in the DB column, no file) still downloads via the |
| +// legacy fallback β existing image/avatar blobs must keep working after the |
| +// switch to disk-backed storage. |
| +func TestBlobLegacyColumnFallback(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok := mintDeviceToken(t, srv.db, "x") |
| + |
| + if _, err := srv.db.sql.Exec( |
| + "INSERT INTO blobs(id, data, size, created_at) VALUES(?, ?, ?, ?)", |
| + "legacyone", []byte("old-column-bytes"), 16, time.Now().Unix(), |
| + ); err != nil { |
| + t.Fatalf("seed legacy blob: %v", err) |
| + } |
| + resp, _ := http.Get(ts.URL + "/api/blob/legacyone?token=" + tok) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("legacy download: %d", resp.StatusCode) |
| + } |
| + got, _ := io.ReadAll(resp.Body) |
| + if string(got) != "old-column-bytes" { |
| + t.Fatalf("legacy blob mismatch: got %q", got) |
| + } |
| +} |
| + |
| +// A blob tagged with its thread is reclaimable by thread-scoped GC, and a client |
| +// can only drop blobs from its own thread (the scope guard). |
| +func TestBlobThreadScopedGC(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + tok := mintDeviceToken(t, srv.db, "x") |
| + |
| + resp, err := http.Post(ts.URL+"/api/upload?token="+tok+"&thread=T1", "application/octet-stream", strings.NewReader("clip-bytes")) |
| + if err != nil || resp.StatusCode != http.StatusOK { |
| + t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode) |
| + } |
| + var up struct { |
| + ID string `json:"id"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&up) |
| + |
| + // Wrong thread: the scope guard means nothing is deleted. |
| + if err := srv.db.deleteBlobs("OTHER", []string{up.ID}); err != nil { |
| + t.Fatalf("deleteBlobs(other): %v", err) |
| + } |
| + if resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("blob wrongly deleted by cross-thread GC: %d", resp.StatusCode) |
| + } |
| + |
| + // Correct thread: row + file both go. |
| + if err := srv.db.deleteBlobs("T1", []string{up.ID}); err != nil { |
| + t.Fatalf("deleteBlobs(T1): %v", err) |
| + } |
| + if resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok); resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("blob not GC'd: %d", resp.StatusCode) |
| + } |
| + if _, err := os.Stat(srv.db.blobPath(up.ID)); !os.IsNotExist(err) { |
| + t.Fatalf("blob file not unlinked: %v", err) |
| + } |
| +} |
| + |
| +// Uploads past the per-instance storage quota are refused with 507. |
| +func TestBlobQuota(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + srv.blobQuota = 8 // bytes β tiny, for the test |
| + tok := mintDeviceToken(t, srv.db, "x") |
| + |
| + resp, _ := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader("way-too-many-bytes")) |
| + if resp.StatusCode != http.StatusInsufficientStorage { |
| + t.Fatalf("over-quota upload: want 507, got %d", resp.StatusCode) |
| + } |
| } |
| func TestPushSubscribeGating(t *testing.T) { |
diff --git a/hub.go b/hub.go| index a3818a9..ffa2f71 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -117,6 +117,11 @@ type wsIn struct { |
| Blob string `json:"blob"` |
| MessageID int64 `json:"message_id"` |
| Emoji string `json:"emoji"` |
| + // BlobIDs are the attachment blob ids a deleted message referenced. The server |
| + // can't read them from the (encrypted) message body, so the client β which can |
| + // β reports them on delete, and the server GCs those blobs scoped to the thread |
| + // (a client can only drop its own thread's blobs). See the "delete" case. |
| + BlobIDs []string `json:"blob_ids,omitempty"` |
| // Ephemeral asks for relay-only delivery: fan the blob out to the thread's |
| // members without storing or pushing it (presence-style traffic β typing, |
| // live cursors). The flag lives on the envelope, not the ciphertext, so the |
| @@ -328,6 +333,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| continue // not the author, or unknown id β silently ignore |
| } |
| + // Reclaim the message's attachment blobs (scoped to this thread, so a |
| + // client can't drop another thread's blobs). Best-effort β a failure |
| + // here shouldn't block the delete fan-out. |
| + if len(in.BlobIDs) > 0 { |
| + if err := s.db.deleteBlobs(in.ThreadID, in.BlobIDs); err != nil { |
| + log.Println("gc attachment blobs:", err) |
| + } |
| + } |
| out, _ := json.Marshal(wsOut{Type: "delete", ThreadID: in.ThreadID, ID: in.MessageID}) |
| s.broadcastToThread(in.ThreadID, out) |
diff --git a/main.go b/main.go| index a65d152..fdbfd14 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -28,10 +28,17 @@ import ( |
| "lchat/internal/apns" |
| ) |
| -// maxUploadBytes caps an encrypted attachment upload: ~25 MB of plaintext (the |
| -// client's MAX_UPLOAD ceiling) plus AES-GCM overhead. The blob is opaque |
| -// ciphertext β the server can't tell an image from a document. |
| -const maxUploadBytes = 26 << 20 |
| +// defaultMaxUploadBytes caps an encrypted attachment upload when |
| +// LCHAT_MAX_UPLOAD_BYTES is unset: ~50 MB, enough for a short Signal-style video |
| +// clip plus AES-GCM overhead. The blob is opaque ciphertext β the server can't |
| +// tell a video from a document. |
| +const defaultMaxUploadBytes = 50 << 20 |
| + |
| +// defaultBlobQuotaBytes caps total stored blob bytes per instance when |
| +// LCHAT_BLOB_QUOTA_BYTES is unset β the disk backstop. Uploads past it are |
| +// refused (507). Blobs are GC'd on message/thread delete, so this bounds |
| +// worst-case media growth rather than being a hard forever-ceiling. |
| +const defaultBlobQuotaBytes = 2 << 30 |
| // maxServerAvatarBytes caps the admin-set instance branding photo. It's tiny by |
| // design (the admin console downscales it to ~400px first), so a small cap is |
| @@ -66,6 +73,8 @@ type Server struct { |
| vapidPub string |
| vapidPriv string |
| apns apnsSender // nil unless APNs (direct p8 or relay) is configured |
| + maxUpload int64 // per-upload attachment cap (LCHAT_MAX_UPLOAD_BYTES) |
| + blobQuota int64 // per-instance total blob-bytes ceiling (LCHAT_BLOB_QUOTA_BYTES) |
| // inflight de-duplicates concurrent unfurls of the same URL. |
| inflight sync.Map |
| // active tracks the last time each device token reported being open + focused |
| @@ -175,7 +184,9 @@ func main() { |
| homeOrigin = defaultHomeOrigin |
| } |
| s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(), |
| - appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin} |
| + appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin, |
| + maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes), |
| + blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes)} |
| // Web Push: self-generated VAPID keypair (no third-party account). |
| if pub, priv, err := db.ensureVAPID(); err != nil { |
| @@ -431,6 +442,10 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| "script-src 'self' 'nonce-"+nonce+"'; "+ |
| "style-src 'self' 'unsafe-inline'; "+ |
| "img-src 'self' data: blob: https:; "+ |
| + // Video/audio play from client-decrypted blob: URLs (E2E β the server |
| + // only holds ciphertext), so media-src must allow blob: or the |
| + // <video> is refused. Without this it falls back to default-src 'self'. |
| + "media-src 'self' blob:; "+ |
| "connect-src 'self'; "+ |
| "frame-src https://www.youtube-nocookie.com; "+ |
| "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") |
| @@ -1254,27 +1269,78 @@ func (s *Server) postThreadRemove(w http.ResponseWriter, r *http.Request) { |
| // ---- attachments -------------------------------------------------------- |
| +// validBlobID guards path building for a disk-backed blob: ids are randToken |
| +// (base64url), so anything with a path separator, dot, or other character is |
| +// bogus and rejected before it can reach the filesystem. |
| +func validBlobID(id string) bool { |
| + if id == "" || len(id) > 64 { |
| + return false |
| + } |
| + for _, c := range id { |
| + if !(c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| func (s *Server) uploadBlob(w http.ResponseWriter, r *http.Request) { |
| if !s.tokenOK(r.URL.Query().Get("token")) { |
| httpError(w, http.StatusForbidden, "invalid invite") |
| return |
| } |
| - r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) |
| - data, err := io.ReadAll(r.Body) |
| + // Quota (disk backstop): refuse up front when the instance is already at its |
| + // storage ceiling, so a full disk can't be nudged further. |
| + if total, err := s.db.blobBytesTotal(); err == nil && total >= s.blobQuota { |
| + httpError(w, http.StatusInsufficientStorage, "storage full") |
| + return |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, s.maxUpload) |
| + id, err := randToken() |
| if err != nil { |
| - httpError(w, http.StatusRequestEntityTooLarge, "file too large (max 25 MB)") |
| + httpError(w, http.StatusInternalServerError, "id error") |
| return |
| } |
| - if len(data) == 0 { |
| - httpError(w, http.StatusBadRequest, "empty upload") |
| + // Stream to a temp file (never buffer a whole video in RAM); rename into place |
| + // only once it's fully written, so a partial/aborted upload is never served. |
| + // The "." in the temp name makes it an invalid blob id (validBlobID), so an |
| + // in-flight temp can never be fetched via /api/blob. Same dir as the final |
| + // file so the rename is atomic (same filesystem). |
| + tmp, err := os.CreateTemp(s.db.blobDir, "up.*.part") |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "storage error") |
| return |
| } |
| - id, err := randToken() |
| + tmpName := tmp.Name() |
| + size, err := io.Copy(tmp, r.Body) |
| + tmp.Close() |
| if err != nil { |
| - httpError(w, http.StatusInternalServerError, "id error") |
| + os.Remove(tmpName) |
| + // MaxBytesReader trips here when the body exceeds the cap. |
| + httpError(w, http.StatusRequestEntityTooLarge, "file too large") |
| + return |
| + } |
| + if size == 0 { |
| + os.Remove(tmpName) |
| + httpError(w, http.StatusBadRequest, "empty upload") |
| + return |
| + } |
| + // Re-check the quota against the real size now we have it. |
| + if total, err := s.db.blobBytesTotal(); err == nil && total+size > s.blobQuota { |
| + os.Remove(tmpName) |
| + httpError(w, http.StatusInsufficientStorage, "storage full") |
| + return |
| + } |
| + if err := os.Rename(tmpName, s.db.blobPath(id)); err != nil { |
| + os.Remove(tmpName) |
| + httpError(w, http.StatusInternalServerError, "storage error") |
| return |
| } |
| - if err := s.db.putBlob(id, data); err != nil { |
| + // Tag with the (server-known, non-secret) thread id so the blob can be GC'd |
| + // with its thread / when the referencing message is deleted. Optional: avatar |
| + // uploads carry no thread. |
| + if err := s.db.recordBlob(id, r.URL.Query().Get("thread"), size); err != nil { |
| + os.Remove(s.db.blobPath(id)) |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| @@ -1286,7 +1352,28 @@ func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "invalid invite") |
| return |
| } |
| - data, err := s.db.getBlob(r.PathValue("id")) |
| + id := r.PathValue("id") |
| + if !validBlobID(id) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + // Blob ids are unique and content is immutable, so it's safe to cache hard. |
| + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") |
| + // nosniff (set globally) stops a mistyped blob being sniffed as HTML; we serve |
| + // everything as opaque bytes. |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + // Disk-backed (all new blobs): stream from the file. ServeContent gives Range |
| + // support + conditional requests for free (matters for large video), and never |
| + // loads the whole file into RAM. |
| + if f, err := os.Open(s.db.blobPath(id)); err == nil { |
| + defer f.Close() |
| + if fi, err := f.Stat(); err == nil { |
| + http.ServeContent(w, r, "", fi.ModTime(), f) |
| + return |
| + } |
| + } |
| + // Legacy: bytes still in the DB column (photos uploaded before disk-backing). |
| + data, err := s.db.legacyBlob(id) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1295,9 +1382,6 @@ func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| - w.Header().Set("Content-Type", "application/octet-stream") |
| - // Blob ids are unique and content is immutable, so it's safe to cache hard. |
| - w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") |
| w.Write(data) |
| } |
| @@ -2157,6 +2241,17 @@ func envOr(key, fallback string) string { |
| return fallback |
| } |
| +// envBytes reads a byte-count env var (a plain integer number of bytes), falling |
| +// back to def when unset, unparseable, or non-positive. |
| +func envBytes(key string, def int64) int64 { |
| + if v := strings.TrimSpace(os.Getenv(key)); v != "" { |
| + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { |
| + return n |
| + } |
| + } |
| + return def |
| +} |
| + |
| func printAdminLink(addr, token string) { |
| host := addr |
| if strings.HasPrefix(host, ":") { |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 2f2e118..5721a6b 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -584,6 +584,8 @@ function renderItem(m, ctx) { |
| }); |
| } |
| content.appendChild(img); |
| + } else if (a.type && a.type.startsWith("video/")) { |
| + content.appendChild(buildVideoCard(a, ctx)); |
| } else { |
| content.appendChild(buildFileCard(a, ctx)); |
| } |
| @@ -851,7 +853,7 @@ function openMessagePicker(m, ctx, anchor) { |
| } |
| if (mine) { |
| action(icon("trash"), "Delete", () => { |
| - if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid); |
| + if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m)); |
| }); |
| } |
| for (const act of messageActionsFor({ text: m.text, from: m.name })) { |
| @@ -952,7 +954,7 @@ function sheetActions(m, ctx) { |
| icon: icon("trash"), |
| danger: true, |
| run: () => { |
| - if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid); |
| + if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m)); |
| }, |
| }); |
| } |
| @@ -1398,6 +1400,159 @@ function buildFileCard(a, ctx) { |
| return card; |
| } |
| +// A download glyph (static, in-code SVG β safe for innerHTML) for the unplayable |
| +// video fallback. host.icons has no download icon, so it lives here. |
| +const ICON_DOWNLOAD = |
| + '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16"/></svg>'; |
| + |
| +// Whole seconds as m:ss (or h:mm:ss) for the video duration badge. |
| +function formatDuration(secs) { |
| + secs = Math.max(0, Math.round(secs)); |
| + const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60; |
| + const mm = h ? String(m).padStart(2, "0") : String(m); |
| + return (h ? h + ":" : "") + mm + ":" + String(s).padStart(2, "0"); |
| +} |
| + |
| +// Download an object-URL'd blob under a filename β the video fallback when the |
| +// browser can't play the format inline. |
| +function triggerDownload(url, name) { |
| + const a = document.createElement("a"); |
| + a.href = url; |
| + a.download = name; |
| + document.body.appendChild(a); |
| + a.click(); |
| + a.remove(); |
| +} |
| + |
| +// A lazy, send-and-play video card: poster + play badge + duration, with NO bytes |
| +// fetched until it's tapped (a thread full of clips must not auto-download). On |
| +// tap we download+decrypt the clip (a radial ring shows progress) then play it |
| +// full-screen in the lightbox. If the browser can't decode the format (e.g. an |
| +// iPhone HEVC clip) we download it instead β the bytes are always retrievable. |
| +function buildVideoCard(a, ctx) { |
| + const card = document.createElement("div"); |
| + card.className = "msg-video"; |
| + // Reserve the box before the poster loads so the feed doesn't jump; fall back to |
| + // 16/9 when we couldn't read the clip's dimensions (so it's never a collapsed or |
| + // oddly-shaped box). |
| + if (a.w && a.h) { |
| + // Reserve the box before the poster loads so the feed doesn't jump. |
| + card.style.aspectRatio = `${a.w} / ${a.h}`; |
| + const scale = Math.min(260 / a.w, 320 / a.h, 1); |
| + card.style.width = Math.round(a.w * scale) + "px"; |
| + } else { |
| + // No stored dimensions (older upload, or a clip whose metadata didn't decode): |
| + // a sensible default box β NOT a 16px sliver from treating the 16:9 fallback |
| + // ratio as pixel dimensions. |
| + card.style.aspectRatio = "16 / 9"; |
| + card.style.width = "260px"; |
| + } |
| + |
| + // canPlayType is only a hint. "" means definitely-can't-play β the card is a |
| + // download from the start. |
| + const probe = document.createElement("video"); |
| + const playable = !!(a.type && probe.canPlayType && probe.canPlayType(a.type) !== ""); |
| + |
| + // The resting (poster) contents, reused if we have to revert after a failed play. |
| + const renderPoster = (downloadOnly) => { |
| + const poster = document.createElement("img"); |
| + poster.className = "msg-video-poster"; |
| + poster.alt = a.name || "video"; |
| + if (host && a.poster) { |
| + host.loadBlob(ctx.threadId, { id: a.poster, type: "image/jpeg" }).then((url) => url && (poster.src = url)); |
| + } |
| + const badge = document.createElement("span"); |
| + badge.className = "msg-video-play"; |
| + badge.innerHTML = downloadOnly ? ICON_DOWNLOAD : icon("play"); |
| + const kids = [poster, badge]; |
| + if (a.dur) { |
| + const dur = document.createElement("span"); |
| + dur.className = "msg-video-dur"; |
| + dur.textContent = formatDuration(a.dur); |
| + kids.push(dur); |
| + } |
| + card.replaceChildren(...kids); |
| + card.classList.toggle("unplayable", !!downloadOnly); |
| + }; |
| + renderPoster(!playable); |
| + |
| + card.onclick = () => { |
| + if (!host || card.dataset.loading === "1") return; |
| + card.dataset.loading = "1"; |
| + // Show the radial progress ring only if the download takes a beat β a cached |
| + // or fast load skips it, so there's no flash of an empty ring. |
| + let ring = null; |
| + const ringTimer = setTimeout(() => { ring = showVideoRing(card); }, 150); |
| + host.loadBlob(ctx.threadId, a, (p) => ring && ring.set(p)).then((url) => { |
| + clearTimeout(ringTimer); |
| + card.dataset.loading = ""; |
| + if (ring) ring.remove(); |
| + if (!url) return; |
| + if (!playable) { triggerDownload(url, a.name || "video"); return; } |
| + openVideoLightbox(url, () => triggerDownload(url, a.name || "video")); |
| + }); |
| + }; |
| + return card; |
| +} |
| + |
| +// A circular download-progress ring around the play button, shown while a clip |
| +// downloads on tap. Returns { set(0..1), remove() }. |
| +function showVideoRing(card) { |
| + const R = 20, C = 2 * Math.PI * R; |
| + const wrap = document.createElement("div"); |
| + wrap.className = "msg-video-ring"; |
| + wrap.innerHTML = |
| + '<svg viewBox="0 0 48 48"><circle class="ring-track" cx="24" cy="24" r="' + R + '"></circle>' + |
| + '<circle class="ring-fill" cx="24" cy="24" r="' + R + '"></circle></svg>'; |
| + const fill = wrap.querySelector(".ring-fill"); |
| + fill.style.strokeDasharray = String(C); |
| + fill.style.strokeDashoffset = String(C); |
| + card.appendChild(wrap); |
| + card.classList.add("downloading"); |
| + return { |
| + set: (p) => { fill.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, p)))); }, |
| + remove: () => { wrap.remove(); card.classList.remove("downloading"); }, |
| + }; |
| +} |
| + |
| +// Play a decrypted clip full-screen in the lightbox (a small inline player isn't |
| +// worth much). Backdrop tap / Escape closes; a decode error falls back to download. |
| +function openVideoLightbox(url, onDownloadFallback) { |
| + const back = document.createElement("div"); |
| + back.className = "lightbox video-lightbox"; |
| + const backdrop = document.createElement("div"); |
| + backdrop.className = "lightbox-backdrop"; |
| + const vid = document.createElement("video"); |
| + vid.className = "lightbox-video"; |
| + vid.src = url; |
| + vid.controls = true; |
| + vid.autoplay = true; |
| + vid.playsInline = true; |
| + back.append(backdrop, vid); |
| + const close = () => { |
| + vid.pause(); |
| + back.remove(); |
| + document.removeEventListener("keydown", onKey); |
| + }; |
| + const onKey = (e) => { if (e.key === "Escape") close(); }; |
| + back.addEventListener("click", (e) => { if (e.target !== vid) close(); }); |
| + vid.onerror = () => { close(); if (onDownloadFallback) onDownloadFallback(); }; |
| + document.addEventListener("keydown", onKey); |
| + document.body.appendChild(back); |
| + vid.play().catch(() => {}); |
| +} |
| + |
| +// The attachment blob ids a message references (clip + poster + images/files), |
| +// reported to the host on delete so the server can GC them. |
| +function attachmentBlobIds(m) { |
| + const ids = []; |
| + for (const a of (m && m.attachments) || []) { |
| + if (a && a.id) ids.push(a.id); |
| + if (a && a.poster) ids.push(a.poster); |
| + } |
| + return ids; |
| +} |
| + |
| function openLightbox(url, source) { |
| const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| const back = document.createElement("div"); |
| @@ -1953,7 +2108,7 @@ function buildSkeleton(el, ctx) { |
| const fileInput = document.createElement("input"); |
| fileInput.type = "file"; |
| fileInput.multiple = true; |
| - fileInput.accept = "image/*,.pdf,.txt,.md,.markdown,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx"; |
| + fileInput.accept = "image/*,video/mp4,video/quicktime,video/webm,.pdf,.txt,.md,.markdown,.csv,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx"; |
| fileInput.hidden = true; |
| const field = document.createElement("div"); |
| field.className = "composer-field"; |
| @@ -2079,18 +2234,47 @@ function buildSkeleton(el, ctx) { |
| const chip = document.createElement("div"); |
| chip.className = "preview-chip"; |
| if (!p.att) { |
| - const spin = document.createElement("span"); |
| - spin.className = "preview-spinner"; |
| - spin.setAttribute("aria-hidden", "true"); |
| const label = document.createElement("span"); |
| label.className = "preview-name"; |
| - label.textContent = p.isImage ? "Adding photoβ¦" : "Adding fileβ¦"; |
| - chip.append(spin, label); |
| + if (p.isVideo && typeof p.progress === "number") { |
| + // Determinate upload progress for the (large) clip: percentage + a bar, |
| + // updated in place by the onProgress callback (see stageFiles). |
| + chip.classList.add("preview-uploading"); |
| + label.textContent = "Uploading " + Math.round(p.progress * 100) + "%"; |
| + const bar = document.createElement("div"); |
| + bar.className = "preview-progress"; |
| + const fill = document.createElement("div"); |
| + fill.className = "preview-progress-fill"; |
| + fill.style.width = Math.round(p.progress * 100) + "%"; |
| + bar.appendChild(fill); |
| + chip.append(label, bar); |
| + p._fill = fill; |
| + p._label = label; |
| + } else { |
| + const spin = document.createElement("span"); |
| + spin.className = "preview-spinner"; |
| + spin.setAttribute("aria-hidden", "true"); |
| + label.textContent = p.isImage ? "Adding photoβ¦" : p.isVideo ? "Adding videoβ¦" : "Adding fileβ¦"; |
| + chip.append(spin, label); |
| + } |
| } else if (p.isImage) { |
| const img = document.createElement("img"); |
| img.alt = p.att.name || "photo"; |
| if (host) host.loadBlob(ctx.threadId, p.att).then((url) => url && (img.src = url)); |
| chip.appendChild(img); |
| + } else if (p.isVideo) { |
| + // Show the poster thumbnail with a small play glyph, so a staged clip |
| + // reads as a video rather than a generic file. |
| + chip.classList.add("preview-video"); |
| + const img = document.createElement("img"); |
| + img.alt = p.att.name || "video"; |
| + if (host && p.att.poster) { |
| + host.loadBlob(ctx.threadId, { id: p.att.poster, type: "image/jpeg" }).then((url) => url && (img.src = url)); |
| + } |
| + const play = document.createElement("span"); |
| + play.className = "preview-play"; |
| + play.innerHTML = icon("play"); |
| + chip.append(img, play); |
| } else { |
| const ic = document.createElement("span"); |
| ic.className = "preview-file"; |
| @@ -2147,7 +2331,8 @@ function buildSkeleton(el, ctx) { |
| // Matches maxUploadBytes (main.go) / the original composer's MAX_UPLOAD β used |
| // only to word the "too big" message; the host bridge does the real limiting. |
| const MAX_UPLOAD = 25 * 1024 * 1024; |
| - const UNSUPPORTED_TYPE = "That file type isn't supported. You can share images, PDFs, text, CSV, JSON, and Office documents."; |
| + const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; // clips get a larger cap (see shell.js) |
| + const UNSUPPORTED_TYPE = "That file type isn't supported. You can share images, video, PDFs, text, CSV, JSON, and Office documents."; |
| // alert() would freeze an embedded webview (modal blocks the native message |
| // channel) β route the error to the shell instead. |
| const stageError = (msg) => { |
| @@ -2159,17 +2344,28 @@ function buildSkeleton(el, ctx) { |
| if (!host || !host.upload) return; |
| for (const file of files) { |
| const isImage = !!(file.type && file.type.startsWith("image/")); |
| + const isVideo = !!(file.type && file.type.startsWith("video/")); |
| // Reject unsupported types before staging so no loading chip flashes for a |
| // file we were never going to accept (the same allow-list the bridge uses). |
| - if (!isImage && !fileAllowed(file.name)) { |
| + if (!isImage && !isVideo && !fileAllowed(file.name)) { |
| stageError(UNSUPPORTED_TYPE); |
| continue; |
| } |
| - const entry = { att: null, isImage }; |
| + const entry = { att: null, isImage, isVideo }; |
| pending.push(entry); |
| refreshStaged(); |
| refreshSend(); |
| - const att = await host.upload(ctx.threadId, file); |
| + const att = await host.upload(ctx.threadId, file, (p) => { |
| + // First measurable byte β build the bar once (a refresh); after that update |
| + // it in place so we don't rebuild the whole strip on every progress tick. |
| + const first = typeof entry.progress !== "number"; |
| + entry.progress = p; |
| + if (first || !entry._fill || !entry._fill.isConnected) refreshStaged(); |
| + else { |
| + entry._fill.style.width = Math.round(p * 100) + "%"; |
| + if (entry._label) entry._label.textContent = "Uploading " + Math.round(p * 100) + "%"; |
| + } |
| + }); |
| if (!pending.includes(entry)) continue; // removed while uploading |
| if (att && typeof att === "object") entry.att = att; |
| else { |
| @@ -2178,6 +2374,7 @@ function buildSkeleton(el, ctx) { |
| // gets the original composer's distinct wording. |
| const msg = att === "photo" ? "Couldn't add that photo. Try a smaller one." |
| : att === "size" ? `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).` |
| + : att === "video" ? `Couldn't add that video (max ${formatBytes(MAX_VIDEO_UPLOAD)}).` |
| : att === "type" ? UNSUPPORTED_TYPE |
| : "Couldn't add that file."; |
| stageError(msg); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 9918e12..a8d8e1f 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -99,7 +99,11 @@ import { |
| const SESSION_KEY = "lchat_session"; |
| const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it |
| const isCoarse = matchMedia("(pointer: coarse)").matches; |
| -const MAX_UPLOAD = 25 * 1024 * 1024; // matches maxUploadBytes (main.go) |
| +const MAX_UPLOAD = 25 * 1024 * 1024; // matches the default upload cap (main.go) |
| +// Video clips get a larger ceiling (Signal-style send-and-play). Matches the |
| +// server's default LCHAT_MAX_UPLOAD_BYTES; a lower per-instance cap just means |
| +// the server rejects the upload and the composer shows a "couldn't add" message. |
| +const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; |
| const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| // ---- themes ------------------------------------------------------------- |
| @@ -1889,9 +1893,13 @@ setChatAppHost({ |
| ws.send(JSON.stringify({ type: on ? "react" : "unreact", thread_id: threadId, message_id: messageId, emoji })); |
| return true; |
| }, |
| - del: (threadId, messageId) => { |
| + del: (threadId, messageId, blobIds) => { |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| - ws.send(JSON.stringify({ type: "delete", thread_id: threadId, message_id: messageId })); |
| + const frame = { type: "delete", thread_id: threadId, message_id: messageId }; |
| + // The server can't see which blobs a message referenced (they're inside the |
| + // ciphertext), so the app β which can β reports them for GC on delete. |
| + if (blobIds && blobIds.length) frame.blob_ids = blobIds; |
| + ws.send(JSON.stringify(frame)); |
| }, |
| // Status pill for app-side actions (e.g. Copy) β reuses the classic feed's |
| // toast so confirmations look identical to the original chat. |
| @@ -1908,8 +1916,8 @@ setChatAppHost({ |
| }, |
| // Fetch + decrypt an attachment blob (cached object URL β same path the |
| // original feed uses for thumbnails and downloads). |
| - loadBlob: (threadId, att) => loadBlob(att.id, threadId, att.type), |
| - upload: (threadId, file) => chatAppUpload(threadId, file), |
| + loadBlob: (threadId, att, onProgress) => loadBlob(att.id, threadId, att.type, onProgress), |
| + upload: (threadId, file, onProgress) => chatAppUpload(threadId, file, onProgress), |
| unfurl: async (url) => { |
| try { |
| return await ( |
| @@ -1985,17 +1993,44 @@ let chatAppNative = null; |
| // it returns the attachment metadata that rides inside the encrypted payload; on |
| // failure it returns a reason string ("type" / "photo" / "size" / "generic") so |
| // the app can restore the original composer's three distinct error messages. |
| -async function chatAppUpload(threadId, file) { |
| +// POST encrypted bytes to the blob store, reporting upload progress (0..1) via |
| +// onProgress. Uses XHR because fetch() can't observe request-body upload progress |
| +// (and streaming request bodies aren't supported on Safari/iOS). |
| +function uploadEncrypted(threadId, bytes, onProgress) { |
| + return new Promise((resolve, reject) => { |
| + const xhr = new XMLHttpRequest(); |
| + xhr.open( |
| + "POST", |
| + `/api/upload?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}`, |
| + ); |
| + if (onProgress && xhr.upload) { |
| + xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); }; |
| + } |
| + xhr.onload = () => { |
| + if (xhr.status >= 200 && xhr.status < 300) { |
| + try { resolve(JSON.parse(xhr.responseText).id); } catch { reject(new Error("bad upload response")); } |
| + } else { |
| + reject(new Error("upload " + xhr.status)); |
| + } |
| + }; |
| + xhr.onerror = () => reject(new Error("upload network error")); |
| + xhr.send(bytes); |
| + }); |
| +} |
| + |
| +async function chatAppUpload(threadId, file, onProgress) { |
| const key = threadKeys[threadId]; |
| if (!key || !file) return "generic"; |
| const isImage = !!(file.type && file.type.startsWith("image/")); |
| - if (!isImage && !fileAllowed(file.name)) return "type"; |
| + const isVideo = !!(file.type && file.type.startsWith("video/")); |
| + if (!isImage && !isVideo && !fileAllowed(file.name)) return "type"; |
| try { |
| - const post = async (bytes) => { |
| + // Every blob is tagged with its thread so the server can GC it with the |
| + // thread / when the referencing message is deleted β it never sees the blob |
| + // id (it's inside the encrypted payload). The thread id isn't secret to it. |
| + const post = async (bytes, onProg) => { |
| const enc = await encryptBytes(key, bytes); |
| - const res = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc }); |
| - if (!res.ok) throw new Error("upload"); |
| - return (await res.json()).id; |
| + return uploadEncrypted(threadId, enc, onProg); |
| }; |
| if (isImage) { |
| const { bytes, type, w, h } = await processImage(file); |
| @@ -2003,6 +2038,28 @@ async function chatAppUpload(threadId, file) { |
| const id = await post(bytes); |
| return { id, name: (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg", type, w, h }; |
| } |
| + if (isVideo) { |
| + // Send-and-play: encrypt the clip whole + upload it, plus a client-extracted |
| + // poster frame (the server can't make one β it only holds ciphertext). Dims |
| + // and duration ride inside the encrypted payload as attachment metadata. |
| + const bytes = new Uint8Array(await file.arrayBuffer()); |
| + if (bytes.length > MAX_VIDEO_UPLOAD) return "video"; |
| + // Extract the poster concurrently with the clip upload (independent work), |
| + // so it doesn't delay the progress bar starting to move. |
| + const metaPromise = videoMeta(file); |
| + const id = await post(bytes, onProgress); |
| + const att = { id, name: file.name || "video", type: file.type || "video/mp4", size: bytes.length }; |
| + const meta = await metaPromise; |
| + if (meta) { |
| + if (meta.w) att.w = meta.w; |
| + if (meta.h) att.h = meta.h; |
| + if (meta.dur) att.dur = Math.round(meta.dur); |
| + if (meta.posterBytes) { |
| + try { att.poster = await post(meta.posterBytes); } catch { /* poster is optional */ } |
| + } |
| + } |
| + return att; |
| + } |
| const bytes = new Uint8Array(await file.arrayBuffer()); |
| if (bytes.length > MAX_UPLOAD) return "size"; |
| const id = await post(bytes); |
| @@ -2499,6 +2556,76 @@ function deleteMessage(msgId) { |
| // ---- attachments (images + files) --------------------------------------- |
| +// Pull a poster frame (JPEG), native dimensions, and duration out of a video |
| +// File entirely client-side β the server only ever holds ciphertext, so it can't |
| +// make a thumbnail. We draw the first decodable frame to a canvas and re-encode |
| +// it small (which also strips any metadata). Resolves with a null posterBytes on |
| +// failure (a codec the browser can't decode, a draw that throws) β the clip still |
| +// uploads and plays wherever the format is supported. Never rejects. |
| +function videoMeta(file, maxPosterDim = 640) { |
| + return new Promise((resolve) => { |
| + const url = URL.createObjectURL(file); |
| + const v = document.createElement("video"); |
| + v.muted = true; |
| + v.playsInline = true; |
| + v.preload = "auto"; |
| + // Safari won't decode/paint frames from a *detached* <video>, so a canvas draw |
| + // would come out blank β keep it in the DOM but off-screen while we grab the |
| + // frame, then remove it. |
| + // Keep it in the DOM but off-screen at its NATURAL size while we grab a frame. |
| + // Safari won't decode frames for a detached or effectively-invisible video (a |
| + // 1px/opacity:0 element blanks the canvas), so only push it off-screen. |
| + v.style.cssText = "position:fixed;left:-9999px;top:0;pointer-events:none"; |
| + document.body.appendChild(v); |
| + |
| + let done = false; |
| + let w = 0, h = 0, dur = 0; // captured at loadedmetadata, so dims survive a failed frame grab |
| + const finish = (posterBytes) => { |
| + if (done) return; |
| + done = true; |
| + clearTimeout(guard); |
| + try { v.pause(); } catch { /* ignore */ } |
| + URL.revokeObjectURL(url); |
| + v.remove(); |
| + resolve({ w, h, dur, posterBytes }); |
| + }; |
| + // Give up (still returning any dims we captured) if decoding stalls. |
| + const guard = setTimeout(() => finish(null), 8000); |
| + |
| + const grab = async () => { |
| + if (done || !w || !h) return; |
| + let posterBytes = null; |
| + try { |
| + const scale = Math.min(maxPosterDim / w, maxPosterDim / h, 1); |
| + const cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale)); |
| + const canvas = document.createElement("canvas"); |
| + canvas.width = cw; |
| + canvas.height = ch; |
| + canvas.getContext("2d").drawImage(v, 0, 0, cw, ch); |
| + const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.8)); |
| + if (blob && blob.size > 0) posterBytes = new Uint8Array(await blob.arrayBuffer()); |
| + } catch { /* undecodable frame β no poster (dims kept) */ } |
| + finish(posterBytes); |
| + }; |
| + |
| + v.onerror = () => finish(null); |
| + v.onloadedmetadata = () => { |
| + w = v.videoWidth || 0; |
| + h = v.videoHeight || 0; |
| + dur = v.duration || 0; |
| + }; |
| + // Playing muted forces Safari to actually decode a frame (a paused video |
| + // often won't); grab once it's advancing, then stop. Seek fallback if play |
| + // is blocked. |
| + v.oncanplay = () => { |
| + v.play().catch(() => { try { v.currentTime = Math.min(0.1, (dur || 0) / 2); } catch { /* ignore */ } }); |
| + }; |
| + v.onplaying = () => { setTimeout(grab, 150); }; |
| + v.onseeked = grab; |
| + v.src = url; |
| + }); |
| +} |
| + |
| // Downscale + re-encode an image in-browser (caps the long edge, strips EXIF/GPS). |
| async function processImage(file, maxDim = MAX_IMAGE_DIM) { |
| const bitmap = await createImageBitmap(file); |
| @@ -2648,14 +2775,34 @@ function updateSendVisibility() { |
| // Fetch + decrypt a blob (with the active thread's key), cached as an object URL. |
| // `type` (optional) sets the Blob's MIME so non-image files open/download right. |
| -async function loadBlob(id, threadId = activeId, type) { |
| +async function loadBlob(id, threadId = activeId, type, onProgress) { |
| if (blobURLs[id]) return blobURLs[id]; |
| const key = threadKeys[threadId]; |
| if (!key) return null; |
| try { |
| - const buf = new Uint8Array( |
| - await (await fetch(`/api/blob/${encodeURIComponent(id)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer() |
| - ); |
| + const resp = await fetch(`/api/blob/${encodeURIComponent(id)}?token=${encodeURIComponent(session.token)}`); |
| + if (!resp.ok) return null; |
| + let buf; |
| + const total = Number(resp.headers.get("Content-Length")) || 0; |
| + // Stream the body so download progress can drive the video card's radial ring |
| + // (fetch CAN observe download progress, unlike upload); plain read otherwise. |
| + if (onProgress && total && resp.body && resp.body.getReader) { |
| + const reader = resp.body.getReader(); |
| + const chunks = []; |
| + let received = 0; |
| + for (;;) { |
| + const { done, value } = await reader.read(); |
| + if (done) break; |
| + chunks.push(value); |
| + received += value.length; |
| + onProgress(Math.min(1, received / total)); |
| + } |
| + buf = new Uint8Array(received); |
| + let off = 0; |
| + for (const c of chunks) { buf.set(c, off); off += c.length; } |
| + } else { |
| + buf = new Uint8Array(await resp.arrayBuffer()); |
| + } |
| const plain = await decryptBytes(key, buf); |
| const url = URL.createObjectURL(new Blob([plain], type ? { type } : undefined)); |
| blobURLs[id] = url; |
diff --git a/web/static/style.css b/web/static/style.css| index 67e7ed8..0e73d69 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -560,6 +560,104 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .yt-poster:hover .yt-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); } |
| .yt-frame { width: 100%; height: 100%; border: 0; display: block; } |
| +/* send-and-play video card (see buildVideoCard) β poster + play/download badge; |
| + the clip is fetched + decrypted only when tapped (never auto-downloaded) */ |
| +.msg-video { |
| + position: relative; |
| + display: block; |
| + max-width: min(260px, 100%); |
| + max-height: 320px; |
| + border-radius: var(--radius); |
| + overflow: hidden; |
| + background: #000; |
| + cursor: pointer; |
| + line-height: 0; |
| +} |
| +.msg-video-poster { |
| + display: block; |
| + width: 100%; |
| + height: 100%; |
| + object-fit: cover; |
| + background: var(--bubble-them); |
| +} |
| +.msg-video-play { |
| + position: absolute; |
| + inset: 0; |
| + display: grid; |
| + place-items: center; |
| + color: #fff; |
| + pointer-events: none; |
| +} |
| +.msg-video-play svg { |
| + width: 52px; |
| + height: 52px; |
| + padding: 12px; |
| + box-sizing: border-box; |
| + border-radius: 50%; |
| + background: rgba(0, 0, 0, 0.55); |
| + transition: transform 0.15s ease, background 0.15s ease; |
| +} |
| +.msg-video:hover .msg-video-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); } |
| +.msg-video.downloading .msg-video-play svg { opacity: 0.5; } |
| +.msg-video-dur { |
| + position: absolute; |
| + right: 6px; |
| + bottom: 6px; |
| + padding: 1px 6px; |
| + border-radius: 10px; |
| + background: rgba(0, 0, 0, 0.6); |
| + color: #fff; |
| + font-size: 12px; |
| + font-variant-numeric: tabular-nums; |
| + pointer-events: none; |
| +} |
| +/* radial download-progress ring around the play button (see showVideoRing) */ |
| +.msg-video-ring { |
| + position: absolute; |
| + inset: 0; |
| + display: grid; |
| + place-items: center; |
| + pointer-events: none; |
| +} |
| +.msg-video-ring svg { width: 60px; height: 60px; transform: rotate(-90deg); } |
| +.msg-video-ring .ring-track { fill: none; stroke: rgba(255, 255, 255, 0.3); stroke-width: 3; } |
| +.msg-video-ring .ring-fill { |
| + fill: none; |
| + stroke: #fff; |
| + stroke-width: 3; |
| + stroke-linecap: round; |
| + transition: stroke-dashoffset 0.12s linear; |
| +} |
| + |
| +/* full-screen video player in the lightbox */ |
| +.video-lightbox { animation: fade-in 0.2s ease; } |
| +.lightbox-video { |
| + position: relative; |
| + z-index: 1; /* above the backdrop */ |
| + max-width: 100%; |
| + max-height: 100%; |
| + border-radius: 8px; |
| + background: #000; |
| +} |
| +/* staged video preview: poster tile with a small play glyph over it */ |
| +.preview-chip.preview-video { position: relative; } |
| +.preview-chip.preview-video .preview-play { |
| + position: absolute; |
| + inset: 0; |
| + display: grid; |
| + place-items: center; |
| + color: #fff; |
| + pointer-events: none; |
| +} |
| +.preview-chip.preview-video .preview-play svg { |
| + width: 24px; |
| + height: 24px; |
| + padding: 5px; |
| + box-sizing: border-box; |
| + border-radius: 50%; |
| + background: rgba(0, 0, 0, 0.5); |
| +} |
| + |
| /* link (OpenGraph) preview card */ |
| .link-preview { |
| display: flex; |
| @@ -625,6 +723,26 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| } |
| .preview-chip img { width: 40px; height: 40px; object-fit: cover; border-radius: 8px; } |
| .preview-file { font-size: 24px; } |
| +/* video upload progress chip: percentage label above a determinate bar */ |
| +.preview-chip.preview-uploading { |
| + flex-direction: column; |
| + align-items: stretch; |
| + gap: 6px; |
| + width: 160px; |
| +} |
| +.preview-progress { |
| + height: 6px; |
| + border-radius: 3px; |
| + background: var(--border); |
| + overflow: hidden; |
| +} |
| +.preview-progress-fill { |
| + height: 100%; |
| + width: 0; |
| + background: var(--accent); |
| + border-radius: 3px; |
| + transition: width 0.15s ease; |
| +} |
| .preview-name { |
| font-size: 13px; |
| color: var(--ink); |