tinyrelaycommit

Commit c1c6760aef40

browse files at this commit | history

1diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md
2index 6b6efdc..df520d1 100644
3--- a/docs/files-and-private-repositories.md
4+++ b/docs/files-and-private-repositories.md
5@@ -53,6 +53,8 @@ Each uploader pays the full size of every file they claim, even when another upl
6
7 ## Agent uploads
8
9+Agents can upload room attachments through MCP with `upload_attachment`, passing bounded Base64 bytes, a MIME type and an optional filename. The result includes the Blossom URL, SHA-256, size and NIP-92 metadata. Pass it to `post_message`, `start_thread` or `reply_in_thread` in an `attachments` array. `read_attachment` retrieves up to 4 MiB by hash, with native MCP content for images and audio. Room uploads follow room access rules. See [MCP room attachments](mcp.md#room-attachments) for upload limits and the HTTP endpoint for larger files.
10+
11 An agent whose grant carries a `sites` tag with a ttl uploads on a clock: each file it stores is kept for the longest ttl among the grant's site entries, then removed by maintenance. A person who claims the same file keeps it, and the agent's claim alone is released. An agent whose grant says `encrypted` may store only encrypted blobs and manifests. See [Static sites](agents.md#static-sites).
12
13 Clients can check an upload with [BUD-06](https://github.com/hzrd149/blossom/blob/master/buds/06.md) `HEAD /upload`, including empty files with `X-Content-Length: 0`. The result is advisory; the upload itself checks the current limits. File descriptors include the optional Nostr metadata field defined by [BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md). A [BUD-13](https://github.com/hzrd149/blossom/pull/100) remote upload may name up to eight `url` sources.
14diff --git a/docs/mcp.md b/docs/mcp.md
15index 0d0ee8f..b8a83a4 100644
16--- a/docs/mcp.md
17+++ b/docs/mcp.md
18@@ -58,6 +58,7 @@ Read tools, which need a key that may read the relay:
19 | `read_pull_request` | Read a pull request with replies, updates and the available diff. |
20 | `list_files` | List stored files visible to the key. |
21 | `read_file` | Read a stored file's metadata and preview by SHA-256 hash. |
22+| `read_attachment` | Read a stored file by SHA-256 hash. Images and audio return native MCP content; all file types include Base64 data. Reads are limited to 4 MiB. |
23 | `read_status` | Read service health, storage and job status as an owner or moderator. |
24 | `read_management` | Run a read-only management method: stats, getpolicy, listaudit, listjobs, listbackups, listdumps, deliverystatus, storagestats, gitstorage, listconnections or listmembers. |
25 | `list_rooms` | List chat rooms visible to the key with member counts and the time of the last message. |
26@@ -101,13 +102,14 @@ Write tools, which publish through the same path as `POST /events`:
27 | Tool | Description |
28 | --- | --- |
29 | `publish_event` | Publish any signed Nostr event. |
30+| `upload_attachment` | Upload Base64 file bytes for a room and return a Blossom descriptor with NIP-92 metadata. |
31 | `create_issue` | Open a kind 1621 issue on a hosted repository. |
32 | `create_pull_request` | Open a kind 1618 pull request with its commit and clone URL. |
33 | `comment` | Reply to an issue, pull request or comment with a kind 1111 event carrying NIP-22 tags. `file`, `line` and `side` anchor the comment to one diff line. |
34 | `set_status` | Mark an issue or pull request open, resolved, merged, closed or draft with a kind 1630 to 1633 event. |
35-| `post_message` | Post a kind 9 message in a room, with optional `mentions` as `p` tags. |
36-| `start_thread` | Start a kind 11 thread in a room with an optional title. |
37-| `reply_in_thread` | Reply to a thread with a kind 12 event naming the root in its `e` tag. |
38+| `post_message` | Post a kind 9 message in a room, with optional `mentions` and `attachments`. |
39+| `start_thread` | Start a kind 11 thread in a room with an optional title and `attachments`. |
40+| `reply_in_thread` | Reply to a thread with a kind 12 event naming the root in its `e` tag and optional `attachments`. |
41 | `react` | Publish a kind 7 reaction to an event: `+`, `-` or one emoji, with `room` for a room message. |
42 | `publish_wiki_page` | Publish or replace the key's version of a kind 30818 wiki page, with `fork_author` and `fork_event` to record a fork. |
43 | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. |
44@@ -136,6 +138,18 @@ The person answers with a kind 7 reaction to the published event from the asked
45
46 `publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/pull/2004) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).
47
48+### Room attachments
49+
50+Call `upload_attachment` with `room`, standard Base64 `data`, a MIME `type` and an optional `filename`. The caller needs permission to post in the room and upload files. Agent uploads also follow the agent's grant. File storage must be enabled.
51+
52+The tool accepts up to 700 KiB of Base64 data, about 525 KiB of file bytes. For larger files, send the raw bytes to `PUT /rooms/<id>/attachments?filename=<name>` with a NIP-98 authorization covering the complete URL, method and body hash. That endpoint accepts files from 1 byte through 32 MiB. Storage quotas and agent grants may impose lower limits.
53+
54+The result contains `url`, `sha256`, `size`, `type`, `filename`, `uploaded` and `nip94` metadata. Pass the descriptor in an `attachments` array to `post_message`, `start_thread` or `reply_in_thread`, with up to eight attachments per message. The tool adds Markdown references and NIP-92 `imeta` tags. Attachments may be sent without other message text. Sign the returned unsigned event, then call the same write tool with `event` to publish it.
55+
56+Call `read_attachment` with `sha256` to retrieve a file your key may read. `max_bytes` defaults to 4 MiB and cannot exceed that limit. Images and audio return native MCP content blocks. Every file includes Base64 bytes in `structuredContent.data`; text files also include text content. Video and other files remain available through their authenticated download URLs.
57+
58+Room uploads use `/media/<hash>.<extension>` URLs compatible with Buzz. Reads follow the room's current visibility through media, Blossom and file download routes. To share the same bytes in another room, upload them to that room first. Files already stored without room restrictions keep their existing visibility and cannot be converted to private attachments; share their existing links instead.
59+
60 ### Long tasks
61
62 `request_job` builds a [NIP-90](https://github.com/nostr-protocol/nips/blob/master/90.md) job request. Pass `kind` (5000 to 5999) and `inputs`, each with `data` and a `type` of `url`, `event`, `job` or `text` plus an optional `relay` and `marker`, and any of `output`, `params`, `bid` in millisats, `relays` and `expiration`. Each input becomes an `i` tag and each parameter a `param` tag.
63diff --git a/docs/rooms.md b/docs/rooms.md
64index 1095a68..c0287c5 100644
65--- a/docs/rooms.md
66+++ b/docs/rooms.md
67@@ -61,6 +61,14 @@ Room events reach device notifications and relay push callbacks the same way as
68
69 Open a room to read its messages, oldest first. Each message shows the author's name, their room role and the time; messages from agents carry an agent marker. Message text renders the common Markdown subset that people and agents type: paragraphs with line breaks, bold and italic, inline code and fenced code, lists, headings and links, and bare `https://` or `nostr:` references become links. Nothing else in a message is treated as markup. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages.
70
71+Choose **Attach files**, paste files or drop them into the compose bar. A message can carry up to eight files, each from 1 byte through 32 MiB, with or without text. Files upload when you send. If sending fails, retrying reuses completed uploads. Relay quotas and agent grants also apply.
72+
73+Messages with NIP-92 `imeta` attachment tags and matching content references display images, video and audio inline, with download links for every file. Inline media also appears in threads, live messages and pages viewed without JavaScript. An edit replaces the message's attachment metadata along with its text.
74+
75+Room uploads follow the room's current access rule. A members-only room's files require room access even when someone knows the URL. Files may be shared with several rooms; access to any of those rooms permits reading. Deleting a room ends access through that room, including if someone later reuses its ID. Room attachments use access controls and are not end-to-end encrypted.
76+
77+Agents can call MCP `upload_attachment`, pass the returned descriptor to a room write tool and sign the resulting event to display generated media. `read_attachment` lets authorized agents retrieve incoming files. See [MCP room attachments](mcp.md#room-attachments).
78+
79 The compose bar sits at the bottom of the column. Enter sends and Shift+Enter starts a new line. Mention a person with `@npub...` or `@<hex key>`; the relay notifies them. Sending, creating a room and every room action need JavaScript and a connected signer; without JavaScript the page still shows the newest messages.
80
81 New messages arrive as they are accepted, and the page follows them when you are reading the end of the room. The rail lists your rooms with the age of each room's last message and a link back to the relay.
82diff --git a/internal/blob/blob.go b/internal/blob/blob.go
83index 2a7f45e..baffbe4 100644
84--- a/internal/blob/blob.go
85+++ b/internal/blob/blob.go
86@@ -99,6 +99,10 @@ type PutOptions struct {
87 Type string
88 Uploader string
89 Hash string
90+ // Commit records ownership metadata in the blob transaction. The final
91+ // argument reports whether the blob is new. An error rolls back all claims.
92+ // New bytes remain unavailable until their ownership metadata is durable.
93+ Commit func(context.Context, *sql.Tx, Blob, bool) error
94 }
95
96 type Service struct {
97@@ -207,10 +211,14 @@ func (s *Service) Put(ctx context.Context, options PutOptions) (Blob, error) {
98 if options.Reader == nil {
99 return Blob{}, errors.New("blob reader is required")
100 }
101- entry, _, err := s.put(ctx, options.Reader, options.Type, options.Uploader, options.Hash)
102+ entry, _, err := s.putOptions(ctx, options)
103 return entry, err
104 }
105
106+func (s *Service) putOptions(ctx context.Context, options PutOptions) (Blob, bool, error) {
107+ return s.putValidatedWithCommit(ctx, options.Reader, options.Type, options.Uploader, options.Hash, nil, options.Commit)
108+}
109+
110 // Get opens a verified metadata-backed blob for streaming to another service.
111 func (s *Service) Get(ctx context.Context, sha string) (Blob, io.ReadCloser, error) {
112 if !shaPattern.MatchString(sha) {
113@@ -608,10 +616,14 @@ func (s *Service) delete(w http.ResponseWriter, r *http.Request, sha string) {
114 }
115
116 func (s *Service) put(ctx context.Context, source io.Reader, typ, uploader, claimed string) (Blob, bool, error) {
117- return s.putValidated(ctx, source, typ, uploader, claimed, nil)
118+ return s.putValidatedWithCommit(ctx, source, typ, uploader, claimed, nil, nil)
119 }
120
121 func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error) (Blob, bool, error) {
122+ return s.putValidatedWithCommit(ctx, source, typ, uploader, claimed, validate, nil)
123+}
124+
125+func (s *Service) putValidatedWithCommit(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error, commit func(context.Context, *sql.Tx, Blob, bool) error) (Blob, bool, error) {
126 limits := s.currentLimits()
127 streamLimit := limits.MaxFileBytes
128 quotaStream := false
129@@ -658,12 +670,13 @@ func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploa
130 return Blob{}, false, err
131 }
132 }
133- return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader})
134+ return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, commit: commit})
135 }
136
137 type uploadCandidate struct {
138 path, hash, typ, uploader, reservation string
139 size int64
140+ commit func(context.Context, *sql.Tx, Blob, bool) error
141 }
142
143 func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate) (Blob, bool, error) {
144@@ -701,14 +714,35 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
145 return Blob{}, false, ErrQuotaExceeded
146 }
147 }
148- if err := s.claim(ctx, sha, uploader, terms.ExpiresAt); err != nil {
149- return Blob{}, false, err
150+ }
151+ if err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
152+ if candidate.commit != nil {
153+ if err := candidate.commit(ctx, tx, existing, false); err != nil {
154+ return err
155+ }
156 }
157+ if uploader != "" {
158+ if _, err := tx.ExecContext(ctx, claimSQL, sha, uploader, time.Now().Unix(), terms.ExpiresAt); err != nil {
159+ return err
160+ }
161+ }
162+ if candidate.reservation != "" {
163+ if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {
164+ return err
165+ }
166+ }
167+ return nil
168+ }); err != nil {
169+ return Blob{}, false, err
170 }
171- if candidate.reservation != "" {
172- if _, err := s.store.DB().ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {
173+ // A crash after a scoped metadata commit may leave the bytes missing.
174+ // A retry supplies those same verified bytes without losing the scope.
175+ if _, err := os.Stat(filepath.Join(s.root, sha)); errors.Is(err, os.ErrNotExist) {
176+ if err := s.installBlobFile(candidate.path, sha); err != nil {
177 return Blob{}, false, err
178 }
179+ } else if err != nil {
180+ return Blob{}, false, err
181 }
182 return existing, false, nil
183 } else if !errors.Is(err, sql.ErrNoRows) {
184@@ -733,12 +767,20 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
185 if terms.Encrypted && !encryptedTypes[typ] {
186 return Blob{}, false, ErrNotEncrypted
187 }
188- final := filepath.Join(s.root, sha)
189- if err := os.Rename(candidate.path, final); err != nil && !errors.Is(err, os.ErrExist) {
190- return Blob{}, false, fmt.Errorf("install blob: %w", err)
191+ stage := ""
192+ if candidate.commit != nil {
193+ stage = filepath.Join(s.root, ".scoped-"+sha)
194+ if err := os.Rename(candidate.path, stage); err != nil {
195+ return Blob{}, false, fmt.Errorf("stage scoped blob: %w", err)
196+ }
197+ if err := syncDirectory(s.root); err != nil {
198+ return Blob{}, false, fmt.Errorf("sync staged blob directory: %w", err)
199+ }
200 }
201- if err := syncDirectory(s.root); err != nil {
202- return Blob{}, false, fmt.Errorf("sync blob directory: %w", err)
203+ if candidate.commit == nil {
204+ if err := s.installBlobFile(candidate.path, sha); err != nil {
205+ return Blob{}, false, err
206+ }
207 }
208 now := time.Now().UTC().Unix()
209 entry := Blob{SHA256: sha, Size: count, Type: typ, Uploader: uploader, Uploaded: now}
210@@ -759,17 +801,40 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
211 return fmt.Errorf("release upload reservation: %w", err)
212 }
213 }
214+ if candidate.commit != nil {
215+ if err := candidate.commit(ctx, tx, entry, true); err != nil {
216+ return err
217+ }
218+ }
219 return nil
220 })
221 if err != nil {
222+ if stage != "" {
223+ _ = os.Remove(stage)
224+ }
225 return Blob{}, false, err
226 }
227+ if candidate.commit != nil {
228+ if err := s.installBlobFile(stage, sha); err != nil {
229+ return Blob{}, false, err
230+ }
231+ }
232 if saved, err := s.lookup(ctx, sha); err == nil {
233 return saved, saved.Uploader == uploader && saved.Uploaded == now, nil
234 }
235 return entry, true, nil
236 }
237
238+func (s *Service) installBlobFile(source, hash string) error {
239+ if err := os.Rename(source, filepath.Join(s.root, hash)); err != nil {
240+ return fmt.Errorf("install blob: %w", err)
241+ }
242+ if err := syncDirectory(s.root); err != nil {
243+ return fmt.Errorf("sync blob directory: %w", err)
244+ }
245+ return nil
246+}
247+
248 func (s *Service) quotaUsage(ctx context.Context, uploader string) (int64, error) {
249 return s.quotaUsageExcept(ctx, uploader, "")
250 }
251@@ -883,6 +948,19 @@ func reconcile(ctx context.Context, store *storage.Store, root string) error {
252 continue
253 }
254 name := entry.Name()
255+ if strings.HasPrefix(name, ".scoped-") {
256+ sha := strings.TrimPrefix(name, ".scoped-")
257+ if !shaPattern.MatchString(sha) {
258+ if err := os.Remove(filepath.Join(root, name)); err != nil {
259+ return fmt.Errorf("remove invalid staged blob: %w", err)
260+ }
261+ continue
262+ }
263+ if err := reconcileStaged(ctx, store, root, name, sha); err != nil {
264+ return err
265+ }
266+ continue
267+ }
268 if strings.HasPrefix(name, ".upload-") || strings.HasPrefix(name, ".multipart-chunk-") {
269 if err := os.Remove(filepath.Join(root, name)); err != nil {
270 return fmt.Errorf("remove abandoned upload: %w", err)
271@@ -911,6 +989,52 @@ func reconcile(ctx context.Context, store *storage.Store, root string) error {
272 return nil
273 }
274
275+func reconcileStaged(ctx context.Context, store *storage.Store, root, name, sha string) error {
276+ var size int64
277+ if err := store.DB().QueryRowContext(ctx, "SELECT size FROM blobs WHERE sha256=?", sha).Scan(&size); errors.Is(err, sql.ErrNoRows) {
278+ if removeErr := os.Remove(filepath.Join(root, name)); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
279+ return removeErr
280+ }
281+ return nil
282+ } else if err != nil {
283+ return fmt.Errorf("check staged blob %q: %w", sha, err)
284+ }
285+ file, err := os.Open(filepath.Join(root, name))
286+ if err != nil {
287+ return err
288+ }
289+ info, err := file.Stat()
290+ if err != nil {
291+ _ = file.Close()
292+ return err
293+ }
294+ if info.Size() != size {
295+ _ = file.Close()
296+ return fmt.Errorf("staged blob %q has size %d, want %d", sha, info.Size(), size)
297+ }
298+ hasher := sha256.New()
299+ if _, err := io.Copy(hasher, file); err != nil {
300+ _ = file.Close()
301+ return err
302+ }
303+ if err := file.Close(); err != nil {
304+ return err
305+ }
306+ if hex.EncodeToString(hasher.Sum(nil)) != sha {
307+ return fmt.Errorf("staged blob %q failed content hash", sha)
308+ }
309+ if _, err := os.Stat(filepath.Join(root, sha)); errors.Is(err, os.ErrNotExist) {
310+ if err := os.Rename(filepath.Join(root, name), filepath.Join(root, sha)); err != nil {
311+ return err
312+ }
313+ } else if err != nil {
314+ return err
315+ } else {
316+ _ = os.Remove(filepath.Join(root, name))
317+ }
318+ return syncDirectory(root)
319+}
320+
321 func recoverBlob(ctx context.Context, store *storage.Store, root, name string) error {
322 file, err := os.Open(filepath.Join(root, name))
323 if err != nil {
324diff --git a/internal/blob/scoped_upload_test.go b/internal/blob/scoped_upload_test.go
325new file mode 100644
326index 0000000..4f3c31f
327--- /dev/null
328+++ b/internal/blob/scoped_upload_test.go
329@@ -0,0 +1,125 @@
330+package blob
331+
332+import (
333+ "context"
334+ "crypto/sha256"
335+ "database/sql"
336+ "encoding/hex"
337+ "errors"
338+ "os"
339+ "path/filepath"
340+ "strings"
341+ "testing"
342+)
343+
344+func scopeTable(t *testing.T, service *Service) {
345+ t.Helper()
346+ if _, err := service.store.DB().Exec(`CREATE TABLE attachment_scope (sha256 TEXT PRIMARY KEY)`); err != nil {
347+ t.Fatal(err)
348+ }
349+}
350+
351+func scopeCommit(fail bool, seen *[]bool) func(context.Context, *sql.Tx, Blob, bool) error {
352+ return func(ctx context.Context, tx *sql.Tx, entry Blob, created bool) error {
353+ *seen = append(*seen, created)
354+ if fail {
355+ return errors.New("scope rejected")
356+ }
357+ _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO attachment_scope(sha256) VALUES(?)", entry.SHA256)
358+ return err
359+ }
360+}
361+
362+func TestScopedCommitNewIsAtomic(t *testing.T) {
363+ service := testService(t)
364+ scopeTable(t, service)
365+ body := []byte("atomic scoped upload")
366+ digest := sha256.Sum256(body)
367+ hash := hex.EncodeToString(digest[:])
368+ var seen []bool
369+ _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: testUploader, Commit: scopeCommit(true, &seen)})
370+ if err == nil || !strings.Contains(err.Error(), "scope rejected") {
371+ t.Fatalf("failed scope commit error = %v", err)
372+ }
373+ if len(seen) != 1 || !seen[0] {
374+ t.Fatalf("commit states = %v, want [true]", seen)
375+ }
376+ var count int
377+ if err := service.store.DB().QueryRow("SELECT COUNT(*) FROM blobs WHERE sha256=?", hash).Scan(&count); err != nil {
378+ t.Fatal(err)
379+ }
380+ if count != 0 {
381+ t.Fatalf("rolled back blob metadata count = %d", count)
382+ }
383+ if _, err := os.Stat(filepath.Join(service.root, hash)); !errors.Is(err, os.ErrNotExist) {
384+ t.Fatalf("rolled back final file stat = %v", err)
385+ }
386+}
387+
388+func TestScopedCommitDedupAndRecovery(t *testing.T) {
389+ service := testService(t)
390+ scopeTable(t, service)
391+ body := []byte("deduplicated scoped upload")
392+ var first []bool
393+ entry, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: testUploader, Commit: scopeCommit(false, &first)})
394+ if err != nil || len(first) != 1 || !first[0] {
395+ t.Fatalf("initial scoped upload entry=%+v states=%v err=%v", entry, first, err)
396+ }
397+ other := strings.Repeat("b", 64)
398+ var rejected []bool
399+ if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: other, Commit: scopeCommit(true, &rejected)}); err == nil {
400+ t.Fatal("rejected deduplicated scope commit succeeded")
401+ }
402+ var claims int
403+ if err := service.store.DB().QueryRow("SELECT COUNT(*) FROM blob_claims WHERE sha256=? AND uploader=?", entry.SHA256, other).Scan(&claims); err != nil {
404+ t.Fatal(err)
405+ }
406+ if claims != 0 {
407+ t.Fatalf("rolled back deduplicated claim count = %d", claims)
408+ }
409+ var accepted []bool
410+ if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: other, Commit: scopeCommit(false, &accepted)}); err != nil || len(accepted) != 1 || accepted[0] {
411+ t.Fatalf("deduplicated scoped upload states=%v err=%v", accepted, err)
412+ }
413+ if err := os.Remove(filepath.Join(service.root, entry.SHA256)); err != nil {
414+ t.Fatal(err)
415+ }
416+ if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: testUploader}); err != nil {
417+ t.Fatalf("recovery upload: %v", err)
418+ }
419+ if _, err := os.Stat(filepath.Join(service.root, entry.SHA256)); err != nil {
420+ t.Fatalf("recovered final file: %v", err)
421+ }
422+}
423+
424+func TestReconcileStagedBlobRequiresCommittedMetadata(t *testing.T) {
425+ service := testService(t)
426+ body := []byte("staged durable bytes")
427+ digest := sha256.Sum256(body)
428+ hash := hex.EncodeToString(digest[:])
429+ stage := filepath.Join(service.root, ".scoped-"+hash)
430+ if err := os.WriteFile(stage, body, 0o600); err != nil {
431+ t.Fatal(err)
432+ }
433+ if err := reconcile(context.Background(), service.store, service.root); err != nil {
434+ t.Fatal(err)
435+ }
436+ if _, err := os.Stat(stage); !errors.Is(err, os.ErrNotExist) {
437+ t.Fatalf("uncommitted stage stat = %v", err)
438+ }
439+ if _, err := os.Stat(filepath.Join(service.root, hash)); !errors.Is(err, os.ErrNotExist) {
440+ t.Fatalf("uncommitted final stat = %v", err)
441+ }
442+ if err := os.WriteFile(stage, body, 0o600); err != nil {
443+ t.Fatal(err)
444+ }
445+ if _, err := service.store.DB().Exec("INSERT INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)", hash, len(body), "text/plain", "", 1); err != nil {
446+ t.Fatal(err)
447+ }
448+ if err := reconcile(context.Background(), service.store, service.root); err != nil {
449+ t.Fatal(err)
450+ }
451+ if _, err := os.Stat(filepath.Join(service.root, hash)); err != nil {
452+ t.Fatalf("committed final stat = %v", err)
453+ }
454+}
455diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
456index d644ad3..db2a880 100644
457--- a/internal/daemon/client_browse.go
458+++ b/internal/daemon/client_browse.go
459@@ -347,6 +347,9 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
460 if !t.Policy().Features.Files {
461 return nil, errors.New("not found: files are disabled")
462 }
463+ if err := t.roomAttachmentAccess(ctx, actor, hash); err != nil {
464+ return nil, err
465+ }
466 entry, body, err := t.blobs.Get(ctx, hash)
467 if err != nil {
468 return nil, err
469diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go
470index 662abbd..2af94db 100644
471--- a/internal/daemon/client_browse_http.go
472+++ b/internal/daemon/client_browse_http.go
473@@ -1,6 +1,7 @@
474 package daemon
475
476 import (
477+ "database/sql"
478 "errors"
479 "io"
480 "mime"
481@@ -40,7 +41,7 @@ func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {
482 if r.URL.Path == "/repo/raw" {
483 err = t.sourceDownload(w, r, actor)
484 } else {
485- err = t.fileDownload(w, r)
486+ err = t.fileDownload(w, r, actor)
487 }
488 if err != nil {
489 browseHTTPError(w, err)
490@@ -53,11 +54,13 @@ func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {
491 func browseHTTPError(w http.ResponseWriter, err error) {
492 status := http.StatusBadRequest
493 switch {
494+ case strings.HasPrefix(err.Error(), "conflict:"):
495+ status = http.StatusConflict
496 case strings.HasPrefix(err.Error(), "auth-required:"):
497 status = http.StatusUnauthorized
498 case strings.HasPrefix(err.Error(), "restricted:"), strings.HasPrefix(err.Error(), "blocked:"):
499 status = http.StatusForbidden
500- case errors.Is(err, os.ErrNotExist), strings.HasPrefix(err.Error(), "not found:"):
501+ case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows), strings.HasPrefix(err.Error(), "not found:"):
502 status = http.StatusNotFound
503 }
504 http.Error(w, err.Error(), status)
505@@ -91,11 +94,14 @@ func mediaInline(typ string) bool {
506 return strings.HasPrefix(typ, "image/") || strings.HasPrefix(typ, "video/") || strings.HasPrefix(typ, "audio/") || typ == "application/pdf"
507 }
508
509-func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request) error {
510+func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request, actor string) error {
511 if !t.Policy().Features.Files {
512 return os.ErrNotExist
513 }
514 hash := r.URL.Query().Get("hash")
515+ if err := t.roomAttachmentAccess(r.Context(), actor, hash); err != nil {
516+ return err
517+ }
518 entry, body, err := t.blobs.Get(r.Context(), hash)
519 if err != nil {
520 return err
521diff --git a/internal/daemon/http.go b/internal/daemon/http.go
522index 9c47d81..31035f1 100644
523--- a/internal/daemon/http.go
524+++ b/internal/daemon/http.go
525@@ -83,6 +83,9 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
526 if t.tryBrowseHTTP(w, r) {
527 return
528 }
529+ if t.blobs != nil && t.roomAttachmentHTTP(w, r) {
530+ return
531+ }
532 if isViewArtifactPath(r.URL.Path) {
533 t.viewArtifactHTTP(w, r)
534 return
535diff --git a/internal/daemon/mcp_attachments.go b/internal/daemon/mcp_attachments.go
536new file mode 100644
537index 0000000..a0c6a91
538--- /dev/null
539+++ b/internal/daemon/mcp_attachments.go
540@@ -0,0 +1,218 @@
541+package daemon
542+
543+import (
544+ "context"
545+ "encoding/base64"
546+ "errors"
547+ "io"
548+ "mime"
549+ "net/url"
550+ "strconv"
551+ "strings"
552+ "unicode"
553+
554+ "github.com/FelineStateMachine/tinyrelay/internal/event"
555+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
556+)
557+
558+var mcpAttachment = mcp.Object(map[string]any{
559+ "url": mcpText,
560+ "sha256": mcpHash,
561+ "size": map[string]any{"type": "integer", "minimum": 0},
562+ "type": mcpText,
563+ "filename": mcpText,
564+ "room": mcpRoomID,
565+ "uploaded": mcpUnixTime,
566+ "nip94": map[string]any{"type": "array"},
567+}, "url", "sha256", "size", "type")
568+
569+func mcpAttachmentURL(raw string) error {
570+ if strings.ContainsAny(raw, "\\<>\"'()") || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
571+ return errors.New("attachment URL contains unsafe characters; percent-encode them")
572+ }
573+ u, err := url.Parse(raw)
574+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
575+ return errors.New("attachment URL must be absolute HTTP or HTTPS without credentials")
576+ }
577+ return nil
578+}
579+
580+func mcpAttachmentMIME(raw string) error {
581+ typ, _, err := mime.ParseMediaType(raw)
582+ if err != nil || !strings.Contains(typ, "/") || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
583+ return errors.New("attachment type must be a MIME type")
584+ }
585+ return nil
586+}
587+
588+func mcpAttachmentTags(call mcp.Call) (string, [][]string, error) {
589+ values, _ := call.Arguments["attachments"].([]any)
590+ if len(values) > 8 {
591+ return "", nil, errors.New("attach up to 8 files per message")
592+ }
593+ var links []string
594+ var tags [][]string
595+ for _, raw := range values {
596+ item, ok := raw.(map[string]any)
597+ if !ok {
598+ return "", nil, errors.New("each attachment must be an object")
599+ }
600+ link, _ := item["url"].(string)
601+ hash, _ := item["sha256"].(string)
602+ typ, _ := item["type"].(string)
603+ size, ok := item["size"].(float64)
604+ if !ok || size < 0 || size != float64(int64(size)) || !hex64(hash) {
605+ return "", nil, errors.New("attachment needs url, sha256, non-negative integer size and type")
606+ }
607+ if err := mcpAttachmentURL(link); err != nil {
608+ return "", nil, err
609+ }
610+ if err := mcpAttachmentMIME(typ); err != nil {
611+ return "", nil, err
612+ }
613+ if room, _ := item["room"].(string); room != "" && room != call.String("room") {
614+ return "", nil, errors.New("upload the attachment to the destination room first")
615+ }
616+ filename, _ := item["filename"].(string)
617+ filename = roomAttachmentFilename(filename)
618+ tags = append(tags, []string{"imeta", "url " + link, "m " + typ, "x " + hash, "size " + strconv.FormatInt(int64(size), 10), "filename " + filename})
619+ mediaType, _, _ := mime.ParseMediaType(typ)
620+ switch {
621+ case strings.HasPrefix(mediaType, "image/"):
622+ links = append(links, "![image]("+link+")")
623+ case strings.HasPrefix(mediaType, "video/"):
624+ links = append(links, "![video]("+link+")")
625+ default:
626+ label := strings.NewReplacer("\\", "\\\\", "[", "\\[", "]", "\\]").Replace(filename)
627+ links = append(links, "["+label+"]("+link+")")
628+ }
629+ }
630+ return strings.Join(links, "\n"), tags, nil
631+}
632+
633+func mcpRoomContent(call mcp.Call) (string, [][]string, error) {
634+ content, tags, err := mcpAttachmentTags(call)
635+ if err != nil {
636+ return "", nil, err
637+ }
638+ if text := call.String("content"); strings.TrimSpace(text) != "" {
639+ if content != "" {
640+ content = text + "\n" + content
641+ } else {
642+ content = text
643+ }
644+ }
645+ return content, tags, nil
646+}
647+
648+func mcpCheckAttachments(e event.Event) error {
649+ for _, tag := range e.Tags {
650+ if len(tag) == 0 || tag[0] != "imeta" {
651+ continue
652+ }
653+ values := map[string]string{}
654+ for _, field := range tag[1:] {
655+ parts := strings.SplitN(field, " ", 2)
656+ if len(parts) == 2 {
657+ values[parts[0]] = parts[1]
658+ }
659+ }
660+ if values["url"] == "" || !hex64(values["x"]) || strings.TrimSpace(values["m"]) == "" {
661+ return errors.New("imeta attachment needs url, m and x fields")
662+ }
663+ if err := mcpAttachmentURL(values["url"]); err != nil {
664+ return err
665+ }
666+ if err := mcpAttachmentMIME(values["m"]); err != nil {
667+ return err
668+ }
669+ if size := values["size"]; size != "" {
670+ n, err := strconv.ParseInt(size, 10, 64)
671+ if err != nil || n < 0 {
672+ return errors.New("imeta size must be a non-negative integer")
673+ }
674+ }
675+ }
676+ return nil
677+}
678+
679+func (t *Tenant) mcpUploadAttachment(ctx context.Context, call mcp.Call) (mcp.Result, error) {
680+ if !t.Policy().Features.Files || t.blobs == nil {
681+ return mcp.Failure("file storage is disabled", nil), nil
682+ }
683+ room := call.String("room")
684+ if len(call.String("data")) > 700*1024 {
685+ return mcp.Failure("Base64 data exceeds 700 KiB; use the HTTP room attachment endpoint for larger files", nil), nil
686+ }
687+ raw, err := base64.StdEncoding.DecodeString(call.String("data"))
688+ if err != nil {
689+ return mcp.Failure("data must be standard Base64", nil), nil
690+ }
691+ if int64(len(raw)) > roomAttachmentMaxBytes {
692+ return mcp.Failure("attachment exceeds the 32 MiB room limit", nil), nil
693+ }
694+ typ := strings.TrimSpace(call.String("type"))
695+ if typ == "" || strings.ContainsAny(typ, "\r\n") {
696+ return mcp.Failure("type is required", nil), nil
697+ }
698+ descriptor, err := t.storeRoomAttachment(ctx, call.Actor, room, roomAttachmentInput{Body: raw, Type: typ, Filename: call.String("filename")})
699+ if err != nil {
700+ return mcp.Failure("upload failed: "+err.Error(), nil), nil
701+ }
702+ return mcp.Value(descriptor), nil
703+}
704+
705+func (t *Tenant) mcpReadAttachment(ctx context.Context, call mcp.Call) (mcp.Result, error) {
706+ if !t.Policy().Features.Files || t.blobs == nil {
707+ return mcp.Failure("file storage is disabled", nil), nil
708+ }
709+ if err := t.browseRead(ctx, call.Actor); err != nil {
710+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil
711+ }
712+ if err := t.roomAttachmentAccess(ctx, call.Actor, call.String("sha256")); err != nil {
713+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil
714+ }
715+ entry, body, err := t.blobs.Get(ctx, call.String("sha256"))
716+ if err != nil {
717+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil
718+ }
719+ defer body.Close()
720+ max := call.Int("max_bytes")
721+ if max == 0 {
722+ max = 4 * 1024 * 1024
723+ }
724+ if max < 1 || max > 4*1024*1024 {
725+ return mcp.Failure("max_bytes must be between 1 and 4194304", nil), nil
726+ }
727+ if entry.Size > int64(max) {
728+ return mcp.Failure("attachment exceeds max_bytes", map[string]any{"size": entry.Size}), nil
729+ }
730+ raw, err := io.ReadAll(io.LimitReader(body, int64(max)+1))
731+ if err != nil {
732+ return mcp.Result{}, err
733+ }
734+ if len(raw) > max {
735+ return mcp.Failure("attachment exceeds max_bytes", nil), nil
736+ }
737+ descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/"), entry)
738+ descriptor["data"] = base64.StdEncoding.EncodeToString(raw)
739+ if strings.HasPrefix(entry.Type, "text/") {
740+ descriptor["text"] = string(raw)
741+ }
742+ result := mcp.Result{StructuredContent: descriptor}
743+ mediaType := ""
744+ if strings.HasPrefix(entry.Type, "image/") {
745+ mediaType = "image"
746+ }
747+ if strings.HasPrefix(entry.Type, "audio/") {
748+ mediaType = "audio"
749+ }
750+ if mediaType != "" {
751+ result.Content = []mcp.Content{{Type: mediaType, Data: base64.StdEncoding.EncodeToString(raw), MimeType: entry.Type}}
752+ } else if strings.HasPrefix(entry.Type, "text/") {
753+ result.Content = []mcp.Content{{Type: "text", Text: string(raw)}}
754+ } else {
755+ result.Content = []mcp.Content{{Type: "text", Text: "File bytes are in structuredContent.data as Base64."}}
756+ }
757+ return result, nil
758+}
759diff --git a/internal/daemon/mcp_attachments_test.go b/internal/daemon/mcp_attachments_test.go
760new file mode 100644
761index 0000000..bfbdcbe
762--- /dev/null
763+++ b/internal/daemon/mcp_attachments_test.go
764@@ -0,0 +1,156 @@
765+package daemon
766+
767+import (
768+ "context"
769+ "encoding/base64"
770+ "encoding/json"
771+ "strings"
772+ "testing"
773+
774+ "github.com/FelineStateMachine/tinyrelay/internal/event"
775+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
776+)
777+
778+func TestMCPAttachmentRegistryRoundTrip(t *testing.T) {
779+ _, tenant := testTenant(t)
780+ registry, err := tenant.mcpTools()
781+ if err != nil {
782+ t.Fatal(err)
783+ }
784+ upload, ok := registry.Lookup("upload_attachment")
785+ if !ok {
786+ t.Fatal("upload_attachment is not registered")
787+ }
788+ owner := tenant.Policy().Owner
789+ raw := []byte("agent document")
790+ result, err := upload.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "data": base64.StdEncoding.EncodeToString(raw), "type": "text/plain", "filename": "note.txt"}})
791+ if err != nil || result.IsError {
792+ t.Fatalf("upload: %v %+v", err, result)
793+ }
794+ descriptor := result.StructuredContent.(map[string]any)
795+ encodedDescriptor, err := json.Marshal(descriptor)
796+ if err != nil {
797+ t.Fatal(err)
798+ }
799+ if err := json.Unmarshal(encodedDescriptor, &descriptor); err != nil {
800+ t.Fatal(err)
801+ }
802+ if descriptor["sha256"] == "" || descriptor["url"] == "" || descriptor["filename"] != "note.txt" {
803+ t.Fatalf("descriptor: %#v", descriptor)
804+ }
805+ post, ok := registry.Lookup("post_message")
806+ if !ok {
807+ t.Fatal("post_message is not registered")
808+ }
809+ if err := mcp.Validate(post.InputSchema, map[string]any{"room": "main", "attachments": []any{descriptor}}); err != nil {
810+ t.Fatalf("descriptor schema: %v", err)
811+ }
812+ unsigned, err := mcpBuildMessage(mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}})
813+ if err != nil || !strings.Contains(unsigned.Content, descriptor["url"].(string)) || len(unsigned.Tags) != 2 {
814+ t.Fatalf("attachment-only message: %+v %v", unsigned, err)
815+ }
816+ if thread, err := mcpBuildThread(mcp.Call{Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}}); err != nil || thread.Content == "" {
817+ t.Fatalf("attachment-only thread: %+v %v", thread, err)
818+ }
819+ if reply, err := mcpBuildReply(mcp.Call{Arguments: map[string]any{"room": "main", "root": strings.Repeat("a", 64), "attachments": []any{descriptor}}}); err != nil || reply.Content == "" {
820+ t.Fatalf("attachment-only reply: %+v %v", reply, err)
821+ }
822+ read, ok := registry.Lookup("read_attachment")
823+ if !ok {
824+ t.Fatal("read_attachment is not registered")
825+ }
826+ readResult, err := read.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"sha256": descriptor["sha256"], "max_bytes": float64(1024)}})
827+ if err != nil || readResult.IsError {
828+ t.Fatalf("read: %v %+v", err, readResult)
829+ }
830+ readData := readResult.StructuredContent.(map[string]any)["data"].(string)
831+ if got, _ := base64.StdEncoding.DecodeString(readData); string(got) != string(raw) {
832+ t.Fatalf("read data %q", got)
833+ }
834+ for _, media := range []struct {
835+ typ, want string
836+ body []byte
837+ }{{"image/png", "image", []byte("\x89PNG\r\n\x1a\n")}, {"audio/ogg", "audio", []byte("OggS test")}} {
838+ mediaResult, err := upload.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "data": base64.StdEncoding.EncodeToString(media.body), "type": media.typ}})
839+ if err != nil || mediaResult.IsError {
840+ t.Fatalf("media upload %s: %v %+v", media.typ, err, mediaResult)
841+ }
842+ mediaDescriptor := mediaResult.StructuredContent.(map[string]any)
843+ got, err := read.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"sha256": mediaDescriptor["sha256"], "max_bytes": float64(1024)}})
844+ if err != nil || got.IsError || len(got.Content) != 1 || got.Content[0].Type != media.want {
845+ t.Fatalf("media read %s: %v %+v", media.typ, err, got)
846+ }
847+ }
848+ unsignedEvent, err := mcpBuildMessage(mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}})
849+ if err != nil {
850+ t.Fatal(err)
851+ }
852+ published := event.Event{Kind: unsignedEvent.Kind, CreatedAt: unsignedEvent.CreatedAt, Tags: unsignedEvent.Tags, Content: unsignedEvent.Content}
853+ if err := event.Sign(&published, strings.Repeat("0", 63)+"1"); err != nil {
854+ t.Fatal(err)
855+ }
856+ encoded, _ := json.Marshal(published)
857+ var signed map[string]any
858+ _ = json.Unmarshal(encoded, &signed)
859+ posted, err := post.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"event": signed}})
860+ if err != nil || posted.IsError {
861+ t.Fatalf("signed attachment post: %v %+v", err, posted)
862+ }
863+ roomResult, err := tenant.Execute(context.Background(), owner, "browseroom", []json.RawMessage{json.RawMessage(`{"id":"main"}`)})
864+ if err != nil || !strings.Contains(string(mcpJSON(roomResult)), "imeta") {
865+ t.Fatalf("room lost attachment metadata: %v %#v", err, roomResult)
866+ }
867+}
868+
869+func mcpJSON(value any) []byte { raw, _ := json.Marshal(value); return raw }
870+
871+func TestMCPAttachmentRejectsUnauthorizedAndMalformed(t *testing.T) {
872+ _, tenant := testTenant(t)
873+ registry, err := tenant.mcpTools()
874+ if err != nil {
875+ t.Fatal(err)
876+ }
877+ upload, _ := registry.Lookup("upload_attachment")
878+ if result, err := upload.Handler(context.Background(), mcp.Call{Actor: strings.Repeat("f", 64), Arguments: map[string]any{"room": "main", "data": "YQ==", "type": "text/plain"}}); err != nil || !result.IsError {
879+ t.Fatalf("unjoined upload accepted: %v %+v", err, result)
880+ }
881+ if err := mcpCheckAttachments(event.Event{Tags: [][]string{{"imeta", "url https://relay.test/media/not-a-hash", "m text/plain", "x bad"}}}); err == nil {
882+ t.Fatal("malformed imeta accepted")
883+ }
884+ h := newRoomHarness(t)
885+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private"}, {"closed"}}, "")
886+ stored, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["alice"], "private", roomAttachmentInput{Body: []byte("secret"), Type: "text/plain"})
887+ if err != nil {
888+ t.Fatal(err)
889+ }
890+ result, err := h.tenant.mcpReadAttachment(h.ctx, mcp.Call{Actor: h.keys["bob"], Arguments: map[string]any{"sha256": stored["sha256"]}})
891+ if err != nil || !result.IsError {
892+ t.Fatalf("outsider read accepted: %v %+v", err, result)
893+ }
894+}
895+
896+func TestMCPAttachmentEscapingAndValidation(t *testing.T) {
897+ descriptor := map[string]any{"url": "https://relay.test/media/" + strings.Repeat("a", 64), "sha256": strings.Repeat("a", 64), "size": float64(8), "type": "text/plain", "filename": "report] [x].txt"}
898+ call := mcp.Call{Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}}
899+ unsigned, err := mcpBuildMessage(call)
900+ if err != nil || !strings.HasPrefix(unsigned.Content, `[report\] \[x\].txt](`) {
901+ t.Fatalf("filename escaping: %q %v", unsigned.Content, err)
902+ }
903+ for _, invalid := range []string{"javascript:alert(1)", "https://user:password@relay.test/a", "https://relay.test/a)\n![bad](https://tracker.test)", "https://relay.test/a b"} {
904+ descriptor["url"] = invalid
905+ if _, err := mcpBuildMessage(call); err == nil {
906+ t.Errorf("unsafe URL accepted: %q", invalid)
907+ }
908+ }
909+ descriptor["url"] = "https://relay.test/video.mp4"
910+ descriptor["type"] = "video/mp4"
911+ if unsigned, err := mcpBuildMessage(call); err != nil || !strings.HasPrefix(unsigned.Content, "![video](") {
912+ t.Fatalf("Buzz video marker: %+v %v", unsigned, err)
913+ }
914+ for _, invalid := range []string{"invalid", "text/plain\r\nx bad", ""} {
915+ descriptor["type"] = invalid
916+ if _, err := mcpBuildMessage(call); err == nil {
917+ t.Errorf("unsafe MIME accepted: %q", invalid)
918+ }
919+ }
920+}
921diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
922index 1ee0130..021f061 100644
923--- a/internal/daemon/mcp_tools.go
924+++ b/internal/daemon/mcp_tools.go
925@@ -108,6 +108,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
926 add("read_pull_request", "Read a pull request, its replies, authorized updates and available diff.", collaborationDetail, mcpReads, t.mcpBrowse("browsepull"))
927 add("list_files", "List stored files visible to your account.", mcp.Object(map[string]any{"cursor": mcpText, "limit": mcpLimit, "q": mcpText}), mcpReads, t.mcpBrowse("browsefiles"))
928 add("read_file", "Read a stored file's metadata and available preview by SHA-256 hash.", mcp.Object(map[string]any{"hash": mcpHash}, "hash"), mcpReads, t.mcpBrowse("browsefile"))
929+ add("upload_attachment", "Upload a bounded file for a room message. Returns its Blossom URL and NIP-92 metadata; pass the descriptor as an attachment to a room write tool.", mcp.Object(map[string]any{"room": mcpRoomID, "data": map[string]any{"type": "string", "description": "Standard Base64 file bytes, at most 700 KiB of encoded data. Use PUT /rooms/<id>/attachments for larger files."}, "type": map[string]any{"type": "string", "minLength": 1, "maxLength": 255}, "filename": map[string]any{"type": "string", "minLength": 1, "maxLength": 255}}, "room", "data", "type"), mcpPublishes, t.mcpUploadAttachment)
930+ add("read_attachment", "Read a stored attachment your key may access. Images and audio are returned as native MCP content; other files return Base64 data. Reads are limited to 4 MiB.", mcp.Object(map[string]any{"sha256": mcpHash, "max_bytes": map[string]any{"type": "integer", "minimum": 1, "maximum": 4 * 1024 * 1024}}, "sha256"), mcpReads, t.mcpReadAttachment)
931 add("read_status", "Read service health, storage and job status. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, t.mcpBrowse("browsestatus"))
932 add("read_management", "Read relay configuration, jobs, backups, delivery status or members with your key's permissions. gitstorage requires owner and repo.", mcp.Object(map[string]any{"method": map[string]any{"type": "string", "enum": mcpReadMethods}, "owner": mcpPubKey, "repo": mcpID}, "method"), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
933 method := call.String("method")
934@@ -218,9 +220,10 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
935 add("create_pull_request", "Open a pull request on a hosted repository. Pass owner, repo, title, commit and clone (plus optional content, merge_base and labels) to receive the unsigned kind 1618 event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "title": mcpText, "content": mcpText, "commit": mcpCommit, "clone": map[string]any{"type": "string", "description": "HTTP or HTTPS clone URL without credentials."}, "merge_base": mcpCommit, "labels": map[string]any{"type": "array", "items": mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildPull, mcpCheckPull, mcpPullShape))
936 add("comment", "Reply to an issue, pull request or comment with a NIP-22 kind 1111 event. Pass owner, repo, root, root_kind, root_pubkey and content (plus parent, parent_kind and parent_pubkey to answer a comment) to receive the unsigned event, sign it, then call again with the signed event. To review one line of a pull request or patch diff, add file, line and side (old for the base, new for the change).", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_kind": mcpRootKind, "root_pubkey": mcpPubKey, "parent": mcpHash, "parent_kind": map[string]any{"type": "integer", "enum": []int{1111, 1617, 1618, 1621}}, "parent_pubkey": mcpPubKey, "content": mcpText, "file": map[string]any{"type": "string", "minLength": 1, "description": "Path of the diff file the comment is about."}, "line": map[string]any{"type": "integer", "minimum": 1, "description": "Line number on the chosen side of the diff."}, "side": map[string]any{"type": "string", "enum": []string{"old", "new"}}}), mcpPublishes, t.mcpWrite(mcpBuildComment, mcpCheckComment, mcpCommentShape))
937 add("set_status", "Change the status of an issue or pull request. The author, repository owner and maintainers may do this. Pass owner, repo, root, root_pubkey and status to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_pubkey": mcpPubKey, "status": map[string]any{"type": "string", "enum": []string{"open", "resolved", "merged", "closed", "draft"}}}), mcpPublishes, t.mcpWrite(mcpBuildStatus, mcpCheckStatus, mcpStatusShape))
938- add("post_message", "Post a kind 9 chat message in a room. Pass room and content (plus mentions, a list of public keys) to receive the unsigned event, sign it, then call again with the signed event. Posting in an open room joins it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "content": mcpText, "mentions": map[string]any{"type": "array", "items": mcpPubKey}}), mcpPublishes, t.mcpWrite(mcpBuildMessage, mcpCheckMessage, mcpMessageShape))
939- add("start_thread", "Start a kind 11 thread in a room. Pass room and content (plus an optional title) to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "title": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildThread, mcpCheckThread, mcpThreadShape))
940- add("reply_in_thread", "Reply to a thread in a room with a kind 12 event. Pass room, root (the thread's event id), root_pubkey and content to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "root": mcpHash, "root_pubkey": mcpPubKey, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildReply, mcpCheckReply, mcpReplyShape))
941+ attachments := map[string]any{"type": "array", "items": mcpAttachment, "maxItems": 8}
942+ add("post_message", "Post a kind 9 chat message in a room. Pass room and content or attachments, plus optional mentions, to receive an unsigned event. Sign it, then call again with event. Posting in an open room joins it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "content": mcpText, "mentions": map[string]any{"type": "array", "items": mcpPubKey}, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildMessage, mcpCheckMessage, mcpMessageShape))
943+ add("start_thread", "Start a kind 11 thread in a room. Pass room and content or attachments, plus an optional title, to receive an unsigned event. Sign it, then call again with event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "title": mcpText, "content": mcpText, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildThread, mcpCheckThread, mcpThreadShape))
944+ add("reply_in_thread", "Reply with a kind 12 event. Pass room, root and content or attachments, plus optional root_pubkey, to receive an unsigned event. Sign it, then call again with event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "root": mcpHash, "root_pubkey": mcpPubKey, "content": mcpText, "attachments": attachments}), mcpPublishes, t.mcpWrite(mcpBuildReply, mcpCheckReply, mcpReplyShape))
945 add("react", "React to an event with a kind 7 reaction: + to like or approve, - to dislike or decline, or one emoji. Pass target, target_pubkey and content (plus room for a room message) to receive the unsigned event, sign it, then call again with the signed event. A + or - from a wiki merge request's destination author answers the request.", mcp.Object(map[string]any{"event": mcpEvent, "target": mcpHash, "target_pubkey": mcpPubKey, "content": map[string]any{"type": "string", "minLength": 1, "description": "+, - or one emoji."}, "room": mcpRoomID}), mcpPublishes, t.mcpWrite(mcpBuildReact, mcpCheckReact, mcpReactShape))
946 add("publish_wiki_page", "Publish or replace your version of a kind 30818 wiki page in Djot markup. Pass d (the page name), title and content, plus optional summary and, to fork another author's version, fork_author and fork_event, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "title": mcpText, "summary": mcpText, "content": mcpText, "fork_author": mcpPubKey, "fork_event": mcpHash}), mcpPublishes, t.mcpWrite(mcpBuildWikiPage, mcpCheckWikiPage, mcpWikiShape))
947 add("propose_wiki_merge", "Ask a wiki author to take in changes from another version with a kind 818 merge request. Pass d, destination (the author asked), source (the proposed version's event id) and content, plus an optional base version id, to receive the unsigned event, sign it, then call again with the signed event. The destination author answers with a + or - reaction.", mcp.Object(map[string]any{"event": mcpEvent, "d": mcpPageName, "destination": mcpPubKey, "source": mcpHash, "base": mcpHash, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildWikiMerge, mcpCheckWikiMerge, mcpMergeShape))
948@@ -549,10 +552,14 @@ func mcpBuildMessage(call mcp.Call) (mcpUnsigned, error) {
949 if err != nil {
950 return mcpUnsigned{}, err
951 }
952- content := call.String("content")
953+ content, attachmentTags, err := mcpRoomContent(call)
954+ if err != nil {
955+ return mcpUnsigned{}, err
956+ }
957 if strings.TrimSpace(content) == "" {
958- return mcpUnsigned{}, errors.New("content is required")
959+ return mcpUnsigned{}, errors.New("content or attachments is required")
960 }
961+ tags = append(tags, attachmentTags...)
962 return mcpUnsigned{Kind: event.KIND_CHAT, CreatedAt: time.Now().Unix(), Tags: mcpPubKeys(call, "mentions", tags), Content: content}, nil
963 }
964
965@@ -562,12 +569,17 @@ func mcpBuildThread(call mcp.Call) (mcpUnsigned, error) {
966 return mcpUnsigned{}, err
967 }
968 title, content := strings.TrimSpace(call.String("title")), call.String("content")
969+ content, attachmentTags, err := mcpRoomContent(call)
970+ if err != nil {
971+ return mcpUnsigned{}, err
972+ }
973 if strings.TrimSpace(content) == "" {
974- return mcpUnsigned{}, errors.New("content is required")
975+ return mcpUnsigned{}, errors.New("content or attachments is required")
976 }
977 if title != "" {
978 tags = append(tags, []string{"subject", title})
979 }
980+ tags = append(tags, attachmentTags...)
981 return mcpUnsigned{Kind: event.KIND_THREAD, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
982 }
983
984@@ -576,14 +588,16 @@ func mcpBuildReply(call mcp.Call) (mcpUnsigned, error) {
985 if err != nil {
986 return mcpUnsigned{}, err
987 }
988- root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")
989+ root, rootPubKey := call.String("root"), call.String("root_pubkey")
990+ content, attachmentTags, err := mcpRoomContent(call)
991 if root == "" || strings.TrimSpace(content) == "" {
992- return mcpUnsigned{}, errors.New("root and content are required")
993+ return mcpUnsigned{}, errors.New("root and content or attachments are required")
994 }
995 tags = append(tags, []string{"e", root})
996 if rootPubKey != "" {
997 tags = append(tags, []string{"p", rootPubKey})
998 }
999+ tags = append(tags, attachmentTags...)
1000 return mcpUnsigned{Kind: event.KIND_THREAD_REPLY, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
1001 }
1002
1003@@ -760,7 +774,7 @@ func mcpCheckMessage(e event.Event) error {
1004 if strings.TrimSpace(e.Content) == "" {
1005 return errors.New("content must not be empty")
1006 }
1007- return nil
1008+ return mcpCheckAttachments(e)
1009 }
1010
1011 func mcpCheckThread(e event.Event) error {
1012@@ -770,7 +784,7 @@ func mcpCheckThread(e event.Event) error {
1013 if strings.TrimSpace(e.Content) == "" {
1014 return errors.New("content must not be empty")
1015 }
1016- return nil
1017+ return mcpCheckAttachments(e)
1018 }
1019
1020 func mcpCheckReply(e event.Event) error {
1021@@ -783,7 +797,7 @@ func mcpCheckReply(e event.Event) error {
1022 if strings.TrimSpace(e.Content) == "" {
1023 return errors.New("content must not be empty")
1024 }
1025- return nil
1026+ return mcpCheckAttachments(e)
1027 }
1028
1029 func mcpCheckReact(e event.Event) error {
1030diff --git a/internal/daemon/room_attachment_security_test.go b/internal/daemon/room_attachment_security_test.go
1031new file mode 100644
1032index 0000000..4089854
1033--- /dev/null
1034+++ b/internal/daemon/room_attachment_security_test.go
1035@@ -0,0 +1,197 @@
1036+package daemon
1037+
1038+import (
1039+ "crypto/sha256"
1040+ "encoding/base64"
1041+ "encoding/hex"
1042+ "encoding/json"
1043+ "net/http"
1044+ "net/http/httptest"
1045+ "strconv"
1046+ "strings"
1047+ "testing"
1048+ "time"
1049+
1050+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1051+)
1052+
1053+func roomAttachmentAuth(t *testing.T, secret, rawURL, method, body string) string {
1054+ t.Helper()
1055+ hash := sha256.Sum256([]byte(body))
1056+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", rawURL}, {"method", method}, {"payload", hex.EncodeToString(hash[:])}}}
1057+ if err := event.Sign(&e, secret); err != nil {
1058+ t.Fatal(err)
1059+ }
1060+ raw, err := json.Marshal(e)
1061+ if err != nil {
1062+ t.Fatal(err)
1063+ }
1064+ return "Nostr " + base64.RawURLEncoding.EncodeToString(raw)
1065+}
1066+
1067+func roomAttachmentBlossomAuth(t *testing.T, secret, action, hash string) string {
1068+ t.Helper()
1069+ e := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", action}, {"x", hash}, {"expiration", strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)}}}
1070+ if err := event.Sign(&e, secret); err != nil {
1071+ t.Fatal(err)
1072+ }
1073+ raw, err := json.Marshal(e)
1074+ if err != nil {
1075+ t.Fatal(err)
1076+ }
1077+ // The daemon's authorization parser uses the Nostr scheme for both NIP-98
1078+ // and kind-24242 Blossom proofs; the event kind selects the verifier.
1079+ return "Nostr " + base64.RawURLEncoding.EncodeToString(raw)
1080+}
1081+
1082+func TestRoomAttachmentUploadAndPrivateReadDoors(t *testing.T) {
1083+ h := newRoomHarness(t)
1084+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")
1085+ body := "private attachment"
1086+ path := "/rooms/secret/attachments?filename=secret.txt"
1087+ req := httptest.NewRequest(http.MethodPut, "http://relay.test"+path, strings.NewReader(body))
1088+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], req.URL.String(), req.Method, body))
1089+ req.Header.Set("Content-Type", "text/plain")
1090+ res := httptest.NewRecorder()
1091+ h.tenant.ServeHTTP(res, req)
1092+ if res.Code != http.StatusOK {
1093+ t.Fatalf("member upload = %d: %s", res.Code, res.Body.String())
1094+ }
1095+ var uploaded struct {
1096+ Hash string `json:"sha256"`
1097+ }
1098+ if err := json.Unmarshal(res.Body.Bytes(), &uploaded); err != nil || len(uploaded.Hash) != 64 {
1099+ t.Fatalf("upload response = %s: %v", res.Body.String(), err)
1100+ }
1101+
1102+ for _, door := range []string{"/media/" + uploaded.Hash, "/files/raw?hash=" + uploaded.Hash} {
1103+ t.Run("outsider "+door, func(t *testing.T) {
1104+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)
1105+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["bob"], req.URL.String(), req.Method, ""))
1106+ got := httptest.NewRecorder()
1107+ h.tenant.ServeHTTP(got, req)
1108+ if got.Code != http.StatusForbidden && got.Code != http.StatusUnauthorized {
1109+ t.Fatalf("outsider read = %d: %s", got.Code, got.Body.String())
1110+ }
1111+ })
1112+ t.Run("member "+door, func(t *testing.T) {
1113+ // Alice owns the closed room; a signed GET must be accepted.
1114+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)
1115+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], req.URL.String(), req.Method, ""))
1116+ got := httptest.NewRecorder()
1117+ h.tenant.ServeHTTP(got, req)
1118+ if got.Code != http.StatusOK || got.Body.String() != body {
1119+ t.Fatalf("member read = %d %q", got.Code, got.Body.String())
1120+ }
1121+ })
1122+ }
1123+ for _, door := range []string{"/" + uploaded.Hash + ".txt", "/media/" + uploaded.Hash + ".txt"} {
1124+ t.Run("blossom outsider "+door, func(t *testing.T) {
1125+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)
1126+ req.Header.Set("Authorization", roomAttachmentBlossomAuth(t, h.secrets["bob"], "get", uploaded.Hash))
1127+ got := httptest.NewRecorder()
1128+ h.tenant.ServeHTTP(got, req)
1129+ if got.Code != http.StatusForbidden && got.Code != http.StatusUnauthorized {
1130+ t.Fatalf("Blossom outsider read = %d: %s", got.Code, got.Body.String())
1131+ }
1132+ })
1133+ }
1134+ for _, door := range []string{"/" + uploaded.Hash + ".txt", "/media/" + uploaded.Hash + ".txt"} {
1135+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)
1136+ req.Header.Set("Authorization", roomAttachmentBlossomAuth(t, h.secrets["alice"], "get", uploaded.Hash))
1137+ got := httptest.NewRecorder()
1138+ h.tenant.ServeHTTP(got, req)
1139+ if got.Code != http.StatusOK || got.Body.String() != body {
1140+ t.Fatalf("Blossom member read = %d %q", got.Code, got.Body.String())
1141+ }
1142+ }
1143+ if _, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": uploaded.Hash})}); err == nil {
1144+ t.Fatal("outsider browsefile exposed private attachment")
1145+ }
1146+}
1147+
1148+func TestRoomAttachmentMediaSandboxesActiveTypes(t *testing.T) {
1149+ h := newRoomHarness(t)
1150+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")
1151+ body := "<html><script>document.body.dataset.pwned='yes'</script></html>"
1152+ req := httptest.NewRequest(http.MethodPut, "http://relay.test/rooms/secret/attachments?filename=x.html", strings.NewReader(body))
1153+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], req.URL.String(), req.Method, body))
1154+ req.Header.Set("Content-Type", "text/html")
1155+ res := httptest.NewRecorder()
1156+ h.tenant.ServeHTTP(res, req)
1157+ if res.Code != http.StatusOK {
1158+ t.Fatalf("upload = %d: %s", res.Code, res.Body.String())
1159+ }
1160+ var uploaded struct {
1161+ Hash string `json:"sha256"`
1162+ }
1163+ _ = json.Unmarshal(res.Body.Bytes(), &uploaded)
1164+ get := httptest.NewRequest(http.MethodGet, "http://relay.test/media/"+uploaded.Hash, nil)
1165+ get.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], get.URL.String(), get.Method, ""))
1166+ got := httptest.NewRecorder()
1167+ h.tenant.ServeHTTP(got, get)
1168+ if got.Code != http.StatusOK {
1169+ t.Fatalf("media read = %d: %s", got.Code, got.Body.String())
1170+ }
1171+ if got.Header().Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(got.Header().Get("Content-Security-Policy"), "sandbox") {
1172+ t.Fatalf("active media lacks isolation: nosniff=%q csp=%q", got.Header().Get("X-Content-Type-Options"), got.Header().Get("Content-Security-Policy"))
1173+ }
1174+ if got.Header().Get("Content-Type") == "text/html" {
1175+ t.Fatal("HTML attachment served as active text/html")
1176+ }
1177+}
1178+
1179+func TestRoomAttachmentScopeDiesWithMembershipAndRoomGeneration(t *testing.T) {
1180+ h := newRoomHarness(t)
1181+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "removed"}, {"closed"}}, "")
1182+ h.must("alice", event.KIND_PUT_USER, [][]string{{"h", "removed"}, {"p", h.keys["bob"]}}, "")
1183+ removed, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["bob"], "removed", roomAttachmentInput{Body: []byte("member bytes"), Type: "text/plain"})
1184+ if err != nil {
1185+ t.Fatalf("removed-member attachment = %v", err)
1186+ }
1187+ h.must("alice", event.KIND_REMOVE_USER, [][]string{{"h", "removed"}, {"p", h.keys["bob"]}}, "")
1188+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["bob"], removed["sha256"].(string)); err == nil {
1189+ t.Fatal("removed member retained attachment access")
1190+ }
1191+
1192+ created := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "reused"}, {"closed"}}, "")
1193+ body := []byte("old room bytes")
1194+ result, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["alice"], "reused", roomAttachmentInput{Body: body, Type: "text/plain", Filename: "old.txt"})
1195+ if err != nil {
1196+ t.Fatalf("store attachment = %v", err)
1197+ }
1198+ hash := result["sha256"].(string)
1199+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice"], hash); err != nil {
1200+ t.Fatalf("initial room access = %v", err)
1201+ }
1202+ // Deleting the room must invalidate its attachment scope, even if the
1203+ // identifier is later reused for a newly created room.
1204+ h.must("alice", event.KIND_DELETE_GROUP, [][]string{{"h", "reused"}}, "")
1205+ h.projectRooms()
1206+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice"], hash); err == nil {
1207+ t.Fatal("deleted room retained attachment access")
1208+ }
1209+ newRoom := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "reused"}, {"closed"}, {"name", "Reused again"}}, "")
1210+ if created.ID == newRoom.ID {
1211+ t.Fatal("room recreation did not produce a new event")
1212+ }
1213+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice"], hash); err == nil {
1214+ t.Fatal("new room generation inherited old attachment")
1215+ }
1216+}
1217+
1218+func TestRoomAttachmentHTTPRejectsChangedPayload(t *testing.T) {
1219+ h := newRoomHarness(t)
1220+ request := httptest.NewRequest(http.MethodPut, "http://relay.test/rooms/main/attachments", strings.NewReader("changed bytes"))
1221+ request.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["owner"], request.URL.String(), request.Method, "signed bytes"))
1222+ request.Header.Set("Content-Type", "text/plain")
1223+ response := httptest.NewRecorder()
1224+ h.tenant.ServeHTTP(response, request)
1225+ if response.Code != http.StatusUnauthorized {
1226+ t.Fatalf("changed payload: %d %s", response.Code, response.Body.String())
1227+ }
1228+ var count int
1229+ if err := h.tenant.store.DB().QueryRowContext(h.ctx, "SELECT COUNT(*) FROM room_attachments").Scan(&count); err != nil || count != 0 {
1230+ t.Fatalf("changed payload was stored: %d %v", count, err)
1231+ }
1232+}
1233diff --git a/internal/daemon/room_attachments.go b/internal/daemon/room_attachments.go
1234new file mode 100644
1235index 0000000..c18ce61
1236--- /dev/null
1237+++ b/internal/daemon/room_attachments.go
1238@@ -0,0 +1,277 @@
1239+package daemon
1240+
1241+import (
1242+ "bytes"
1243+ "context"
1244+ "crypto/sha256"
1245+ "database/sql"
1246+ "encoding/hex"
1247+ "encoding/json"
1248+ "errors"
1249+ "fmt"
1250+ "io"
1251+ "mime"
1252+ "net/http"
1253+ "path"
1254+ "regexp"
1255+ "strings"
1256+
1257+ "github.com/FelineStateMachine/tinyrelay/internal/auth"
1258+ "github.com/FelineStateMachine/tinyrelay/internal/blob"
1259+ "github.com/FelineStateMachine/tinyrelay/internal/community"
1260+)
1261+
1262+const roomAttachmentMaxBytes int64 = 32 << 20
1263+
1264+var roomAttachmentPath = regexp.MustCompile(`^/rooms/([a-z0-9_-]{1,64})/attachments$`)
1265+
1266+var mediaPath = regexp.MustCompile(`^/media/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$`)
1267+
1268+type roomAttachmentScope struct {
1269+ RoomID, EventID string
1270+}
1271+
1272+type roomAttachmentInput struct {
1273+ Body []byte
1274+ Type string
1275+ Filename string
1276+}
1277+
1278+// storeRoomAttachment applies the same upload policy to browser and MCP calls.
1279+func (t *Tenant) storeRoomAttachment(ctx context.Context, actor, roomID string, input roomAttachmentInput) (map[string]any, error) {
1280+ if err := t.roomAttachmentUploader(ctx, actor, roomID); err != nil {
1281+ return nil, err
1282+ }
1283+ room, _, err := t.roomFor(ctx, actor, roomID)
1284+ if err != nil {
1285+ return nil, err
1286+ }
1287+ if len(input.Body) == 0 || int64(len(input.Body)) > roomAttachmentMaxBytes {
1288+ return nil, errors.New("invalid: attachment must be between 1 byte and 32 MiB")
1289+ }
1290+ typ := input.Type
1291+ if typ == "" {
1292+ typ = http.DetectContentType(input.Body)
1293+ }
1294+ if _, _, err := mime.ParseMediaType(typ); err != nil {
1295+ return nil, errors.New("invalid: attachment media type")
1296+ }
1297+ filename := roomAttachmentFilename(input.Filename)
1298+ commit := func(commitCtx context.Context, tx *sql.Tx, entry blob.Blob, created bool) error {
1299+ if !created {
1300+ var scoped int
1301+ if err := tx.QueryRowContext(commitCtx, "SELECT COUNT(*) FROM room_attachments WHERE sha256=?", entry.SHA256).Scan(&scoped); err != nil {
1302+ return err
1303+ }
1304+ if scoped == 0 {
1305+ return errors.New("conflict: this file already exists without room restrictions; share its existing link")
1306+ }
1307+ }
1308+ var currentEvent string
1309+ if err := tx.QueryRowContext(commitCtx, "SELECT event_id FROM rooms WHERE id=? AND deleted_at=0", roomID).Scan(&currentEvent); err != nil {
1310+ return err
1311+ }
1312+ if currentEvent != room.EventID {
1313+ return errors.New("conflict: the room changed while uploading; try again")
1314+ }
1315+ _, err := tx.ExecContext(commitCtx, `INSERT OR IGNORE INTO room_attachments(room_id,room_event_id,sha256,filename,created_at) VALUES(?,?,?,?,unixepoch())`, roomID, room.EventID, entry.SHA256, filename)
1316+ return err
1317+ }
1318+ entry, err := t.blobs.Put(ctx, blob.PutOptions{Reader: bytes.NewReader(input.Body), Type: typ, Uploader: actor, Commit: commit})
1319+ if err != nil {
1320+ return nil, err
1321+ }
1322+ descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/")+"/media", entry)
1323+ descriptor["filename"] = filename
1324+ descriptor["room"] = roomID
1325+ descriptor["nip94"] = append(descriptor["nip94"].([][]string), []string{"filename", filename})
1326+ return descriptor, nil
1327+}
1328+
1329+func roomAttachmentFilename(filename string) string {
1330+ filename = strings.Map(func(r rune) rune {
1331+ if r < 32 || r == 127 {
1332+ return ' '
1333+ }
1334+ return r
1335+ }, path.Base(filename))
1336+ runes := []rune(strings.TrimSpace(filename))
1337+ if len(runes) > 255 {
1338+ runes = runes[:255]
1339+ }
1340+ filename = string(runes)
1341+ if filename == "" || filename == "." || filename == "/" {
1342+ return "file"
1343+ }
1344+ return filename
1345+}
1346+
1347+func (t *Tenant) roomAttachmentUploader(ctx context.Context, actor, roomID string) error {
1348+ if actor == "" {
1349+ return errors.New("auth-required: attachment uploader")
1350+ }
1351+ if !t.Policy().Features.Files {
1352+ return errors.New("restricted: file uploads are disabled")
1353+ }
1354+ banned, err := t.community.IsBanned(ctx, actor)
1355+ if err != nil {
1356+ return fmt.Errorf("check attachment uploader ban: %w", err)
1357+ }
1358+ if banned {
1359+ return errors.New("blocked: this pubkey is banned from this relay")
1360+ }
1361+ standing, err := t.community.Role(ctx, actor)
1362+ if err != nil {
1363+ return fmt.Errorf("check attachment uploader role: %w", err)
1364+ }
1365+ allowed := t.Policy().Writes == "open" || (t.Policy().Writes == "owner" && standing == "owner") || (t.Policy().Writes != "owner" && standing != "")
1366+ if !allowed {
1367+ return errors.New("restricted: file upload is not allowed")
1368+ }
1369+ if standing == "agent" {
1370+ if _, err := t.agentUploadGrant(ctx, actor); err != nil {
1371+ return err
1372+ }
1373+ }
1374+ if room, role, err := t.roomFor(ctx, actor, roomID); err != nil {
1375+ return err
1376+ } else if role == "" && !(room.Access == community.RoomOpen && standing != "") {
1377+ return errors.New("restricted: join the room before uploading attachments")
1378+ }
1379+ return nil
1380+}
1381+
1382+func (t *Tenant) initRoomAttachments(ctx context.Context) error {
1383+ _, err := t.store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS room_attachments (
1384+ room_id TEXT NOT NULL, room_event_id TEXT NOT NULL DEFAULT '',
1385+ sha256 TEXT NOT NULL, filename TEXT NOT NULL DEFAULT '',
1386+ created_at INTEGER NOT NULL, PRIMARY KEY(room_id, room_event_id, sha256),
1387+ FOREIGN KEY(sha256) REFERENCES blobs(sha256) ON DELETE CASCADE
1388+ ); CREATE INDEX IF NOT EXISTS room_attachments_hash ON room_attachments(sha256);`)
1389+ if err != nil {
1390+ return fmt.Errorf("initialize room attachments: %w", err)
1391+ }
1392+ return nil
1393+}
1394+
1395+func (t *Tenant) roomAttachmentRooms(ctx context.Context, hash string) ([]roomAttachmentScope, error) {
1396+ rows, err := t.store.DB().QueryContext(ctx, "SELECT room_id,room_event_id FROM room_attachments WHERE sha256=?", hash)
1397+ if err != nil {
1398+ return nil, fmt.Errorf("lookup room attachment: %w", err)
1399+ }
1400+ defer rows.Close()
1401+ var rooms []roomAttachmentScope
1402+ for rows.Next() {
1403+ var room roomAttachmentScope
1404+ if err := rows.Scan(&room.RoomID, &room.EventID); err != nil {
1405+ return nil, fmt.Errorf("scan room attachment: %w", err)
1406+ }
1407+ rooms = append(rooms, room)
1408+ }
1409+ return rooms, rows.Err()
1410+}
1411+
1412+func (t *Tenant) roomAttachmentAccess(ctx context.Context, actor, hash string) error {
1413+ rooms, err := t.roomAttachmentRooms(ctx, hash)
1414+ if err != nil {
1415+ return err
1416+ }
1417+ if len(rooms) == 0 {
1418+ return nil
1419+ }
1420+ for _, scope := range rooms {
1421+ if room, _, err := t.roomFor(ctx, actor, scope.RoomID); err == nil && room.EventID == scope.EventID {
1422+ return nil
1423+ }
1424+ }
1425+ if actor == "" {
1426+ return errors.New("auth-required: room attachment")
1427+ }
1428+ return errors.New("restricted: room attachment membership required")
1429+}
1430+
1431+func (t *Tenant) roomAttachmentHTTP(w http.ResponseWriter, r *http.Request) bool {
1432+ if match := roomAttachmentPath.FindStringSubmatch(r.URL.Path); match != nil {
1433+ if r.Method == http.MethodPut {
1434+ t.uploadRoomAttachment(w, r, match[1])
1435+ } else {
1436+ w.Header().Set("Allow", http.MethodPut)
1437+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1438+ }
1439+ return true
1440+ }
1441+ match := mediaPath.FindStringSubmatch(r.URL.Path)
1442+ if match == nil {
1443+ return false
1444+ }
1445+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
1446+ w.Header().Set("Allow", "GET, HEAD")
1447+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1448+ return true
1449+ }
1450+ if err := t.serveRoomMedia(w, r, match[1]); err != nil {
1451+ browseHTTPError(w, err)
1452+ }
1453+ return true
1454+}
1455+
1456+func (t *Tenant) serveRoomMedia(w http.ResponseWriter, r *http.Request, hash string) error {
1457+ var actor string
1458+ var err error
1459+ if auth.IsBlossomAuthorization(r.Header.Get("Authorization")) {
1460+ actor, err = t.authorizeBlob(r, blob.ActionGet)
1461+ } else {
1462+ actor, err = t.resolveUIActor(r)
1463+ }
1464+ if err != nil {
1465+ return err
1466+ }
1467+ if err := t.browseRead(r.Context(), actor); err != nil {
1468+ return err
1469+ }
1470+ // Use the same safe content headers and range serving as /files/raw.
1471+ w.Header().Set("Cache-Control", "private, no-store")
1472+ w.Header().Set("Content-Security-Policy", "sandbox")
1473+ w.Header().Set("X-Content-Type-Options", "nosniff")
1474+ request := r.Clone(r.Context())
1475+ query := request.URL.Query()
1476+ query.Set("hash", hash)
1477+ request.URL.RawQuery = query.Encode()
1478+ return t.fileDownload(w, request, actor)
1479+}
1480+
1481+func (t *Tenant) uploadRoomAttachment(w http.ResponseWriter, r *http.Request, roomID string) {
1482+ // Authenticate and authorize before buffering file bytes. The payload
1483+ // binding is checked after reading, without consuming the proof twice.
1484+ token, err := t.auth.VerifyNIP98(r.Header.Get("Authorization"), t.requestURL(r), r.Method, "")
1485+ if err != nil {
1486+ browseHTTPError(w, err)
1487+ return
1488+ }
1489+ if err := t.roomAttachmentUploader(r.Context(), token.PubKey, roomID); err != nil {
1490+ browseHTTPError(w, err)
1491+ return
1492+ }
1493+ r.Body = http.MaxBytesReader(w, r.Body, roomAttachmentMaxBytes+1)
1494+ body, err := io.ReadAll(r.Body)
1495+ if err != nil {
1496+ http.Error(w, "attachment exceeds 32 MiB", http.StatusRequestEntityTooLarge)
1497+ return
1498+ }
1499+ if int64(len(body)) > roomAttachmentMaxBytes {
1500+ http.Error(w, "attachment exceeds 32 MiB", http.StatusRequestEntityTooLarge)
1501+ return
1502+ }
1503+ hash := sha256.Sum256(body)
1504+ if err := t.auth.ValidateBlobPayload(r.Header.Get("Authorization"), hex.EncodeToString(hash[:])); err != nil {
1505+ browseHTTPError(w, err)
1506+ return
1507+ }
1508+ result, err := t.storeRoomAttachment(r.Context(), token.PubKey, roomID, roomAttachmentInput{Body: body, Type: r.Header.Get("Content-Type"), Filename: r.URL.Query().Get("filename")})
1509+ if err != nil {
1510+ browseHTTPError(w, err)
1511+ return
1512+ }
1513+ w.Header().Set("Content-Type", "application/json")
1514+ _ = json.NewEncoder(w).Encode(result)
1515+}
1516diff --git a/internal/daemon/room_attachments_test.go b/internal/daemon/room_attachments_test.go
1517new file mode 100644
1518index 0000000..3affe2d
1519--- /dev/null
1520+++ b/internal/daemon/room_attachments_test.go
1521@@ -0,0 +1,98 @@
1522+package daemon
1523+
1524+import (
1525+ "context"
1526+ "encoding/json"
1527+ "strings"
1528+ "testing"
1529+ "time"
1530+
1531+ "github.com/FelineStateMachine/tinyrelay/internal/blob"
1532+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1533+)
1534+
1535+func TestRoomAttachmentAccessFollowsMembership(t *testing.T) {
1536+ h := newRoomHarness(t)
1537+ ctx := context.Background()
1538+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")
1539+ entry, err := h.tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("room secret"), Type: "text/plain", Uploader: h.keys["alice"]})
1540+ if err != nil {
1541+ t.Fatal(err)
1542+ }
1543+ room, err := h.tenant.community.Room(ctx, "secret")
1544+ if err != nil {
1545+ t.Fatal(err)
1546+ }
1547+ if _, err := h.tenant.store.DB().ExecContext(ctx, "INSERT INTO room_attachments(room_id,room_event_id,sha256,filename,created_at) VALUES(?,?,?,?,unixepoch())", "secret", room.EventID, entry.SHA256, "secret.txt"); err != nil {
1548+ t.Fatal(err)
1549+ }
1550+ if err := h.tenant.roomAttachmentAccess(ctx, h.keys["stranger"], entry.SHA256); err == nil {
1551+ t.Fatal("nonmember can read room attachment")
1552+ }
1553+ if err := h.tenant.roomAttachmentAccess(ctx, h.keys["owner"], entry.SHA256); err != nil {
1554+ t.Fatalf("member cannot read room attachment: %v", err)
1555+ }
1556+}
1557+
1558+func TestGenericBlobRemainsUnscoped(t *testing.T) {
1559+ h := newRoomHarness(t)
1560+ entry, err := h.tenant.blobs.Put(context.Background(), blob.PutOptions{Reader: strings.NewReader("public"), Type: "text/plain", Uploader: h.keys["alice"]})
1561+ if err != nil {
1562+ t.Fatal(err)
1563+ }
1564+ if err := h.tenant.roomAttachmentAccess(context.Background(), "", entry.SHA256); err != nil {
1565+ t.Fatalf("generic blob unexpectedly scoped: %v", err)
1566+ }
1567+}
1568+
1569+func TestRoomAttachmentAgentGrantAndStorageLimits(t *testing.T) {
1570+ _, tenant := testTenant(t)
1571+ ctx := context.Background()
1572+ owner := tenant.Policy().Owner
1573+ agent, _ := event.PublicKey(testAgentSecret)
1574+ now := time.Now().Unix()
1575+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "9"}, []string{"h", "main"})); err != nil {
1576+ t.Fatal(err)
1577+ }
1578+ input := roomAttachmentInput{Body: []byte("generated attachment"), Type: "text/plain"}
1579+ if _, err := tenant.storeRoomAttachment(ctx, agent, "main", input); err != nil {
1580+ t.Fatalf("active agent upload: %v", err)
1581+ }
1582+ if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{rawJSON(agent)}); err != nil {
1583+ t.Fatal(err)
1584+ }
1585+ if _, err := tenant.storeRoomAttachment(ctx, agent, "main", input); err == nil {
1586+ t.Fatal("paused agent uploaded")
1587+ }
1588+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{rawJSON(agent)}); err != nil {
1589+ t.Fatal(err)
1590+ }
1591+ if _, err := tenant.storeRoomAttachment(ctx, agent, "main", input); err == nil {
1592+ t.Fatal("revoked agent uploaded")
1593+ }
1594+ if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{rawJSON(map[string]any{"fileLimits": map[string]any{"maxFileBytes": 4}})}); err != nil {
1595+ t.Fatal(err)
1596+ }
1597+ if _, err := tenant.storeRoomAttachment(ctx, owner, "main", input); err == nil {
1598+ t.Fatal("room upload bypassed file limit")
1599+ }
1600+}
1601+
1602+func TestRoomAttachmentCannotPrivatizeExistingUnscopedBlob(t *testing.T) {
1603+ h := newRoomHarness(t)
1604+ entry, err := h.tenant.blobs.Put(h.ctx, blob.PutOptions{Reader: strings.NewReader("shared bytes"), Type: "text/plain", Uploader: h.keys["alice"]})
1605+ if err != nil {
1606+ t.Fatal(err)
1607+ }
1608+ if _, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["bob"], "main", roomAttachmentInput{Body: []byte("shared bytes"), Type: "text/plain"}); err == nil || !strings.HasPrefix(err.Error(), "conflict:") {
1609+ t.Fatalf("existing public blob should conflict: %v", err)
1610+ }
1611+ scopes, err := h.tenant.roomAttachmentRooms(h.ctx, entry.SHA256)
1612+ if err != nil || len(scopes) != 0 {
1613+ t.Fatalf("conflicted upload changed visibility: %v %v", scopes, err)
1614+ }
1615+ var claims int
1616+ if err := h.tenant.store.DB().QueryRowContext(h.ctx, "SELECT COUNT(*) FROM blob_claims WHERE sha256=? AND uploader=?", entry.SHA256, h.keys["bob"]).Scan(&claims); err != nil || claims != 0 {
1617+ t.Fatalf("conflicted upload left a claim: %d %v", claims, err)
1618+ }
1619+}
1620diff --git a/internal/daemon/room_edit_history_test.go b/internal/daemon/room_edit_history_test.go
1621new file mode 100644
1622index 0000000..1c1d16a
1623--- /dev/null
1624+++ b/internal/daemon/room_edit_history_test.go
1625@@ -0,0 +1,98 @@
1626+package daemon
1627+
1628+import (
1629+ "strconv"
1630+ "testing"
1631+ "time"
1632+
1633+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1634+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
1635+)
1636+
1637+func TestBrowseThreadReturnsNewestAuthorEditsOutsideCursor(t *testing.T) {
1638+ h := newRoomHarness(t)
1639+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, "")
1640+ root := h.must("alice", event.KIND_THREAD, [][]string{{"h", "general"}}, "original root")
1641+ reply := h.must("bob", event.KIND_THREAD_REPLY, [][]string{{"h", "general"}, {"e", root.ID}}, "original reply")
1642+
1643+ cursor := collaborationCursor(collaborationItemFrom(reply))
1644+
1645+ publishAt := func(who string, createdAt int64, target event.Event, content string) event.Event {
1646+ e := h.sign(who, event.KIND_CONTENT_EDIT, [][]string{{"h", "general"}, {"e", target.ID}}, content)
1647+ e.CreatedAt = createdAt
1648+ if err := event.Sign(&e, h.secrets[who]); err != nil {
1649+ t.Fatal(err)
1650+ }
1651+ if _, err := h.tenant.router.Publish(h.ctx, e, relay.Session{PubKeys: []string{h.keys[who]}, RelayURL: h.tenant.RelayURL()}); err != nil {
1652+ t.Fatal(err)
1653+ }
1654+ return e
1655+ }
1656+ old := publishAt("alice", time.Now().Unix()+1, root, "root v1")
1657+ newest := publishAt("alice", old.CreatedAt+1, root, "root v2")
1658+ replyEdit := publishAt("bob", newest.CreatedAt+1, reply, "reply v1")
1659+
1660+ page, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID, "cursor": cursor, "limit": 1})
1661+ if err != nil {
1662+ t.Fatal(err)
1663+ }
1664+ edits, ok := page["edits"].([]any)
1665+ if !ok {
1666+ t.Fatalf("edits = %#v", page["edits"])
1667+ }
1668+ found := map[string]bool{}
1669+ for _, raw := range edits {
1670+ row := raw.(map[string]any)
1671+ found[row["id"].(string)] = true
1672+ }
1673+ if !found[newest.ID] || found[replyEdit.ID] || found[old.ID] {
1674+ t.Fatalf("edits = %#v, want newest visible root only", edits)
1675+ }
1676+
1677+ forged := h.sign("alice", event.KIND_CONTENT_EDIT, [][]string{{"h", "general"}, {"e", reply.ID}}, "wrong author")
1678+ // The target belongs to Bob, so the author filter must exclude this edit.
1679+ if _, err := h.tenant.router.Publish(h.ctx, forged, relay.Session{PubKeys: []string{h.keys["alice"]}, RelayURL: h.tenant.RelayURL()}); err != nil {
1680+ t.Fatal(err)
1681+ }
1682+ page, err = h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID})
1683+ if err != nil {
1684+ t.Fatal(err)
1685+ }
1686+ found = map[string]bool{}
1687+ for _, raw := range page["edits"].([]any) {
1688+ found[raw.(map[string]any)["id"].(string)] = true
1689+ if raw.(map[string]any)["content"] == "wrong author" {
1690+ t.Fatal("forged edit was returned")
1691+ }
1692+ }
1693+ if !found[replyEdit.ID] || !found[newest.ID] {
1694+ t.Fatalf("unpaged edits = %#v, want root and reply", page["edits"])
1695+ }
1696+}
1697+
1698+func TestRoomEditsKeepIndependentTargetsAndUseLastReference(t *testing.T) {
1699+ h := newRoomHarness(t)
1700+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, "")
1701+ first := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "first")
1702+ second := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "second")
1703+ wanted := h.must("alice", event.KIND_CONTENT_EDIT, [][]string{{"h", "general"}, {"e", first.ID}}, "first replacement")
1704+ var latest event.Event
1705+ for i := 0; i < 105; i++ {
1706+ // The first reference is context; only the last reference is edited.
1707+ latest = signedEvent(t, h.secrets["alice"], event.KIND_CONTENT_EDIT, first.CreatedAt+int64(i)+1, [][]string{{"h", "general"}, {"e", first.ID}, {"e", second.ID}}, "second replacement "+strconv.Itoa(i))
1708+ if err := publishAs(t, h.tenant, latest); err != nil {
1709+ t.Fatal(err)
1710+ }
1711+ }
1712+ edits, err := h.tenant.roomEdits(h.ctx, h.keys["alice"], "general", []event.Event{first, second})
1713+ if err != nil {
1714+ t.Fatal(err)
1715+ }
1716+ found := map[string]bool{}
1717+ for _, edit := range edits {
1718+ found[edit.ID] = true
1719+ }
1720+ if len(edits) != 2 || !found[wanted.ID] || !found[latest.ID] {
1721+ t.Fatalf("lost independent target edit: %+v", edits)
1722+ }
1723+}
1724diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go
1725index 535316e..f0d69ba 100644
1726--- a/internal/daemon/rooms_browse.go
1727+++ b/internal/daemon/rooms_browse.go
1728@@ -152,7 +152,11 @@ func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseReque
1729 if err != nil {
1730 return nil, err
1731 }
1732- return map[string]any{"room": summary, "members": members, "messages": messages, "next_cursor": next}, nil
1733+ edits, err := t.roomEdits(ctx, actor, room.ID, messages)
1734+ if err != nil {
1735+ return nil, err
1736+ }
1737+ return map[string]any{"room": summary, "members": members, "messages": messages, "edits": edits, "next_cursor": next}, nil
1738 }
1739
1740 func (t *Tenant) browseThread(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
1741@@ -178,7 +182,14 @@ func (t *Tenant) browseThread(ctx context.Context, actor string, q roomBrowseReq
1742 if err != nil {
1743 return nil, err
1744 }
1745- return map[string]any{"room": room, "root": roots[0], "replies": replies, "next_cursor": next}, nil
1746+ targets := make([]event.Event, 0, len(replies)+1)
1747+ targets = append(targets, roots[0])
1748+ targets = append(targets, replies...)
1749+ edits, err := t.roomEdits(ctx, actor, room.ID, targets)
1750+ if err != nil {
1751+ return nil, err
1752+ }
1753+ return map[string]any{"room": room, "root": roots[0], "replies": replies, "edits": edits, "next_cursor": next}, nil
1754 }
1755
1756 // roomMessages pages a room filter newest first through the read gate. The
1757@@ -206,3 +217,69 @@ func (t *Tenant) roomMessages(ctx context.Context, actor string, filter event.Fi
1758 }
1759 return rows, next, nil
1760 }
1761+
1762+// roomEdits returns the newest author-owned edit for each visible message.
1763+// It deliberately queries independently of the timeline cursor: an edit is
1764+// a replacement for its target and may have been published after the page
1765+// containing that target was read. A thread page has at most 100 replies
1766+// plus its root; each target contributes at most one replacement.
1767+func (t *Tenant) roomEdits(ctx context.Context, actor, roomID string, targets []event.Event) ([]event.Event, error) {
1768+ if len(targets) == 0 {
1769+ return []event.Event{}, nil
1770+ }
1771+ validTargets := make([]event.Event, 0, len(targets))
1772+ for _, target := range targets {
1773+ if len(target.ID) != 64 || !hexLower(target.ID) || target.PubKey == "" ||
1774+ (target.Kind != event.KIND_CHAT && target.Kind != event.KIND_THREAD && target.Kind != event.KIND_THREAD_REPLY && target.Kind != event.KIND_RICH_CONTENT) {
1775+ continue
1776+ }
1777+ validTargets = append(validTargets, target)
1778+ if len(validTargets) == 101 {
1779+ break
1780+ }
1781+ }
1782+ if len(validTargets) == 0 {
1783+ return []event.Event{}, nil
1784+ }
1785+ newest := make(map[string]event.Event, len(validTargets))
1786+ for _, target := range validTargets {
1787+ since := target.CreatedAt
1788+ filter := event.Filter{Kinds: []int{event.KIND_CONTENT_EDIT}, Authors: []string{target.PubKey}, Tags: map[string][]string{"h": {roomID}, "e": {target.ID}}, Since: &since}
1789+ var cursor *storage.EventCursor
1790+ for {
1791+ edits, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)
1792+ if err != nil {
1793+ return nil, err
1794+ }
1795+ for _, edit := range edits {
1796+ if lastRoomEditTarget(edit) == target.ID {
1797+ newest[target.ID] = edit
1798+ break
1799+ }
1800+ }
1801+ if _, found := newest[target.ID]; found || next == "" {
1802+ break
1803+ }
1804+ cursor, err = parseCollaborationCursor(next)
1805+ if err != nil {
1806+ return nil, err
1807+ }
1808+ }
1809+ }
1810+ result := make([]event.Event, 0, len(newest))
1811+ for _, edit := range newest {
1812+ result = append(result, edit)
1813+ }
1814+ sortEventsNewestFirst(result)
1815+ return result, nil
1816+}
1817+
1818+func lastRoomEditTarget(e event.Event) string {
1819+ target := ""
1820+ for _, tag := range e.Tags {
1821+ if len(tag) >= 2 && tag[0] == "e" {
1822+ target = tag[1]
1823+ }
1824+ }
1825+ return target
1826+}
1827diff --git a/internal/daemon/services.go b/internal/daemon/services.go
1828index d760074..9685f20 100644
1829--- a/internal/daemon/services.go
1830+++ b/internal/daemon/services.go
1831@@ -89,10 +89,20 @@ func (t *Tenant) initServices(ctx context.Context) error {
1832 if err != nil {
1833 return err
1834 }
1835+ if err := t.initRoomAttachments(ctx); err != nil {
1836+ return err
1837+ }
1838 t.community.ConfigurePolicy(t.Policy)
1839 t.sites, err = sites.New(sites.Config{
1840 Store: t.store,
1841 GetBlob: func(ctx context.Context, hash string) (sites.Blob, error) {
1842+ rooms, err := t.roomAttachmentRooms(ctx, hash)
1843+ if err != nil {
1844+ return sites.Blob{}, err
1845+ }
1846+ if len(rooms) > 0 {
1847+ return sites.Blob{}, errors.New("restricted: room attachment cannot be served by a site")
1848+ }
1849 entry, body, err := t.blobs.Get(ctx, hash)
1850 if err != nil {
1851 return sites.Blob{}, err
1852@@ -247,6 +257,7 @@ func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, err
1853 if action == blob.ActionGet || action == blob.ActionDelete || (action == blob.ActionUpload && (r.Method == http.MethodPut || r.Method == http.MethodPatch)) {
1854 path := strings.TrimPrefix(r.URL.Path, "/")
1855 path = strings.TrimPrefix(path, "nip96/")
1856+ path = strings.TrimPrefix(path, "media/")
1857 hash = strings.Split(path, ".")[0]
1858 if len(hash) != 64 || strings.Trim(hash, "0123456789abcdef") != "" {
1859 hash = ""
1860@@ -295,6 +306,11 @@ func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, err
1861 if err != nil {
1862 return "", err
1863 }
1864+ if action == blob.ActionGet {
1865+ if err := t.roomAttachmentAccess(r.Context(), pubkey, hash); err != nil {
1866+ return "", err
1867+ }
1868+ }
1869 if pubkey == "" {
1870 if action == blob.ActionGet {
1871 return "", nil
1872diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
1873index b8eb599..114456d 100644
1874--- a/internal/mcp/tools.go
1875+++ b/internal/mcp/tools.go
1876@@ -52,8 +52,10 @@ func (c Call) Int(key string) int {
1877
1878 // Content is one unstructured content block of a tool result.
1879 type Content struct {
1880- Type string `json:"type"`
1881- Text string `json:"text"`
1882+ Type string `json:"type"`
1883+ Text string `json:"text,omitempty"`
1884+ Data string `json:"data,omitempty"`
1885+ MimeType string `json:"mimeType,omitempty"`
1886 }
1887
1888 // Result is what a tool returns. StructuredContent is mirrored into a text
1889diff --git a/internal/webui/components.js b/internal/webui/components.js
1890index 1c50bb5..37c4a4f 100644
1891--- a/internal/webui/components.js
1892+++ b/internal/webui/components.js
1893@@ -845,6 +845,109 @@
1894 flush();
1895 return node;
1896 };
1897+ // NIP-92 references are matched outside code. Only standalone attachment
1898+ // lines are removed; prose links and examples keep their original text.
1899+ const attachmentURLPattern = /^https?:\/\/[^\s\x00-\x1f\x7f\\<>"']+$/;
1900+ const attachmentReference = /!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>"']+)/g;
1901+ const attachmentLines = (text, visit) => {
1902+ let marker = "";
1903+ return String(text || "").replace(/\r\n/g, "\n").split("\n").map(line => {
1904+ const trimmed = line.trim();
1905+ if (marker) {
1906+ if (trimmed.length >= marker.length && [...trimmed].every(c => c === marker[0])) marker = "";
1907+ return line;
1908+ }
1909+ const fence = trimmed.match(/^(`{3,}|~{3,})/);
1910+ if (fence) { marker = fence[1]; return line; }
1911+ return visit(line);
1912+ }).join("\n");
1913+ };
1914+ const attachmentOutsideCode = line => {
1915+ let out = "";
1916+ while (line.length) {
1917+ const start = line.indexOf("`");
1918+ if (start < 0) return out + line;
1919+ out += line.slice(0, start);
1920+ line = line.slice(start);
1921+ const delimiter = line.match(/^`+/)[0], end = line.indexOf(delimiter, delimiter.length);
1922+ if (end < 0) return out + line;
1923+ out += " ";
1924+ line = line.slice(end + delimiter.length);
1925+ }
1926+ return out;
1927+ };
1928+ const attachmentReferenceURL = match => match[2] || match[3].replace(/[.,;:!?)]+$/, "");
1929+ const attachmentURLVisible = (text, target) => {
1930+ let visible = false;
1931+ attachmentLines(text, line => {
1932+ for (const match of attachmentOutsideCode(line).matchAll(attachmentReference)) if (attachmentReferenceURL(match) === target) visible = true;
1933+ return line;
1934+ });
1935+ return visible;
1936+ };
1937+ const attachmentText = (text, limit) => [...String(text || "")].slice(0, limit).join("");
1938+ const roomAttachments = event => {
1939+ const out = [], seen = new Set(), content = String(event?.content || "");
1940+ for (const tag of (event?.tags || [])) {
1941+ if (!Array.isArray(tag) || tag[0] !== "imeta" || out.length >= 16) continue;
1942+ const item = {};
1943+ for (const field of tag.slice(1)) {
1944+ if (typeof field !== "string") continue;
1945+ const split = field.indexOf(" ");
1946+ if (split < 1) continue;
1947+ const key = field.slice(0, split), value = field.slice(split + 1);
1948+ if (!value) continue;
1949+ if (key === "url") item.url = value;
1950+ else if (key === "m") item.mime = value.toLowerCase();
1951+ else if (key === "x") item.hash = value;
1952+ else if (key === "size") item.size = /^-?\d+$/.test(value) ? Number(value) : 0;
1953+ else if (key === "filename" || key === "name") item.name = value;
1954+ else if (key === "alt") item.alt = value;
1955+ }
1956+ if (!attachmentURLPattern.test(item.url || "") || seen.has(item.url) || !attachmentURLVisible(content, item.url)) continue;
1957+ let parsed;
1958+ try { parsed = new URL(item.url); } catch { continue; }
1959+ if (!parsed.hostname || parsed.username || parsed.password) continue;
1960+ item.size = Number.isSafeInteger(item.size) && item.size > 0 && item.size <= 1099511627776 ? item.size : 0;
1961+ item.name = attachmentText(item.name, 255);
1962+ item.alt = attachmentText(item.alt, 500);
1963+ if (!item.name) item.name = item.alt;
1964+ if (!item.name) { try { item.name = decodeURIComponent(parsed.pathname.split("/").pop() || "Attachment"); } catch { item.name = "Attachment"; } }
1965+ seen.add(item.url);
1966+ out.push(item);
1967+ }
1968+ return out;
1969+ };
1970+ const attachmentContent = (text, items) => {
1971+ const urls = new Set(items.map(item => item.url));
1972+ return attachmentLines(text, line => {
1973+ const trimmed = line.trim(), match = [...trimmed.matchAll(attachmentReference)][0];
1974+ return match && match[0] === trimmed && urls.has(attachmentReferenceURL(match)) ? "" : line;
1975+ }).trim();
1976+ };
1977+ const attachmentSize = size => size < 1024 ? size + " B" : size < 1048576 ? Math.floor(size / 1024) + " KB" : Math.floor(size / 1048576) + " MB";
1978+ const attachmentNodes = items => {
1979+ if (!items.length) return null;
1980+ const box = el("div"); box.dataset.roomAttachments = "";
1981+ for (const item of items) {
1982+ const figure = el("figure"), name = item.name || "Attachment";
1983+ if (item.mime?.startsWith("image/") && item.mime !== "image/svg+xml") {
1984+ const link = el("a"), image = el("img");
1985+ link.href = item.url; link.rel = "noopener noreferrer"; link.setAttribute("fx-ignore", "");
1986+ image.src = item.url; image.alt = item.alt || name; image.loading = "lazy"; image.referrerPolicy = "no-referrer";
1987+ link.append(image); figure.append(link);
1988+ } else if (item.mime?.startsWith("video/") || item.mime?.startsWith("audio/")) {
1989+ const media = el(item.mime.startsWith("video/") ? "video" : "audio");
1990+ media.controls = true; media.preload = "none"; media.src = item.url; figure.append(media);
1991+ }
1992+ const caption = el("figcaption"), link = el("a", name);
1993+ link.href = item.url; link.download = name; link.dataset.download = ""; link.rel = "noopener noreferrer"; link.setAttribute("fx-ignore", "");
1994+ caption.append(link);
1995+ if (item.size > 0) caption.append(" ", el("small", attachmentSize(item.size)));
1996+ figure.append(caption); box.append(figure);
1997+ }
1998+ return box;
1999+ };
2000 const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };
2001 // nameNode shows a person: the vendored nostr-name element replaces the short
2002 // id with the profile name published on this relay.
2003@@ -865,6 +968,7 @@
2004 node.id = "msg-" + event.id;
2005 node.dataset.id = event.id;
2006 node.dataset.kind = String(event.kind);
2007+ node.dataset.updatedAt = String(event.created_at);
2008 node.dataset.pubkey = pubkey;
2009 const member = members[pubkey];
2010 if (member?.agent) node.dataset.agent = "";
2011@@ -875,8 +979,10 @@
2012 time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";
2013 small.append(time);
2014 header.append(name, member?.role ? " | " + member.role : "", small);
2015- const body = notice ? el("p", notice) : chatMarkdown(el("div"), event.content || "");
2016+ const attachments = notice ? [] : roomAttachments(event);
2017+ const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));
2018 node.append(header, body);
2019+ const media = attachmentNodes(attachments); if (media) node.append(media);
2020 const footer = el("footer");
2021 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);
2022 if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); }
2023@@ -934,9 +1040,15 @@
2024 const targets = (event.tags || []).filter(tag => tag[0] === "e");
2025 const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);
2026 if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;
2027+ const updatedAt = Number(event.created_at);
2028+ if (!Number.isFinite(updatedAt) || updatedAt < Number(target.dataset.updatedAt || 0)) return;
2029 const body = target.querySelector(":scope > div");
2030 if (!body) return;
2031- body.replaceWith(chatMarkdown(el("div"), event.content || ""));
2032+ target.dataset.updatedAt = String(updatedAt);
2033+ const attachments = roomAttachments(event);
2034+ body.replaceWith(chatMarkdown(el("div"), attachmentContent(event.content || "", attachments)));
2035+ target.querySelector(":scope > [data-room-attachments]")?.remove();
2036+ const media = attachmentNodes(attachments); if (media) target.insertBefore(media, target.querySelector(":scope > footer"));
2037 if (target.dataset.edited !== undefined) return;
2038 target.dataset.edited = "";
2039 let footer = target.querySelector(":scope > footer");
2040@@ -947,13 +1059,14 @@
2041 };
2042 // signAndPublish signs one room event, checks it came back unchanged and
2043 // valid, and publishes it once.
2044- const signAndPublish = async unsigned => {
2045+ const signAndPublish = async (unsigned, signal) => {
2046 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
2047 const expected = JSON.stringify(unsigned);
2048 const event = await window.nostr.signEvent(JSON.parse(expected));
2049 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
2050 if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");
2051- const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
2052+ if (signal?.aborted) throw Error("Sending canceled.");
2053+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json", signal});
2054 const result = await response.json();
2055 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
2056 return event;
2057@@ -971,9 +1084,79 @@
2058 event.preventDefault();
2059 this.form?.requestSubmit();
2060 });
2061+ this.addEventListener("change", event => {
2062+ if (event.target.name !== "attachments") return;
2063+ this.chooseFiles(event.target.files);
2064+ event.target.value = "";
2065+ });
2066+ this.addEventListener("paste", event => {
2067+ if (!this.querySelector("room-files")) return;
2068+ const files = [...(event.clipboardData?.files || [])];
2069+ if (!files.length) return;
2070+ event.preventDefault();
2071+ this.chooseFiles(files);
2072+ });
2073+ this.addEventListener("dragover", event => {
2074+ if (!this.querySelector("room-files") || ![...(event.dataTransfer?.types || [])].includes("Files")) return;
2075+ event.preventDefault();
2076+ if (!this.submitting) this.dataset.over = "";
2077+ });
2078+ this.addEventListener("dragleave", () => { delete this.dataset.over; });
2079+ this.addEventListener("drop", event => {
2080+ delete this.dataset.over;
2081+ if (!this.querySelector("room-files") || !event.dataTransfer?.files?.length) return;
2082+ event.preventDefault();
2083+ this.chooseFiles(event.dataTransfer.files);
2084+ });
2085+ this.renderFiles();
2086 }
2087
2088- event(content) {
2089+ disconnectedCallback() { this.uploadController?.abort(); }
2090+
2091+ busy(on) {
2092+ super.busy(on);
2093+ this.form?.querySelectorAll("input, textarea").forEach(input => { input.disabled = on; });
2094+ }
2095+
2096+ chooseFiles(files) {
2097+ try { this.addFiles(files); this.report(""); }
2098+ catch (error) { this.report(error.message, true); }
2099+ }
2100+
2101+ addFiles(files) {
2102+ if (this.submitting) return;
2103+ const chosen = [...files];
2104+ const pending = this.pendingFiles || [];
2105+ if (pending.length + chosen.length > 8) throw Error("Attach up to 8 files per message.");
2106+ if (chosen.some(file => file.size === 0 || file.size > 32 * 1024 * 1024)) throw Error("Each attachment must be between 1 byte and 32 MiB.");
2107+ this.pendingFiles = [...pending, ...chosen.map(file => ({file}))];
2108+ this.renderFiles();
2109+ }
2110+
2111+ renderFiles() {
2112+ const pending = this.pendingFiles || [];
2113+ const content = this.form?.elements?.content;
2114+ if (content) content.required = pending.length === 0;
2115+ const target = this.querySelector("room-files");
2116+ if (!target) return;
2117+ const list = el("ul");
2118+ pending.forEach((entry, index) => {
2119+ const row = el("li"), remove = el("button", "Remove");
2120+ remove.type = "button";
2121+ remove.disabled = Boolean(this.submitting);
2122+ remove.setAttribute("aria-label", "Remove " + entry.file.name);
2123+ remove.addEventListener("click", () => {
2124+ if (this.submitting) return;
2125+ this.pendingFiles.splice(index, 1);
2126+ this.renderFiles();
2127+ });
2128+ row.append(el("span", entry.file.name), " ", el("small", entry.descriptor ? "uploaded" : Math.ceil(entry.file.size / 1024) + " KB"), " ", remove);
2129+ list.append(row);
2130+ });
2131+ target.replaceChildren(...(pending.length ? [list] : []));
2132+ }
2133+
2134+ event(content, attachments = []) {
2135 const room = this.getAttribute("room") || "";
2136 if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");
2137 const kind = Number(this.getAttribute("kind") || 9);
2138@@ -986,17 +1169,60 @@
2139 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);
2140 }
2141 roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });
2142+ const lines = [];
2143+ attachments.forEach(file => {
2144+ tags.push(["imeta", "url " + file.url, "m " + file.type, "x " + file.sha256, "size " + file.size, "filename " + file.filename]);
2145+ const label = file.filename.replace(/[\\[\]]/g, "\\$&");
2146+ lines.push(file.type.startsWith("image/") ? `![image](${file.url})` : file.type.startsWith("video/") ? `![video](${file.url})` : `[${label}](${file.url})`);
2147+ });
2148+ content = [content, lines.join("\n")].filter(Boolean).join("\n\n");
2149 return {kind, created_at: now(), tags, content};
2150 }
2151
2152+ async uploadFiles(signal) {
2153+ const pending = this.pendingFiles || [];
2154+ for (const [index, entry] of pending.entries()) {
2155+ if (signal.aborted) throw Error("Upload canceled.");
2156+ if (entry.descriptor) continue;
2157+ const file = entry.file;
2158+ this.report("Uploading " + file.name + " (" + (index + 1) + " of " + pending.length + ")…");
2159+ const bytes = new Uint8Array(await file.arrayBuffer());
2160+ const hash = await tiny.sha256hex(bytes);
2161+ const filename = Array.from(file.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim()).slice(0, 255).join("") || "file";
2162+ const path = "/rooms/" + encodeURIComponent(this.getAttribute("room")) + "/attachments?filename=" + encodeURIComponent(filename);
2163+ const response = await tiny.signedFetch(path, "PUT", bytes, {contentType: file.type || "application/octet-stream", signal});
2164+ if (signal.aborted) throw Error("Upload canceled.");
2165+ const descriptor = await response.json();
2166+ if (descriptor.sha256 !== hash || descriptor.size !== bytes.byteLength) throw Error("The upload descriptor has an unexpected hash or size.");
2167+ let url;
2168+ try { url = new URL(descriptor.url); } catch { throw Error("The upload descriptor has an invalid URL."); }
2169+ const prefix = tiny.localPath("/media/" + hash);
2170+ if (url.origin !== new URL(location.href).origin || url.username || url.password || url.search || url.hash || !(url.pathname === prefix || new RegExp("^" + prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\.[a-z0-9]{1,16}$").test(url.pathname))) throw Error("The upload descriptor has an unexpected URL.");
2171+ if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(descriptor.type || "")) throw Error("The upload descriptor has an invalid media type.");
2172+ entry.descriptor = {...descriptor, filename};
2173+ this.renderFiles();
2174+ }
2175+ return pending.map(entry => entry.descriptor);
2176+ }
2177+
2178 async submit(form) {
2179 const content = (form.elements.content?.value || "").trim();
2180- if (!content) return;
2181- this.report("Signing…");
2182- const event = await signAndPublish(this.event(content));
2183- form.reset();
2184- this.report("");
2185- roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});
2186+ if (!content && !this.pendingFiles?.length) return;
2187+ this.event(content);
2188+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
2189+ const controller = new AbortController();
2190+ this.uploadController = controller;
2191+ try {
2192+ const files = await this.uploadFiles(controller.signal);
2193+ if (controller.signal.aborted) throw Error("Upload canceled.");
2194+ this.report("Signing…");
2195+ const event = await signAndPublish(this.event(content, files), controller.signal);
2196+ form.reset();
2197+ this.pendingFiles = [];
2198+ this.renderFiles();
2199+ this.report("");
2200+ roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});
2201+ } finally { this.uploadController = null; }
2202 }
2203 }
2204
2205@@ -1113,7 +1339,7 @@
2206 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});
2207 }
2208 }
2209- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown});
2210+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});
2211
2212 // JsonView renders any JSON value. Arrays of objects become tables, objects
2213 // become definition lists, scalar arrays become lists, and deep nesting
2214diff --git a/internal/webui/room_attachments.go b/internal/webui/room_attachments.go
2215new file mode 100644
2216index 0000000..fecf025
2217--- /dev/null
2218+++ b/internal/webui/room_attachments.go
2219@@ -0,0 +1,246 @@
2220+package webui
2221+
2222+import (
2223+ "encoding/json"
2224+ "html/template"
2225+ "net/url"
2226+ "path"
2227+
2228+ "regexp"
2229+ "strconv"
2230+ "strings"
2231+
2232+ "github.com/FelineStateMachine/tinyrelay/internal/views"
2233+)
2234+
2235+const maxRoomAttachments = 16
2236+
2237+type roomAttachment struct {
2238+ URL, MIME, Hash, Name, Alt string
2239+ Size int64
2240+}
2241+
2242+var roomAttachmentURL = regexp.MustCompile(`^(https?://[^\s\x00-\x1f\x7f\\<>"']+)$`)
2243+var roomAttachmentReference = regexp.MustCompile(`!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?://[^\s<>"']+)`)
2244+
2245+func roomAttachments(value any) []roomAttachment {
2246+ content := plainString(valueMap(value)["content"])
2247+ items := roomAttachmentsFromTags(roomTags(value))
2248+ out := items[:0]
2249+ seen := map[string]bool{}
2250+ for _, item := range items {
2251+ if seen[item.URL] || !roomAttachmentURLVisible(content, item.URL) {
2252+ continue
2253+ }
2254+ seen[item.URL] = true
2255+ out = append(out, item)
2256+ }
2257+ return out
2258+}
2259+
2260+func roomAttachmentsFromTags(tags [][]string) []roomAttachment {
2261+ var out []roomAttachment
2262+ for _, tag := range tags {
2263+ if len(tag) < 2 || tag[0] != "imeta" || len(out) >= maxRoomAttachments {
2264+ continue
2265+ }
2266+ item := roomAttachment{}
2267+ for _, field := range tag[1:] {
2268+ key, val, ok := strings.Cut(field, " ")
2269+ if !ok || val == "" {
2270+ continue
2271+ }
2272+ switch key {
2273+ case "url":
2274+ item.URL = val
2275+ case "m":
2276+ item.MIME = strings.ToLower(val)
2277+ case "x":
2278+ item.Hash = val
2279+ case "size":
2280+ item.Size, _ = strconv.ParseInt(val, 10, 64)
2281+ case "filename", "name":
2282+ item.Name = val
2283+ case "alt":
2284+ item.Alt = val
2285+ }
2286+ }
2287+ if !roomAttachmentURL.MatchString(item.URL) || !safeRoomAttachmentURL(item.URL) {
2288+ continue
2289+ }
2290+ if item.Size < 0 || item.Size > 1<<40 {
2291+ item.Size = 0
2292+ }
2293+ item.Name = boundedAttachmentText(item.Name, 255)
2294+ item.Alt = boundedAttachmentText(item.Alt, 500)
2295+ if item.Name == "" {
2296+ item.Name = item.Alt
2297+ }
2298+ if item.Name == "" {
2299+ u, _ := url.Parse(item.URL)
2300+ item.Name = path.Base(u.Path)
2301+ if item.Name == "." || item.Name == "/" {
2302+ item.Name = "Attachment"
2303+ }
2304+ }
2305+ out = append(out, item)
2306+ }
2307+ return out
2308+}
2309+
2310+func roomTags(value any) [][]string {
2311+ var tags [][]string
2312+ encoded, err := json.Marshal(valueMap(value)["tags"])
2313+ if err == nil {
2314+ _ = json.Unmarshal(encoded, &tags)
2315+ }
2316+ return tags
2317+}
2318+
2319+func safeRoomAttachmentURL(raw string) bool {
2320+ u, err := url.Parse(raw)
2321+ return err == nil && u.User == nil && u.Hostname() != "" && (u.Scheme == "http" || u.Scheme == "https")
2322+}
2323+
2324+// attachmentLines visits only lines outside fenced code, retaining original
2325+// lines so removing a standalone reference cannot rewrite prose or examples.
2326+func attachmentLines(content string, visit func(string) string) string {
2327+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
2328+ marker := ""
2329+ for i, line := range lines {
2330+ if marker != "" {
2331+ if views.ClosesFence(line, marker) {
2332+ marker = ""
2333+ }
2334+ continue
2335+ }
2336+ if next, _, ok := views.OpenFence(line); ok {
2337+ marker = next
2338+ continue
2339+ }
2340+ lines[i] = visit(line)
2341+ }
2342+ return strings.Join(lines, "\n")
2343+}
2344+
2345+func attachmentOutsideCode(line string) string {
2346+ var out strings.Builder
2347+ for len(line) > 0 {
2348+ start := strings.IndexByte(line, '`')
2349+ if start < 0 {
2350+ out.WriteString(line)
2351+ break
2352+ }
2353+ out.WriteString(line[:start])
2354+ line = line[start:]
2355+ n := len(line) - len(strings.TrimLeft(line, "`"))
2356+ end := strings.Index(line[n:], line[:n])
2357+ if end < 0 {
2358+ out.WriteString(line)
2359+ break
2360+ }
2361+ out.WriteByte(' ')
2362+ line = line[n+end+n:]
2363+ }
2364+ return out.String()
2365+}
2366+
2367+func attachmentReferenceURL(match []string) string {
2368+ if match[2] != "" {
2369+ return match[2]
2370+ }
2371+ return strings.TrimRight(match[3], ".,;:!?)")
2372+}
2373+
2374+func roomAttachmentContent(content string, items []roomAttachment) string {
2375+ urls := make(map[string]bool, len(items))
2376+ for _, item := range items {
2377+ urls[item.URL] = true
2378+ }
2379+ return strings.TrimSpace(attachmentLines(content, func(line string) string {
2380+ trimmed := strings.TrimSpace(line)
2381+ match := roomAttachmentReference.FindStringSubmatch(trimmed)
2382+ if match != nil && match[0] == trimmed && urls[attachmentReferenceURL(match)] {
2383+ return ""
2384+ }
2385+ return line
2386+ }))
2387+}
2388+
2389+func roomAttachmentURLVisible(content, target string) bool {
2390+ visible := false
2391+ attachmentLines(content, func(line string) string {
2392+ for _, match := range roomAttachmentReference.FindAllStringSubmatch(attachmentOutsideCode(line), -1) {
2393+ if attachmentReferenceURL(match) == target {
2394+ visible = true
2395+ }
2396+ }
2397+ return line
2398+ })
2399+ return visible
2400+}
2401+
2402+func boundedAttachmentText(text string, limit int) string {
2403+ runes := []rune(text)
2404+ if len(runes) > limit {
2405+ runes = runes[:limit]
2406+ }
2407+ return string(runes)
2408+}
2409+
2410+func roomAttachmentLabel(item roomAttachment) string {
2411+ if item.Alt != "" {
2412+ return item.Alt
2413+ }
2414+ return item.Name
2415+}
2416+
2417+func roomAttachmentMarkup(items []roomAttachment) template.HTML {
2418+ if len(items) == 0 {
2419+ return ""
2420+ }
2421+ var out strings.Builder
2422+ out.WriteString(`<div data-room-attachments>`)
2423+ for _, item := range items {
2424+ out.WriteString(roomAttachmentFigure(item))
2425+ }
2426+ out.WriteString(`</div>`)
2427+ return template.HTML(out.String())
2428+}
2429+
2430+func roomAttachmentFigure(item roomAttachment) string {
2431+ href := template.HTMLEscapeString(item.URL)
2432+ name := template.HTMLEscapeString(item.Name)
2433+ if name == "" {
2434+ name = "Attachment"
2435+ }
2436+ alt := template.HTMLEscapeString(roomAttachmentLabel(item))
2437+ var out strings.Builder
2438+ out.WriteString(`<figure>`)
2439+ switch {
2440+ case strings.HasPrefix(item.MIME, "image/") && item.MIME != "image/svg+xml":
2441+ out.WriteString(`<a href="` + href + `" rel="noopener noreferrer" fx-ignore><img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer"></a>`)
2442+ case strings.HasPrefix(item.MIME, "video/") || strings.HasPrefix(item.MIME, "audio/"):
2443+ kind := "audio"
2444+ if strings.HasPrefix(item.MIME, "video/") {
2445+ kind = "video"
2446+ }
2447+ out.WriteString(`<` + kind + ` controls preload="none" src="` + href + `"></` + kind + `>`)
2448+ }
2449+ out.WriteString(`<figcaption><a data-download href="` + href + `" download="` + name + `" rel="noopener noreferrer" fx-ignore>` + name + `</a>`)
2450+ if item.Size > 0 {
2451+ out.WriteString(` <small>` + roomFileSize(item.Size) + `</small>`)
2452+ }
2453+ out.WriteString(`</figcaption></figure>`)
2454+ return out.String()
2455+}
2456+
2457+func roomFileSize(size int64) string {
2458+ if size < 1024 {
2459+ return strconv.FormatInt(size, 10) + " B"
2460+ }
2461+ if size < 1<<20 {
2462+ return strconv.FormatInt(size/1024, 10) + " KB"
2463+ }
2464+ return strconv.FormatInt(size/(1<<20), 10) + " MB"
2465+}
2466diff --git a/internal/webui/room_attachments_integration_test.go b/internal/webui/room_attachments_integration_test.go
2467new file mode 100644
2468index 0000000..dfc7507
2469--- /dev/null
2470+++ b/internal/webui/room_attachments_integration_test.go
2471@@ -0,0 +1,62 @@
2472+package webui
2473+
2474+import (
2475+ "strings"
2476+ "testing"
2477+)
2478+
2479+func TestRoomAttachmentRenderingIntegration(t *testing.T) {
2480+ url := "https://cdn.example.test/report.pdf"
2481+ row := map[string]any{"content": "See [report](" + url + ") and keep this prose.", "tags": [][]string{{"imeta", "url " + url, "m application/pdf", "filename report.pdf"}}}
2482+ items := roomAttachments(row)
2483+ if len(items) != 1 {
2484+ t.Fatalf("items = %#v", items)
2485+ }
2486+ if got := roomAttachmentContent(row["content"].(string), items); got != row["content"].(string) {
2487+ t.Fatalf("generic markdown changed: %q", got)
2488+ }
2489+ markup := string(roomAttachmentMarkup(items))
2490+ if strings.Contains(markup, "[report]()") || !strings.Contains(markup, "report.pdf") {
2491+ t.Fatalf("generic attachment markup: %s", markup)
2492+ }
2493+}
2494+
2495+func TestRoomAttachmentVisibleURLMatchingIsExactAndSafe(t *testing.T) {
2496+ url := "https://cdn.example.test/photo.png"
2497+ base := [][]string{{"imeta", "url " + url, "m image/png"}}
2498+ for name, content := range map[string]string{
2499+ "prefix": url + "-backup",
2500+ "fenced": "```\n" + url + "\n```",
2501+ "inline": "`" + url + "`",
2502+ } {
2503+ if got := roomAttachments(map[string]any{"content": content, "tags": base}); len(got) != 0 {
2504+ t.Errorf("%s matched %#v", name, got)
2505+ }
2506+ }
2507+ for _, unsafe := range []string{"javascript:alert(1)", "data:image/png,abc", "//cdn.example/photo.png", "/media/" + strings.Repeat("a", 64)} {
2508+ if got := roomAttachments(map[string]any{"content": unsafe, "tags": [][]string{{"imeta", "url " + unsafe, "m image/png"}}}); len(got) != 0 {
2509+ t.Errorf("unsafe URL %q accepted", unsafe)
2510+ }
2511+ }
2512+}
2513+
2514+func TestRoomAttachmentEditAndThreadRootReplacement(t *testing.T) {
2515+ rootID := strings.Repeat("1", 64)
2516+ oldURL := "https://cdn.example.test/old.png"
2517+ newURL := "https://cdn.example.test/new.mp4"
2518+ root := map[string]any{"id": rootID, "pubkey": strings.Repeat("a", 64), "kind": 11, "created_at": int64(10), "content": oldURL, "tags": [][]string{{"imeta", "url " + oldURL, "m image/png"}}}
2519+ edit := map[string]any{"id": strings.Repeat("2", 64), "pubkey": root["pubkey"], "kind": 40003, "created_at": int64(11), "content": newURL, "tags": [][]string{{"e", rootID}, {"imeta", "url " + newURL, "m video/mp4"}}}
2520+ value := map[string]any{"room": map[string]any{"id": "general"}, "root": root, "replies": []any{edit}}
2521+ item := roomRoot(value)
2522+ if !item.Edited || item.Content != newURL || len(item.Attachments) != 1 || item.Attachments[0].URL != newURL {
2523+ t.Fatalf("root edit = %#v", item)
2524+ }
2525+}
2526+
2527+func TestRoomAttachmentMediaMarkupHasFallbackAndBounds(t *testing.T) {
2528+ items := []roomAttachment{{URL: "https://cdn.example.test/a.mp4", MIME: "video/mp4", Name: "clip.mp4"}, {URL: "https://cdn.example.test/a.mp3", MIME: "audio/mpeg", Name: "audio.mp3"}}
2529+ markup := string(roomAttachmentMarkup(items))
2530+ if !strings.Contains(markup, "clip.mp4") || !strings.Contains(markup, "audio.mp3") {
2531+ t.Fatalf("media fallback missing: %s", markup)
2532+ }
2533+}
2534diff --git a/internal/webui/room_attachments_test.go b/internal/webui/room_attachments_test.go
2535new file mode 100644
2536index 0000000..dbd4df7
2537--- /dev/null
2538+++ b/internal/webui/room_attachments_test.go
2539@@ -0,0 +1,52 @@
2540+package webui
2541+
2542+import (
2543+ "strings"
2544+ "testing"
2545+)
2546+
2547+func TestRoomAttachmentsParsesNIP92AndStripsReferences(t *testing.T) {
2548+ url := "https://cdn.example.test/photo.png"
2549+ row := map[string]any{"content": "Look ![photo](" + url + ") and " + url, "tags": [][]string{{"imeta", "url " + url, "m image/png", "x " + strings.Repeat("a", 64), "size 42", "alt a <photo>"}}}
2550+ got := roomAttachments(row)
2551+ if len(got) != 1 || got[0].URL != url || got[0].MIME != "image/png" || got[0].Hash != strings.Repeat("a", 64) || got[0].Size != 42 || got[0].Name != "a <photo>" {
2552+ t.Fatalf("attachments = %#v", got)
2553+ }
2554+ if text := roomAttachmentContent(valueMap(row)["content"].(string), got); text != valueMap(row)["content"].(string) {
2555+ t.Fatalf("content = %q", text)
2556+ }
2557+}
2558+
2559+func TestRoomAttachmentsRejectUnsafeAndBoundValues(t *testing.T) {
2560+ long := strings.Repeat("x", 5000)
2561+ row := map[string]any{"content": "https://cdn.example/file", "tags": [][]string{{"imeta", "url javascript:alert(1)", "m image/svg+xml"}, {"imeta", "url data:image/png;base64,abc", "m image/png"}, {"imeta", "url https://cdn.example/file", "m application/octet-stream", "filename " + long}}}
2562+ got := roomAttachments(row)
2563+ if len(got) != 1 || got[0].URL != "https://cdn.example/file" {
2564+ t.Fatalf("unsafe attachments = %#v", got)
2565+ }
2566+}
2567+
2568+func TestRoomAttachmentsIgnoreCodeAndDeduplicate(t *testing.T) {
2569+ url := "https://cdn.example.test/photo.png"
2570+ row := map[string]any{"content": "`" + url + "`\n" + url, "tags": [][]string{{"imeta", "url " + url, "m image/png"}, {"imeta", "url " + url, "m image/png"}}}
2571+ if got := roomAttachments(row); len(got) != 1 {
2572+ t.Fatalf("attachments = %#v", got)
2573+ }
2574+ row["content"] = "```\n" + url + "\n```"
2575+ if got := roomAttachments(row); len(got) != 0 {
2576+ t.Fatalf("code attachment = %#v", got)
2577+ }
2578+}
2579+
2580+func TestRoomAttachmentMarkupEscapesAndClassifiesMedia(t *testing.T) {
2581+ items := []roomAttachment{{URL: "https://cdn.example/a.mp4?a=1&b=2", MIME: "video/mp4", Name: "A \"clip\""}, {URL: "https://cdn.example/report.pdf", MIME: "application/pdf", Name: "report.pdf", Size: 2048}}
2582+ markup := string(roomAttachmentMarkup(items))
2583+ for _, want := range []string{"room-attachments", "<video", "controls", "report.pdf", "download", "2 KB"} {
2584+ if !strings.Contains(markup, want) {
2585+ t.Errorf("markup missing %q: %s", want, markup)
2586+ }
2587+ }
2588+ if strings.Contains(markup, `class=`) || strings.Contains(markup, `javascript:`) {
2589+ t.Fatalf("unsafe markup: %s", markup)
2590+ }
2591+}
2592diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
2593index ce6e198..a7ebd99 100644
2594--- a/internal/webui/rooms.go
2595+++ b/internal/webui/rooms.go
2596@@ -104,6 +104,7 @@ type reaction struct {
2597 type roomEdit struct {
2598 PubKey, Content string
2599 CreatedAt int64
2600+ Tags [][]string
2601 }
2602
2603 // editSummary maps message ids to the newest edit (kind 40003, Buzz's
2604@@ -111,7 +112,7 @@ type roomEdit struct {
2605 // replies, so a stranger's edit never shadows the author's own.
2606 func editSummary(value any) map[string]map[string]roomEdit {
2607 edits := map[string]map[string]roomEdit{}
2608- for _, field := range []string{"messages", "replies"} {
2609+ for _, field := range []string{"messages", "replies", "edits"} {
2610 for _, row := range roomSlice(value, field) {
2611 e := valueMap(row)
2612 if plainString(e["kind"]) != "40003" {
2613@@ -122,7 +123,7 @@ func editSummary(value any) map[string]map[string]roomEdit {
2614 continue
2615 }
2616 target := targets[len(targets)-1]
2617- edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"])}
2618+ edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"]), Tags: roomTags(row)}
2619 if edits[target] == nil {
2620 edits[target] = map[string]roomEdit{}
2621 }
2622@@ -248,11 +249,12 @@ func clock(value any) string {
2623 // author's markers, the thread state and the reactions it has received.
2624 type roomItem struct {
2625 ID, PubKey, Kind, Content, Room, Root, Role, Notice string
2626- CreatedAt int64
2627+ CreatedAt, UpdatedAt int64
2628 Agent, InThread, Edited bool
2629 Mentions []string
2630 Replies int
2631 Reactions []reaction
2632+ Attachments []roomAttachment
2633 }
2634
2635 // roomItems turns a browse result's messages or replies into view items,
2636@@ -273,7 +275,8 @@ func roomItems(value any, field string) []roomItem {
2637 }
2638 // Only the author edits their own message, and only forward in time.
2639 if edit, found := edits[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {
2640- item.Content, item.Edited = edit.Content, true
2641+ item.Content, item.Attachments, item.Edited = edit.Content, roomAttachments(map[string]any{"content": edit.Content, "tags": edit.Tags}), true
2642+ item.UpdatedAt = edit.CreatedAt
2643 }
2644 item.Replies = replies[item.ID]
2645 item.Reactions = reactions[item.ID]
2646@@ -287,6 +290,10 @@ func roomItems(value any, field string) []roomItem {
2647 func roomRoot(value any) roomItem {
2648 data := valueMap(value)
2649 item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))
2650+ if edit, found := editSummary(value)[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {
2651+ item.Content, item.Attachments, item.Edited = edit.Content, roomAttachments(map[string]any{"content": edit.Content, "tags": edit.Tags}), true
2652+ item.UpdatedAt = edit.CreatedAt
2653+ }
2654 item.Replies = len(roomSlice(value, "replies"))
2655 item.InThread = true
2656 return item
2657@@ -295,6 +302,8 @@ func roomRoot(value any) roomItem {
2658 func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) {
2659 e := valueMap(row)
2660 item := roomItem{ID: plainString(e["id"]), PubKey: plainString(e["pubkey"]), Kind: plainString(e["kind"]), Content: plainString(e["content"]), Room: room, CreatedAt: unixSeconds(e["created_at"])}
2661+ item.UpdatedAt = item.CreatedAt
2662+ item.Attachments = roomAttachments(row)
2663 switch item.Kind {
2664 case "7", "40003":
2665 return roomItem{}, false
2666diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
2667index e8bd029..02ebe73 100644
2668--- a/internal/webui/rooms.html
2669+++ b/internal/webui/rooms.html
2670@@ -31,7 +31,7 @@
2671 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}
2672 </div>
2673 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>
2674-{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}"><textarea name="content" rows="2" placeholder="Message #{{$id}}. Mention with @npub or @hex." required></textarea><p><button>Send</button></p></room-compose>
2675+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}"><textarea name="content" rows="2" placeholder="Message #{{$id}}. Mention with @npub or @hex." required></textarea>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<p><button>Send</button></p></room-compose>
2676 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>
2677 {{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>
2678 {{end}}{{end}}</section>
2679@@ -50,15 +50,17 @@
2680 </div>
2681 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}
2682 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>
2683-{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}"><textarea name="content" rows="2" placeholder="Reply in this thread" required></textarea><p><button>Reply</button></p></room-compose>
2684+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}"><textarea name="content" rows="2" placeholder="Reply in this thread" required></textarea>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<p><button>Reply</button></p></room-compose>
2685 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>
2686 {{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>
2687 {{end}}{{end}}</section>
2688 {{end}}{{end}}
2689
2690+{{define "roomFilePicker"}}<label>Attach files <input type="file" name="attachments" multiple></label><small>Paste or drop files. Up to 8 files, 32 MiB each.</small><room-files aria-live="polite"></room-files>{{end}}
2691+
2692 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
2693-{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}>
2694+{{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}>
2695 <header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
2696-{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown .Content}}</div>{{end}}
2697+{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}
2698 {{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}
2699 </room-message>{{end}}
2700diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
2701index f2bad6f..0ce88fe 100644
2702--- a/internal/webui/rooms_test.go
2703+++ b/internal/webui/rooms_test.go
2704@@ -142,7 +142,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
2705 for _, want := range []string{
2706 `<h1>General <small>open room</small></h1>`, `<p>Where General happens.</p>`,
2707 `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`,
2708- `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-pubkey="` + roomAgent + `" data-agent data-edited>`,
2709+ `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-updated-at="1757203650" data-pubkey="` + roomAgent + `" data-agent data-edited>`,
2710 `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,
2711 `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,
2712 `&lt;script&gt;alert(1)&lt;/script&gt;`,
2713diff --git a/internal/webui/style.css b/internal/webui/style.css
2714index da26a8d..b72fcab 100644
2715--- a/internal/webui/style.css
2716+++ b/internal/webui/style.css
2717@@ -287,6 +287,7 @@ room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wra
2718 room-message>p a,room-message>div a{color:var(--accent)}
2719 room-message>div p{margin:0 0 6px}room-message>div>:last-child{margin-bottom:0}room-message>div ul,room-message>div ol{margin:0 0 6px 20px;padding:0}
2720 room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);border:1px solid var(--line);overflow:auto;font:12px/1.4 var(--mono);white-space:pre}room-message>div code{font:12px var(--mono)}room-message>div h1,room-message>div h2,room-message>div h3,room-message>div h4{margin:4px 0 6px;font-size:14px;border:0}
2721+room-message>[data-room-attachments]{margin-top:8px}room-message>[data-room-attachments] figure{display:inline-block;vertical-align:top;margin:0 12px 12px 0;max-width:100%;min-width:0}room-message>[data-room-attachments] img{display:block;width:auto;max-width:100%;max-height:360px;border:1px solid var(--line);object-fit:contain}room-message>[data-room-attachments] video{display:block;width:420px;max-width:100%;max-height:360px}room-message>[data-room-attachments] audio{display:block;width:360px;max-width:100%}room-message>[data-room-attachments] figcaption{margin-top:4px;font:12px var(--mono);overflow-wrap:anywhere}room-message>[data-room-attachments] a[data-download]{color:var(--accent)}room-message>[data-room-attachments] small{color:var(--muted);font:11px var(--mono)}
2722 room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}
2723 room-message>footer>*+*::before{content:" | "}
2724 room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}
2725@@ -295,6 +296,8 @@ room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:righ
2726 room-compose{display:block;position:sticky;bottom:0;margin:8px -22px 0;padding:10px 22px 8px;background:var(--paper);border-top:1px solid var(--line)}
2727 room-compose textarea{min-height:3.2em;width:100%;font:14px var(--sans);max-width:720px;display:block}
2728 room-compose p{margin:6px 0 0}room-compose button{margin-right:6px}
2729+room-compose>form>label{display:inline-block;margin:6px 12px 0 0;font:12px var(--mono);color:var(--muted)}room-compose input[type=file]{max-width:100%;font:12px var(--mono)}room-compose>form>small{display:block;color:var(--muted);font:12px var(--sans);margin-top:4px}
2730+room-compose[data-over]{outline:2px dashed var(--accent);outline-offset:-4px}room-files{display:block}room-files ul{list-style:none;margin:6px 0 0;padding:0}room-files li{margin:4px 0;min-height:26px;font:12px var(--mono)}room-files li::after{content:"";display:block;clear:both}room-files li>span{display:inline-block;vertical-align:middle;overflow-wrap:anywhere;max-width:calc(100% - 145px)}room-files small{color:var(--muted);white-space:nowrap}room-files button{float:right;font-size:11px}
2731 room-compose output,room-create output,room-action output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
2732 room-compose output[data-error],room-create output[data-error],room-action output[data-error]{color:var(--alert)}
2733 room-create{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}
2734diff --git a/internal/webui/templates.go b/internal/webui/templates.go
2735index 553cd32..3391c3d 100644
2736--- a/internal/webui/templates.go
2737+++ b/internal/webui/templates.go
2738@@ -324,6 +324,9 @@ func parseTemplates(a *App) (*template.Template, error) {
2739 "age": age,
2740 "clock": clock,
2741 "roomAdmin": roomAdmin,
2742+
2743+ "roomAttachmentContent": roomAttachmentContent,
2744+ "roomAttachmentMarkup": roomAttachmentMarkup,
2745 }
2746 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
2747 if err != nil {
2748diff --git a/scripts/qa/README.md b/scripts/qa/README.md
2749index 393e018..f4838e0 100644
2750--- a/scripts/qa/README.md
2751+++ b/scripts/qa/README.md
2752@@ -47,3 +47,30 @@ npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \
2753 The menu checks cover public pages, hosted viewers and management pages with JavaScript enabled and disabled. They verify that mobile navigation starts closed, opens with a click or keyboard input, and leaves content in place. Desktop navigation remains visible.
2754
2755 Each browser check returns its results and failure count. A real phone signer is still needed to verify the operating system handoff and approval screens.
2756+
2757+## Room attachments
2758+
2759+Build the relay, create a disposable tenant on port 18459, and seed the room fixture:
2760+
2761+```sh
2762+go build -o /tmp/tiny-room-attachments ./cmd/tiny
2763+/tmp/tiny-room-attachments tenant create --data-dir /tmp/tinyrelay-room-attachments --name main \
2764+ --owner 5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c36369
2765+/tmp/tiny-room-attachments serve --data-dir /tmp/tinyrelay-room-attachments --default-tenant main \
2766+ --listen 127.0.0.1:18459 --allow-private-relays
2767+node scripts/qa/room-attachments-fixture.mjs --relay http://127.0.0.1:18459 \
2768+ --data /tmp/tinyrelay-room-attachments
2769+```
2770+
2771+Sign in as the fixture owner using the local NIP-46 bunker, then run the browser check:
2772+
2773+```sh
2774+BUNKER_USER_SECRET=fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd \
2775+ node scripts/qa/nip46-bunker.mjs
2776+npx --package @playwright/cli playwright-cli -s=tiny-room-attachments open \
2777+ http://127.0.0.1:18459/signin --headed
2778+npx --package @playwright/cli playwright-cli -s=tiny-room-attachments run-code \
2779+ --filename scripts/qa/room-attachments-browser.js
2780+```
2781+
2782+Screenshots are saved under `output/playwright/room-attachments/`.
2783diff --git a/scripts/qa/room-attachments-browser.js b/scripts/qa/room-attachments-browser.js
2784new file mode 100644
2785index 0000000..c9a391f
2786--- /dev/null
2787+++ b/scripts/qa/room-attachments-browser.js
2788@@ -0,0 +1,106 @@
2789+// Run with playwright-cli run-code after signing in as the fixture owner:
2790+// ... run-code --filename scripts/qa/room-attachments-browser.js
2791+// The fixture is created by room-attachments-fixture.mjs.
2792+async page => {
2793+ const results = [], errors = [];
2794+ const check = (condition, message) => { if (!condition) throw Error(message); };
2795+ const run = async (name, action) => { try { results.push({name, ok: true, detail: await action()}); } catch (error) { results.push({name, ok: false, error: error.message}); } };
2796+ page.on("pageerror", error => errors.push(error.message));
2797+ const base = await page.evaluate(() => location.origin);
2798+ const room = "/rooms/attachments-qa";
2799+ await page.setViewportSize({width: 1440, height: 900});
2800+
2801+ await run("SSR room shows image, generic file and agent message", async () => {
2802+ await page.goto(base + room);
2803+ await page.waitForSelector("#messages room-message");
2804+ await page.waitForFunction(() => typeof window.tiny?.signer === "function" && typeof window.tiny.signer()?.signEvent === "function");
2805+ const info = await page.evaluate(() => ({
2806+ messages: document.querySelectorAll("#messages room-message").length,
2807+ images: document.querySelectorAll("#messages img").length,
2808+ links: [...document.querySelectorAll("#messages a")].map(a => a.textContent.trim()),
2809+ videos: document.querySelectorAll("#messages video").length,
2810+ agent: [...document.querySelectorAll("#messages room-message")].filter(node => node.textContent.includes("Agent published file")).length,
2811+ live: !!document.querySelector("room-live")
2812+ }));
2813+ check(info.images >= 1, "initial image was not rendered inline");
2814+ check(info.links.some(label => label.includes("fixture notes.txt")), "generic file link missing");
2815+ check(info.agent >= 1, "agent message missing from room timeline");
2816+ check(info.live, "room live stream missing");
2817+ const liveText = "Live agent attachment";
2818+ await page.evaluate(async text => {
2819+ const url = document.querySelector("#messages img")?.src;
2820+ const unsigned = {kind: 9, created_at: Math.floor(Date.now() / 1000), tags: [["h", "attachments-qa"], ["imeta", "url " + url, "m image/png"]], content: text + "\n\n![image](" + url + ")"};
2821+ const signer = window.tiny.signer();
2822+ if (!signer?.signEvent) throw Error("connected signer unavailable for live check");
2823+ const signed = await signer.signEvent(unsigned);
2824+ const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(signed), {contentType: "application/json"});
2825+ if (!response.ok) throw Error("live publish failed");
2826+ }, liveText);
2827+ await page.waitForFunction(text => document.querySelector("#messages")?.textContent.includes(text), liveText);
2828+ await page.screenshot({path: "output/playwright/room-attachments/room-desktop.png"});
2829+ return {...info, liveAppend: true};
2830+ });
2831+
2832+ await run("file picker previews and publishes an image", async () => {
2833+ const picker = page.locator('room-compose input[name="attachments"]');
2834+ check(await picker.count() === 1, "attachment picker missing");
2835+ await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png");
2836+ await page.waitForSelector("room-files li");
2837+ check((await page.locator("room-files").textContent()).includes("browser-upload.png"), "pending file preview missing");
2838+ await page.locator('room-compose textarea[name="content"]').fill("Browser upload");
2839+ const request = page.waitForRequest(req => req.method() === "PUT" && req.url().includes("/rooms/attachments-qa/attachments"));
2840+ await page.locator("room-compose").getByRole("button", {name: "Send", exact: true}).click();
2841+ await request;
2842+ await page.waitForFunction(() => !document.querySelector("room-files li"));
2843+ check(await page.locator("#messages").textContent().then(text => text.includes("Browser upload")), "published upload missing from timeline");
2844+ return {uploaded: true};
2845+ });
2846+
2847+ await run("attachment-only message is accepted", async () => {
2848+ const picker = page.locator('room-compose input[name="attachments"]');
2849+ await picker.setInputFiles("output/playwright/room-attachments/only.txt");
2850+ await page.waitForSelector("room-files li");
2851+ check(await page.locator('room-compose textarea[name="content"]').getAttribute("required") === null, "content remained required with an attachment");
2852+ await page.locator("room-compose").getByRole("button", {name: "Send", exact: true}).click();
2853+ await page.waitForFunction(() => !document.querySelector("room-files li"));
2854+ return {uploaded: true};
2855+ });
2856+
2857+ await run("reload and narrow viewport preserve media layout", async () => {
2858+ await page.reload();
2859+ await page.waitForSelector("#messages room-message");
2860+ await page.setViewportSize({width: 360, height: 800});
2861+ const metrics = await page.evaluate(() => ({width: innerWidth, scrollWidth: document.documentElement.scrollWidth, content: document.querySelector("#content")?.scrollWidth, image: document.querySelector("#messages img")?.complete}));
2862+ check(metrics.scrollWidth <= metrics.width && metrics.content <= metrics.width, "room overflows at narrow viewport");
2863+ check(metrics.image, "image did not load after reload");
2864+ await page.screenshot({path: "output/playwright/room-attachments/room-mobile.png"});
2865+ return metrics;
2866+ });
2867+
2868+ await run("JavaScript-disabled SSR still renders media", async () => {
2869+ const context = await page.context().browser().newContext({javaScriptEnabled: false, viewport: {width: 360, height: 800}, storageState: await page.context().storageState()});
2870+ try {
2871+ const noJS = await context.newPage();
2872+ await noJS.goto(base + room);
2873+ const info = await noJS.evaluate(() => ({images: document.querySelectorAll("#messages img").length, links: document.querySelectorAll("#messages a").length, compose: !!document.querySelector("room-compose")}));
2874+ check(info.images >= 1 && info.links >= 1, "no-JS room omitted attachment markup");
2875+ check(info.compose, "no-JS room omitted the attachment composer markup");
2876+ await noJS.screenshot({path: "output/playwright/room-attachments/room-nojs.png"});
2877+ return info;
2878+ } finally { await context.close(); }
2879+ });
2880+
2881+ await run("guest cannot fetch private media", async () => {
2882+ const context = await page.context().browser().newContext();
2883+ try {
2884+ const guest = await context.newPage();
2885+ const mediaURL = await page.locator("#messages img").first().getAttribute("src");
2886+ check(mediaURL, "could not locate fixture media URL");
2887+ const media = await context.request.get(mediaURL);
2888+ check([401, 403].includes(media.status()), "guest private media returned " + media.status());
2889+ return {status: media.status()};
2890+ } finally { await context.close(); }
2891+ });
2892+ results.push({name: "uncaught browser errors", ok: errors.length === 0, errors});
2893+ return {passed: results.filter(item => item.ok).length, failed: results.filter(item => !item.ok).length, results};
2894+}
2895diff --git a/scripts/qa/room-attachments-fixture.mjs b/scripts/qa/room-attachments-fixture.mjs
2896new file mode 100644
2897index 0000000..e027bea
2898--- /dev/null
2899+++ b/scripts/qa/room-attachments-fixture.mjs
2900@@ -0,0 +1,64 @@
2901+#!/usr/bin/env node
2902+// Seed a disposable members-only room with image and generic-file messages.
2903+// The owner key is the public QA key used by the other scripts in this folder.
2904+import { mkdir, writeFile, readFile } from "node:fs/promises";
2905+import { execFile } from "node:child_process";
2906+import { promisify } from "node:util";
2907+import { resolve, join } from "node:path";
2908+import { createHash } from "node:crypto";
2909+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
2910+import { npubEncode } from "nostr-tools/nip19";
2911+
2912+const args = process.argv.slice(2);
2913+const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;
2914+const base = option("--relay", "http://127.0.0.1:18459").replace(/\/$/, "");
2915+const dataDir = resolve(option("--data", "/tmp/tinyrelay-room-attachments"));
2916+const outDir = resolve(option("--out", "output/playwright/room-attachments"));
2917+const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));
2918+const agentSecret = Uint8Array.from(Buffer.from("4f6d7a2be7f2c6f677b1dc2d02f4388cbd1b9aeb8e5e47d41e4ae0f2eb3bde5f", "hex"));
2919+const owner = getPublicKey(secret), agent = getPublicKey(agentSecret), room = "attachments-qa";
2920+const json = value => JSON.stringify(value);
2921+const now = () => Math.floor(Date.now() / 1000);
2922+const exec = promisify(execFile);
2923+async function signed(path, method, value, signer = secret, contentType = "application/json") {
2924+ const body = Buffer.isBuffer(value) ? value : Buffer.from(json(value));
2925+ const payload = createHash("sha256").update(body).digest("hex");
2926+ const auth = finalizeEvent({kind: 27235, created_at: now(), tags: [["u", base + path], ["method", method], ["payload", payload]], content: ""}, signer);
2927+ const token = "Nostr " + Buffer.from(json(auth)).toString("base64");
2928+ const response = await fetch(base + path, {method, headers: {authorization: token, "content-type": contentType}, body});
2929+ const result = await response.json().catch(() => ({}));
2930+ if (!response.ok && !(method === "POST" && path === "/events" && String(result.message || "").startsWith("duplicate:"))) throw Error(`${method} ${path}: ${response.status} ${json(result)}`);
2931+ return result;
2932+}
2933+async function publish(event) { return signed("/events", "POST", event); }
2934+async function upload(bytes, type, filename) {
2935+ const hash = createHash("sha256").update(bytes).digest("hex");
2936+ const descriptor = await signed(`/rooms/${room}/attachments?filename=${encodeURIComponent(filename)}`, "PUT", bytes, secret, type);
2937+ if (descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw Error("fixture upload descriptor mismatch");
2938+ return descriptor;
2939+}
2940+await mkdir(dataDir, {recursive: true});
2941+await mkdir(outDir, {recursive: true});
2942+// Tenant ownership grants management access but event writes still require a
2943+// community membership record in a fresh disposable tenant.
2944+await signed("/manage/rpc", "POST", {method: "setmember", params: [owner, {role: "member"}]});
2945+const create = finalizeEvent({kind: 9007, created_at: now(), tags: [["h", room], ["name", "Attachment QA"], ["about", "Disposable room for browser attachment checks."], ["visibility", "members"]], content: ""}, secret);
2946+await publish(create);
2947+await signed("/manage/rpc", "POST", {method: "setmember", params: [agent, {role: "member"}]});
2948+await publish(finalizeEvent({kind: 9000, created_at: now(), tags: [["h", room], ["p", agent, "member"]], content: ""}, secret));
2949+const cardPath = join(outDir, "fixture-card.png");
2950+await exec("/opt/homebrew/bin/convert", ["-size", "320x160", "gradient:#244f3b-#9bd4b1", "-fill", "#10261e", "-stroke", "#d7ffe6", "-strokewidth", "3", "-draw", "roundrectangle 15,15 305,145 12,12", cardPath]);
2951+const png = await readFile(cardPath);
2952+await writeFile(join(outDir, "browser-upload.png"), png);
2953+const image = await upload(png, "image/png", "fixture.png");
2954+const text = Buffer.from("Attachment QA generic file\n");
2955+await writeFile(join(outDir, "only.txt"), Buffer.from("only attachment"));
2956+const file = await upload(text, "text/plain", "fixture notes.txt");
2957+const message = (descriptor, signer = secret, content = "") => finalizeEvent({kind: 9, created_at: now(), tags: [["h", room], ["imeta", `url ${descriptor.url}`, `m ${descriptor.type}`, `x ${descriptor.sha256}`, `size ${descriptor.size}`, `filename ${descriptor.filename}`]], content: `${content ? content + "\n\n" : ""}${descriptor.type.startsWith("image/") ? `![image](${descriptor.url})` : `[${descriptor.filename}](${descriptor.url})`}`}, signer);
2958+const initial = message(image, secret, "Initial image attachment");
2959+await publish(initial);
2960+const agentMessage = message(file, agentSecret, "Agent published file");
2961+await signed("/events", "POST", agentMessage, agentSecret);
2962+const manifest = {schema: 1, relay: base, dataDir, room, owner, ownerNpub: npubEncode(owner), agent, image, file, initial: initial.id, agentMessage: agentMessage.id};
2963+await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");
2964+console.log(json(manifest));
2965diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs
2966index 8f2f5bc..7a707cf 100644
2967--- a/scripts/rooms.test.mjs
2968+++ b/scripts/rooms.test.mjs
2969@@ -4,27 +4,32 @@ import fs from "node:fs";
2970 import vm from "node:vm";
2971 import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";
2972 import * as nip19 from "nostr-tools/nip19";
2973+import { createHash } from "node:crypto";
2974
2975 const source = fs.readFileSync("internal/webui/components.js", "utf8");
2976 const roomsSource = source.slice(source.indexOf(" // Rooms: the compose bar"), source.indexOf(" // JsonView renders any JSON value."));
2977
2978 // A small document: enough of the DOM for the room elements to render
2979 // messages, find their targets and keep the list bounded.
2980-class FakeNode {
2981+export class FakeNode {
2982 constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.parent = null; this.id = ""; }
2983 get children() { return this.childNodes.filter(node => node instanceof FakeNode); }
2984 get firstElementChild() { return this.children[0] || null; }
2985 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }
2986 append(...nodes) { for (const node of nodes) { if (node instanceof FakeNode) { node.remove(); node.parent = this; this.childNodes.push(node); } else if (String(node)) this.childNodes.push(String(node)); } }
2987+ insertBefore(node, before) { if (!before) return this.append(node); node.remove(); const index = this.childNodes.indexOf(before); node.parent = this; this.childNodes.splice(index, 0, node); }
2988 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }
2989 replaceWith(node) { if (!this.parent) return; const index = this.parent.childNodes.indexOf(this); node.remove(); node.parent = this.parent; this.parent.childNodes[index] = node; this.parent = null; }
2990+ replaceChildren(...nodes) { this.children.forEach(node => { node.parent = null; }); this.childNodes = []; this.append(...nodes); }
2991 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }
2992 set textContent(value) { this.childNodes = value ? [String(value)] : []; }
2993 setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }
2994 getAttribute(name) { return name === "id" ? this.id || null : this.attributes[name] ?? null; }
2995- hasAttribute(name) { return name.startsWith("data-") ? name.slice(5) in this.dataset : name in this.attributes; }
2996+ removeAttribute(name) { delete this.attributes[name]; }
2997+ hasAttribute(name) { return name.startsWith("data-") ? name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset : name in this.attributes; }
2998 matches(selector) { return selector === this.localName; }
2999- addEventListener() {}
3000+ addEventListener(name, handler) { this.listeners ||= {}; (this.listeners[name] ||= []).push(handler); }
3001+ emit(name, event) { (this.listeners?.[name] || []).forEach(handler => handler(event)); }
3002 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
3003 querySelectorAll(selector) {
3004 const steps = selector.replace(/^:scope\s*>\s*/, "> ").trim().split(/\s*>\s*|\s+/).filter(Boolean);
3005@@ -40,8 +45,8 @@ class FakeNode {
3006 descendants() { return this.children.flatMap(node => [node, ...node.descendants()]); }
3007 matchesSimple(step) {
3008 if (step.startsWith("#")) return this.id === step.slice(1);
3009- const [, tag, attr] = step.match(/^([a-z-]+)(?:\[([a-z-]+)\])?$/) || [];
3010- return tag === this.localName && (!attr || this.hasAttribute(attr));
3011+ const [, tag, attr] = step.match(/^([a-z-]+)?(?:\[([a-z-]+)\])?$/) || [];
3012+ return (!tag || tag === this.localName) && (!attr || this.hasAttribute(attr));
3013 }
3014 }
3015 const document = new FakeNode("#document");
3016@@ -53,7 +58,7 @@ document.getElementById = id => document.body.descendants().find(node => node.id
3017 document.querySelector = selector => document.body.querySelector(selector);
3018 document.querySelectorAll = selector => document.body.querySelectorAll(selector);
3019
3020-function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {
3021+export function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {
3022 const sent = [], navigated = [];
3023 const secret = generateSecretKey(), pubkey = getPublicKey(secret);
3024 document.body.childNodes = [];
3025@@ -74,10 +79,11 @@ function setup({attributes = {}, result = {accepted: true}, change = false} = {}
3026 report(message) { this.message = message; }
3027 }
3028 const sandbox = {
3029- document, HTMLElement, FormElement, URL, encodeURIComponent, setTimeout, clearTimeout,
3030+ document, HTMLElement, FormElement, URL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,
3031+ location: {href: "https://relay.test/r/work/rooms/build", origin: "https://relay.test"},
3032 getComputedStyle: () => ({overflowY: "auto"}),
3033 window: {scrollY: 0, innerHeight: 800, scrollTo() {}, NostrSigner: {verifyEvent, decodeNpub: value => { const decoded = nip19.decode(value); if (decoded.type !== "npub") throw Error("not an npub"); return decoded.data; }}, nostr: {signEvent: async event => { const copy = JSON.parse(JSON.stringify(event)); if (change) copy.content = "changed"; return finalizeEvent(copy, secret); }}},
3034- tiny: {localPath: path => "/r/work" + path, navigate: async (href, push) => { navigated.push({href, push}); }, signedFetch: async (path, method, body, options) => { sent.push({path, method, options, event: JSON.parse(body)}); return Response.json(result); }},
3035+ tiny: {localPath: path => "/r/work" + path, sha256hex: async bytes => createHash("sha256").update(bytes).digest("hex"), navigate: async (href, push) => { navigated.push({href, push}); }, signedFetch: async (path, method, body, options) => { sent.push({path, method, options, event: JSON.parse(body)}); return Response.json(result); }},
3036 el: (tag, text) => { const node = new FakeNode(tag); if (text !== undefined) node.textContent = text; return node; },
3037 isHex64: value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)
3038 };
3039@@ -131,6 +137,81 @@ test("room-compose replies in a thread with the root and its author, and refuses
3040 await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);
3041 });
3042
3043+const selectedFile = (name = "chart.png", type = "image/png", text = "image bytes") => ({name, type, size: Buffer.byteLength(text), arrayBuffer: async () => Uint8Array.from(Buffer.from(text)).buffer});
3044+const descriptorFor = (file, bytes) => {
3045+ const sha256 = createHash("sha256").update(bytes).digest("hex");
3046+ return {sha256, size: file.size, type: file.type, url: "https://relay.test/r/work/media/" + sha256 + ".png"};
3047+};
3048+
3049+test("room-compose uploads only on send, then publishes Buzz metadata and an attachment-only message", async () => {
3050+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();
3051+ const uploads = [], publish = s.sandbox.tiny.signedFetch;
3052+ const file = selectedFile("my chart.png");
3053+ s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {
3054+ if (path === "/events") return publish(path, method, bytes, options);
3055+ uploads.push({path, method, bytes, options});
3056+ return Response.json(descriptorFor(file, bytes));
3057+ };
3058+ compose.addFiles([file]);
3059+ assert.equal(uploads.length, 0);
3060+ const form = s.form({content: ""});
3061+ await compose.submit(form);
3062+ assert.equal(uploads.length, 1);
3063+ assert.equal(uploads[0].path, "/rooms/build/attachments?filename=my%20chart.png");
3064+ assert.equal(uploads[0].method, "PUT");
3065+ assert.equal(uploads[0].options.contentType, "image/png");
3066+ const descriptor = descriptorFor(file, uploads[0].bytes);
3067+ assert.equal(s.sent[0].event.content, `![image](${descriptor.url})`);
3068+ same(s.sent[0].event.tags, [["h", "build"], ["imeta", "url " + descriptor.url, "m image/png", "x " + descriptor.sha256, "size " + file.size, "filename my chart.png"]]);
3069+ assert.equal(verifyEvent(s.sent[0].event), true);
3070+ assert.equal(compose.pendingFiles.length, 0);
3071+ assert.equal(form.resets, 1);
3072+});
3073+
3074+test("room-compose retains uploaded files after a publish failure and does not upload them twice on retry", async () => {
3075+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();
3076+ let uploads = 0, publishes = 0;
3077+ const publish = s.sandbox.tiny.signedFetch;
3078+ s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {
3079+ if (path !== "/events") { uploads++; return Response.json(descriptorFor(file, bytes)); }
3080+ if (++publishes === 1) throw Error("connection lost");
3081+ return publish(path, method, bytes, options);
3082+ };
3083+ compose.addFiles([file]);
3084+ const form = s.form({content: "a chart"});
3085+ await assert.rejects(compose.submit(form), /connection lost/);
3086+ assert.equal(compose.pendingFiles.length, 1);
3087+ assert.equal(form.resets, undefined);
3088+ await compose.submit(form);
3089+ assert.equal(uploads, 1);
3090+ assert.equal(publishes, 2);
3091+ assert.equal(s.sent[0].event.content.startsWith("a chart\n\n![image]"), true);
3092+});
3093+
3094+test("room-compose refuses oversized selections and untrusted upload descriptors without publishing", async () => {
3095+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();
3096+ assert.throws(() => compose.addFiles([{...file, size: 32 * 1024 * 1024 + 1}]), /32 MiB/);
3097+ assert.throws(() => compose.addFiles(Array.from({length: 9}, (_, i) => selectedFile(i + ".png"))), /8 files/);
3098+ compose.addFiles([file]);
3099+ for (const change of [d => ({...d, sha256: "0".repeat(64)}), d => ({...d, size: 1}), d => ({...d, url: "https://another.test/media/" + d.sha256 + ".png"})]) {
3100+ s.sandbox.tiny.signedFetch = async (_path, _method, bytes) => Response.json(change(descriptorFor(file, bytes)));
3101+ await assert.rejects(compose.submit(s.form({content: ""})), /descriptor|hash|size|URL/i);
3102+ assert.equal(s.sent.length, 0);
3103+ assert.equal(compose.pendingFiles.length, 1);
3104+ }
3105+});
3106+
3107+test("room-compose does not publish after leaving during an upload", async () => {
3108+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();
3109+ s.sandbox.tiny.signedFetch = async (_path, _method, bytes) => {
3110+ compose.disconnectedCallback();
3111+ return Response.json(descriptorFor(file, bytes));
3112+ };
3113+ compose.addFiles([file]);
3114+ await assert.rejects(compose.submit(s.form({content: ""})), /cancel/i);
3115+ assert.equal(s.sent.length, 0);
3116+});
3117+
3118 test("room-create derives the id from the name and signs 9007 before opening the room", async () => {
3119 const s = setup();
3120 assert.equal(s.rooms.roomID("Build & Release 2026!"), "build-release-2026");
3121@@ -262,3 +343,27 @@ test("chat markdown renders the shared subset without ever parsing markup", () =
3122 assert.equal(pre.querySelectorAll("a").length, 0);
3123 });
3124
3125+test("room attachment renderer matches visible imeta URLs and preserves prose and code", () => {
3126+ const s = setup(), url = "https://cdn.example.test/report.pdf";
3127+ const event = {content: "See [report](" + url + ") and `" + url + "`", tags: [["imeta", "url " + url, "m application/pdf", "filename report.pdf"]]};
3128+ const items = s.rooms.roomAttachments(event);
3129+ assert.equal(items.length, 1);
3130+ assert.equal(s.rooms.attachmentContent(event.content, items), event.content);
3131+ assert.equal(s.rooms.roomAttachments({content: "```\n" + url + "\n```", tags: event.tags}).length, 0);
3132+ assert.equal(s.rooms.roomAttachments({content: url + "-backup", tags: event.tags}).length, 0);
3133+ assert.equal(s.rooms.roomAttachments({content: url, tags: [["imeta", "url " + url, "m image/png"], ["imeta", "url " + url, "m image/png"]]}).length, 1);
3134+});
3135+
3136+test("room attachment renderer replaces edited media without stale nodes", () => {
3137+ const s = setup(), id = "a".repeat(64), author = "b".repeat(64), oldURL = "https://cdn.example.test/old.png", newURL = "https://cdn.example.test/new.mp4";
3138+ const node = s.rooms.messageNode({id, pubkey: author, kind: 9, created_at: 1, content: oldURL, tags: [["imeta", "url " + oldURL, "m image/png"]]});
3139+ s.list.append(node);
3140+ s.rooms.roomEdit({id: "c".repeat(64), pubkey: author, kind: 40003, created_at: 3, content: newURL, tags: [["e", id], ["imeta", "url " + newURL, "m video/mp4"]]});
3141+ assert.equal(node.querySelectorAll("img").length, 0);
3142+ assert.equal(node.querySelectorAll("video").length, 1);
3143+ s.rooms.roomEdit({pubkey: author, kind: 40003, created_at: 2, content: oldURL, tags: [["e", id], ["imeta", "url " + oldURL, "m image/png"]]});
3144+ assert.equal(node.querySelectorAll("video").length, 1, "older edit must not replace newer media");
3145+ s.rooms.roomEdit({pubkey: author, kind: 40003, created_at: 4, content: "Attachment removed", tags: [["e", id]]});
3146+ assert.equal(node.querySelectorAll("video").length, 0);
3147+ assert.equal(node.querySelectorAll("[data-room-attachments]").length, 0);
3148+});

About

ownerc01503e8035f
visibilitypublic
headc1c6760aef40

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs