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