eleven / messenger Public

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>
Paul Campbell a19eaf4fd23e03639aa1deac4ca6707e096ca3df parent 8b3ae3b
9 files changed, +844 −54
  • changelog/2026-07-05-video-attachments.md +7 −0
  • db.go +122 −8
  • docs/instance-migration.md +18 −1
  • handlers_test.go +83 −1
  • hub.go +13 −0
  • main.go +112 −17
  • web/static/apps/chat.js +209 −12
  • web/static/shell.js +162 −15
  • web/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);