Support file picker, paste and drop uploads, Buzz-compatible media rendering, and MCP attachment upload, read and message tools. Enforce room access across file routes, recover interrupted uploads, and preserve attachment edits across live updates and paginated history. Validated with the Go suite, go vet, targeted race tests, 28 JavaScript tests and seven browser checks.
- commit
- c1c6760aef409dc4ff11513e1f920763830cd1f8
- parent
- 835dc162e3dec3b7db2e5d3b4569be1c6e7273e3
- author
- Dami <dami.etoile@gmail.com>
@@ -53,6 +53,8 @@ Each uploader pays the full size of every file they claim, even when another upl
5353 5454 ## Agent uploads5555 56+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.57+5658 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).5759 5860 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.@@ -58,6 +58,7 @@ Read tools, which need a key that may read the relay:
5858 | `read_pull_request` | Read a pull request with replies, updates and the available diff. |5959 | `list_files` | List stored files visible to the key. |6060 | `read_file` | Read a stored file's metadata and preview by SHA-256 hash. |61+| `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. |6162 | `read_status` | Read service health, storage and job status as an owner or moderator. |6263 | `read_management` | Run a read-only management method: stats, getpolicy, listaudit, listjobs, listbackups, listdumps, deliverystatus, storagestats, gitstorage, listconnections or listmembers. |6364 | `list_rooms` | List chat rooms visible to the key with member counts and the time of the last message. |@@ -101,13 +102,14 @@ Write tools, which publish through the same path as `POST /events`:
101102 | Tool | Description |102103 | --- | --- |103104 | `publish_event` | Publish any signed Nostr event. |105+| `upload_attachment` | Upload Base64 file bytes for a room and return a Blossom descriptor with NIP-92 metadata. |104106 | `create_issue` | Open a kind 1621 issue on a hosted repository. |105107 | `create_pull_request` | Open a kind 1618 pull request with its commit and clone URL. |106108 | `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. |107109 | `set_status` | Mark an issue or pull request open, resolved, merged, closed or draft with a kind 1630 to 1633 event. |108-| `post_message` | Post a kind 9 message in a room, with optional `mentions` as `p` tags. |109-| `start_thread` | Start a kind 11 thread in a room with an optional title. |110-| `reply_in_thread` | Reply to a thread with a kind 12 event naming the root in its `e` tag. |110+| `post_message` | Post a kind 9 message in a room, with optional `mentions` and `attachments`. |111+| `start_thread` | Start a kind 11 thread in a room with an optional title and `attachments`. |112+| `reply_in_thread` | Reply to a thread with a kind 12 event naming the root in its `e` tag and optional `attachments`. |111113 | `react` | Publish a kind 7 reaction to an event: `+`, `-` or one emoji, with `room` for a room message. |112114 | `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. |113115 | `propose_wiki_merge` | Ask a page's author to take in a version with a kind 818 merge request. |@@ -136,6 +138,18 @@ The person answers with a kind 7 reaction to the published event from the asked
136138 137139 `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).138140 141+### Room attachments142+143+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.144+145+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.146+147+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.148+149+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.150+151+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.152+139153 ### Long tasks140154 141155 `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.@@ -61,6 +61,14 @@ Room events reach device notifications and relay push callbacks the same way as
6161 6262 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.6363 64+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.65+66+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.67+68+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.69+70+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).71+6472 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.6573 6674 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.@@ -99,6 +99,10 @@ type PutOptions struct {
9999 Type string100100 Uploader string101101 Hash string102+ // Commit records ownership metadata in the blob transaction. The final103+ // argument reports whether the blob is new. An error rolls back all claims.104+ // New bytes remain unavailable until their ownership metadata is durable.105+ Commit func(context.Context, *sql.Tx, Blob, bool) error102106 }103107 104108 type Service struct {@@ -207,10 +211,14 @@ func (s *Service) Put(ctx context.Context, options PutOptions) (Blob, error) {
207211 if options.Reader == nil {208212 return Blob{}, errors.New("blob reader is required")209213 }210- entry, _, err := s.put(ctx, options.Reader, options.Type, options.Uploader, options.Hash)214+ entry, _, err := s.putOptions(ctx, options)211215 return entry, err212216 }213217 218+func (s *Service) putOptions(ctx context.Context, options PutOptions) (Blob, bool, error) {219+ return s.putValidatedWithCommit(ctx, options.Reader, options.Type, options.Uploader, options.Hash, nil, options.Commit)220+}221+214222 // Get opens a verified metadata-backed blob for streaming to another service.215223 func (s *Service) Get(ctx context.Context, sha string) (Blob, io.ReadCloser, error) {216224 if !shaPattern.MatchString(sha) {@@ -608,10 +616,14 @@ func (s *Service) delete(w http.ResponseWriter, r *http.Request, sha string) {
608616 }609617 610618 func (s *Service) put(ctx context.Context, source io.Reader, typ, uploader, claimed string) (Blob, bool, error) {611- return s.putValidated(ctx, source, typ, uploader, claimed, nil)619+ return s.putValidatedWithCommit(ctx, source, typ, uploader, claimed, nil, nil)612620 }613621 614622 func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error) (Blob, bool, error) {623+ return s.putValidatedWithCommit(ctx, source, typ, uploader, claimed, validate, nil)624+}625+626+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) {615627 limits := s.currentLimits()616628 streamLimit := limits.MaxFileBytes617629 quotaStream := false@@ -658,12 +670,13 @@ func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploa
658670 return Blob{}, false, err659671 }660672 }661- return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader})673+ return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, commit: commit})662674 }663675 664676 type uploadCandidate struct {665677 path, hash, typ, uploader, reservation string666678 size int64679+ commit func(context.Context, *sql.Tx, Blob, bool) error667680 }668681 669682 func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate) (Blob, bool, error) {@@ -701,14 +714,35 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
701714 return Blob{}, false, ErrQuotaExceeded702715 }703716 }704- if err := s.claim(ctx, sha, uploader, terms.ExpiresAt); err != nil {705- return Blob{}, false, err717+ }718+ if err := s.store.WithTx(ctx, func(tx *sql.Tx) error {719+ if candidate.commit != nil {720+ if err := candidate.commit(ctx, tx, existing, false); err != nil {721+ return err722+ }706723 }724+ if uploader != "" {725+ if _, err := tx.ExecContext(ctx, claimSQL, sha, uploader, time.Now().Unix(), terms.ExpiresAt); err != nil {726+ return err727+ }728+ }729+ if candidate.reservation != "" {730+ if _, err := tx.ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {731+ return err732+ }733+ }734+ return nil735+ }); err != nil {736+ return Blob{}, false, err707737 }708- if candidate.reservation != "" {709- if _, err := s.store.DB().ExecContext(ctx, "DELETE FROM multipart_uploads WHERE id=?", candidate.reservation); err != nil {738+ // A crash after a scoped metadata commit may leave the bytes missing.739+ // A retry supplies those same verified bytes without losing the scope.740+ if _, err := os.Stat(filepath.Join(s.root, sha)); errors.Is(err, os.ErrNotExist) {741+ if err := s.installBlobFile(candidate.path, sha); err != nil {710742 return Blob{}, false, err711743 }744+ } else if err != nil {745+ return Blob{}, false, err712746 }713747 return existing, false, nil714748 } else if !errors.Is(err, sql.ErrNoRows) {@@ -733,12 +767,20 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
733767 if terms.Encrypted && !encryptedTypes[typ] {734768 return Blob{}, false, ErrNotEncrypted735769 }736- final := filepath.Join(s.root, sha)737- if err := os.Rename(candidate.path, final); err != nil && !errors.Is(err, os.ErrExist) {738- return Blob{}, false, fmt.Errorf("install blob: %w", err)770+ stage := ""771+ if candidate.commit != nil {772+ stage = filepath.Join(s.root, ".scoped-"+sha)773+ if err := os.Rename(candidate.path, stage); err != nil {774+ return Blob{}, false, fmt.Errorf("stage scoped blob: %w", err)775+ }776+ if err := syncDirectory(s.root); err != nil {777+ return Blob{}, false, fmt.Errorf("sync staged blob directory: %w", err)778+ }739779 }740- if err := syncDirectory(s.root); err != nil {741- return Blob{}, false, fmt.Errorf("sync blob directory: %w", err)780+ if candidate.commit == nil {781+ if err := s.installBlobFile(candidate.path, sha); err != nil {782+ return Blob{}, false, err783+ }742784 }743785 now := time.Now().UTC().Unix()744786 entry := Blob{SHA256: sha, Size: count, Type: typ, Uploader: uploader, Uploaded: now}@@ -759,17 +801,40 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
759801 return fmt.Errorf("release upload reservation: %w", err)760802 }761803 }804+ if candidate.commit != nil {805+ if err := candidate.commit(ctx, tx, entry, true); err != nil {806+ return err807+ }808+ }762809 return nil763810 })764811 if err != nil {812+ if stage != "" {813+ _ = os.Remove(stage)814+ }765815 return Blob{}, false, err766816 }817+ if candidate.commit != nil {818+ if err := s.installBlobFile(stage, sha); err != nil {819+ return Blob{}, false, err820+ }821+ }767822 if saved, err := s.lookup(ctx, sha); err == nil {768823 return saved, saved.Uploader == uploader && saved.Uploaded == now, nil769824 }770825 return entry, true, nil771826 }772827 828+func (s *Service) installBlobFile(source, hash string) error {829+ if err := os.Rename(source, filepath.Join(s.root, hash)); err != nil {830+ return fmt.Errorf("install blob: %w", err)831+ }832+ if err := syncDirectory(s.root); err != nil {833+ return fmt.Errorf("sync blob directory: %w", err)834+ }835+ return nil836+}837+773838 func (s *Service) quotaUsage(ctx context.Context, uploader string) (int64, error) {774839 return s.quotaUsageExcept(ctx, uploader, "")775840 }@@ -883,6 +948,19 @@ func reconcile(ctx context.Context, store *storage.Store, root string) error {
883948 continue884949 }885950 name := entry.Name()951+ if strings.HasPrefix(name, ".scoped-") {952+ sha := strings.TrimPrefix(name, ".scoped-")953+ if !shaPattern.MatchString(sha) {954+ if err := os.Remove(filepath.Join(root, name)); err != nil {955+ return fmt.Errorf("remove invalid staged blob: %w", err)956+ }957+ continue958+ }959+ if err := reconcileStaged(ctx, store, root, name, sha); err != nil {960+ return err961+ }962+ continue963+ }886964 if strings.HasPrefix(name, ".upload-") || strings.HasPrefix(name, ".multipart-chunk-") {887965 if err := os.Remove(filepath.Join(root, name)); err != nil {888966 return fmt.Errorf("remove abandoned upload: %w", err)@@ -911,6 +989,52 @@ func reconcile(ctx context.Context, store *storage.Store, root string) error {
911989 return nil912990 }913991 992+func reconcileStaged(ctx context.Context, store *storage.Store, root, name, sha string) error {993+ var size int64994+ if err := store.DB().QueryRowContext(ctx, "SELECT size FROM blobs WHERE sha256=?", sha).Scan(&size); errors.Is(err, sql.ErrNoRows) {995+ if removeErr := os.Remove(filepath.Join(root, name)); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {996+ return removeErr997+ }998+ return nil999+ } else if err != nil {1000+ return fmt.Errorf("check staged blob %q: %w", sha, err)1001+ }1002+ file, err := os.Open(filepath.Join(root, name))1003+ if err != nil {1004+ return err1005+ }1006+ info, err := file.Stat()1007+ if err != nil {1008+ _ = file.Close()1009+ return err1010+ }1011+ if info.Size() != size {1012+ _ = file.Close()1013+ return fmt.Errorf("staged blob %q has size %d, want %d", sha, info.Size(), size)1014+ }1015+ hasher := sha256.New()1016+ if _, err := io.Copy(hasher, file); err != nil {1017+ _ = file.Close()1018+ return err1019+ }1020+ if err := file.Close(); err != nil {1021+ return err1022+ }1023+ if hex.EncodeToString(hasher.Sum(nil)) != sha {1024+ return fmt.Errorf("staged blob %q failed content hash", sha)1025+ }1026+ if _, err := os.Stat(filepath.Join(root, sha)); errors.Is(err, os.ErrNotExist) {1027+ if err := os.Rename(filepath.Join(root, name), filepath.Join(root, sha)); err != nil {1028+ return err1029+ }1030+ } else if err != nil {1031+ return err1032+ } else {1033+ _ = os.Remove(filepath.Join(root, name))1034+ }1035+ return syncDirectory(root)1036+}1037+9141038 func recoverBlob(ctx context.Context, store *storage.Store, root, name string) error {9151039 file, err := os.Open(filepath.Join(root, name))9161040 if err != nil {@@ -0,0 +1,125 @@
1+package blob2+3+import (4+ "context"5+ "crypto/sha256"6+ "database/sql"7+ "encoding/hex"8+ "errors"9+ "os"10+ "path/filepath"11+ "strings"12+ "testing"13+)14+15+func scopeTable(t *testing.T, service *Service) {16+ t.Helper()17+ if _, err := service.store.DB().Exec(`CREATE TABLE attachment_scope (sha256 TEXT PRIMARY KEY)`); err != nil {18+ t.Fatal(err)19+ }20+}21+22+func scopeCommit(fail bool, seen *[]bool) func(context.Context, *sql.Tx, Blob, bool) error {23+ return func(ctx context.Context, tx *sql.Tx, entry Blob, created bool) error {24+ *seen = append(*seen, created)25+ if fail {26+ return errors.New("scope rejected")27+ }28+ _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO attachment_scope(sha256) VALUES(?)", entry.SHA256)29+ return err30+ }31+}32+33+func TestScopedCommitNewIsAtomic(t *testing.T) {34+ service := testService(t)35+ scopeTable(t, service)36+ body := []byte("atomic scoped upload")37+ digest := sha256.Sum256(body)38+ hash := hex.EncodeToString(digest[:])39+ var seen []bool40+ _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: testUploader, Commit: scopeCommit(true, &seen)})41+ if err == nil || !strings.Contains(err.Error(), "scope rejected") {42+ t.Fatalf("failed scope commit error = %v", err)43+ }44+ if len(seen) != 1 || !seen[0] {45+ t.Fatalf("commit states = %v, want [true]", seen)46+ }47+ var count int48+ if err := service.store.DB().QueryRow("SELECT COUNT(*) FROM blobs WHERE sha256=?", hash).Scan(&count); err != nil {49+ t.Fatal(err)50+ }51+ if count != 0 {52+ t.Fatalf("rolled back blob metadata count = %d", count)53+ }54+ if _, err := os.Stat(filepath.Join(service.root, hash)); !errors.Is(err, os.ErrNotExist) {55+ t.Fatalf("rolled back final file stat = %v", err)56+ }57+}58+59+func TestScopedCommitDedupAndRecovery(t *testing.T) {60+ service := testService(t)61+ scopeTable(t, service)62+ body := []byte("deduplicated scoped upload")63+ var first []bool64+ entry, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: testUploader, Commit: scopeCommit(false, &first)})65+ if err != nil || len(first) != 1 || !first[0] {66+ t.Fatalf("initial scoped upload entry=%+v states=%v err=%v", entry, first, err)67+ }68+ other := strings.Repeat("b", 64)69+ var rejected []bool70+ if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: other, Commit: scopeCommit(true, &rejected)}); err == nil {71+ t.Fatal("rejected deduplicated scope commit succeeded")72+ }73+ var claims int74+ if err := service.store.DB().QueryRow("SELECT COUNT(*) FROM blob_claims WHERE sha256=? AND uploader=?", entry.SHA256, other).Scan(&claims); err != nil {75+ t.Fatal(err)76+ }77+ if claims != 0 {78+ t.Fatalf("rolled back deduplicated claim count = %d", claims)79+ }80+ var accepted []bool81+ 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] {82+ t.Fatalf("deduplicated scoped upload states=%v err=%v", accepted, err)83+ }84+ if err := os.Remove(filepath.Join(service.root, entry.SHA256)); err != nil {85+ t.Fatal(err)86+ }87+ if _, err := service.Put(context.Background(), PutOptions{Reader: strings.NewReader(string(body)), Type: "text/plain", Uploader: testUploader}); err != nil {88+ t.Fatalf("recovery upload: %v", err)89+ }90+ if _, err := os.Stat(filepath.Join(service.root, entry.SHA256)); err != nil {91+ t.Fatalf("recovered final file: %v", err)92+ }93+}94+95+func TestReconcileStagedBlobRequiresCommittedMetadata(t *testing.T) {96+ service := testService(t)97+ body := []byte("staged durable bytes")98+ digest := sha256.Sum256(body)99+ hash := hex.EncodeToString(digest[:])100+ stage := filepath.Join(service.root, ".scoped-"+hash)101+ if err := os.WriteFile(stage, body, 0o600); err != nil {102+ t.Fatal(err)103+ }104+ if err := reconcile(context.Background(), service.store, service.root); err != nil {105+ t.Fatal(err)106+ }107+ if _, err := os.Stat(stage); !errors.Is(err, os.ErrNotExist) {108+ t.Fatalf("uncommitted stage stat = %v", err)109+ }110+ if _, err := os.Stat(filepath.Join(service.root, hash)); !errors.Is(err, os.ErrNotExist) {111+ t.Fatalf("uncommitted final stat = %v", err)112+ }113+ if err := os.WriteFile(stage, body, 0o600); err != nil {114+ t.Fatal(err)115+ }116+ if _, err := service.store.DB().Exec("INSERT INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)", hash, len(body), "text/plain", "", 1); err != nil {117+ t.Fatal(err)118+ }119+ if err := reconcile(context.Background(), service.store, service.root); err != nil {120+ t.Fatal(err)121+ }122+ if _, err := os.Stat(filepath.Join(service.root, hash)); err != nil {123+ t.Fatalf("committed final stat = %v", err)124+ }125+}@@ -347,6 +347,9 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
347347 if !t.Policy().Features.Files {348348 return nil, errors.New("not found: files are disabled")349349 }350+ if err := t.roomAttachmentAccess(ctx, actor, hash); err != nil {351+ return nil, err352+ }350353 entry, body, err := t.blobs.Get(ctx, hash)351354 if err != nil {352355 return nil, err@@ -1,6 +1,7 @@
11 package daemon22 33 import (4+ "database/sql"45 "errors"56 "io"67 "mime"@@ -40,7 +41,7 @@ func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {
4041 if r.URL.Path == "/repo/raw" {4142 err = t.sourceDownload(w, r, actor)4243 } else {43- err = t.fileDownload(w, r)44+ err = t.fileDownload(w, r, actor)4445 }4546 if err != nil {4647 browseHTTPError(w, err)@@ -53,11 +54,13 @@ func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {
5354 func browseHTTPError(w http.ResponseWriter, err error) {5455 status := http.StatusBadRequest5556 switch {57+ case strings.HasPrefix(err.Error(), "conflict:"):58+ status = http.StatusConflict5659 case strings.HasPrefix(err.Error(), "auth-required:"):5760 status = http.StatusUnauthorized5861 case strings.HasPrefix(err.Error(), "restricted:"), strings.HasPrefix(err.Error(), "blocked:"):5962 status = http.StatusForbidden60- case errors.Is(err, os.ErrNotExist), strings.HasPrefix(err.Error(), "not found:"):63+ case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows), strings.HasPrefix(err.Error(), "not found:"):6164 status = http.StatusNotFound6265 }6366 http.Error(w, err.Error(), status)@@ -91,11 +94,14 @@ func mediaInline(typ string) bool {
9194 return strings.HasPrefix(typ, "image/") || strings.HasPrefix(typ, "video/") || strings.HasPrefix(typ, "audio/") || typ == "application/pdf"9295 }9396 94-func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request) error {97+func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request, actor string) error {9598 if !t.Policy().Features.Files {9699 return os.ErrNotExist97100 }98101 hash := r.URL.Query().Get("hash")102+ if err := t.roomAttachmentAccess(r.Context(), actor, hash); err != nil {103+ return err104+ }99105 entry, body, err := t.blobs.Get(r.Context(), hash)100106 if err != nil {101107 return err@@ -83,6 +83,9 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8383 if t.tryBrowseHTTP(w, r) {8484 return8585 }86+ if t.blobs != nil && t.roomAttachmentHTTP(w, r) {87+ return88+ }8689 if isViewArtifactPath(r.URL.Path) {8790 t.viewArtifactHTTP(w, r)8891 return@@ -0,0 +1,218 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/base64"6+ "errors"7+ "io"8+ "mime"9+ "net/url"10+ "strconv"11+ "strings"12+ "unicode"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"16+)17+18+var mcpAttachment = mcp.Object(map[string]any{19+ "url": mcpText,20+ "sha256": mcpHash,21+ "size": map[string]any{"type": "integer", "minimum": 0},22+ "type": mcpText,23+ "filename": mcpText,24+ "room": mcpRoomID,25+ "uploaded": mcpUnixTime,26+ "nip94": map[string]any{"type": "array"},27+}, "url", "sha256", "size", "type")28+29+func mcpAttachmentURL(raw string) error {30+ if strings.ContainsAny(raw, "\\<>\"'()") || strings.IndexFunc(raw, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {31+ return errors.New("attachment URL contains unsafe characters; percent-encode them")32+ }33+ u, err := url.Parse(raw)34+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {35+ return errors.New("attachment URL must be absolute HTTP or HTTPS without credentials")36+ }37+ return nil38+}39+40+func mcpAttachmentMIME(raw string) error {41+ typ, _, err := mime.ParseMediaType(raw)42+ if err != nil || !strings.Contains(typ, "/") || strings.IndexFunc(raw, unicode.IsControl) >= 0 {43+ return errors.New("attachment type must be a MIME type")44+ }45+ return nil46+}47+48+func mcpAttachmentTags(call mcp.Call) (string, [][]string, error) {49+ values, _ := call.Arguments["attachments"].([]any)50+ if len(values) > 8 {51+ return "", nil, errors.New("attach up to 8 files per message")52+ }53+ var links []string54+ var tags [][]string55+ for _, raw := range values {56+ item, ok := raw.(map[string]any)57+ if !ok {58+ return "", nil, errors.New("each attachment must be an object")59+ }60+ link, _ := item["url"].(string)61+ hash, _ := item["sha256"].(string)62+ typ, _ := item["type"].(string)63+ size, ok := item["size"].(float64)64+ if !ok || size < 0 || size != float64(int64(size)) || !hex64(hash) {65+ return "", nil, errors.New("attachment needs url, sha256, non-negative integer size and type")66+ }67+ if err := mcpAttachmentURL(link); err != nil {68+ return "", nil, err69+ }70+ if err := mcpAttachmentMIME(typ); err != nil {71+ return "", nil, err72+ }73+ if room, _ := item["room"].(string); room != "" && room != call.String("room") {74+ return "", nil, errors.New("upload the attachment to the destination room first")75+ }76+ filename, _ := item["filename"].(string)77+ filename = roomAttachmentFilename(filename)78+ tags = append(tags, []string{"imeta", "url " + link, "m " + typ, "x " + hash, "size " + strconv.FormatInt(int64(size), 10), "filename " + filename})79+ mediaType, _, _ := mime.ParseMediaType(typ)80+ switch {81+ case strings.HasPrefix(mediaType, "image/"):82+ links = append(links, "")83+ case strings.HasPrefix(mediaType, "video/"):84+ links = append(links, "")85+ default:86+ label := strings.NewReplacer("\\", "\\\\", "[", "\\[", "]", "\\]").Replace(filename)87+ links = append(links, "["+label+"]("+link+")")88+ }89+ }90+ return strings.Join(links, "\n"), tags, nil91+}92+93+func mcpRoomContent(call mcp.Call) (string, [][]string, error) {94+ content, tags, err := mcpAttachmentTags(call)95+ if err != nil {96+ return "", nil, err97+ }98+ if text := call.String("content"); strings.TrimSpace(text) != "" {99+ if content != "" {100+ content = text + "\n" + content101+ } else {102+ content = text103+ }104+ }105+ return content, tags, nil106+}107+108+func mcpCheckAttachments(e event.Event) error {109+ for _, tag := range e.Tags {110+ if len(tag) == 0 || tag[0] != "imeta" {111+ continue112+ }113+ values := map[string]string{}114+ for _, field := range tag[1:] {115+ parts := strings.SplitN(field, " ", 2)116+ if len(parts) == 2 {117+ values[parts[0]] = parts[1]118+ }119+ }120+ if values["url"] == "" || !hex64(values["x"]) || strings.TrimSpace(values["m"]) == "" {121+ return errors.New("imeta attachment needs url, m and x fields")122+ }123+ if err := mcpAttachmentURL(values["url"]); err != nil {124+ return err125+ }126+ if err := mcpAttachmentMIME(values["m"]); err != nil {127+ return err128+ }129+ if size := values["size"]; size != "" {130+ n, err := strconv.ParseInt(size, 10, 64)131+ if err != nil || n < 0 {132+ return errors.New("imeta size must be a non-negative integer")133+ }134+ }135+ }136+ return nil137+}138+139+func (t *Tenant) mcpUploadAttachment(ctx context.Context, call mcp.Call) (mcp.Result, error) {140+ if !t.Policy().Features.Files || t.blobs == nil {141+ return mcp.Failure("file storage is disabled", nil), nil142+ }143+ room := call.String("room")144+ if len(call.String("data")) > 700*1024 {145+ return mcp.Failure("Base64 data exceeds 700 KiB; use the HTTP room attachment endpoint for larger files", nil), nil146+ }147+ raw, err := base64.StdEncoding.DecodeString(call.String("data"))148+ if err != nil {149+ return mcp.Failure("data must be standard Base64", nil), nil150+ }151+ if int64(len(raw)) > roomAttachmentMaxBytes {152+ return mcp.Failure("attachment exceeds the 32 MiB room limit", nil), nil153+ }154+ typ := strings.TrimSpace(call.String("type"))155+ if typ == "" || strings.ContainsAny(typ, "\r\n") {156+ return mcp.Failure("type is required", nil), nil157+ }158+ descriptor, err := t.storeRoomAttachment(ctx, call.Actor, room, roomAttachmentInput{Body: raw, Type: typ, Filename: call.String("filename")})159+ if err != nil {160+ return mcp.Failure("upload failed: "+err.Error(), nil), nil161+ }162+ return mcp.Value(descriptor), nil163+}164+165+func (t *Tenant) mcpReadAttachment(ctx context.Context, call mcp.Call) (mcp.Result, error) {166+ if !t.Policy().Features.Files || t.blobs == nil {167+ return mcp.Failure("file storage is disabled", nil), nil168+ }169+ if err := t.browseRead(ctx, call.Actor); err != nil {170+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil171+ }172+ if err := t.roomAttachmentAccess(ctx, call.Actor, call.String("sha256")); err != nil {173+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil174+ }175+ entry, body, err := t.blobs.Get(ctx, call.String("sha256"))176+ if err != nil {177+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil178+ }179+ defer body.Close()180+ max := call.Int("max_bytes")181+ if max == 0 {182+ max = 4 * 1024 * 1024183+ }184+ if max < 1 || max > 4*1024*1024 {185+ return mcp.Failure("max_bytes must be between 1 and 4194304", nil), nil186+ }187+ if entry.Size > int64(max) {188+ return mcp.Failure("attachment exceeds max_bytes", map[string]any{"size": entry.Size}), nil189+ }190+ raw, err := io.ReadAll(io.LimitReader(body, int64(max)+1))191+ if err != nil {192+ return mcp.Result{}, err193+ }194+ if len(raw) > max {195+ return mcp.Failure("attachment exceeds max_bytes", nil), nil196+ }197+ descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/"), entry)198+ descriptor["data"] = base64.StdEncoding.EncodeToString(raw)199+ if strings.HasPrefix(entry.Type, "text/") {200+ descriptor["text"] = string(raw)201+ }202+ result := mcp.Result{StructuredContent: descriptor}203+ mediaType := ""204+ if strings.HasPrefix(entry.Type, "image/") {205+ mediaType = "image"206+ }207+ if strings.HasPrefix(entry.Type, "audio/") {208+ mediaType = "audio"209+ }210+ if mediaType != "" {211+ result.Content = []mcp.Content{{Type: mediaType, Data: base64.StdEncoding.EncodeToString(raw), MimeType: entry.Type}}212+ } else if strings.HasPrefix(entry.Type, "text/") {213+ result.Content = []mcp.Content{{Type: "text", Text: string(raw)}}214+ } else {215+ result.Content = []mcp.Content{{Type: "text", Text: "File bytes are in structuredContent.data as Base64."}}216+ }217+ return result, nil218+}@@ -0,0 +1,156 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/base64"6+ "encoding/json"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/event"11+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"12+)13+14+func TestMCPAttachmentRegistryRoundTrip(t *testing.T) {15+ _, tenant := testTenant(t)16+ registry, err := tenant.mcpTools()17+ if err != nil {18+ t.Fatal(err)19+ }20+ upload, ok := registry.Lookup("upload_attachment")21+ if !ok {22+ t.Fatal("upload_attachment is not registered")23+ }24+ owner := tenant.Policy().Owner25+ raw := []byte("agent document")26+ 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"}})27+ if err != nil || result.IsError {28+ t.Fatalf("upload: %v %+v", err, result)29+ }30+ descriptor := result.StructuredContent.(map[string]any)31+ encodedDescriptor, err := json.Marshal(descriptor)32+ if err != nil {33+ t.Fatal(err)34+ }35+ if err := json.Unmarshal(encodedDescriptor, &descriptor); err != nil {36+ t.Fatal(err)37+ }38+ if descriptor["sha256"] == "" || descriptor["url"] == "" || descriptor["filename"] != "note.txt" {39+ t.Fatalf("descriptor: %#v", descriptor)40+ }41+ post, ok := registry.Lookup("post_message")42+ if !ok {43+ t.Fatal("post_message is not registered")44+ }45+ if err := mcp.Validate(post.InputSchema, map[string]any{"room": "main", "attachments": []any{descriptor}}); err != nil {46+ t.Fatalf("descriptor schema: %v", err)47+ }48+ unsigned, err := mcpBuildMessage(mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}})49+ if err != nil || !strings.Contains(unsigned.Content, descriptor["url"].(string)) || len(unsigned.Tags) != 2 {50+ t.Fatalf("attachment-only message: %+v %v", unsigned, err)51+ }52+ if thread, err := mcpBuildThread(mcp.Call{Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}}); err != nil || thread.Content == "" {53+ t.Fatalf("attachment-only thread: %+v %v", thread, err)54+ }55+ if reply, err := mcpBuildReply(mcp.Call{Arguments: map[string]any{"room": "main", "root": strings.Repeat("a", 64), "attachments": []any{descriptor}}}); err != nil || reply.Content == "" {56+ t.Fatalf("attachment-only reply: %+v %v", reply, err)57+ }58+ read, ok := registry.Lookup("read_attachment")59+ if !ok {60+ t.Fatal("read_attachment is not registered")61+ }62+ readResult, err := read.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"sha256": descriptor["sha256"], "max_bytes": float64(1024)}})63+ if err != nil || readResult.IsError {64+ t.Fatalf("read: %v %+v", err, readResult)65+ }66+ readData := readResult.StructuredContent.(map[string]any)["data"].(string)67+ if got, _ := base64.StdEncoding.DecodeString(readData); string(got) != string(raw) {68+ t.Fatalf("read data %q", got)69+ }70+ for _, media := range []struct {71+ typ, want string72+ body []byte73+ }{{"image/png", "image", []byte("\x89PNG\r\n\x1a\n")}, {"audio/ogg", "audio", []byte("OggS test")}} {74+ 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}})75+ if err != nil || mediaResult.IsError {76+ t.Fatalf("media upload %s: %v %+v", media.typ, err, mediaResult)77+ }78+ mediaDescriptor := mediaResult.StructuredContent.(map[string]any)79+ got, err := read.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"sha256": mediaDescriptor["sha256"], "max_bytes": float64(1024)}})80+ if err != nil || got.IsError || len(got.Content) != 1 || got.Content[0].Type != media.want {81+ t.Fatalf("media read %s: %v %+v", media.typ, err, got)82+ }83+ }84+ unsignedEvent, err := mcpBuildMessage(mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}})85+ if err != nil {86+ t.Fatal(err)87+ }88+ published := event.Event{Kind: unsignedEvent.Kind, CreatedAt: unsignedEvent.CreatedAt, Tags: unsignedEvent.Tags, Content: unsignedEvent.Content}89+ if err := event.Sign(&published, strings.Repeat("0", 63)+"1"); err != nil {90+ t.Fatal(err)91+ }92+ encoded, _ := json.Marshal(published)93+ var signed map[string]any94+ _ = json.Unmarshal(encoded, &signed)95+ posted, err := post.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"event": signed}})96+ if err != nil || posted.IsError {97+ t.Fatalf("signed attachment post: %v %+v", err, posted)98+ }99+ roomResult, err := tenant.Execute(context.Background(), owner, "browseroom", []json.RawMessage{json.RawMessage(`{"id":"main"}`)})100+ if err != nil || !strings.Contains(string(mcpJSON(roomResult)), "imeta") {101+ t.Fatalf("room lost attachment metadata: %v %#v", err, roomResult)102+ }103+}104+105+func mcpJSON(value any) []byte { raw, _ := json.Marshal(value); return raw }106+107+func TestMCPAttachmentRejectsUnauthorizedAndMalformed(t *testing.T) {108+ _, tenant := testTenant(t)109+ registry, err := tenant.mcpTools()110+ if err != nil {111+ t.Fatal(err)112+ }113+ upload, _ := registry.Lookup("upload_attachment")114+ 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 {115+ t.Fatalf("unjoined upload accepted: %v %+v", err, result)116+ }117+ if err := mcpCheckAttachments(event.Event{Tags: [][]string{{"imeta", "url https://relay.test/media/not-a-hash", "m text/plain", "x bad"}}}); err == nil {118+ t.Fatal("malformed imeta accepted")119+ }120+ h := newRoomHarness(t)121+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private"}, {"closed"}}, "")122+ stored, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["alice"], "private", roomAttachmentInput{Body: []byte("secret"), Type: "text/plain"})123+ if err != nil {124+ t.Fatal(err)125+ }126+ result, err := h.tenant.mcpReadAttachment(h.ctx, mcp.Call{Actor: h.keys["bob"], Arguments: map[string]any{"sha256": stored["sha256"]}})127+ if err != nil || !result.IsError {128+ t.Fatalf("outsider read accepted: %v %+v", err, result)129+ }130+}131+132+func TestMCPAttachmentEscapingAndValidation(t *testing.T) {133+ 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"}134+ call := mcp.Call{Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}}135+ unsigned, err := mcpBuildMessage(call)136+ if err != nil || !strings.HasPrefix(unsigned.Content, `[report\] \[x\].txt](`) {137+ t.Fatalf("filename escaping: %q %v", unsigned.Content, err)138+ }139+ for _, invalid := range []string{"javascript:alert(1)", "https://user:password@relay.test/a", "https://relay.test/a)\n", "https://relay.test/a b"} {140+ descriptor["url"] = invalid141+ if _, err := mcpBuildMessage(call); err == nil {142+ t.Errorf("unsafe URL accepted: %q", invalid)143+ }144+ }145+ descriptor["url"] = "https://relay.test/video.mp4"146+ descriptor["type"] = "video/mp4"147+ if unsigned, err := mcpBuildMessage(call); err != nil || !strings.HasPrefix(unsigned.Content, " {148+ t.Fatalf("Buzz video marker: %+v %v", unsigned, err)149+ }150+ for _, invalid := range []string{"invalid", "text/plain\r\nx bad", ""} {151+ descriptor["type"] = invalid152+ if _, err := mcpBuildMessage(call); err == nil {153+ t.Errorf("unsafe MIME accepted: %q", invalid)154+ }155+ }156+}@@ -108,6 +108,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
108108 add("read_pull_request", "Read a pull request, its replies, authorized updates and available diff.", collaborationDetail, mcpReads, t.mcpBrowse("browsepull"))109109 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"))110110 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"))111+ 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)112+ 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)111113 add("read_status", "Read service health, storage and job status. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, t.mcpBrowse("browsestatus"))112114 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) {113115 method := call.String("method")@@ -218,9 +220,10 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
218220 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))219221 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))220222 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))221- 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))222- 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))223- 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))223+ attachments := map[string]any{"type": "array", "items": mcpAttachment, "maxItems": 8}224+ 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))225+ 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))226+ 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))224227 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))225228 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))226229 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))@@ -549,10 +552,14 @@ func mcpBuildMessage(call mcp.Call) (mcpUnsigned, error) {
549552 if err != nil {550553 return mcpUnsigned{}, err551554 }552- content := call.String("content")555+ content, attachmentTags, err := mcpRoomContent(call)556+ if err != nil {557+ return mcpUnsigned{}, err558+ }553559 if strings.TrimSpace(content) == "" {554- return mcpUnsigned{}, errors.New("content is required")560+ return mcpUnsigned{}, errors.New("content or attachments is required")555561 }562+ tags = append(tags, attachmentTags...)556563 return mcpUnsigned{Kind: event.KIND_CHAT, CreatedAt: time.Now().Unix(), Tags: mcpPubKeys(call, "mentions", tags), Content: content}, nil557564 }558565 @@ -562,12 +569,17 @@ func mcpBuildThread(call mcp.Call) (mcpUnsigned, error) {
562569 return mcpUnsigned{}, err563570 }564571 title, content := strings.TrimSpace(call.String("title")), call.String("content")572+ content, attachmentTags, err := mcpRoomContent(call)573+ if err != nil {574+ return mcpUnsigned{}, err575+ }565576 if strings.TrimSpace(content) == "" {566- return mcpUnsigned{}, errors.New("content is required")577+ return mcpUnsigned{}, errors.New("content or attachments is required")567578 }568579 if title != "" {569580 tags = append(tags, []string{"subject", title})570581 }582+ tags = append(tags, attachmentTags...)571583 return mcpUnsigned{Kind: event.KIND_THREAD, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil572584 }573585 @@ -576,14 +588,16 @@ func mcpBuildReply(call mcp.Call) (mcpUnsigned, error) {
576588 if err != nil {577589 return mcpUnsigned{}, err578590 }579- root, rootPubKey, content := call.String("root"), call.String("root_pubkey"), call.String("content")591+ root, rootPubKey := call.String("root"), call.String("root_pubkey")592+ content, attachmentTags, err := mcpRoomContent(call)580593 if root == "" || strings.TrimSpace(content) == "" {581- return mcpUnsigned{}, errors.New("root and content are required")594+ return mcpUnsigned{}, errors.New("root and content or attachments are required")582595 }583596 tags = append(tags, []string{"e", root})584597 if rootPubKey != "" {585598 tags = append(tags, []string{"p", rootPubKey})586599 }600+ tags = append(tags, attachmentTags...)587601 return mcpUnsigned{Kind: event.KIND_THREAD_REPLY, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil588602 }589603 @@ -760,7 +774,7 @@ func mcpCheckMessage(e event.Event) error {
760774 if strings.TrimSpace(e.Content) == "" {761775 return errors.New("content must not be empty")762776 }763- return nil777+ return mcpCheckAttachments(e)764778 }765779 766780 func mcpCheckThread(e event.Event) error {@@ -770,7 +784,7 @@ func mcpCheckThread(e event.Event) error {
770784 if strings.TrimSpace(e.Content) == "" {771785 return errors.New("content must not be empty")772786 }773- return nil787+ return mcpCheckAttachments(e)774788 }775789 776790 func mcpCheckReply(e event.Event) error {@@ -783,7 +797,7 @@ func mcpCheckReply(e event.Event) error {
783797 if strings.TrimSpace(e.Content) == "" {784798 return errors.New("content must not be empty")785799 }786- return nil800+ return mcpCheckAttachments(e)787801 }788802 789803 func mcpCheckReact(e event.Event) error {@@ -0,0 +1,197 @@
1+package daemon2+3+import (4+ "crypto/sha256"5+ "encoding/base64"6+ "encoding/hex"7+ "encoding/json"8+ "net/http"9+ "net/http/httptest"10+ "strconv"11+ "strings"12+ "testing"13+ "time"14+15+ "github.com/FelineStateMachine/tinyrelay/internal/event"16+)17+18+func roomAttachmentAuth(t *testing.T, secret, rawURL, method, body string) string {19+ t.Helper()20+ hash := sha256.Sum256([]byte(body))21+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", rawURL}, {"method", method}, {"payload", hex.EncodeToString(hash[:])}}}22+ if err := event.Sign(&e, secret); err != nil {23+ t.Fatal(err)24+ }25+ raw, err := json.Marshal(e)26+ if err != nil {27+ t.Fatal(err)28+ }29+ return "Nostr " + base64.RawURLEncoding.EncodeToString(raw)30+}31+32+func roomAttachmentBlossomAuth(t *testing.T, secret, action, hash string) string {33+ t.Helper()34+ 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)}}}35+ if err := event.Sign(&e, secret); err != nil {36+ t.Fatal(err)37+ }38+ raw, err := json.Marshal(e)39+ if err != nil {40+ t.Fatal(err)41+ }42+ // The daemon's authorization parser uses the Nostr scheme for both NIP-9843+ // and kind-24242 Blossom proofs; the event kind selects the verifier.44+ return "Nostr " + base64.RawURLEncoding.EncodeToString(raw)45+}46+47+func TestRoomAttachmentUploadAndPrivateReadDoors(t *testing.T) {48+ h := newRoomHarness(t)49+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")50+ body := "private attachment"51+ path := "/rooms/secret/attachments?filename=secret.txt"52+ req := httptest.NewRequest(http.MethodPut, "http://relay.test"+path, strings.NewReader(body))53+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], req.URL.String(), req.Method, body))54+ req.Header.Set("Content-Type", "text/plain")55+ res := httptest.NewRecorder()56+ h.tenant.ServeHTTP(res, req)57+ if res.Code != http.StatusOK {58+ t.Fatalf("member upload = %d: %s", res.Code, res.Body.String())59+ }60+ var uploaded struct {61+ Hash string `json:"sha256"`62+ }63+ if err := json.Unmarshal(res.Body.Bytes(), &uploaded); err != nil || len(uploaded.Hash) != 64 {64+ t.Fatalf("upload response = %s: %v", res.Body.String(), err)65+ }66+67+ for _, door := range []string{"/media/" + uploaded.Hash, "/files/raw?hash=" + uploaded.Hash} {68+ t.Run("outsider "+door, func(t *testing.T) {69+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)70+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["bob"], req.URL.String(), req.Method, ""))71+ got := httptest.NewRecorder()72+ h.tenant.ServeHTTP(got, req)73+ if got.Code != http.StatusForbidden && got.Code != http.StatusUnauthorized {74+ t.Fatalf("outsider read = %d: %s", got.Code, got.Body.String())75+ }76+ })77+ t.Run("member "+door, func(t *testing.T) {78+ // Alice owns the closed room; a signed GET must be accepted.79+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)80+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], req.URL.String(), req.Method, ""))81+ got := httptest.NewRecorder()82+ h.tenant.ServeHTTP(got, req)83+ if got.Code != http.StatusOK || got.Body.String() != body {84+ t.Fatalf("member read = %d %q", got.Code, got.Body.String())85+ }86+ })87+ }88+ for _, door := range []string{"/" + uploaded.Hash + ".txt", "/media/" + uploaded.Hash + ".txt"} {89+ t.Run("blossom outsider "+door, func(t *testing.T) {90+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)91+ req.Header.Set("Authorization", roomAttachmentBlossomAuth(t, h.secrets["bob"], "get", uploaded.Hash))92+ got := httptest.NewRecorder()93+ h.tenant.ServeHTTP(got, req)94+ if got.Code != http.StatusForbidden && got.Code != http.StatusUnauthorized {95+ t.Fatalf("Blossom outsider read = %d: %s", got.Code, got.Body.String())96+ }97+ })98+ }99+ for _, door := range []string{"/" + uploaded.Hash + ".txt", "/media/" + uploaded.Hash + ".txt"} {100+ req := httptest.NewRequest(http.MethodGet, "http://relay.test"+door, nil)101+ req.Header.Set("Authorization", roomAttachmentBlossomAuth(t, h.secrets["alice"], "get", uploaded.Hash))102+ got := httptest.NewRecorder()103+ h.tenant.ServeHTTP(got, req)104+ if got.Code != http.StatusOK || got.Body.String() != body {105+ t.Fatalf("Blossom member read = %d %q", got.Code, got.Body.String())106+ }107+ }108+ if _, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": uploaded.Hash})}); err == nil {109+ t.Fatal("outsider browsefile exposed private attachment")110+ }111+}112+113+func TestRoomAttachmentMediaSandboxesActiveTypes(t *testing.T) {114+ h := newRoomHarness(t)115+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")116+ body := "<html><script>document.body.dataset.pwned='yes'</script></html>"117+ req := httptest.NewRequest(http.MethodPut, "http://relay.test/rooms/secret/attachments?filename=x.html", strings.NewReader(body))118+ req.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], req.URL.String(), req.Method, body))119+ req.Header.Set("Content-Type", "text/html")120+ res := httptest.NewRecorder()121+ h.tenant.ServeHTTP(res, req)122+ if res.Code != http.StatusOK {123+ t.Fatalf("upload = %d: %s", res.Code, res.Body.String())124+ }125+ var uploaded struct {126+ Hash string `json:"sha256"`127+ }128+ _ = json.Unmarshal(res.Body.Bytes(), &uploaded)129+ get := httptest.NewRequest(http.MethodGet, "http://relay.test/media/"+uploaded.Hash, nil)130+ get.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["alice"], get.URL.String(), get.Method, ""))131+ got := httptest.NewRecorder()132+ h.tenant.ServeHTTP(got, get)133+ if got.Code != http.StatusOK {134+ t.Fatalf("media read = %d: %s", got.Code, got.Body.String())135+ }136+ if got.Header().Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(got.Header().Get("Content-Security-Policy"), "sandbox") {137+ t.Fatalf("active media lacks isolation: nosniff=%q csp=%q", got.Header().Get("X-Content-Type-Options"), got.Header().Get("Content-Security-Policy"))138+ }139+ if got.Header().Get("Content-Type") == "text/html" {140+ t.Fatal("HTML attachment served as active text/html")141+ }142+}143+144+func TestRoomAttachmentScopeDiesWithMembershipAndRoomGeneration(t *testing.T) {145+ h := newRoomHarness(t)146+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "removed"}, {"closed"}}, "")147+ h.must("alice", event.KIND_PUT_USER, [][]string{{"h", "removed"}, {"p", h.keys["bob"]}}, "")148+ removed, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["bob"], "removed", roomAttachmentInput{Body: []byte("member bytes"), Type: "text/plain"})149+ if err != nil {150+ t.Fatalf("removed-member attachment = %v", err)151+ }152+ h.must("alice", event.KIND_REMOVE_USER, [][]string{{"h", "removed"}, {"p", h.keys["bob"]}}, "")153+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["bob"], removed["sha256"].(string)); err == nil {154+ t.Fatal("removed member retained attachment access")155+ }156+157+ created := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "reused"}, {"closed"}}, "")158+ body := []byte("old room bytes")159+ result, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["alice"], "reused", roomAttachmentInput{Body: body, Type: "text/plain", Filename: "old.txt"})160+ if err != nil {161+ t.Fatalf("store attachment = %v", err)162+ }163+ hash := result["sha256"].(string)164+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice"], hash); err != nil {165+ t.Fatalf("initial room access = %v", err)166+ }167+ // Deleting the room must invalidate its attachment scope, even if the168+ // identifier is later reused for a newly created room.169+ h.must("alice", event.KIND_DELETE_GROUP, [][]string{{"h", "reused"}}, "")170+ h.projectRooms()171+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice"], hash); err == nil {172+ t.Fatal("deleted room retained attachment access")173+ }174+ newRoom := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "reused"}, {"closed"}, {"name", "Reused again"}}, "")175+ if created.ID == newRoom.ID {176+ t.Fatal("room recreation did not produce a new event")177+ }178+ if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice"], hash); err == nil {179+ t.Fatal("new room generation inherited old attachment")180+ }181+}182+183+func TestRoomAttachmentHTTPRejectsChangedPayload(t *testing.T) {184+ h := newRoomHarness(t)185+ request := httptest.NewRequest(http.MethodPut, "http://relay.test/rooms/main/attachments", strings.NewReader("changed bytes"))186+ request.Header.Set("Authorization", roomAttachmentAuth(t, h.secrets["owner"], request.URL.String(), request.Method, "signed bytes"))187+ request.Header.Set("Content-Type", "text/plain")188+ response := httptest.NewRecorder()189+ h.tenant.ServeHTTP(response, request)190+ if response.Code != http.StatusUnauthorized {191+ t.Fatalf("changed payload: %d %s", response.Code, response.Body.String())192+ }193+ var count int194+ if err := h.tenant.store.DB().QueryRowContext(h.ctx, "SELECT COUNT(*) FROM room_attachments").Scan(&count); err != nil || count != 0 {195+ t.Fatalf("changed payload was stored: %d %v", count, err)196+ }197+}@@ -0,0 +1,277 @@
1+package daemon2+3+import (4+ "bytes"5+ "context"6+ "crypto/sha256"7+ "database/sql"8+ "encoding/hex"9+ "encoding/json"10+ "errors"11+ "fmt"12+ "io"13+ "mime"14+ "net/http"15+ "path"16+ "regexp"17+ "strings"18+19+ "github.com/FelineStateMachine/tinyrelay/internal/auth"20+ "github.com/FelineStateMachine/tinyrelay/internal/blob"21+ "github.com/FelineStateMachine/tinyrelay/internal/community"22+)23+24+const roomAttachmentMaxBytes int64 = 32 << 2025+26+var roomAttachmentPath = regexp.MustCompile(`^/rooms/([a-z0-9_-]{1,64})/attachments$`)27+28+var mediaPath = regexp.MustCompile(`^/media/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$`)29+30+type roomAttachmentScope struct {31+ RoomID, EventID string32+}33+34+type roomAttachmentInput struct {35+ Body []byte36+ Type string37+ Filename string38+}39+40+// storeRoomAttachment applies the same upload policy to browser and MCP calls.41+func (t *Tenant) storeRoomAttachment(ctx context.Context, actor, roomID string, input roomAttachmentInput) (map[string]any, error) {42+ if err := t.roomAttachmentUploader(ctx, actor, roomID); err != nil {43+ return nil, err44+ }45+ room, _, err := t.roomFor(ctx, actor, roomID)46+ if err != nil {47+ return nil, err48+ }49+ if len(input.Body) == 0 || int64(len(input.Body)) > roomAttachmentMaxBytes {50+ return nil, errors.New("invalid: attachment must be between 1 byte and 32 MiB")51+ }52+ typ := input.Type53+ if typ == "" {54+ typ = http.DetectContentType(input.Body)55+ }56+ if _, _, err := mime.ParseMediaType(typ); err != nil {57+ return nil, errors.New("invalid: attachment media type")58+ }59+ filename := roomAttachmentFilename(input.Filename)60+ commit := func(commitCtx context.Context, tx *sql.Tx, entry blob.Blob, created bool) error {61+ if !created {62+ var scoped int63+ if err := tx.QueryRowContext(commitCtx, "SELECT COUNT(*) FROM room_attachments WHERE sha256=?", entry.SHA256).Scan(&scoped); err != nil {64+ return err65+ }66+ if scoped == 0 {67+ return errors.New("conflict: this file already exists without room restrictions; share its existing link")68+ }69+ }70+ var currentEvent string71+ if err := tx.QueryRowContext(commitCtx, "SELECT event_id FROM rooms WHERE id=? AND deleted_at=0", roomID).Scan(¤tEvent); err != nil {72+ return err73+ }74+ if currentEvent != room.EventID {75+ return errors.New("conflict: the room changed while uploading; try again")76+ }77+ _, 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)78+ return err79+ }80+ entry, err := t.blobs.Put(ctx, blob.PutOptions{Reader: bytes.NewReader(input.Body), Type: typ, Uploader: actor, Commit: commit})81+ if err != nil {82+ return nil, err83+ }84+ descriptor := t.blobs.Descriptor(strings.TrimRight(t.publicURL, "/")+"/media", entry)85+ descriptor["filename"] = filename86+ descriptor["room"] = roomID87+ descriptor["nip94"] = append(descriptor["nip94"].([][]string), []string{"filename", filename})88+ return descriptor, nil89+}90+91+func roomAttachmentFilename(filename string) string {92+ filename = strings.Map(func(r rune) rune {93+ if r < 32 || r == 127 {94+ return ' '95+ }96+ return r97+ }, path.Base(filename))98+ runes := []rune(strings.TrimSpace(filename))99+ if len(runes) > 255 {100+ runes = runes[:255]101+ }102+ filename = string(runes)103+ if filename == "" || filename == "." || filename == "/" {104+ return "file"105+ }106+ return filename107+}108+109+func (t *Tenant) roomAttachmentUploader(ctx context.Context, actor, roomID string) error {110+ if actor == "" {111+ return errors.New("auth-required: attachment uploader")112+ }113+ if !t.Policy().Features.Files {114+ return errors.New("restricted: file uploads are disabled")115+ }116+ banned, err := t.community.IsBanned(ctx, actor)117+ if err != nil {118+ return fmt.Errorf("check attachment uploader ban: %w", err)119+ }120+ if banned {121+ return errors.New("blocked: this pubkey is banned from this relay")122+ }123+ standing, err := t.community.Role(ctx, actor)124+ if err != nil {125+ return fmt.Errorf("check attachment uploader role: %w", err)126+ }127+ allowed := t.Policy().Writes == "open" || (t.Policy().Writes == "owner" && standing == "owner") || (t.Policy().Writes != "owner" && standing != "")128+ if !allowed {129+ return errors.New("restricted: file upload is not allowed")130+ }131+ if standing == "agent" {132+ if _, err := t.agentUploadGrant(ctx, actor); err != nil {133+ return err134+ }135+ }136+ if room, role, err := t.roomFor(ctx, actor, roomID); err != nil {137+ return err138+ } else if role == "" && !(room.Access == community.RoomOpen && standing != "") {139+ return errors.New("restricted: join the room before uploading attachments")140+ }141+ return nil142+}143+144+func (t *Tenant) initRoomAttachments(ctx context.Context) error {145+ _, err := t.store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS room_attachments (146+ room_id TEXT NOT NULL, room_event_id TEXT NOT NULL DEFAULT '',147+ sha256 TEXT NOT NULL, filename TEXT NOT NULL DEFAULT '',148+ created_at INTEGER NOT NULL, PRIMARY KEY(room_id, room_event_id, sha256),149+ FOREIGN KEY(sha256) REFERENCES blobs(sha256) ON DELETE CASCADE150+ ); CREATE INDEX IF NOT EXISTS room_attachments_hash ON room_attachments(sha256);`)151+ if err != nil {152+ return fmt.Errorf("initialize room attachments: %w", err)153+ }154+ return nil155+}156+157+func (t *Tenant) roomAttachmentRooms(ctx context.Context, hash string) ([]roomAttachmentScope, error) {158+ rows, err := t.store.DB().QueryContext(ctx, "SELECT room_id,room_event_id FROM room_attachments WHERE sha256=?", hash)159+ if err != nil {160+ return nil, fmt.Errorf("lookup room attachment: %w", err)161+ }162+ defer rows.Close()163+ var rooms []roomAttachmentScope164+ for rows.Next() {165+ var room roomAttachmentScope166+ if err := rows.Scan(&room.RoomID, &room.EventID); err != nil {167+ return nil, fmt.Errorf("scan room attachment: %w", err)168+ }169+ rooms = append(rooms, room)170+ }171+ return rooms, rows.Err()172+}173+174+func (t *Tenant) roomAttachmentAccess(ctx context.Context, actor, hash string) error {175+ rooms, err := t.roomAttachmentRooms(ctx, hash)176+ if err != nil {177+ return err178+ }179+ if len(rooms) == 0 {180+ return nil181+ }182+ for _, scope := range rooms {183+ if room, _, err := t.roomFor(ctx, actor, scope.RoomID); err == nil && room.EventID == scope.EventID {184+ return nil185+ }186+ }187+ if actor == "" {188+ return errors.New("auth-required: room attachment")189+ }190+ return errors.New("restricted: room attachment membership required")191+}192+193+func (t *Tenant) roomAttachmentHTTP(w http.ResponseWriter, r *http.Request) bool {194+ if match := roomAttachmentPath.FindStringSubmatch(r.URL.Path); match != nil {195+ if r.Method == http.MethodPut {196+ t.uploadRoomAttachment(w, r, match[1])197+ } else {198+ w.Header().Set("Allow", http.MethodPut)199+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)200+ }201+ return true202+ }203+ match := mediaPath.FindStringSubmatch(r.URL.Path)204+ if match == nil {205+ return false206+ }207+ if r.Method != http.MethodGet && r.Method != http.MethodHead {208+ w.Header().Set("Allow", "GET, HEAD")209+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)210+ return true211+ }212+ if err := t.serveRoomMedia(w, r, match[1]); err != nil {213+ browseHTTPError(w, err)214+ }215+ return true216+}217+218+func (t *Tenant) serveRoomMedia(w http.ResponseWriter, r *http.Request, hash string) error {219+ var actor string220+ var err error221+ if auth.IsBlossomAuthorization(r.Header.Get("Authorization")) {222+ actor, err = t.authorizeBlob(r, blob.ActionGet)223+ } else {224+ actor, err = t.resolveUIActor(r)225+ }226+ if err != nil {227+ return err228+ }229+ if err := t.browseRead(r.Context(), actor); err != nil {230+ return err231+ }232+ // Use the same safe content headers and range serving as /files/raw.233+ w.Header().Set("Cache-Control", "private, no-store")234+ w.Header().Set("Content-Security-Policy", "sandbox")235+ w.Header().Set("X-Content-Type-Options", "nosniff")236+ request := r.Clone(r.Context())237+ query := request.URL.Query()238+ query.Set("hash", hash)239+ request.URL.RawQuery = query.Encode()240+ return t.fileDownload(w, request, actor)241+}242+243+func (t *Tenant) uploadRoomAttachment(w http.ResponseWriter, r *http.Request, roomID string) {244+ // Authenticate and authorize before buffering file bytes. The payload245+ // binding is checked after reading, without consuming the proof twice.246+ token, err := t.auth.VerifyNIP98(r.Header.Get("Authorization"), t.requestURL(r), r.Method, "")247+ if err != nil {248+ browseHTTPError(w, err)249+ return250+ }251+ if err := t.roomAttachmentUploader(r.Context(), token.PubKey, roomID); err != nil {252+ browseHTTPError(w, err)253+ return254+ }255+ r.Body = http.MaxBytesReader(w, r.Body, roomAttachmentMaxBytes+1)256+ body, err := io.ReadAll(r.Body)257+ if err != nil {258+ http.Error(w, "attachment exceeds 32 MiB", http.StatusRequestEntityTooLarge)259+ return260+ }261+ if int64(len(body)) > roomAttachmentMaxBytes {262+ http.Error(w, "attachment exceeds 32 MiB", http.StatusRequestEntityTooLarge)263+ return264+ }265+ hash := sha256.Sum256(body)266+ if err := t.auth.ValidateBlobPayload(r.Header.Get("Authorization"), hex.EncodeToString(hash[:])); err != nil {267+ browseHTTPError(w, err)268+ return269+ }270+ result, err := t.storeRoomAttachment(r.Context(), token.PubKey, roomID, roomAttachmentInput{Body: body, Type: r.Header.Get("Content-Type"), Filename: r.URL.Query().Get("filename")})271+ if err != nil {272+ browseHTTPError(w, err)273+ return274+ }275+ w.Header().Set("Content-Type", "application/json")276+ _ = json.NewEncoder(w).Encode(result)277+}@@ -0,0 +1,98 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/blob"11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+func TestRoomAttachmentAccessFollowsMembership(t *testing.T) {15+ h := newRoomHarness(t)16+ ctx := context.Background()17+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "secret"}, {"closed"}}, "")18+ entry, err := h.tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("room secret"), Type: "text/plain", Uploader: h.keys["alice"]})19+ if err != nil {20+ t.Fatal(err)21+ }22+ room, err := h.tenant.community.Room(ctx, "secret")23+ if err != nil {24+ t.Fatal(err)25+ }26+ 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 {27+ t.Fatal(err)28+ }29+ if err := h.tenant.roomAttachmentAccess(ctx, h.keys["stranger"], entry.SHA256); err == nil {30+ t.Fatal("nonmember can read room attachment")31+ }32+ if err := h.tenant.roomAttachmentAccess(ctx, h.keys["owner"], entry.SHA256); err != nil {33+ t.Fatalf("member cannot read room attachment: %v", err)34+ }35+}36+37+func TestGenericBlobRemainsUnscoped(t *testing.T) {38+ h := newRoomHarness(t)39+ entry, err := h.tenant.blobs.Put(context.Background(), blob.PutOptions{Reader: strings.NewReader("public"), Type: "text/plain", Uploader: h.keys["alice"]})40+ if err != nil {41+ t.Fatal(err)42+ }43+ if err := h.tenant.roomAttachmentAccess(context.Background(), "", entry.SHA256); err != nil {44+ t.Fatalf("generic blob unexpectedly scoped: %v", err)45+ }46+}47+48+func TestRoomAttachmentAgentGrantAndStorageLimits(t *testing.T) {49+ _, tenant := testTenant(t)50+ ctx := context.Background()51+ owner := tenant.Policy().Owner52+ agent, _ := event.PublicKey(testAgentSecret)53+ now := time.Now().Unix()54+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "9"}, []string{"h", "main"})); err != nil {55+ t.Fatal(err)56+ }57+ input := roomAttachmentInput{Body: []byte("generated attachment"), Type: "text/plain"}58+ if _, err := tenant.storeRoomAttachment(ctx, agent, "main", input); err != nil {59+ t.Fatalf("active agent upload: %v", err)60+ }61+ if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{rawJSON(agent)}); err != nil {62+ t.Fatal(err)63+ }64+ if _, err := tenant.storeRoomAttachment(ctx, agent, "main", input); err == nil {65+ t.Fatal("paused agent uploaded")66+ }67+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{rawJSON(agent)}); err != nil {68+ t.Fatal(err)69+ }70+ if _, err := tenant.storeRoomAttachment(ctx, agent, "main", input); err == nil {71+ t.Fatal("revoked agent uploaded")72+ }73+ if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{rawJSON(map[string]any{"fileLimits": map[string]any{"maxFileBytes": 4}})}); err != nil {74+ t.Fatal(err)75+ }76+ if _, err := tenant.storeRoomAttachment(ctx, owner, "main", input); err == nil {77+ t.Fatal("room upload bypassed file limit")78+ }79+}80+81+func TestRoomAttachmentCannotPrivatizeExistingUnscopedBlob(t *testing.T) {82+ h := newRoomHarness(t)83+ entry, err := h.tenant.blobs.Put(h.ctx, blob.PutOptions{Reader: strings.NewReader("shared bytes"), Type: "text/plain", Uploader: h.keys["alice"]})84+ if err != nil {85+ t.Fatal(err)86+ }87+ 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:") {88+ t.Fatalf("existing public blob should conflict: %v", err)89+ }90+ scopes, err := h.tenant.roomAttachmentRooms(h.ctx, entry.SHA256)91+ if err != nil || len(scopes) != 0 {92+ t.Fatalf("conflicted upload changed visibility: %v %v", scopes, err)93+ }94+ var claims int95+ 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 {96+ t.Fatalf("conflicted upload left a claim: %d %v", claims, err)97+ }98+}@@ -0,0 +1,98 @@
1+package daemon2+3+import (4+ "strconv"5+ "testing"6+ "time"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+ "github.com/FelineStateMachine/tinyrelay/internal/relay"10+)11+12+func TestBrowseThreadReturnsNewestAuthorEditsOutsideCursor(t *testing.T) {13+ h := newRoomHarness(t)14+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, "")15+ root := h.must("alice", event.KIND_THREAD, [][]string{{"h", "general"}}, "original root")16+ reply := h.must("bob", event.KIND_THREAD_REPLY, [][]string{{"h", "general"}, {"e", root.ID}}, "original reply")17+18+ cursor := collaborationCursor(collaborationItemFrom(reply))19+20+ publishAt := func(who string, createdAt int64, target event.Event, content string) event.Event {21+ e := h.sign(who, event.KIND_CONTENT_EDIT, [][]string{{"h", "general"}, {"e", target.ID}}, content)22+ e.CreatedAt = createdAt23+ if err := event.Sign(&e, h.secrets[who]); err != nil {24+ t.Fatal(err)25+ }26+ if _, err := h.tenant.router.Publish(h.ctx, e, relay.Session{PubKeys: []string{h.keys[who]}, RelayURL: h.tenant.RelayURL()}); err != nil {27+ t.Fatal(err)28+ }29+ return e30+ }31+ old := publishAt("alice", time.Now().Unix()+1, root, "root v1")32+ newest := publishAt("alice", old.CreatedAt+1, root, "root v2")33+ replyEdit := publishAt("bob", newest.CreatedAt+1, reply, "reply v1")34+35+ page, err := h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID, "cursor": cursor, "limit": 1})36+ if err != nil {37+ t.Fatal(err)38+ }39+ edits, ok := page["edits"].([]any)40+ if !ok {41+ t.Fatalf("edits = %#v", page["edits"])42+ }43+ found := map[string]bool{}44+ for _, raw := range edits {45+ row := raw.(map[string]any)46+ found[row["id"].(string)] = true47+ }48+ if !found[newest.ID] || found[replyEdit.ID] || found[old.ID] {49+ t.Fatalf("edits = %#v, want newest visible root only", edits)50+ }51+52+ forged := h.sign("alice", event.KIND_CONTENT_EDIT, [][]string{{"h", "general"}, {"e", reply.ID}}, "wrong author")53+ // The target belongs to Bob, so the author filter must exclude this edit.54+ if _, err := h.tenant.router.Publish(h.ctx, forged, relay.Session{PubKeys: []string{h.keys["alice"]}, RelayURL: h.tenant.RelayURL()}); err != nil {55+ t.Fatal(err)56+ }57+ page, err = h.browse("bob", "browsethread", map[string]any{"id": "general", "event": root.ID})58+ if err != nil {59+ t.Fatal(err)60+ }61+ found = map[string]bool{}62+ for _, raw := range page["edits"].([]any) {63+ found[raw.(map[string]any)["id"].(string)] = true64+ if raw.(map[string]any)["content"] == "wrong author" {65+ t.Fatal("forged edit was returned")66+ }67+ }68+ if !found[replyEdit.ID] || !found[newest.ID] {69+ t.Fatalf("unpaged edits = %#v, want root and reply", page["edits"])70+ }71+}72+73+func TestRoomEditsKeepIndependentTargetsAndUseLastReference(t *testing.T) {74+ h := newRoomHarness(t)75+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "general"}}, "")76+ first := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "first")77+ second := h.must("alice", event.KIND_CHAT, [][]string{{"h", "general"}}, "second")78+ wanted := h.must("alice", event.KIND_CONTENT_EDIT, [][]string{{"h", "general"}, {"e", first.ID}}, "first replacement")79+ var latest event.Event80+ for i := 0; i < 105; i++ {81+ // The first reference is context; only the last reference is edited.82+ 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))83+ if err := publishAs(t, h.tenant, latest); err != nil {84+ t.Fatal(err)85+ }86+ }87+ edits, err := h.tenant.roomEdits(h.ctx, h.keys["alice"], "general", []event.Event{first, second})88+ if err != nil {89+ t.Fatal(err)90+ }91+ found := map[string]bool{}92+ for _, edit := range edits {93+ found[edit.ID] = true94+ }95+ if len(edits) != 2 || !found[wanted.ID] || !found[latest.ID] {96+ t.Fatalf("lost independent target edit: %+v", edits)97+ }98+}@@ -152,7 +152,11 @@ func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseReque
152152 if err != nil {153153 return nil, err154154 }155- return map[string]any{"room": summary, "members": members, "messages": messages, "next_cursor": next}, nil155+ edits, err := t.roomEdits(ctx, actor, room.ID, messages)156+ if err != nil {157+ return nil, err158+ }159+ return map[string]any{"room": summary, "members": members, "messages": messages, "edits": edits, "next_cursor": next}, nil156160 }157161 158162 func (t *Tenant) browseThread(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {@@ -178,7 +182,14 @@ func (t *Tenant) browseThread(ctx context.Context, actor string, q roomBrowseReq
178182 if err != nil {179183 return nil, err180184 }181- return map[string]any{"room": room, "root": roots[0], "replies": replies, "next_cursor": next}, nil185+ targets := make([]event.Event, 0, len(replies)+1)186+ targets = append(targets, roots[0])187+ targets = append(targets, replies...)188+ edits, err := t.roomEdits(ctx, actor, room.ID, targets)189+ if err != nil {190+ return nil, err191+ }192+ return map[string]any{"room": room, "root": roots[0], "replies": replies, "edits": edits, "next_cursor": next}, nil182193 }183194 184195 // roomMessages pages a room filter newest first through the read gate. The@@ -206,3 +217,69 @@ func (t *Tenant) roomMessages(ctx context.Context, actor string, filter event.Fi
206217 }207218 return rows, next, nil208219 }220+221+// roomEdits returns the newest author-owned edit for each visible message.222+// It deliberately queries independently of the timeline cursor: an edit is223+// a replacement for its target and may have been published after the page224+// containing that target was read. A thread page has at most 100 replies225+// plus its root; each target contributes at most one replacement.226+func (t *Tenant) roomEdits(ctx context.Context, actor, roomID string, targets []event.Event) ([]event.Event, error) {227+ if len(targets) == 0 {228+ return []event.Event{}, nil229+ }230+ validTargets := make([]event.Event, 0, len(targets))231+ for _, target := range targets {232+ if len(target.ID) != 64 || !hexLower(target.ID) || target.PubKey == "" ||233+ (target.Kind != event.KIND_CHAT && target.Kind != event.KIND_THREAD && target.Kind != event.KIND_THREAD_REPLY && target.Kind != event.KIND_RICH_CONTENT) {234+ continue235+ }236+ validTargets = append(validTargets, target)237+ if len(validTargets) == 101 {238+ break239+ }240+ }241+ if len(validTargets) == 0 {242+ return []event.Event{}, nil243+ }244+ newest := make(map[string]event.Event, len(validTargets))245+ for _, target := range validTargets {246+ since := target.CreatedAt247+ filter := event.Filter{Kinds: []int{event.KIND_CONTENT_EDIT}, Authors: []string{target.PubKey}, Tags: map[string][]string{"h": {roomID}, "e": {target.ID}}, Since: &since}248+ var cursor *storage.EventCursor249+ for {250+ edits, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)251+ if err != nil {252+ return nil, err253+ }254+ for _, edit := range edits {255+ if lastRoomEditTarget(edit) == target.ID {256+ newest[target.ID] = edit257+ break258+ }259+ }260+ if _, found := newest[target.ID]; found || next == "" {261+ break262+ }263+ cursor, err = parseCollaborationCursor(next)264+ if err != nil {265+ return nil, err266+ }267+ }268+ }269+ result := make([]event.Event, 0, len(newest))270+ for _, edit := range newest {271+ result = append(result, edit)272+ }273+ sortEventsNewestFirst(result)274+ return result, nil275+}276+277+func lastRoomEditTarget(e event.Event) string {278+ target := ""279+ for _, tag := range e.Tags {280+ if len(tag) >= 2 && tag[0] == "e" {281+ target = tag[1]282+ }283+ }284+ return target285+}@@ -89,10 +89,20 @@ func (t *Tenant) initServices(ctx context.Context) error {
8989 if err != nil {9090 return err9191 }92+ if err := t.initRoomAttachments(ctx); err != nil {93+ return err94+ }9295 t.community.ConfigurePolicy(t.Policy)9396 t.sites, err = sites.New(sites.Config{9497 Store: t.store,9598 GetBlob: func(ctx context.Context, hash string) (sites.Blob, error) {99+ rooms, err := t.roomAttachmentRooms(ctx, hash)100+ if err != nil {101+ return sites.Blob{}, err102+ }103+ if len(rooms) > 0 {104+ return sites.Blob{}, errors.New("restricted: room attachment cannot be served by a site")105+ }96106 entry, body, err := t.blobs.Get(ctx, hash)97107 if err != nil {98108 return sites.Blob{}, err@@ -247,6 +257,7 @@ func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, err
247257 if action == blob.ActionGet || action == blob.ActionDelete || (action == blob.ActionUpload && (r.Method == http.MethodPut || r.Method == http.MethodPatch)) {248258 path := strings.TrimPrefix(r.URL.Path, "/")249259 path = strings.TrimPrefix(path, "nip96/")260+ path = strings.TrimPrefix(path, "media/")250261 hash = strings.Split(path, ".")[0]251262 if len(hash) != 64 || strings.Trim(hash, "0123456789abcdef") != "" {252263 hash = ""@@ -295,6 +306,11 @@ func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, err
295306 if err != nil {296307 return "", err297308 }309+ if action == blob.ActionGet {310+ if err := t.roomAttachmentAccess(r.Context(), pubkey, hash); err != nil {311+ return "", err312+ }313+ }298314 if pubkey == "" {299315 if action == blob.ActionGet {300316 return "", nil@@ -52,8 +52,10 @@ func (c Call) Int(key string) int {
5252 5353 // Content is one unstructured content block of a tool result.5454 type Content struct {55- Type string `json:"type"`56- Text string `json:"text"`55+ Type string `json:"type"`56+ Text string `json:"text,omitempty"`57+ Data string `json:"data,omitempty"`58+ MimeType string `json:"mimeType,omitempty"`5759 }5860 5961 // Result is what a tool returns. StructuredContent is mirrored into a text@@ -845,6 +845,109 @@
845845 flush();846846 return node;847847 };848+ // NIP-92 references are matched outside code. Only standalone attachment849+ // lines are removed; prose links and examples keep their original text.850+ const attachmentURLPattern = /^https?:\/\/[^\s\x00-\x1f\x7f\\<>"']+$/;851+ const attachmentReference = /!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>"']+)/g;852+ const attachmentLines = (text, visit) => {853+ let marker = "";854+ return String(text || "").replace(/\r\n/g, "\n").split("\n").map(line => {855+ const trimmed = line.trim();856+ if (marker) {857+ if (trimmed.length >= marker.length && [...trimmed].every(c => c === marker[0])) marker = "";858+ return line;859+ }860+ const fence = trimmed.match(/^(`{3,}|~{3,})/);861+ if (fence) { marker = fence[1]; return line; }862+ return visit(line);863+ }).join("\n");864+ };865+ const attachmentOutsideCode = line => {866+ let out = "";867+ while (line.length) {868+ const start = line.indexOf("`");869+ if (start < 0) return out + line;870+ out += line.slice(0, start);871+ line = line.slice(start);872+ const delimiter = line.match(/^`+/)[0], end = line.indexOf(delimiter, delimiter.length);873+ if (end < 0) return out + line;874+ out += " ";875+ line = line.slice(end + delimiter.length);876+ }877+ return out;878+ };879+ const attachmentReferenceURL = match => match[2] || match[3].replace(/[.,;:!?)]+$/, "");880+ const attachmentURLVisible = (text, target) => {881+ let visible = false;882+ attachmentLines(text, line => {883+ for (const match of attachmentOutsideCode(line).matchAll(attachmentReference)) if (attachmentReferenceURL(match) === target) visible = true;884+ return line;885+ });886+ return visible;887+ };888+ const attachmentText = (text, limit) => [...String(text || "")].slice(0, limit).join("");889+ const roomAttachments = event => {890+ const out = [], seen = new Set(), content = String(event?.content || "");891+ for (const tag of (event?.tags || [])) {892+ if (!Array.isArray(tag) || tag[0] !== "imeta" || out.length >= 16) continue;893+ const item = {};894+ for (const field of tag.slice(1)) {895+ if (typeof field !== "string") continue;896+ const split = field.indexOf(" ");897+ if (split < 1) continue;898+ const key = field.slice(0, split), value = field.slice(split + 1);899+ if (!value) continue;900+ if (key === "url") item.url = value;901+ else if (key === "m") item.mime = value.toLowerCase();902+ else if (key === "x") item.hash = value;903+ else if (key === "size") item.size = /^-?\d+$/.test(value) ? Number(value) : 0;904+ else if (key === "filename" || key === "name") item.name = value;905+ else if (key === "alt") item.alt = value;906+ }907+ if (!attachmentURLPattern.test(item.url || "") || seen.has(item.url) || !attachmentURLVisible(content, item.url)) continue;908+ let parsed;909+ try { parsed = new URL(item.url); } catch { continue; }910+ if (!parsed.hostname || parsed.username || parsed.password) continue;911+ item.size = Number.isSafeInteger(item.size) && item.size > 0 && item.size <= 1099511627776 ? item.size : 0;912+ item.name = attachmentText(item.name, 255);913+ item.alt = attachmentText(item.alt, 500);914+ if (!item.name) item.name = item.alt;915+ if (!item.name) { try { item.name = decodeURIComponent(parsed.pathname.split("/").pop() || "Attachment"); } catch { item.name = "Attachment"; } }916+ seen.add(item.url);917+ out.push(item);918+ }919+ return out;920+ };921+ const attachmentContent = (text, items) => {922+ const urls = new Set(items.map(item => item.url));923+ return attachmentLines(text, line => {924+ const trimmed = line.trim(), match = [...trimmed.matchAll(attachmentReference)][0];925+ return match && match[0] === trimmed && urls.has(attachmentReferenceURL(match)) ? "" : line;926+ }).trim();927+ };928+ const attachmentSize = size => size < 1024 ? size + " B" : size < 1048576 ? Math.floor(size / 1024) + " KB" : Math.floor(size / 1048576) + " MB";929+ const attachmentNodes = items => {930+ if (!items.length) return null;931+ const box = el("div"); box.dataset.roomAttachments = "";932+ for (const item of items) {933+ const figure = el("figure"), name = item.name || "Attachment";934+ if (item.mime?.startsWith("image/") && item.mime !== "image/svg+xml") {935+ const link = el("a"), image = el("img");936+ link.href = item.url; link.rel = "noopener noreferrer"; link.setAttribute("fx-ignore", "");937+ image.src = item.url; image.alt = item.alt || name; image.loading = "lazy"; image.referrerPolicy = "no-referrer";938+ link.append(image); figure.append(link);939+ } else if (item.mime?.startsWith("video/") || item.mime?.startsWith("audio/")) {940+ const media = el(item.mime.startsWith("video/") ? "video" : "audio");941+ media.controls = true; media.preload = "none"; media.src = item.url; figure.append(media);942+ }943+ const caption = el("figcaption"), link = el("a", name);944+ link.href = item.url; link.download = name; link.dataset.download = ""; link.rel = "noopener noreferrer"; link.setAttribute("fx-ignore", "");945+ caption.append(link);946+ if (item.size > 0) caption.append(" ", el("small", attachmentSize(item.size)));947+ figure.append(caption); box.append(figure);948+ }949+ return box;950+ };848951 const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };849952 // nameNode shows a person: the vendored nostr-name element replaces the short850953 // id with the profile name published on this relay.@@ -865,6 +968,7 @@
865968 node.id = "msg-" + event.id;866969 node.dataset.id = event.id;867970 node.dataset.kind = String(event.kind);971+ node.dataset.updatedAt = String(event.created_at);868972 node.dataset.pubkey = pubkey;869973 const member = members[pubkey];870974 if (member?.agent) node.dataset.agent = "";@@ -875,8 +979,10 @@
875979 time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";876980 small.append(time);877981 header.append(name, member?.role ? " | " + member.role : "", small);878- const body = notice ? el("p", notice) : chatMarkdown(el("div"), event.content || "");982+ const attachments = notice ? [] : roomAttachments(event);983+ const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));879984 node.append(header, body);985+ const media = attachmentNodes(attachments); if (media) node.append(media);880986 const footer = el("footer");881987 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);882988 if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); }@@ -934,9 +1040,15 @@
9341040 const targets = (event.tags || []).filter(tag => tag[0] === "e");9351041 const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);9361042 if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;1043+ const updatedAt = Number(event.created_at);1044+ if (!Number.isFinite(updatedAt) || updatedAt < Number(target.dataset.updatedAt || 0)) return;9371045 const body = target.querySelector(":scope > div");9381046 if (!body) return;939- body.replaceWith(chatMarkdown(el("div"), event.content || ""));1047+ target.dataset.updatedAt = String(updatedAt);1048+ const attachments = roomAttachments(event);1049+ body.replaceWith(chatMarkdown(el("div"), attachmentContent(event.content || "", attachments)));1050+ target.querySelector(":scope > [data-room-attachments]")?.remove();1051+ const media = attachmentNodes(attachments); if (media) target.insertBefore(media, target.querySelector(":scope > footer"));9401052 if (target.dataset.edited !== undefined) return;9411053 target.dataset.edited = "";9421054 let footer = target.querySelector(":scope > footer");@@ -947,13 +1059,14 @@
9471059 };9481060 // signAndPublish signs one room event, checks it came back unchanged and9491061 // valid, and publishes it once.950- const signAndPublish = async unsigned => {1062+ const signAndPublish = async (unsigned, signal) => {9511063 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");9521064 const expected = JSON.stringify(unsigned);9531065 const event = await window.nostr.signEvent(JSON.parse(expected));9541066 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});9551067 if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");956- const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});1068+ if (signal?.aborted) throw Error("Sending canceled.");1069+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json", signal});9571070 const result = await response.json();9581071 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");9591072 return event;@@ -971,9 +1084,79 @@
9711084 event.preventDefault();9721085 this.form?.requestSubmit();9731086 });1087+ this.addEventListener("change", event => {1088+ if (event.target.name !== "attachments") return;1089+ this.chooseFiles(event.target.files);1090+ event.target.value = "";1091+ });1092+ this.addEventListener("paste", event => {1093+ if (!this.querySelector("room-files")) return;1094+ const files = [...(event.clipboardData?.files || [])];1095+ if (!files.length) return;1096+ event.preventDefault();1097+ this.chooseFiles(files);1098+ });1099+ this.addEventListener("dragover", event => {1100+ if (!this.querySelector("room-files") || ![...(event.dataTransfer?.types || [])].includes("Files")) return;1101+ event.preventDefault();1102+ if (!this.submitting) this.dataset.over = "";1103+ });1104+ this.addEventListener("dragleave", () => { delete this.dataset.over; });1105+ this.addEventListener("drop", event => {1106+ delete this.dataset.over;1107+ if (!this.querySelector("room-files") || !event.dataTransfer?.files?.length) return;1108+ event.preventDefault();1109+ this.chooseFiles(event.dataTransfer.files);1110+ });1111+ this.renderFiles();9741112 }9751113 976- event(content) {1114+ disconnectedCallback() { this.uploadController?.abort(); }1115+1116+ busy(on) {1117+ super.busy(on);1118+ this.form?.querySelectorAll("input, textarea").forEach(input => { input.disabled = on; });1119+ }1120+1121+ chooseFiles(files) {1122+ try { this.addFiles(files); this.report(""); }1123+ catch (error) { this.report(error.message, true); }1124+ }1125+1126+ addFiles(files) {1127+ if (this.submitting) return;1128+ const chosen = [...files];1129+ const pending = this.pendingFiles || [];1130+ if (pending.length + chosen.length > 8) throw Error("Attach up to 8 files per message.");1131+ if (chosen.some(file => file.size === 0 || file.size > 32 * 1024 * 1024)) throw Error("Each attachment must be between 1 byte and 32 MiB.");1132+ this.pendingFiles = [...pending, ...chosen.map(file => ({file}))];1133+ this.renderFiles();1134+ }1135+1136+ renderFiles() {1137+ const pending = this.pendingFiles || [];1138+ const content = this.form?.elements?.content;1139+ if (content) content.required = pending.length === 0;1140+ const target = this.querySelector("room-files");1141+ if (!target) return;1142+ const list = el("ul");1143+ pending.forEach((entry, index) => {1144+ const row = el("li"), remove = el("button", "Remove");1145+ remove.type = "button";1146+ remove.disabled = Boolean(this.submitting);1147+ remove.setAttribute("aria-label", "Remove " + entry.file.name);1148+ remove.addEventListener("click", () => {1149+ if (this.submitting) return;1150+ this.pendingFiles.splice(index, 1);1151+ this.renderFiles();1152+ });1153+ row.append(el("span", entry.file.name), " ", el("small", entry.descriptor ? "uploaded" : Math.ceil(entry.file.size / 1024) + " KB"), " ", remove);1154+ list.append(row);1155+ });1156+ target.replaceChildren(...(pending.length ? [list] : []));1157+ }1158+1159+ event(content, attachments = []) {9771160 const room = this.getAttribute("room") || "";9781161 if (!/^[a-z0-9_-]{1,64}$/.test(room)) throw Error("The room id is missing.");9791162 const kind = Number(this.getAttribute("kind") || 9);@@ -986,17 +1169,60 @@
9861169 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);9871170 }9881171 roomMentions(content).forEach(key => { if (!tags.some(tag => tag[0] === "p" && tag[1] === key)) tags.push(["p", key]); });1172+ const lines = [];1173+ attachments.forEach(file => {1174+ tags.push(["imeta", "url " + file.url, "m " + file.type, "x " + file.sha256, "size " + file.size, "filename " + file.filename]);1175+ const label = file.filename.replace(/[\\[\]]/g, "\\$&");1176+ lines.push(file.type.startsWith("image/") ? `` : file.type.startsWith("video/") ? `` : `[${label}](${file.url})`);1177+ });1178+ content = [content, lines.join("\n")].filter(Boolean).join("\n\n");9891179 return {kind, created_at: now(), tags, content};9901180 }9911181 1182+ async uploadFiles(signal) {1183+ const pending = this.pendingFiles || [];1184+ for (const [index, entry] of pending.entries()) {1185+ if (signal.aborted) throw Error("Upload canceled.");1186+ if (entry.descriptor) continue;1187+ const file = entry.file;1188+ this.report("Uploading " + file.name + " (" + (index + 1) + " of " + pending.length + ")…");1189+ const bytes = new Uint8Array(await file.arrayBuffer());1190+ const hash = await tiny.sha256hex(bytes);1191+ const filename = Array.from(file.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim()).slice(0, 255).join("") || "file";1192+ const path = "/rooms/" + encodeURIComponent(this.getAttribute("room")) + "/attachments?filename=" + encodeURIComponent(filename);1193+ const response = await tiny.signedFetch(path, "PUT", bytes, {contentType: file.type || "application/octet-stream", signal});1194+ if (signal.aborted) throw Error("Upload canceled.");1195+ const descriptor = await response.json();1196+ if (descriptor.sha256 !== hash || descriptor.size !== bytes.byteLength) throw Error("The upload descriptor has an unexpected hash or size.");1197+ let url;1198+ try { url = new URL(descriptor.url); } catch { throw Error("The upload descriptor has an invalid URL."); }1199+ const prefix = tiny.localPath("/media/" + hash);1200+ 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.");1201+ if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(descriptor.type || "")) throw Error("The upload descriptor has an invalid media type.");1202+ entry.descriptor = {...descriptor, filename};1203+ this.renderFiles();1204+ }1205+ return pending.map(entry => entry.descriptor);1206+ }1207+9921208 async submit(form) {9931209 const content = (form.elements.content?.value || "").trim();994- if (!content) return;995- this.report("Signing…");996- const event = await signAndPublish(this.event(content));997- form.reset();998- this.report("");999- roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});1210+ if (!content && !this.pendingFiles?.length) return;1211+ this.event(content);1212+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");1213+ const controller = new AbortController();1214+ this.uploadController = controller;1215+ try {1216+ const files = await this.uploadFiles(controller.signal);1217+ if (controller.signal.aborted) throw Error("Upload canceled.");1218+ this.report("Signing…");1219+ const event = await signAndPublish(this.event(content, files), controller.signal);1220+ form.reset();1221+ this.pendingFiles = [];1222+ this.renderFiles();1223+ this.report("");1224+ roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});1225+ } finally { this.uploadController = null; }10001226 }10011227 }10021228 @@ -1113,7 +1339,7 @@
11131339 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});11141340 }11151341 }1116- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown});1342+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});11171343 11181344 // JsonView renders any JSON value. Arrays of objects become tables, objects11191345 // become definition lists, scalar arrays become lists, and deep nesting@@ -0,0 +1,246 @@
1+package webui2+3+import (4+ "encoding/json"5+ "html/template"6+ "net/url"7+ "path"8+9+ "regexp"10+ "strconv"11+ "strings"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/views"14+)15+16+const maxRoomAttachments = 1617+18+type roomAttachment struct {19+ URL, MIME, Hash, Name, Alt string20+ Size int6421+}22+23+var roomAttachmentURL = regexp.MustCompile(`^(https?://[^\s\x00-\x1f\x7f\\<>"']+)$`)24+var roomAttachmentReference = regexp.MustCompile(`!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?://[^\s<>"']+)`)25+26+func roomAttachments(value any) []roomAttachment {27+ content := plainString(valueMap(value)["content"])28+ items := roomAttachmentsFromTags(roomTags(value))29+ out := items[:0]30+ seen := map[string]bool{}31+ for _, item := range items {32+ if seen[item.URL] || !roomAttachmentURLVisible(content, item.URL) {33+ continue34+ }35+ seen[item.URL] = true36+ out = append(out, item)37+ }38+ return out39+}40+41+func roomAttachmentsFromTags(tags [][]string) []roomAttachment {42+ var out []roomAttachment43+ for _, tag := range tags {44+ if len(tag) < 2 || tag[0] != "imeta" || len(out) >= maxRoomAttachments {45+ continue46+ }47+ item := roomAttachment{}48+ for _, field := range tag[1:] {49+ key, val, ok := strings.Cut(field, " ")50+ if !ok || val == "" {51+ continue52+ }53+ switch key {54+ case "url":55+ item.URL = val56+ case "m":57+ item.MIME = strings.ToLower(val)58+ case "x":59+ item.Hash = val60+ case "size":61+ item.Size, _ = strconv.ParseInt(val, 10, 64)62+ case "filename", "name":63+ item.Name = val64+ case "alt":65+ item.Alt = val66+ }67+ }68+ if !roomAttachmentURL.MatchString(item.URL) || !safeRoomAttachmentURL(item.URL) {69+ continue70+ }71+ if item.Size < 0 || item.Size > 1<<40 {72+ item.Size = 073+ }74+ item.Name = boundedAttachmentText(item.Name, 255)75+ item.Alt = boundedAttachmentText(item.Alt, 500)76+ if item.Name == "" {77+ item.Name = item.Alt78+ }79+ if item.Name == "" {80+ u, _ := url.Parse(item.URL)81+ item.Name = path.Base(u.Path)82+ if item.Name == "." || item.Name == "/" {83+ item.Name = "Attachment"84+ }85+ }86+ out = append(out, item)87+ }88+ return out89+}90+91+func roomTags(value any) [][]string {92+ var tags [][]string93+ encoded, err := json.Marshal(valueMap(value)["tags"])94+ if err == nil {95+ _ = json.Unmarshal(encoded, &tags)96+ }97+ return tags98+}99+100+func safeRoomAttachmentURL(raw string) bool {101+ u, err := url.Parse(raw)102+ return err == nil && u.User == nil && u.Hostname() != "" && (u.Scheme == "http" || u.Scheme == "https")103+}104+105+// attachmentLines visits only lines outside fenced code, retaining original106+// lines so removing a standalone reference cannot rewrite prose or examples.107+func attachmentLines(content string, visit func(string) string) string {108+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")109+ marker := ""110+ for i, line := range lines {111+ if marker != "" {112+ if views.ClosesFence(line, marker) {113+ marker = ""114+ }115+ continue116+ }117+ if next, _, ok := views.OpenFence(line); ok {118+ marker = next119+ continue120+ }121+ lines[i] = visit(line)122+ }123+ return strings.Join(lines, "\n")124+}125+126+func attachmentOutsideCode(line string) string {127+ var out strings.Builder128+ for len(line) > 0 {129+ start := strings.IndexByte(line, '`')130+ if start < 0 {131+ out.WriteString(line)132+ break133+ }134+ out.WriteString(line[:start])135+ line = line[start:]136+ n := len(line) - len(strings.TrimLeft(line, "`"))137+ end := strings.Index(line[n:], line[:n])138+ if end < 0 {139+ out.WriteString(line)140+ break141+ }142+ out.WriteByte(' ')143+ line = line[n+end+n:]144+ }145+ return out.String()146+}147+148+func attachmentReferenceURL(match []string) string {149+ if match[2] != "" {150+ return match[2]151+ }152+ return strings.TrimRight(match[3], ".,;:!?)")153+}154+155+func roomAttachmentContent(content string, items []roomAttachment) string {156+ urls := make(map[string]bool, len(items))157+ for _, item := range items {158+ urls[item.URL] = true159+ }160+ return strings.TrimSpace(attachmentLines(content, func(line string) string {161+ trimmed := strings.TrimSpace(line)162+ match := roomAttachmentReference.FindStringSubmatch(trimmed)163+ if match != nil && match[0] == trimmed && urls[attachmentReferenceURL(match)] {164+ return ""165+ }166+ return line167+ }))168+}169+170+func roomAttachmentURLVisible(content, target string) bool {171+ visible := false172+ attachmentLines(content, func(line string) string {173+ for _, match := range roomAttachmentReference.FindAllStringSubmatch(attachmentOutsideCode(line), -1) {174+ if attachmentReferenceURL(match) == target {175+ visible = true176+ }177+ }178+ return line179+ })180+ return visible181+}182+183+func boundedAttachmentText(text string, limit int) string {184+ runes := []rune(text)185+ if len(runes) > limit {186+ runes = runes[:limit]187+ }188+ return string(runes)189+}190+191+func roomAttachmentLabel(item roomAttachment) string {192+ if item.Alt != "" {193+ return item.Alt194+ }195+ return item.Name196+}197+198+func roomAttachmentMarkup(items []roomAttachment) template.HTML {199+ if len(items) == 0 {200+ return ""201+ }202+ var out strings.Builder203+ out.WriteString(`<div data-room-attachments>`)204+ for _, item := range items {205+ out.WriteString(roomAttachmentFigure(item))206+ }207+ out.WriteString(`</div>`)208+ return template.HTML(out.String())209+}210+211+func roomAttachmentFigure(item roomAttachment) string {212+ href := template.HTMLEscapeString(item.URL)213+ name := template.HTMLEscapeString(item.Name)214+ if name == "" {215+ name = "Attachment"216+ }217+ alt := template.HTMLEscapeString(roomAttachmentLabel(item))218+ var out strings.Builder219+ out.WriteString(`<figure>`)220+ switch {221+ case strings.HasPrefix(item.MIME, "image/") && item.MIME != "image/svg+xml":222+ out.WriteString(`<a href="` + href + `" rel="noopener noreferrer" fx-ignore><img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer"></a>`)223+ case strings.HasPrefix(item.MIME, "video/") || strings.HasPrefix(item.MIME, "audio/"):224+ kind := "audio"225+ if strings.HasPrefix(item.MIME, "video/") {226+ kind = "video"227+ }228+ out.WriteString(`<` + kind + ` controls preload="none" src="` + href + `"></` + kind + `>`)229+ }230+ out.WriteString(`<figcaption><a data-download href="` + href + `" download="` + name + `" rel="noopener noreferrer" fx-ignore>` + name + `</a>`)231+ if item.Size > 0 {232+ out.WriteString(` <small>` + roomFileSize(item.Size) + `</small>`)233+ }234+ out.WriteString(`</figcaption></figure>`)235+ return out.String()236+}237+238+func roomFileSize(size int64) string {239+ if size < 1024 {240+ return strconv.FormatInt(size, 10) + " B"241+ }242+ if size < 1<<20 {243+ return strconv.FormatInt(size/1024, 10) + " KB"244+ }245+ return strconv.FormatInt(size/(1<<20), 10) + " MB"246+}@@ -0,0 +1,62 @@
1+package webui2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestRoomAttachmentRenderingIntegration(t *testing.T) {9+ url := "https://cdn.example.test/report.pdf"10+ row := map[string]any{"content": "See [report](" + url + ") and keep this prose.", "tags": [][]string{{"imeta", "url " + url, "m application/pdf", "filename report.pdf"}}}11+ items := roomAttachments(row)12+ if len(items) != 1 {13+ t.Fatalf("items = %#v", items)14+ }15+ if got := roomAttachmentContent(row["content"].(string), items); got != row["content"].(string) {16+ t.Fatalf("generic markdown changed: %q", got)17+ }18+ markup := string(roomAttachmentMarkup(items))19+ if strings.Contains(markup, "[report]()") || !strings.Contains(markup, "report.pdf") {20+ t.Fatalf("generic attachment markup: %s", markup)21+ }22+}23+24+func TestRoomAttachmentVisibleURLMatchingIsExactAndSafe(t *testing.T) {25+ url := "https://cdn.example.test/photo.png"26+ base := [][]string{{"imeta", "url " + url, "m image/png"}}27+ for name, content := range map[string]string{28+ "prefix": url + "-backup",29+ "fenced": "```\n" + url + "\n```",30+ "inline": "`" + url + "`",31+ } {32+ if got := roomAttachments(map[string]any{"content": content, "tags": base}); len(got) != 0 {33+ t.Errorf("%s matched %#v", name, got)34+ }35+ }36+ for _, unsafe := range []string{"javascript:alert(1)", "data:image/png,abc", "//cdn.example/photo.png", "/media/" + strings.Repeat("a", 64)} {37+ if got := roomAttachments(map[string]any{"content": unsafe, "tags": [][]string{{"imeta", "url " + unsafe, "m image/png"}}}); len(got) != 0 {38+ t.Errorf("unsafe URL %q accepted", unsafe)39+ }40+ }41+}42+43+func TestRoomAttachmentEditAndThreadRootReplacement(t *testing.T) {44+ rootID := strings.Repeat("1", 64)45+ oldURL := "https://cdn.example.test/old.png"46+ newURL := "https://cdn.example.test/new.mp4"47+ 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"}}}48+ 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"}}}49+ value := map[string]any{"room": map[string]any{"id": "general"}, "root": root, "replies": []any{edit}}50+ item := roomRoot(value)51+ if !item.Edited || item.Content != newURL || len(item.Attachments) != 1 || item.Attachments[0].URL != newURL {52+ t.Fatalf("root edit = %#v", item)53+ }54+}55+56+func TestRoomAttachmentMediaMarkupHasFallbackAndBounds(t *testing.T) {57+ 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"}}58+ markup := string(roomAttachmentMarkup(items))59+ if !strings.Contains(markup, "clip.mp4") || !strings.Contains(markup, "audio.mp3") {60+ t.Fatalf("media fallback missing: %s", markup)61+ }62+}@@ -0,0 +1,52 @@
1+package webui2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestRoomAttachmentsParsesNIP92AndStripsReferences(t *testing.T) {9+ url := "https://cdn.example.test/photo.png"10+ row := map[string]any{"content": "Look  and " + url, "tags": [][]string{{"imeta", "url " + url, "m image/png", "x " + strings.Repeat("a", 64), "size 42", "alt a <photo>"}}}11+ got := roomAttachments(row)12+ 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>" {13+ t.Fatalf("attachments = %#v", got)14+ }15+ if text := roomAttachmentContent(valueMap(row)["content"].(string), got); text != valueMap(row)["content"].(string) {16+ t.Fatalf("content = %q", text)17+ }18+}19+20+func TestRoomAttachmentsRejectUnsafeAndBoundValues(t *testing.T) {21+ long := strings.Repeat("x", 5000)22+ 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}}}23+ got := roomAttachments(row)24+ if len(got) != 1 || got[0].URL != "https://cdn.example/file" {25+ t.Fatalf("unsafe attachments = %#v", got)26+ }27+}28+29+func TestRoomAttachmentsIgnoreCodeAndDeduplicate(t *testing.T) {30+ url := "https://cdn.example.test/photo.png"31+ row := map[string]any{"content": "`" + url + "`\n" + url, "tags": [][]string{{"imeta", "url " + url, "m image/png"}, {"imeta", "url " + url, "m image/png"}}}32+ if got := roomAttachments(row); len(got) != 1 {33+ t.Fatalf("attachments = %#v", got)34+ }35+ row["content"] = "```\n" + url + "\n```"36+ if got := roomAttachments(row); len(got) != 0 {37+ t.Fatalf("code attachment = %#v", got)38+ }39+}40+41+func TestRoomAttachmentMarkupEscapesAndClassifiesMedia(t *testing.T) {42+ 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}}43+ markup := string(roomAttachmentMarkup(items))44+ for _, want := range []string{"room-attachments", "<video", "controls", "report.pdf", "download", "2 KB"} {45+ if !strings.Contains(markup, want) {46+ t.Errorf("markup missing %q: %s", want, markup)47+ }48+ }49+ if strings.Contains(markup, `class=`) || strings.Contains(markup, `javascript:`) {50+ t.Fatalf("unsafe markup: %s", markup)51+ }52+}@@ -104,6 +104,7 @@ type reaction struct {
104104 type roomEdit struct {105105 PubKey, Content string106106 CreatedAt int64107+ Tags [][]string107108 }108109 109110 // editSummary maps message ids to the newest edit (kind 40003, Buzz's@@ -111,7 +112,7 @@ type roomEdit struct {
111112 // replies, so a stranger's edit never shadows the author's own.112113 func editSummary(value any) map[string]map[string]roomEdit {113114 edits := map[string]map[string]roomEdit{}114- for _, field := range []string{"messages", "replies"} {115+ for _, field := range []string{"messages", "replies", "edits"} {115116 for _, row := range roomSlice(value, field) {116117 e := valueMap(row)117118 if plainString(e["kind"]) != "40003" {@@ -122,7 +123,7 @@ func editSummary(value any) map[string]map[string]roomEdit {
122123 continue123124 }124125 target := targets[len(targets)-1]125- edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"])}126+ edit := roomEdit{PubKey: plainString(e["pubkey"]), Content: plainString(e["content"]), CreatedAt: unixSeconds(e["created_at"]), Tags: roomTags(row)}126127 if edits[target] == nil {127128 edits[target] = map[string]roomEdit{}128129 }@@ -248,11 +249,12 @@ func clock(value any) string {
248249 // author's markers, the thread state and the reactions it has received.249250 type roomItem struct {250251 ID, PubKey, Kind, Content, Room, Root, Role, Notice string251- CreatedAt int64252+ CreatedAt, UpdatedAt int64252253 Agent, InThread, Edited bool253254 Mentions []string254255 Replies int255256 Reactions []reaction257+ Attachments []roomAttachment256258 }257259 258260 // roomItems turns a browse result's messages or replies into view items,@@ -273,7 +275,8 @@ func roomItems(value any, field string) []roomItem {
273275 }274276 // Only the author edits their own message, and only forward in time.275277 if edit, found := edits[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {276- item.Content, item.Edited = edit.Content, true278+ item.Content, item.Attachments, item.Edited = edit.Content, roomAttachments(map[string]any{"content": edit.Content, "tags": edit.Tags}), true279+ item.UpdatedAt = edit.CreatedAt277280 }278281 item.Replies = replies[item.ID]279282 item.Reactions = reactions[item.ID]@@ -287,6 +290,10 @@ func roomItems(value any, field string) []roomItem {
287290 func roomRoot(value any) roomItem {288291 data := valueMap(value)289292 item, _ := newRoomItem(data["root"], plainString(valueMap(data["room"])["id"]), memberIndex(value))293+ if edit, found := editSummary(value)[item.ID][item.PubKey]; found && edit.CreatedAt >= item.CreatedAt {294+ item.Content, item.Attachments, item.Edited = edit.Content, roomAttachments(map[string]any{"content": edit.Content, "tags": edit.Tags}), true295+ item.UpdatedAt = edit.CreatedAt296+ }290297 item.Replies = len(roomSlice(value, "replies"))291298 item.InThread = true292299 return item@@ -295,6 +302,8 @@ func roomRoot(value any) roomItem {
295302 func newRoomItem(row any, room string, members map[string]map[string]any) (roomItem, bool) {296303 e := valueMap(row)297304 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"])}305+ item.UpdatedAt = item.CreatedAt306+ item.Attachments = roomAttachments(row)298307 switch item.Kind {299308 case "7", "40003":300309 return roomItem{}, false@@ -31,7 +31,7 @@
3131 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}3232 </div>3333 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>34-{{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>34+{{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>3535 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>3636 {{else}}<p id="guest"><a href="/signin">Sign in</a> to send messages.</p>3737 {{end}}{{end}}</section>@@ -50,15 +50,17 @@
5050 </div>5151 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}5252 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>53-{{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>53+{{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>5454 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>5555 {{else}}<p id="guest"><a href="/signin">Sign in</a> to reply.</p>5656 {{end}}{{end}}</section>5757 {{end}}{{end}}5858 59+{{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}}60+5961 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}60-{{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}}>62+{{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}}>6163 <header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>62-{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown .Content}}</div>{{end}}64+{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}6365 {{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}}6466 </room-message>{{end}}@@ -142,7 +142,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
142142 for _, want := range []string{143143 `<h1>General <small>open room</small></h1>`, `<p>Where General happens.</p>`,144144 `<a href="/rooms/general?cursor=cursor-1">load earlier</a>`,145- `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-pubkey="` + roomAgent + `" data-agent data-edited>`,145+ `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-updated-at="1757203650" data-pubkey="` + roomAgent + `" data-agent data-edited>`,146146 `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.`,147147 `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1`,148148 `<script>alert(1)</script>`,@@ -287,6 +287,7 @@ room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wra
287287 room-message>p a,room-message>div a{color:var(--accent)}288288 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}289289 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}290+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)}290291 room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}291292 room-message>footer>*+*::before{content:" | "}292293 room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}@@ -295,6 +296,8 @@ room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:righ
295296 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)}296297 room-compose textarea{min-height:3.2em;width:100%;font:14px var(--sans);max-width:720px;display:block}297298 room-compose p{margin:6px 0 0}room-compose button{margin-right:6px}299+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}300+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}298301 room-compose output,room-create output,room-action output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}299302 room-compose output[data-error],room-create output[data-error],room-action output[data-error]{color:var(--alert)}300303 room-create{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}@@ -324,6 +324,9 @@ func parseTemplates(a *App) (*template.Template, error) {
324324 "age": age,325325 "clock": clock,326326 "roomAdmin": roomAdmin,327+328+ "roomAttachmentContent": roomAttachmentContent,329+ "roomAttachmentMarkup": roomAttachmentMarkup,327330 }328331 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")329332 if err != nil {@@ -47,3 +47,30 @@ npx --package @playwright/cli playwright-cli -s=tiny-ux run-code \
4747 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.4848 4949 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.50+51+## Room attachments52+53+Build the relay, create a disposable tenant on port 18459, and seed the room fixture:54+55+```sh56+go build -o /tmp/tiny-room-attachments ./cmd/tiny57+/tmp/tiny-room-attachments tenant create --data-dir /tmp/tinyrelay-room-attachments --name main \58+ --owner 5ac640e5df8f7945381c31f435288ba3f587fbd68efb27abaf941792f9c3636959+/tmp/tiny-room-attachments serve --data-dir /tmp/tinyrelay-room-attachments --default-tenant main \60+ --listen 127.0.0.1:18459 --allow-private-relays61+node scripts/qa/room-attachments-fixture.mjs --relay http://127.0.0.1:18459 \62+ --data /tmp/tinyrelay-room-attachments63+```64+65+Sign in as the fixture owner using the local NIP-46 bunker, then run the browser check:66+67+```sh68+BUNKER_USER_SECRET=fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd \69+ node scripts/qa/nip46-bunker.mjs70+npx --package @playwright/cli playwright-cli -s=tiny-room-attachments open \71+ http://127.0.0.1:18459/signin --headed72+npx --package @playwright/cli playwright-cli -s=tiny-room-attachments run-code \73+ --filename scripts/qa/room-attachments-browser.js74+```75+76+Screenshots are saved under `output/playwright/room-attachments/`.@@ -0,0 +1,106 @@
1+// Run with playwright-cli run-code after signing in as the fixture owner:2+// ... run-code --filename scripts/qa/room-attachments-browser.js3+// The fixture is created by room-attachments-fixture.mjs.4+async page => {5+ const results = [], errors = [];6+ const check = (condition, message) => { if (!condition) throw Error(message); };7+ const run = async (name, action) => { try { results.push({name, ok: true, detail: await action()}); } catch (error) { results.push({name, ok: false, error: error.message}); } };8+ page.on("pageerror", error => errors.push(error.message));9+ const base = await page.evaluate(() => location.origin);10+ const room = "/rooms/attachments-qa";11+ await page.setViewportSize({width: 1440, height: 900});12+13+ await run("SSR room shows image, generic file and agent message", async () => {14+ await page.goto(base + room);15+ await page.waitForSelector("#messages room-message");16+ await page.waitForFunction(() => typeof window.tiny?.signer === "function" && typeof window.tiny.signer()?.signEvent === "function");17+ const info = await page.evaluate(() => ({18+ messages: document.querySelectorAll("#messages room-message").length,19+ images: document.querySelectorAll("#messages img").length,20+ links: [...document.querySelectorAll("#messages a")].map(a => a.textContent.trim()),21+ videos: document.querySelectorAll("#messages video").length,22+ agent: [...document.querySelectorAll("#messages room-message")].filter(node => node.textContent.includes("Agent published file")).length,23+ live: !!document.querySelector("room-live")24+ }));25+ check(info.images >= 1, "initial image was not rendered inline");26+ check(info.links.some(label => label.includes("fixture notes.txt")), "generic file link missing");27+ check(info.agent >= 1, "agent message missing from room timeline");28+ check(info.live, "room live stream missing");29+ const liveText = "Live agent attachment";30+ await page.evaluate(async text => {31+ const url = document.querySelector("#messages img")?.src;32+ 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"};33+ const signer = window.tiny.signer();34+ if (!signer?.signEvent) throw Error("connected signer unavailable for live check");35+ const signed = await signer.signEvent(unsigned);36+ const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(signed), {contentType: "application/json"});37+ if (!response.ok) throw Error("live publish failed");38+ }, liveText);39+ await page.waitForFunction(text => document.querySelector("#messages")?.textContent.includes(text), liveText);40+ await page.screenshot({path: "output/playwright/room-attachments/room-desktop.png"});41+ return {...info, liveAppend: true};42+ });43+44+ await run("file picker previews and publishes an image", async () => {45+ const picker = page.locator('room-compose input[name="attachments"]');46+ check(await picker.count() === 1, "attachment picker missing");47+ await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png");48+ await page.waitForSelector("room-files li");49+ check((await page.locator("room-files").textContent()).includes("browser-upload.png"), "pending file preview missing");50+ await page.locator('room-compose textarea[name="content"]').fill("Browser upload");51+ const request = page.waitForRequest(req => req.method() === "PUT" && req.url().includes("/rooms/attachments-qa/attachments"));52+ await page.locator("room-compose").getByRole("button", {name: "Send", exact: true}).click();53+ await request;54+ await page.waitForFunction(() => !document.querySelector("room-files li"));55+ check(await page.locator("#messages").textContent().then(text => text.includes("Browser upload")), "published upload missing from timeline");56+ return {uploaded: true};57+ });58+59+ await run("attachment-only message is accepted", async () => {60+ const picker = page.locator('room-compose input[name="attachments"]');61+ await picker.setInputFiles("output/playwright/room-attachments/only.txt");62+ await page.waitForSelector("room-files li");63+ check(await page.locator('room-compose textarea[name="content"]').getAttribute("required") === null, "content remained required with an attachment");64+ await page.locator("room-compose").getByRole("button", {name: "Send", exact: true}).click();65+ await page.waitForFunction(() => !document.querySelector("room-files li"));66+ return {uploaded: true};67+ });68+69+ await run("reload and narrow viewport preserve media layout", async () => {70+ await page.reload();71+ await page.waitForSelector("#messages room-message");72+ await page.setViewportSize({width: 360, height: 800});73+ const metrics = await page.evaluate(() => ({width: innerWidth, scrollWidth: document.documentElement.scrollWidth, content: document.querySelector("#content")?.scrollWidth, image: document.querySelector("#messages img")?.complete}));74+ check(metrics.scrollWidth <= metrics.width && metrics.content <= metrics.width, "room overflows at narrow viewport");75+ check(metrics.image, "image did not load after reload");76+ await page.screenshot({path: "output/playwright/room-attachments/room-mobile.png"});77+ return metrics;78+ });79+80+ await run("JavaScript-disabled SSR still renders media", async () => {81+ const context = await page.context().browser().newContext({javaScriptEnabled: false, viewport: {width: 360, height: 800}, storageState: await page.context().storageState()});82+ try {83+ const noJS = await context.newPage();84+ await noJS.goto(base + room);85+ const info = await noJS.evaluate(() => ({images: document.querySelectorAll("#messages img").length, links: document.querySelectorAll("#messages a").length, compose: !!document.querySelector("room-compose")}));86+ check(info.images >= 1 && info.links >= 1, "no-JS room omitted attachment markup");87+ check(info.compose, "no-JS room omitted the attachment composer markup");88+ await noJS.screenshot({path: "output/playwright/room-attachments/room-nojs.png"});89+ return info;90+ } finally { await context.close(); }91+ });92+93+ await run("guest cannot fetch private media", async () => {94+ const context = await page.context().browser().newContext();95+ try {96+ const guest = await context.newPage();97+ const mediaURL = await page.locator("#messages img").first().getAttribute("src");98+ check(mediaURL, "could not locate fixture media URL");99+ const media = await context.request.get(mediaURL);100+ check([401, 403].includes(media.status()), "guest private media returned " + media.status());101+ return {status: media.status()};102+ } finally { await context.close(); }103+ });104+ results.push({name: "uncaught browser errors", ok: errors.length === 0, errors});105+ return {passed: results.filter(item => item.ok).length, failed: results.filter(item => !item.ok).length, results};106+}@@ -0,0 +1,64 @@
1+2+// Seed a disposable members-only room with image and generic-file messages.3+// The owner key is the public QA key used by the other scripts in this folder.4+import { mkdir, writeFile, readFile } from "node:fs/promises";5+import { execFile } from "node:child_process";6+import { promisify } from "node:util";7+import { resolve, join } from "node:path";8+import { createHash } from "node:crypto";9+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";10+import { npubEncode } from "nostr-tools/nip19";11+12+const args = process.argv.slice(2);13+const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;14+const base = option("--relay", "http://127.0.0.1:18459").replace(/\/$/, "");15+const dataDir = resolve(option("--data", "/tmp/tinyrelay-room-attachments"));16+const outDir = resolve(option("--out", "output/playwright/room-attachments"));17+const secret = Uint8Array.from(Buffer.from("fc1d06a0fd5e622dcf448d0b3c2fccc891a5ecf74546a7675460d92441fecfdd", "hex"));18+const agentSecret = Uint8Array.from(Buffer.from("4f6d7a2be7f2c6f677b1dc2d02f4388cbd1b9aeb8e5e47d41e4ae0f2eb3bde5f", "hex"));19+const owner = getPublicKey(secret), agent = getPublicKey(agentSecret), room = "attachments-qa";20+const json = value => JSON.stringify(value);21+const now = () => Math.floor(Date.now() / 1000);22+const exec = promisify(execFile);23+async function signed(path, method, value, signer = secret, contentType = "application/json") {24+ const body = Buffer.isBuffer(value) ? value : Buffer.from(json(value));25+ const payload = createHash("sha256").update(body).digest("hex");26+ const auth = finalizeEvent({kind: 27235, created_at: now(), tags: [["u", base + path], ["method", method], ["payload", payload]], content: ""}, signer);27+ const token = "Nostr " + Buffer.from(json(auth)).toString("base64");28+ const response = await fetch(base + path, {method, headers: {authorization: token, "content-type": contentType}, body});29+ const result = await response.json().catch(() => ({}));30+ if (!response.ok && !(method === "POST" && path === "/events" && String(result.message || "").startsWith("duplicate:"))) throw Error(`${method} ${path}: ${response.status} ${json(result)}`);31+ return result;32+}33+async function publish(event) { return signed("/events", "POST", event); }34+async function upload(bytes, type, filename) {35+ const hash = createHash("sha256").update(bytes).digest("hex");36+ const descriptor = await signed(`/rooms/${room}/attachments?filename=${encodeURIComponent(filename)}`, "PUT", bytes, secret, type);37+ if (descriptor.sha256 !== hash || descriptor.size !== bytes.length) throw Error("fixture upload descriptor mismatch");38+ return descriptor;39+}40+await mkdir(dataDir, {recursive: true});41+await mkdir(outDir, {recursive: true});42+// Tenant ownership grants management access but event writes still require a43+// community membership record in a fresh disposable tenant.44+await signed("/manage/rpc", "POST", {method: "setmember", params: [owner, {role: "member"}]});45+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);46+await publish(create);47+await signed("/manage/rpc", "POST", {method: "setmember", params: [agent, {role: "member"}]});48+await publish(finalizeEvent({kind: 9000, created_at: now(), tags: [["h", room], ["p", agent, "member"]], content: ""}, secret));49+const cardPath = join(outDir, "fixture-card.png");50+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]);51+const png = await readFile(cardPath);52+await writeFile(join(outDir, "browser-upload.png"), png);53+const image = await upload(png, "image/png", "fixture.png");54+const text = Buffer.from("Attachment QA generic file\n");55+await writeFile(join(outDir, "only.txt"), Buffer.from("only attachment"));56+const file = await upload(text, "text/plain", "fixture notes.txt");57+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/") ? `` : `[${descriptor.filename}](${descriptor.url})`}`}, signer);58+const initial = message(image, secret, "Initial image attachment");59+await publish(initial);60+const agentMessage = message(file, agentSecret, "Agent published file");61+await signed("/events", "POST", agentMessage, agentSecret);62+const manifest = {schema: 1, relay: base, dataDir, room, owner, ownerNpub: npubEncode(owner), agent, image, file, initial: initial.id, agentMessage: agentMessage.id};63+await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");64+console.log(json(manifest));@@ -4,27 +4,32 @@ import fs from "node:fs";
44 import vm from "node:vm";55 import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";66 import * as nip19 from "nostr-tools/nip19";7+import { createHash } from "node:crypto";78 89 const source = fs.readFileSync("internal/webui/components.js", "utf8");910 const roomsSource = source.slice(source.indexOf(" // Rooms: the compose bar"), source.indexOf(" // JsonView renders any JSON value."));1011 1112 // A small document: enough of the DOM for the room elements to render1213 // messages, find their targets and keep the list bounded.13-class FakeNode {14+export class FakeNode {1415 constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.parent = null; this.id = ""; }1516 get children() { return this.childNodes.filter(node => node instanceof FakeNode); }1617 get firstElementChild() { return this.children[0] || null; }1718 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }1819 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)); } }20+ 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); }1921 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }2022 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; }23+ replaceChildren(...nodes) { this.children.forEach(node => { node.parent = null; }); this.childNodes = []; this.append(...nodes); }2124 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }2225 set textContent(value) { this.childNodes = value ? [String(value)] : []; }2326 setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }2427 getAttribute(name) { return name === "id" ? this.id || null : this.attributes[name] ?? null; }25- hasAttribute(name) { return name.startsWith("data-") ? name.slice(5) in this.dataset : name in this.attributes; }28+ removeAttribute(name) { delete this.attributes[name]; }29+ hasAttribute(name) { return name.startsWith("data-") ? name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset : name in this.attributes; }2630 matches(selector) { return selector === this.localName; }27- addEventListener() {}31+ addEventListener(name, handler) { this.listeners ||= {}; (this.listeners[name] ||= []).push(handler); }32+ emit(name, event) { (this.listeners?.[name] || []).forEach(handler => handler(event)); }2833 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }2934 querySelectorAll(selector) {3035 const steps = selector.replace(/^:scope\s*>\s*/, "> ").trim().split(/\s*>\s*|\s+/).filter(Boolean);@@ -40,8 +45,8 @@ class FakeNode {
4045 descendants() { return this.children.flatMap(node => [node, ...node.descendants()]); }4146 matchesSimple(step) {4247 if (step.startsWith("#")) return this.id === step.slice(1);43- const [, tag, attr] = step.match(/^([a-z-]+)(?:\[([a-z-]+)\])?$/) || [];44- return tag === this.localName && (!attr || this.hasAttribute(attr));48+ const [, tag, attr] = step.match(/^([a-z-]+)?(?:\[([a-z-]+)\])?$/) || [];49+ return (!tag || tag === this.localName) && (!attr || this.hasAttribute(attr));4550 }4651 }4752 const document = new FakeNode("#document");@@ -53,7 +58,7 @@ document.getElementById = id => document.body.descendants().find(node => node.id
5358 document.querySelector = selector => document.body.querySelector(selector);5459 document.querySelectorAll = selector => document.body.querySelectorAll(selector);5560 56-function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {61+export function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {5762 const sent = [], navigated = [];5863 const secret = generateSecretKey(), pubkey = getPublicKey(secret);5964 document.body.childNodes = [];@@ -74,10 +79,11 @@ function setup({attributes = {}, result = {accepted: true}, change = false} = {}
7479 report(message) { this.message = message; }7580 }7681 const sandbox = {77- document, HTMLElement, FormElement, URL, encodeURIComponent, setTimeout, clearTimeout,82+ document, HTMLElement, FormElement, URL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,83+ location: {href: "https://relay.test/r/work/rooms/build", origin: "https://relay.test"},7884 getComputedStyle: () => ({overflowY: "auto"}),7985 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); }}},80- 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); }},86+ 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); }},8187 el: (tag, text) => { const node = new FakeNode(tag); if (text !== undefined) node.textContent = text; return node; },8288 isHex64: value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)8389 };@@ -131,6 +137,81 @@ test("room-compose replies in a thread with the root and its author, and refuses
131137 await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);132138 });133139 140+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});141+const descriptorFor = (file, bytes) => {142+ const sha256 = createHash("sha256").update(bytes).digest("hex");143+ return {sha256, size: file.size, type: file.type, url: "https://relay.test/r/work/media/" + sha256 + ".png"};144+};145+146+test("room-compose uploads only on send, then publishes Buzz metadata and an attachment-only message", async () => {147+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();148+ const uploads = [], publish = s.sandbox.tiny.signedFetch;149+ const file = selectedFile("my chart.png");150+ s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {151+ if (path === "/events") return publish(path, method, bytes, options);152+ uploads.push({path, method, bytes, options});153+ return Response.json(descriptorFor(file, bytes));154+ };155+ compose.addFiles([file]);156+ assert.equal(uploads.length, 0);157+ const form = s.form({content: ""});158+ await compose.submit(form);159+ assert.equal(uploads.length, 1);160+ assert.equal(uploads[0].path, "/rooms/build/attachments?filename=my%20chart.png");161+ assert.equal(uploads[0].method, "PUT");162+ assert.equal(uploads[0].options.contentType, "image/png");163+ const descriptor = descriptorFor(file, uploads[0].bytes);164+ assert.equal(s.sent[0].event.content, ``);165+ 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"]]);166+ assert.equal(verifyEvent(s.sent[0].event), true);167+ assert.equal(compose.pendingFiles.length, 0);168+ assert.equal(form.resets, 1);169+});170+171+test("room-compose retains uploaded files after a publish failure and does not upload them twice on retry", async () => {172+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();173+ let uploads = 0, publishes = 0;174+ const publish = s.sandbox.tiny.signedFetch;175+ s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {176+ if (path !== "/events") { uploads++; return Response.json(descriptorFor(file, bytes)); }177+ if (++publishes === 1) throw Error("connection lost");178+ return publish(path, method, bytes, options);179+ };180+ compose.addFiles([file]);181+ const form = s.form({content: "a chart"});182+ await assert.rejects(compose.submit(form), /connection lost/);183+ assert.equal(compose.pendingFiles.length, 1);184+ assert.equal(form.resets, undefined);185+ await compose.submit(form);186+ assert.equal(uploads, 1);187+ assert.equal(publishes, 2);188+ assert.equal(s.sent[0].event.content.startsWith("a chart\n\n![image]"), true);189+});190+191+test("room-compose refuses oversized selections and untrusted upload descriptors without publishing", async () => {192+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();193+ assert.throws(() => compose.addFiles([{...file, size: 32 * 1024 * 1024 + 1}]), /32 MiB/);194+ assert.throws(() => compose.addFiles(Array.from({length: 9}, (_, i) => selectedFile(i + ".png"))), /8 files/);195+ compose.addFiles([file]);196+ for (const change of [d => ({...d, sha256: "0".repeat(64)}), d => ({...d, size: 1}), d => ({...d, url: "https://another.test/media/" + d.sha256 + ".png"})]) {197+ s.sandbox.tiny.signedFetch = async (_path, _method, bytes) => Response.json(change(descriptorFor(file, bytes)));198+ await assert.rejects(compose.submit(s.form({content: ""})), /descriptor|hash|size|URL/i);199+ assert.equal(s.sent.length, 0);200+ assert.equal(compose.pendingFiles.length, 1);201+ }202+});203+204+test("room-compose does not publish after leaving during an upload", async () => {205+ const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();206+ s.sandbox.tiny.signedFetch = async (_path, _method, bytes) => {207+ compose.disconnectedCallback();208+ return Response.json(descriptorFor(file, bytes));209+ };210+ compose.addFiles([file]);211+ await assert.rejects(compose.submit(s.form({content: ""})), /cancel/i);212+ assert.equal(s.sent.length, 0);213+});214+134215 test("room-create derives the id from the name and signs 9007 before opening the room", async () => {135216 const s = setup();136217 assert.equal(s.rooms.roomID("Build & Release 2026!"), "build-release-2026");@@ -262,3 +343,27 @@ test("chat markdown renders the shared subset without ever parsing markup", () =
262343 assert.equal(pre.querySelectorAll("a").length, 0);263344 });264345 346+test("room attachment renderer matches visible imeta URLs and preserves prose and code", () => {347+ const s = setup(), url = "https://cdn.example.test/report.pdf";348+ const event = {content: "See [report](" + url + ") and `" + url + "`", tags: [["imeta", "url " + url, "m application/pdf", "filename report.pdf"]]};349+ const items = s.rooms.roomAttachments(event);350+ assert.equal(items.length, 1);351+ assert.equal(s.rooms.attachmentContent(event.content, items), event.content);352+ assert.equal(s.rooms.roomAttachments({content: "```\n" + url + "\n```", tags: event.tags}).length, 0);353+ assert.equal(s.rooms.roomAttachments({content: url + "-backup", tags: event.tags}).length, 0);354+ assert.equal(s.rooms.roomAttachments({content: url, tags: [["imeta", "url " + url, "m image/png"], ["imeta", "url " + url, "m image/png"]]}).length, 1);355+});356+357+test("room attachment renderer replaces edited media without stale nodes", () => {358+ 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";359+ const node = s.rooms.messageNode({id, pubkey: author, kind: 9, created_at: 1, content: oldURL, tags: [["imeta", "url " + oldURL, "m image/png"]]});360+ s.list.append(node);361+ 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"]]});362+ assert.equal(node.querySelectorAll("img").length, 0);363+ assert.equal(node.querySelectorAll("video").length, 1);364+ s.rooms.roomEdit({pubkey: author, kind: 40003, created_at: 2, content: oldURL, tags: [["e", id], ["imeta", "url " + oldURL, "m image/png"]]});365+ assert.equal(node.querySelectorAll("video").length, 1, "older edit must not replace newer media");366+ s.rooms.roomEdit({pubkey: author, kind: 40003, created_at: 4, content: "Attachment removed", tags: [["e", id]]});367+ assert.equal(node.querySelectorAll("video").length, 0);368+ assert.equal(node.querySelectorAll("[data-room-attachments]").length, 0);369+});