Enforce relay-member file access across direct downloads, previews, MCP and site serving. Preserve file names and show shared member files in the library. Catalog encrypted uploads as one logical file, retain secret links per account in the browser, and make clipboard copying explicit. Add readable progress, transient retries and reuse completed encrypted chunks. Preview decrypted video, audio, images and bounded text, with technical file details collapsed. Validation: full Go suite, blob/daemon/webui race checks, go vet, 191 JavaScript tests, browser bundle verification, and real 100 MiB MP4 upload and playback checks.
- commit
- 073c8f63eadb8ca8288a1c66ffd8372e6e97d3ab
- parent
- e4a66fec2474fbde4f4dc2844faebbd00dc150b0
- author
- Dami <dami.etoile@gmail.com>
@@ -1,34 +1,36 @@
11 # Files and private repositories22 3-Use **Files** to browse uploads by name, open folders, preview images and download files. **My files** shows your uploads. **Sites** groups published assets by site, and **Rooms** groups attachments by rooms you can access. Owners and moderators can open **Storage** for the full inventory.3+Use **Files** to browse uploads by name, open folders, preview images and play supported audio and video files. **My files** shows your uploads and files shared with relay members. **Sites** groups published assets by site, and **Rooms** groups attachments by rooms you can access. Owners and moderators can open **Storage** for the full inventory.44 55 ## Uploading66 7-The **Upload** panel on the Files page stores files or a folder. Choose files, choose a folder, or drop either onto the panel, and check **encrypt** to encrypt in the browser before sending. Plain uploads preserve filenames and selected folder paths. Uploading while inside a folder adds the selection there. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.7+The **Upload** panel on the Files page stores files or a folder. Choose files, choose a folder, or drop either onto the panel. **Who can open** controls access: **Anyone with the link** is public, while **Relay members** requires current membership. Public is the default. Uploading while inside a folder adds the selection there. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.88 9-Blossom identifies file contents by hash. Tiny keeps plain upload names and paths with your upload record; those labels do not change the file's hash or travel automatically to another Blossom server. Older uploads use names from site manifests or room attachments where available. Otherwise, they show a file type and short hash.9+Use **Advanced** to enable **Encrypt with a secret link** for a public upload. The browser encrypts the contents before sending them, and only someone with the complete secret link can decrypt them. Secret-link encryption is unavailable when **Relay members** is selected because member access is enforced by the relay, which must be able to serve the file after checking membership. Member access is private access control, not end-to-end encryption at rest; the relay stores the uploaded bytes and trusts its membership check.10+11+Blossom identifies file contents by hash. Tiny sends a file's name, path, MIME type and access choice as upload metadata, then keeps those labels with your catalog entry. Metadata does not change the file's hash and does not travel automatically to another Blossom server. The same content hash cannot represent both a public file and a member-only file: uploading identical bytes again with a different access choice does not create a second policy-specific object. Older uploads use names from site manifests or room attachments where available. Otherwise, they appear in **Unorganized uploads** with a file type and short hash.1012 1113 ## Encrypted files1214 13-An encrypted single file gets a fresh AES-256-GCM key and nonce. The resulting share link keeps the key, nonce, original name and file type in its URL fragment. Keep that link: the relay cannot recover the key. Anyone who has the complete link and permission to download the blob can decrypt it.15+An encrypted single file gets a fresh AES-256-GCM key and nonce. The resulting share link keeps the key, nonce, original name and file type in its URL fragment. The browser saves the finished file's key and display metadata in local storage scoped to the current account and tenant, so the file can be recognized after a reload in that browser. Clearing browser storage removes that local copy. Keep the complete link for use on another browser or device; the relay cannot recover the key.1416 1517 Encrypted files remain subject to the tenant's download policy. Sending an encrypted link does not grant membership in a private tenant. Removing a file or revoking membership cannot recall copies someone has already downloaded.1618 17-Open a stored file to copy its Blossom URI or share link. Random-key files can also be sent through [NIP-17 file messages](https://github.com/nostr-protocol/nips/blob/master/17.md). Connect a signer with NIP-44 support and enter the recipient's public key. Both parties need signed kind 10050 inbox relay lists available to this relay. The browser sends encrypted gift wraps to those inbox relays, including a copy for the sender. It refuses delivery when the required lists are missing.19+Open a stored file to copy its Blossom URI or explicitly copy its secret link. The application does not put a secret link on the clipboard automatically. Random-key files can also be sent through [NIP-17 file messages](https://github.com/nostr-protocol/nips/blob/master/17.md). Connect a signer with NIP-44 support and enter the recipient's public key. Both parties need signed kind 10050 inbox relay lists available to this relay. The browser sends encrypted gift wraps to those inbox relays, including a copy for the sender. It refuses delivery when the required lists are missing.1820 1921 ## Folders and large files2022 2123 An encrypted folder, or an encrypted file larger than 64 MiB, is stored as encrypted manifests. Files, chunks and all parent manifests use deduplicated keys that follow the [BUD-15 proposal](https://github.com/hzrd149/blossom/pull/104): the key derives from the content, so identical files share storage. This also reveals when files are identical and permits guesses about predictable content. Names, child keys and file metadata appear only inside encrypted manifests. Keep the complete share link to browse the folder or download individual files.2224 23-Folders use [draft BUD-16](https://github.com/hzrd149/blossom/pull/105). Large files use [draft BUD-17](https://github.com/hzrd149/blossom/pull/106), with 2 MiB plaintext chunks and up to 174 links per manifest. Larger directories use nested manifests that appear as one directory in the browser. Every retrieved object is checked against its hash before decryption, and file downloads verify their declared sizes.25+Folders use [draft BUD-16](https://github.com/hzrd149/blossom/pull/105). Large files use [draft BUD-17](https://github.com/hzrd149/blossom/pull/106), with 2 MiB plaintext chunks and up to 174 links per manifest. Larger directories use nested manifests that appear as one directory in the browser. Every retrieved object is checked against its hash before decryption, and file downloads verify their declared sizes. Decrypted images display inline, and text files open as readable text. Large text previews show the first 256 KiB; downloads contain the complete file. HTML and SVG display as source text. Decrypted MP4, WebM and Ogg video files play in the browser when the browser supports the format.2426 2527 The browser accepts up to 256 MiB per file, 1 GiB per folder, 10,000 files and 32 path levels. Browser folder selection includes files and their paths; empty folders are omitted. These browser limits apply in addition to the tenant's storage allowances.2628 27-Cancel stops the current upload. Retry reuses the selected files while the page stays open. Completed chunks may remain in the Files inventory after an interrupted folder upload. Removing a root manifest does not remove its child blobs, which may be shared by other folders.29+Progress reports human-readable MiB values and a percentage. Cancel stops the current upload. Retry reuses the selected files while the page stays open. For an encrypted retry, completed chunks and the same sealed key are reused. Refreshing the page loses the retry selection, but finished encrypted file keys remain available in this browser until its storage is cleared. A completed encrypted chunk root is cataloged as one named file; chunk objects are hidden from **My files**. Older or unrecognized uploads appear under **Unorganized uploads** so they can be reviewed without being silently removed. Removing a root manifest does not remove its child blobs, which may be shared by other folders.2830 2931 ## Resumable uploads3032 31-Encrypted file uploads use [draft BUD-14](https://github.com/hzrd149/blossom/pull/102) when the server advertises it. Cancel and retry keep the encrypted bytes and key in memory while the page stays open. Closing or reloading the page loses that local state. The uploader falls back to BUD-13 when multipart support is unavailable.33+Encrypted file uploads use [draft BUD-14](https://github.com/hzrd149/blossom/pull/102) when the server advertises it. The browser retries transient failures automatically with backoff and keeps completed encrypted chunks and the same key for a retry on the current page. **Continue in the background** is an explicit opt-in when the browser supports Background Fetch; otherwise the upload remains in the foreground. The uploader falls back to BUD-13 when multipart support is unavailable.3234 3335 Other clients can send binary chunks to `PATCH /<sha256>` with `Upload-Type`, `Upload-Length`, `Upload-Offset` and `Content-Length`. Chunks may overlap or arrive out of order. A partial upload stays unavailable for download until its complete hash is verified. Other uploads can proceed during verification. Blossom proofs name the final hash; NIP-98 proofs bind each chunk's request body.3436 @@ -49,7 +51,7 @@ Set byte allowances with an owner-authorized `setpolicy` patch:
4951 }5052 ```5153 52-This example limits each stored blob to 100 MiB and allows 1 GiB of claimed storage per uploader. Encrypted chunks and manifests each count toward storage. The per-blob limit applies to each chunk, rather than the reassembled size of a chunked file. Zero means unlimited. Changes apply to subsequent uploads; lowering an allowance does not delete existing files.54+This example limits each stored blob to 100 MiB (104,857,600 bytes) and allows 1 GiB of claimed storage per uploader. The Files page displays sizes in MiB; storage policies use exact byte counts. Encrypted chunks and manifests each count toward storage. The per-blob limit applies to each chunk, rather than the reassembled size of a chunked file. Zero means unlimited. Changes apply to subsequent uploads; lowering an allowance does not delete existing files.5355 5456 Each uploader pays the full size of every file they claim, even when another uploader already stores identical bytes. Repeated uploads by the same user count once. Removing an uploader's claim releases their allowance; shared content remains while another claim exists. An owner can remove the stored file for everyone.5557 @@ -1,11 +1,13 @@
11 package blob22 33 import (4+ "bytes"45 "context"56 "database/sql"67 "encoding/json"78 "errors"89 "fmt"10+ "io"911 "net/http"1012 "os"1113 "path/filepath"@@ -44,6 +46,10 @@ func (s *Service) serveHTTP(w http.ResponseWriter, r *http.Request) {
4446 s.mirror(w, r)4547 return4648 }49+ if r.URL.Path == "/files/metadata" && r.Method == http.MethodPost {50+ s.metadata(w, r)51+ return52+ }4753 if match := blobPathPattern.FindStringSubmatch(r.URL.Path); match != nil && (r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodOptions) {4854 if r.Method == http.MethodPatch || r.Method == http.MethodOptions {4955 s.multipart(w, r, match[1])@@ -67,6 +73,43 @@ func (s *Service) serveHTTP(w http.ResponseWriter, r *http.Request) {
6773 s.blob(w, r)6874 }6975 76+func (s *Service) metadata(w http.ResponseWriter, r *http.Request) {77+ uploader, err := s.authorize(r, ActionUpload)78+ if err != nil {79+ s.fail(w, http.StatusUnauthorized, err)80+ return81+ }82+ var input struct {83+ Hash string `json:"hash"`84+ Name string `json:"name"`85+ Path string `json:"path"`86+ Purpose string `json:"purpose"`87+ Size int64 `json:"size"`88+ Type string `json:"type"`89+ }90+ body, readErr := io.ReadAll(io.LimitReader(r.Body, 16*1024+1))91+ if readErr != nil || len(body) > 16*1024 {92+ s.fail(w, http.StatusBadRequest, errors.New("invalid: metadata body"))93+ return94+ }95+ decoder := json.NewDecoder(bytes.NewReader(body))96+ decoder.DisallowUnknownFields()97+ if err := decoder.Decode(&input); err != nil {98+ s.fail(w, http.StatusBadRequest, errors.New("invalid: metadata body"))99+ return100+ }101+ var extra any102+ if err := decoder.Decode(&extra); err != io.EOF {103+ s.fail(w, http.StatusBadRequest, errors.New("invalid: metadata body"))104+ return105+ }106+ if err := s.SetClaimMetadata(r.Context(), uploader, strings.ToLower(strings.TrimSpace(input.Hash)), input.Name, input.Path, input.Purpose, input.Type, input.Size); err != nil {107+ s.fail(w, statusFor(err), err)108+ return109+ }110+ s.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "hash": strings.ToLower(strings.TrimSpace(input.Hash))})111+}112+70113 func (s *Service) upload(w http.ResponseWriter, r *http.Request) {71114 pubkey, err := s.authorize(r, ActionUpload)72115 if err != nil {@@ -83,7 +126,7 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) {
83126 s.fail(w, http.StatusBadRequest, err)84127 return85128 }86- blob, created, err := s.putValidatedWithMetadata(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, name, metadataPath, func(actual string) error {129+ blob, created, err := s.putValidatedWithMetadata(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, name, metadataPath, r.URL.Query().Get("access"), r.URL.Query().Get("purpose"), func(actual string) error {87130 if s.config.ValidateUpload != nil {88131 return s.config.ValidateUpload(r, actual)89132 }@@ -20,6 +20,47 @@ var (
2020 errMismatchedClaimName = errors.New("invalid: filename must match the upload path basename")2121 )2222 23+// SetClaimMetadata records logical file metadata without changing blob bytes.24+// It is used after an encrypted root is uploaded; decryption keys never enter25+// relay storage. The caller must already have authenticated the uploader.26+func (s *Service) SetClaimMetadata(ctx context.Context, uploader, sha, name, rawPath, purpose, typ string, size int64) error {27+ if !shaPattern.MatchString(sha) || uploader == "" {28+ return errors.New("invalid: blob metadata target")29+ }30+ if purpose != "file" && purpose != "chunk" {31+ return errors.New("invalid: blob metadata purpose")32+ }33+ if size < 0 || size > 1<<40 {34+ return errors.New("invalid: logical file size")35+ }36+ name, rawPath, err := claimMetadata(name, rawPath)37+ if err != nil {38+ return err39+ }40+ if purpose == "file" && name == "" {41+ return errors.New("invalid: logical file name is required")42+ }43+ if typ == "" {44+ typ = "application/octet-stream"45+ }46+ if len(typ) > 256 || !strings.Contains(typ, "/") || hasControl(typ) {47+ return errors.New("invalid: logical file type")48+ }49+ return s.store.WithTx(ctx, func(tx *sql.Tx) error {50+ var found int51+ if err := tx.QueryRowContext(ctx, "SELECT 1 FROM blob_claims WHERE sha256=? AND uploader=?", sha, uploader).Scan(&found); err != nil {52+ if errors.Is(err, sql.ErrNoRows) {53+ return errors.New("not found: uploader blob claim")54+ }55+ return err56+ }57+ _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,purpose,logical_size,logical_type,updated_at)58+ VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,purpose=excluded.purpose,logical_size=excluded.logical_size,logical_type=excluded.logical_type,updated_at=excluded.updated_at`,59+ sha, uploader, name, rawPath, purpose, size, typ, nowUnix())60+ return err61+ })62+}63+2364 func validateClaimName(name string) error {2465 if name == "" {2566 return nil@@ -77,13 +118,13 @@ func claimMetadata(name, rawPath string) (string, string, error) {
77118 return name, cleanPath, nil78119 }79120 80-func (s *Service) saveClaimMetadata(ctx context.Context, tx *sql.Tx, sha, uploader, name, metadataPath string) error {81- if uploader == "" || (name == "" && metadataPath == "") {121+func (s *Service) saveClaimMetadata(ctx context.Context, tx *sql.Tx, sha, uploader, name, metadataPath, purpose string) error {122+ if uploader == "" || (name == "" && metadataPath == "" && purpose == "") {82123 return nil83124 }84- _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,updated_at)85- VALUES(?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at`,86- sha, uploader, name, metadataPath, nowUnix())125+ _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,purpose,updated_at)126+ VALUES(?,?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,purpose=excluded.purpose,updated_at=excluded.updated_at`,127+ sha, uploader, name, metadataPath, purpose, nowUnix())87128 if err != nil {88129 return fmt.Errorf("record blob claim metadata: %w", err)89130 }@@ -91,7 +132,7 @@ func (s *Service) saveClaimMetadata(ctx context.Context, tx *sql.Tx, sha, upload
91132 }92133 93134 func (s *Service) ListClaimMetadata(ctx context.Context, uploader string) ([]BlobClaimMetadata, error) {94- rows, err := s.store.DB().QueryContext(ctx, `SELECT c.sha256,c.uploader,COALESCE(m.name,''),COALESCE(m.path,''),COALESCE(m.updated_at,c.claimed_at)135+ rows, err := s.store.DB().QueryContext(ctx, `SELECT c.sha256,c.uploader,COALESCE(m.name,''),COALESCE(m.path,''),COALESCE(m.purpose,''),COALESCE(m.logical_size,0),COALESCE(m.logical_type,''),COALESCE(m.updated_at,c.claimed_at)95136 FROM blob_claims c LEFT JOIN blob_claim_metadata m ON m.sha256=c.sha256 AND m.uploader=c.uploader96137 JOIN blobs b ON b.sha256=c.sha256 WHERE c.uploader=? ORDER BY COALESCE(m.updated_at,c.claimed_at) DESC,c.sha256 DESC`, uploader)97138 if err != nil {@@ -101,7 +142,7 @@ func (s *Service) ListClaimMetadata(ctx context.Context, uploader string) ([]Blo
101142 entries := make([]BlobClaimMetadata, 0)102143 for rows.Next() {103144 var entry BlobClaimMetadata104- if err := rows.Scan(&entry.SHA256, &entry.Uploader, &entry.Name, &entry.Path, &entry.UpdatedAt); err != nil {145+ if err := rows.Scan(&entry.SHA256, &entry.Uploader, &entry.Name, &entry.Path, &entry.Purpose, &entry.Size, &entry.Type, &entry.UpdatedAt); err != nil {105146 return nil, fmt.Errorf("scan blob claim metadata: %w", err)106147 }107148 entries = append(entries, entry)@@ -31,6 +31,24 @@ func TestUploadStoresClaimScopedNameAndPath(t *testing.T) {
3131 }3232 }3333 34+func TestBlobAccessIsDurableAndCannotChangeOnDeduplication(t *testing.T) {35+ s := testService(t)36+ ctx := context.Background()37+ entry, err := s.Put(ctx, PutOptions{Reader: strings.NewReader("member bytes"), Type: "text/plain", Uploader: "uploader", Access: AccessMembers})38+ if err != nil {39+ t.Fatal(err)40+ }41+ if access, err := s.BlobAccess(ctx, entry.SHA256); err != nil || access != AccessMembers {42+ t.Fatalf("access = %q, %v", access, err)43+ }44+ if _, err := s.Put(ctx, PutOptions{Reader: strings.NewReader("member bytes"), Type: "text/plain", Uploader: "other", Access: AccessPublic}); !errors.Is(err, ErrAccessConflict) {45+ t.Fatalf("public deduplication error = %v", err)46+ }47+ if access, err := s.BlobAccess(ctx, entry.SHA256); err != nil || access != AccessMembers {48+ t.Fatalf("access changed after rejected deduplication = %q, %v", access, err)49+ }50+}51+3452 func TestValidateClaimPath(t *testing.T) {3553 for _, path := range []string{"", "file.txt", "dir/file.txt"} {3654 if _, err := validateClaimPath(path); err != nil {@@ -29,8 +29,8 @@ const (
2929 )3030 3131 type multipartRequest struct {32- id, hash, uploader, typ, name, metadataPath string33- length, offset, count int6432+ id, hash, uploader, typ, name, metadataPath, access, purpose string33+ length, offset, count int643434 }3535 3636 type multipartError struct {@@ -90,6 +90,14 @@ func parseMultipart(r *http.Request, hash, uploader string) (multipartRequest, e
9090 return part, multipartFailure(http.StatusBadRequest, metadataErr.Error())9191 }9292 part.name, part.metadataPath = name, metadataPath93+ part.access, metadataErr = parseAccess(r.URL.Query().Get("access"))94+ if metadataErr != nil {95+ return part, multipartFailure(http.StatusBadRequest, metadataErr.Error())96+ }97+ part.purpose = strings.TrimSpace(r.URL.Query().Get("purpose"))98+ if part.purpose != "" && part.purpose != "chunk" && part.purpose != "file" {99+ return part, multipartFailure(http.StatusBadRequest, "invalid upload purpose")100+ }93101 if r.ContentLength < 0 {94102 return part, multipartFailure(411, "Content-Length is required")95103 }@@ -174,12 +182,12 @@ func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest)
174182 return s.checkMultipartQuota(ctx, part, limits)175183 }176184 var length int64177- var typ, name, metadataPath string178- err := s.store.DB().QueryRowContext(ctx, "SELECT length,type,name,path FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ, &name, &metadataPath)185+ var typ, name, metadataPath, access, purpose string186+ err := s.store.DB().QueryRowContext(ctx, "SELECT length,type,name,path,access,purpose FROM multipart_uploads WHERE id=?", part.id).Scan(&length, &typ, &name, &metadataPath, &access, &purpose)179187 if err != nil && !errors.Is(err, sql.ErrNoRows) {180188 return err181189 }182- if err == nil && (length != part.length || typ != part.typ) {190+ if err == nil && (length != part.length || typ != part.typ || access != part.access || (purpose != "" && purpose != part.purpose)) {183191 return ErrMultipartConflict184192 }185193 if err := s.checkMultipartQuota(ctx, part, limits); err != nil {@@ -191,7 +199,10 @@ func (s *Service) multipartAdmission(ctx context.Context, part multipartRequest)
191199 if part.name == "" && part.metadataPath == "" {192200 part.name, part.metadataPath = name, metadataPath193201 }194- _, err = s.store.DB().ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=?,name=?,path=? WHERE id=?", time.Now().Unix(), part.name, part.metadataPath, part.id)202+ if part.purpose == "" {203+ part.purpose = purpose204+ }205+ _, err = s.store.DB().ExecContext(ctx, "UPDATE multipart_uploads SET last_seen=?,name=?,path=?,purpose=? WHERE id=?", time.Now().Unix(), part.name, part.metadataPath, part.purpose, part.id)195206 return err196207 }197208 @@ -230,7 +241,7 @@ func (s *Service) createMultipart(ctx context.Context, part multipartRequest) er
230241 return err231242 }232243 now := time.Now().Unix()233- _, err = s.store.DB().ExecContext(ctx, "INSERT INTO multipart_uploads(id,sha256,uploader,length,type,created,last_seen,name,path) VALUES(?,?,?,?,?,?,?,?,?)", part.id, part.hash, part.uploader, part.length, part.typ, now, now, part.name, part.metadataPath)244+ _, err = s.store.DB().ExecContext(ctx, "INSERT INTO multipart_uploads(id,sha256,uploader,length,type,created,last_seen,name,path,access,purpose) VALUES(?,?,?,?,?,?,?,?,?,?,?)", part.id, part.hash, part.uploader, part.length, part.typ, now, now, part.name, part.metadataPath, part.access, part.purpose)234245 if err != nil {235246 _ = os.Remove(path)236247 }@@ -306,14 +317,14 @@ func (s *Service) commitMultipart(ctx context.Context, part multipartRequest, ch
306317 s.multipartMu.Unlock()307318 return Blob{}, false, false, admissionErr308319 }309- name, metadataPath := part.name, part.metadataPath320+ name, metadataPath, purpose := part.name, part.metadataPath, part.purpose310321 if name == "" && metadataPath == "" {311- if err := s.store.DB().QueryRowContext(ctx, "SELECT name,path FROM multipart_uploads WHERE id=?", part.id).Scan(&name, &metadataPath); err != nil && !errors.Is(err, sql.ErrNoRows) {322+ if err := s.store.DB().QueryRowContext(ctx, "SELECT name,path,purpose FROM multipart_uploads WHERE id=?", part.id).Scan(&name, &metadataPath, &purpose); err != nil && !errors.Is(err, sql.ErrNoRows) {312323 s.multipartMu.Unlock()313324 return Blob{}, false, false, err314325 }315326 }316- candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, name: name, metadataPath: metadataPath, reservation: part.id}327+ candidate := uploadCandidate{hash: part.hash, size: part.length, typ: part.typ, uploader: part.uploader, name: name, metadataPath: metadataPath, access: part.access, purpose: purpose, reservation: part.id}317328 if existing, err := s.lookup(ctx, part.hash); err == nil {318329 if existing.Size != part.length {319330 s.multipartMu.Unlock()@@ -25,14 +25,15 @@ func (s *Service) pathUpload(w http.ResponseWriter, r *http.Request, expectedHas
2525 2626 urls := r.URL.Query()["url"]2727 if len(urls) == 0 {28- entry, created, putErr := s.putValidated(r.Context(), r.Body,28+ entry, created, putErr := s.putValidatedWithMetadata(r.Context(), r.Body,2929 contentType(r.Header.Get("content-type")), pubkey, expectedHash,30+ r.URL.Query().Get("filename"), r.URL.Query().Get("path"), r.URL.Query().Get("access"), r.URL.Query().Get("purpose"),3031 func(actual string) error {3132 if s.config.ValidateUpload != nil {3233 return s.config.ValidateUpload(r, actual)3334 }3435 return nil35- })36+ }, nil)3637 if putErr != nil {3738 s.fail(w, statusFor(putErr), putErr)3839 return@@ -84,13 +85,13 @@ func (s *Service) pathUpload(w http.ResponseWriter, r *http.Request, expectedHas
8485 if typ == "application/octet-stream" {8586 typ = typeForExtension(raw)8687 }87- entry, created, putErr := s.putValidated(r.Context(), response.Body, typ,88- pubkey, expectedHash, func(actual string) error {88+ entry, created, putErr := s.putValidatedWithMetadata(r.Context(), response.Body, typ,89+ pubkey, expectedHash, "", "", r.URL.Query().Get("access"), r.URL.Query().Get("purpose"), func(actual string) error {8990 if s.config.ValidateUpload != nil {9091 return s.config.ValidateUpload(r, actual)9192 }9293 return nil93- })94+ }, nil)9495 _ = response.Body.Close()9596 if putErr == nil {9697 s.writeJSON(w, chooseStatus(created, http.StatusCreated), s.descriptor(r, entry))@@ -129,3 +129,23 @@ func (r *trackingReader) Read(p []byte) (int, error) {
129129 *r.read = true130130 return r.Reader.Read(p)131131 }132+133+func TestPathUploadPreservesAccessPurposeAndClaimMetadata(t *testing.T) {134+ s := testService(t)135+ body := "named member-only file"136+ digest := sha256.Sum256([]byte(body))137+ hash := hex.EncodeToString(digest[:])138+ r := httptest.NewRequest(http.MethodPut, "https://relay.test/"+hash+"?filename=note.txt&path=docs%2Fnote.txt&access=members&purpose=file", strings.NewReader(body))139+ r.Header.Set("Content-Type", "text/plain")140+ response := record(s.Handler(), r)141+ if response.Code != http.StatusCreated {142+ t.Fatalf("upload: %d %s", response.Code, response.Body.String())143+ }144+ entries, err := s.ListClaimMetadata(t.Context(), "uploader")145+ if err != nil || len(entries) != 1 || entries[0].Purpose != "file" || entries[0].Name != "note.txt" || entries[0].Path != "docs/note.txt" {146+ t.Fatalf("claim: %+v %v", entries, err)147+ }148+ if access, err := s.BlobAccess(t.Context(), hash); err != nil || access != AccessMembers {149+ t.Fatalf("access: %q %v", access, err)150+ }151+}@@ -105,6 +105,9 @@ type BlobClaimMetadata struct {
105105 Uploader string `json:"uploader"`106106 Name string `json:"name"`107107 Path string `json:"path"`108+ Purpose string `json:"purpose,omitempty"`109+ Size int64 `json:"size,omitempty"`110+ Type string `json:"type,omitempty"`108111 UpdatedAt int64 `json:"updated_at"`109112 }110113 @@ -116,6 +119,10 @@ type PutOptions struct {
116119 Hash string117120 Name string118121 Path string122+ // Access controls who may retrieve the stored bytes. Empty means public.123+ Access string124+ // Purpose identifies logical encrypted roots or their internal chunks.125+ Purpose string119126 // Commit records ownership metadata in the blob transaction. Its isNew120127 // argument reports whether the blob was newly installed. An error rolls121128 // back all claims.@@ -149,6 +156,13 @@ var ErrFileTooLarge = errors.New("invalid: blob exceeds the per-file size limit"
149156 var ErrQuotaExceeded = errors.New("restricted: uploader storage quota exceeded")150157 var ErrMultipartConflict = errors.New("conflict: upload metadata differs from existing session")151158 var ErrNotEncrypted = errors.New("restricted: agent grant requires encrypted uploads")159+var ErrAccessConflict = errors.New("conflict: the blob already has a different access policy")160+var ErrInvalidAccess = errors.New("invalid: access must be public or members")161+162+const (163+ AccessPublic = "public"164+ AccessMembers = "members"165+)152166 153167 var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)154168 var blobPathPattern = regexp.MustCompile(`^/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$`)@@ -156,6 +170,7 @@ var blobPathPattern = regexp.MustCompile(`^/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$`
156170 // HandlesPath keeps the daemon mount in sync with every supported file door.157171 func HandlesPath(path string) bool {158172 return path == "/upload" || path == "/mirror" || path == "/report" ||173+ path == "/files/metadata" ||159174 path == "/nip96" || path == "/.well-known/nostr/nip96.json" ||160175 strings.HasPrefix(path, "/nip96/") || strings.HasPrefix(path, "/list/") ||161176 blobPathPattern.MatchString(path)@@ -177,7 +192,7 @@ func New(ctx context.Context, config Config) (*Service, error) {
177192 }178193 if _, err := config.Store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS blobs (179194 sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, type TEXT NOT NULL,180- uploader TEXT NOT NULL, uploaded INTEGER NOT NULL195+ uploader TEXT NOT NULL, uploaded INTEGER NOT NULL, access TEXT NOT NULL DEFAULT 'public'181196 ); CREATE TABLE IF NOT EXISTS blob_claims (182197 sha256 TEXT NOT NULL, uploader TEXT NOT NULL, claimed_at INTEGER NOT NULL,183198 PRIMARY KEY (sha256, uploader), FOREIGN KEY (sha256) REFERENCES blobs(sha256) ON DELETE CASCADE@@ -185,6 +200,7 @@ func New(ctx context.Context, config Config) (*Service, error) {
185200 CREATE INDEX IF NOT EXISTS blob_claims_uploader ON blob_claims(uploader, claimed_at DESC, sha256 DESC);186201 CREATE TABLE IF NOT EXISTS blob_claim_metadata (187202 sha256 TEXT NOT NULL, uploader TEXT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL,203+ purpose TEXT NOT NULL DEFAULT '', logical_size INTEGER NOT NULL DEFAULT 0, logical_type TEXT NOT NULL DEFAULT '',188204 updated_at INTEGER NOT NULL, PRIMARY KEY (sha256,uploader,path),189205 FOREIGN KEY (sha256,uploader) REFERENCES blob_claims(sha256,uploader) ON DELETE CASCADE190206 );@@ -199,12 +215,20 @@ func New(ctx context.Context, config Config) (*Service, error) {
199215 WHEN NEW.uploader != '' BEGIN INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at) VALUES(NEW.sha256,NEW.uploader,NEW.uploaded); END;200216 CREATE TABLE IF NOT EXISTS blob_blocks (sha256 TEXT PRIMARY KEY, reason TEXT NOT NULL DEFAULT '', blocked_at INTEGER NOT NULL DEFAULT 0);201217 CREATE TABLE IF NOT EXISTS blob_tombstones (sha256 TEXT PRIMARY KEY, deleted_at INTEGER NOT NULL);202- CREATE TABLE IF NOT EXISTS multipart_uploads (id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, uploader TEXT NOT NULL, length INTEGER NOT NULL, type TEXT NOT NULL, created INTEGER NOT NULL, last_seen INTEGER NOT NULL, name TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '');218+ CREATE TABLE IF NOT EXISTS multipart_uploads (id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, uploader TEXT NOT NULL, length INTEGER NOT NULL, type TEXT NOT NULL, created INTEGER NOT NULL, last_seen INTEGER NOT NULL, name TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '', access TEXT NOT NULL DEFAULT 'public', purpose TEXT NOT NULL DEFAULT '');203219 CREATE TABLE IF NOT EXISTS multipart_parts (upload_id TEXT NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY(upload_id, offset), FOREIGN KEY(upload_id) REFERENCES multipart_uploads(id) ON DELETE CASCADE);204220 INSERT OR IGNORE INTO blob_claims(sha256,uploader,claimed_at)205221 SELECT sha256,uploader,uploaded FROM blobs WHERE uploader != ''`); err != nil {206222 return nil, fmt.Errorf("initialize blob metadata: %w", err)207223 }224+ if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blobs ADD COLUMN access TEXT NOT NULL DEFAULT 'public'"); err != nil && !strings.Contains(err.Error(), "duplicate column") {225+ return nil, fmt.Errorf("add blob access: %w", err)226+ }227+ for _, column := range []string{"purpose TEXT NOT NULL DEFAULT ''", "logical_size INTEGER NOT NULL DEFAULT 0", "logical_type TEXT NOT NULL DEFAULT ''"} {228+ if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blob_claim_metadata ADD COLUMN "+column); err != nil && !strings.Contains(err.Error(), "duplicate column") {229+ return nil, fmt.Errorf("add blob claim metadata field: %w", err)230+ }231+ }208232 // Claims made under an agent grant with a ttl carry an expiry; every209233 // earlier claim keeps 0, which never lapses.210234 if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE blob_claims ADD COLUMN expires INTEGER NOT NULL DEFAULT 0"); err != nil && !strings.Contains(err.Error(), "duplicate column") {@@ -215,6 +239,12 @@ func New(ctx context.Context, config Config) (*Service, error) {
215239 return nil, fmt.Errorf("add multipart metadata: %w", err)216240 }217241 }242+ if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN access TEXT NOT NULL DEFAULT 'public'"); err != nil && !strings.Contains(err.Error(), "duplicate column") {243+ return nil, fmt.Errorf("add multipart access: %w", err)244+ }245+ if _, err := config.Store.DB().ExecContext(ctx, "ALTER TABLE multipart_uploads ADD COLUMN purpose TEXT NOT NULL DEFAULT ''"); err != nil && !strings.Contains(err.Error(), "duplicate column") {246+ return nil, fmt.Errorf("add multipart purpose: %w", err)247+ }218248 if _, err := config.Store.DB().ExecContext(ctx, "CREATE INDEX IF NOT EXISTS blob_claims_expires ON blob_claims(expires) WHERE expires>0"); err != nil {219249 return nil, fmt.Errorf("index blob claim expiry: %w", err)220250 }@@ -27,7 +27,7 @@ func (s *Service) Put(ctx context.Context, options PutOptions) (Blob, error) {
2727 }2828 2929 func (s *Service) putOptions(ctx context.Context, options PutOptions) (Blob, bool, error) {30- return s.putValidatedWithMetadata(ctx, options.Reader, options.Type, options.Uploader, options.Hash, options.Name, options.Path, nil, options.Commit)30+ return s.putValidatedWithMetadata(ctx, options.Reader, options.Type, options.Uploader, options.Hash, options.Name, options.Path, options.Access, options.Purpose, nil, options.Commit)3131 }3232 3333 // Get opens a verified metadata-backed blob for streaming to another service.@@ -205,15 +205,23 @@ func (s *Service) put(ctx context.Context, source io.Reader, typ, uploader, clai
205205 }206206 207207 func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploader, claimed string, validate func(string) error) (Blob, bool, error) {208- return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", validate, nil)208+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", "", "", validate, nil)209+}210+211+func (s *Service) putValidatedWithAccess(ctx context.Context, source io.Reader, typ, uploader, claimed, access string, validate func(string) error) (Blob, bool, error) {212+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", access, "", validate, nil)209213 }210214 211215 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) {212- return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", validate, commit)216+ return s.putValidatedWithMetadata(ctx, source, typ, uploader, claimed, "", "", "", "", validate, commit)213217 }214218 215-func (s *Service) putValidatedWithMetadata(ctx context.Context, source io.Reader, typ, uploader, claimed, name, path string, validate func(string) error, commit func(context.Context, *sql.Tx, Blob, bool) error) (Blob, bool, error) {219+func (s *Service) putValidatedWithMetadata(ctx context.Context, source io.Reader, typ, uploader, claimed, name, path, access, purpose string, validate func(string) error, commit func(context.Context, *sql.Tx, Blob, bool) error) (Blob, bool, error) {216220 var err error221+ access, err = parseAccess(access)222+ if err != nil {223+ return Blob{}, false, err224+ }217225 name, path, err = claimMetadata(name, path)218226 if err != nil {219227 return Blob{}, false, err@@ -264,17 +272,21 @@ func (s *Service) putValidatedWithMetadata(ctx context.Context, source io.Reader
264272 return Blob{}, false, err265273 }266274 }267- return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, name: name, metadataPath: path, commit: commit})275+ return s.installUpload(ctx, uploadCandidate{path: tmpName, hash: sha, size: count, typ: typ, uploader: uploader, name: name, metadataPath: path, access: access, purpose: purpose, commit: commit})268276 }269277 270278 type uploadCandidate struct {271- path, hash, typ, uploader, reservation, name, metadataPath string272- size int64273- commit func(context.Context, *sql.Tx, Blob, bool) error279+ path, hash, typ, uploader, reservation, name, metadataPath, access, purpose string280+ size int64281+ commit func(context.Context, *sql.Tx, Blob, bool) error274282 }275283 276284 func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate) (Blob, bool, error) {277285 sha, count, typ, uploader := candidate.hash, candidate.size, candidate.typ, candidate.uploader286+ access, accessErr := parseAccess(candidate.access)287+ if accessErr != nil {288+ return Blob{}, false, accessErr289+ }278290 // Serialize the lookup, quota check, installation and metadata transaction.279291 // This closes the race where concurrent uploads could each observe the same280292 // remaining quota before one of them installs a new physical object.@@ -295,6 +307,13 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
295307 return Blob{}, false, ErrQuotaExceeded296308 }297309 if existing, err := s.lookup(ctx, sha); err == nil {310+ existingAccess, accessErr := s.BlobAccess(ctx, sha)311+ if accessErr != nil {312+ return Blob{}, false, accessErr313+ }314+ if existingAccess != access {315+ return Blob{}, false, ErrAccessConflict316+ }298317 if terms.Encrypted && !encryptedTypes[existing.Type] {299318 return Blob{}, false, ErrNotEncrypted300319 }@@ -319,7 +338,7 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
319338 if _, err := tx.ExecContext(ctx, claimSQL, sha, uploader, time.Now().Unix(), terms.ExpiresAt); err != nil {320339 return err321340 }322- if err := s.saveClaimMetadata(ctx, tx, sha, uploader, candidate.name, candidate.metadataPath); err != nil {341+ if err := s.saveClaimMetadata(ctx, tx, sha, uploader, candidate.name, candidate.metadataPath, candidate.purpose); err != nil {323342 return err324343 }325344 }@@ -385,14 +404,14 @@ func (s *Service) installUpload(ctx context.Context, candidate uploadCandidate)
385404 if _, err := tx.ExecContext(ctx, "DELETE FROM blob_tombstones WHERE sha256=?", entry.SHA256); err != nil {386405 return fmt.Errorf("clear blob deletion tombstone: %w", err)387406 }388- if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)", entry.SHA256, entry.Size, entry.Type, entry.Uploader, entry.Uploaded); err != nil {407+ if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO blobs(sha256,size,type,uploader,uploaded,access) VALUES(?,?,?,?,?,?)", entry.SHA256, entry.Size, entry.Type, entry.Uploader, entry.Uploaded, access); err != nil {389408 return fmt.Errorf("record blob: %w", err)390409 }391410 if entry.Uploader != "" {392411 if _, err := tx.ExecContext(ctx, claimSQL, entry.SHA256, entry.Uploader, entry.Uploaded, terms.ExpiresAt); err != nil {393412 return fmt.Errorf("record blob claim: %w", err)394413 }395- if err := s.saveClaimMetadata(ctx, tx, entry.SHA256, entry.Uploader, candidate.name, candidate.metadataPath); err != nil {414+ if err := s.saveClaimMetadata(ctx, tx, entry.SHA256, entry.Uploader, candidate.name, candidate.metadataPath, candidate.purpose); err != nil {396415 return err397416 }398417 }@@ -531,6 +550,32 @@ func (s *Service) lookup(ctx context.Context, sha string) (Blob, error) {
531550 return entry, err532551 }533552 553+// BlobAccess returns the durable retrieval policy for a blob. Older databases554+// are treated as public through the column default and NULL-safe fallback.555+func (s *Service) BlobAccess(ctx context.Context, sha string) (string, error) {556+ var access string557+ err := s.store.DB().QueryRowContext(ctx, "SELECT COALESCE(access,?) FROM blobs WHERE sha256=?", AccessPublic, sha).Scan(&access)558+ if err != nil {559+ return "", err560+ }561+ parsed, parseErr := parseAccess(access)562+ if parseErr != nil {563+ return "", parseErr564+ }565+ return parsed, nil566+}567+568+func parseAccess(access string) (string, error) {569+ access = strings.ToLower(strings.TrimSpace(access))570+ if access == "" || access == AccessPublic {571+ return AccessPublic, nil572+ }573+ if access == AccessMembers {574+ return AccessMembers, nil575+ }576+ return "", ErrInvalidAccess577+}578+534579 func (s *Service) blocked(ctx context.Context, sha string) bool {535580 var found int536581 err := s.store.DB().QueryRowContext(ctx, `SELECT 1 FROM hidden_events WHERE id=?@@ -240,6 +240,9 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
240240 if !t.Policy().Features.Files {241241 return nil, errors.New("not found: files are disabled")242242 }243+ if err := t.fileReadAccess(ctx, actor, hash); err != nil {244+ return nil, err245+ }243246 if err := t.roomAttachmentAccess(ctx, actor, hash); err != nil {244247 return nil, err245248 }@@ -258,9 +261,18 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
258261 }259262 binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated)260263 result := t.browseBlobMetadata(entry)264+ if access, accessErr := t.blobs.BlobAccess(ctx, entry.SHA256); accessErr == nil {265+ result["access"] = access266+ }261267 if actor != "" {262268 var name, filePath string263- if err := t.store.DB().QueryRowContext(ctx, `SELECT name,path FROM blob_claim_metadata WHERE sha256=? AND uploader=? ORDER BY updated_at DESC,path LIMIT 1`, entry.SHA256, actor).Scan(&name, &filePath); err == nil {269+ metadataQuery := `SELECT name,path FROM blob_claim_metadata WHERE sha256=? AND uploader=? ORDER BY updated_at DESC,path LIMIT 1`270+ metadataArgs := []any{entry.SHA256, actor}271+ if access, accessErr := t.blobs.BlobAccess(ctx, entry.SHA256); accessErr == nil && access == blob.AccessMembers {272+ metadataQuery = `SELECT name,path FROM blob_claim_metadata WHERE sha256=? ORDER BY updated_at DESC,path LIMIT 1`273+ metadataArgs = []any{entry.SHA256}274+ }275+ if err := t.store.DB().QueryRowContext(ctx, metadataQuery, metadataArgs...).Scan(&name, &filePath); err == nil {264276 if name != "" {265277 result["name"] = name266278 }@@ -99,6 +99,9 @@ func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request, actor stri
9999 return os.ErrNotExist100100 }101101 hash := r.URL.Query().Get("hash")102+ if err := t.fileReadAccess(r.Context(), actor, hash); err != nil {103+ return err104+ }102105 if err := t.roomAttachmentAccess(r.Context(), actor, hash); err != nil {103106 return err104107 }@@ -131,3 +131,21 @@ func TestOwnerFileSearchUsesLiteralTextBeforePagination(t *testing.T) {
131131 t.Fatalf("literal search = %#v", row)132132 }133133 }134+135+func TestLibraryRowsGroupsLegacyUnnamedBinaryAndHidesMarkedChunks(t *testing.T) {136+ rows := []map[string]any{137+ {"sha256": strings.Repeat("a", 64), "name": "", "path": "", "type": "application/octet-stream"},138+ {"sha256": strings.Repeat("b", 64), "name": "", "path": "", "type": "application/octet-stream", "purpose": "chunk"},139+ {"sha256": strings.Repeat("c", 64), "name": "notes.txt", "path": "notes.txt", "type": "text/plain"},140+ }141+ got := libraryRows(rows, nil)142+ if len(got) != 2 {143+ t.Fatalf("library rows = %d, want 2", len(got))144+ }145+ if got[0]["path"] != "Unorganized uploads/aaaaaaaaaaaa" || got[0]["name"] != "aaaaaaaaaaaa" {146+ t.Fatalf("legacy row = %#v", got[0])147+ }148+ if got[1]["path"] != "notes.txt" {149+ t.Fatalf("named row changed = %#v", got[1])150+ }151+}@@ -59,7 +59,7 @@ func (t *Tenant) browseFiles(ctx context.Context, actor string, q clientBrowseRe
5959 return result, nil6060 }6161 62-func (t Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {62+func (t *Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {6363 if actor == "" {6464 return nil, errors.New("auth-required: sign in to browse your files")6565 }@@ -104,7 +104,7 @@ func (t Tenant) browseFilesLegacy(ctx context.Context, actor string, q clientBro
104104 return map[string]any{"items": items, "next_cursor": next}, nil105105 }106106 107-func (t Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {107+func (t *Tenant) browseAllFiles(ctx context.Context, q clientBrowseRequest) ([]blob.Blob, string, error) {108108 query := "SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE sha256>?"109109 args := []any{q.Cursor}110110 needle := strings.ToLower(strings.TrimSpace(q.Query))@@ -141,7 +141,7 @@ func blobMatchesQuery(e blob.Blob, q string) bool {
141141 n := strings.ToLower(strings.TrimSpace(q))142142 return n == "" || strings.Contains(strings.ToLower(e.SHA256), n) || strings.Contains(strings.ToLower(e.Type), n) || strings.Contains(strings.ToLower(e.Uploader), n)143143 }144-func (t Tenant) browseBlobMetadata(e blob.Blob) map[string]any {144+func (t *Tenant) browseBlobMetadata(e blob.Blob) map[string]any {145145 portable := blob.BlossomURI{Hash: e.SHA256, Extension: "bin", Author: e.Uploader, Size: e.Size}146146 if origin, err := url.Parse(t.publicURL); err == nil && strings.Trim(origin.Path, "/") == "" {147147 portable.Servers = []string{origin.Scheme + "://" + origin.Host}@@ -149,12 +149,21 @@ func (t Tenant) browseBlobMetadata(e blob.Blob) map[string]any {
149149 return map[string]any{"sha256": e.SHA256, "size": e.Size, "type": e.Type, "uploader": e.Uploader, "uploaded": e.Uploaded, "url": strings.TrimRight(t.publicURL, "/") + "/" + e.SHA256, "download_url": strings.TrimRight(t.publicURL, "/") + "/files/raw?hash=" + url.QueryEscape(e.SHA256), "blossom_uri": portable.String()}150150 }151151 152-func (t Tenant) claimRows(ctx context.Context, actor string, all bool) ([]map[string]any, error) {153- q := `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded,COALESCE(m.name,''),COALESCE(m.path,'') FROM blobs b JOIN blob_claims c ON c.sha256=b.sha256 LEFT JOIN blob_claim_metadata m ON m.sha256=b.sha256 AND m.uploader=c.uploader`152+func (t *Tenant) claimRows(ctx context.Context, actor string, all bool) ([]map[string]any, error) {153+ q := `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded,COALESCE(m.name,''),COALESCE(m.path,''),COALESCE(m.purpose,''),COALESCE(m.logical_size,0),COALESCE(m.logical_type,''),b.access FROM blobs b JOIN blob_claims c ON c.sha256=b.sha256 LEFT JOIN blob_claim_metadata m ON m.sha256=b.sha256 AND m.uploader=c.uploader`154154 args := []any{}155155 if !all {156- q += " WHERE c.uploader=?"157- args = append(args, actor)156+ includeMembers := false157+ if role, roleErr := t.community.Role(ctx, actor); roleErr == nil && t.memberBlobReader(ctx, actor, role) {158+ includeMembers = true159+ }160+ if includeMembers {161+ q += " WHERE c.uploader=? OR b.access=?"162+ args = append(args, actor, blob.AccessMembers)163+ } else {164+ q += " WHERE c.uploader=?"165+ args = append(args, actor)166+ }158167 }159168 q += " ORDER BY b.uploaded DESC,b.sha256 DESC"160169 rows, err := t.store.DB().QueryContext(ctx, q, args...)@@ -165,19 +174,28 @@ func (t Tenant) claimRows(ctx context.Context, actor string, all bool) ([]map[st
165174 out := []map[string]any{}166175 for rows.Next() {167176 var e blob.Blob168- var name, p string169- if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded, &name, &p); err != nil {177+ var name, p, purpose, logicalType, access string178+ var logicalSize int64179+ if err := rows.Scan(&e.SHA256, &e.Size, &e.Type, &e.Uploader, &e.Uploaded, &name, &p, &purpose, &logicalSize, &logicalType, &access); err != nil {170180 return nil, err171181 }172182 m := t.browseBlobMetadata(e)183+ m["access"] = access173184 m["name"] = name174185 m["path"] = p186+ m["purpose"] = purpose187+ if purpose == "file" && logicalType != "" {188+ m["logical_size"] = logicalSize189+ }190+ if logicalType != "" {191+ m["logical_type"] = logicalType192+ }175193 out = append(out, m)176194 }177195 return out, rows.Err()178196 }179197 180-func (t Tenant) browseLibrary(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {198+func (t *Tenant) browseLibrary(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {181199 rows, err := t.claimRows(ctx, actor, false)182200 if err != nil {183201 return nil, err@@ -193,9 +211,43 @@ func (t Tenant) browseLibrary(ctx context.Context, actor string, q clientBrowseR
193211 for h := range rooms {194212 excluded[h] = true195213 }214+ rows = libraryRows(rows, excluded)196215 return t.folderItems(rows, excluded, q, "library", false), nil197216 }198-func (t Tenant) folderItems(rows []map[string]any, excluded map[string]bool, q clientBrowseRequest, view string, includeExcluded bool) map[string]any {217+218+// libraryRows keeps implementation objects out of the user's file library.219+// New encrypted uploads mark their chunk claims with purpose=chunk. Older220+// unnamed octet streams cannot be identified safely from size alone, so they221+// remain visible in one unorganized folder instead of being deleted or222+// silently hidden.223+func libraryRows(rows []map[string]any, excluded map[string]bool) []map[string]any {224+ out := make([]map[string]any, 0, len(rows))225+ for _, row := range rows {226+ if row["purpose"] == "chunk" {227+ continue228+ }229+ if row["purpose"] == "file" {230+ if size, ok := row["logical_size"]; ok {231+ row["size"] = size232+ }233+ }234+ if excluded[row["sha256"].(string)] && row["name"] == "" {235+ continue236+ }237+ if row["name"] == "" && row["path"] == "" && row["type"] == "application/octet-stream" {238+ hash := row["sha256"].(string)239+ short := hash240+ if len(short) > 12 {241+ short = short[:12]242+ }243+ row["path"] = "Unorganized uploads/" + short244+ row["name"] = short245+ }246+ out = append(out, row)247+ }248+ return out249+}250+func (t *Tenant) folderItems(rows []map[string]any, excluded map[string]bool, q clientBrowseRequest, view string, includeExcluded bool) map[string]any {199251 prefix := strings.Trim(q.Path, "/")200252 if prefix != "" {201253 prefix += "/"@@ -290,12 +342,12 @@ func breadcrumbs(view, p string) []map[string]string {
290342 return out291343 }292344 293-func (t Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {345+func (t *Tenant) contextHashes(ctx context.Context, actor string) (map[string][]string, map[string][]string, error) {294346 sitesH := map[string][]string{}295347 roomsH := map[string][]string{}296348 var before *storage.EventCursor297349 for {298- r, err := t.store.Query(ctx, event.Filter{Kinds: []int{sites.KindSite, sites.KindNamedSite, sites.KindSiteSnapshot}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: browseSession(&t, actor).PubKeys}, Limit: 100, Before: before})350+ r, err := t.store.Query(ctx, event.Filter{Kinds: []int{sites.KindSite, sites.KindNamedSite, sites.KindSiteSnapshot}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: browseSession(t, actor).PubKeys}, Limit: 100, Before: before})299351 if err != nil {300352 return nil, nil, err301353 }@@ -342,7 +394,7 @@ func (t Tenant) contextHashes(ctx context.Context, actor string) (map[string][]s
342394 return sitesH, roomsH, nil343395 }344396 345-func (t Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {397+func (t *Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {346398 query := `SELECT sha256,size,type,uploader,uploaded FROM blobs WHERE 1=1`347399 args := []any{}348400 if q.Cursor != "" {@@ -393,7 +445,7 @@ func (t Tenant) browseStorage(ctx context.Context, actor string, q clientBrowseR
393445 }394446 return map[string]any{"items": items, "view": "storage", "path": "", "breadcrumbs": breadcrumbs("storage", ""), "next_cursor": next}, nil395447 }396-func (t Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {448+func (t *Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {397449 _, rooms, err := t.contextHashes(ctx, actor)398450 if err != nil {399451 return nil, err@@ -427,7 +479,7 @@ func (t Tenant) browseRoomFiles(ctx context.Context, actor string, q clientBrows
427479 }428480 return result, nil429481 }430-func (t Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {482+func (t *Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrowseRequest) (any, error) {431483 sitesH, _, err := t.contextHashes(ctx, actor)432484 if err != nil {433485 return nil, err@@ -447,7 +499,7 @@ func (t Tenant) browseSiteFiles(ctx context.Context, actor string, q clientBrows
447499 448500 // Read only the referenced blobs. Claim labels belong to uploaders and must449501 // not become names for files viewed through someone else's site or room.450-func (t Tenant) contextFileRows(ctx context.Context, refs map[string][]string, contextName string) ([]map[string]any, error) {502+func (t *Tenant) contextFileRows(ctx context.Context, refs map[string][]string, contextName string) ([]map[string]any, error) {451503 hashes := make([]string, 0, len(refs))452504 for hash := range refs {453505 hashes = append(hashes, hash)@@ -33,12 +33,12 @@ func TestFolderItemsKeepsFoldersAndFilesPageable(t *testing.T) {
3333 {"sha256": "aaaaaaaa", "type": "text/plain", "uploader": "alice", "uploaded": int64(2), "name": "readme.txt", "path": "docs/readme.txt"},3434 {"sha256": "bbbbbbbb", "type": "image/png", "uploader": "alice", "uploaded": int64(1), "name": "cover.png", "path": "cover.png"},3535 }36- first := (Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1)}, "library", true)36+ first := (&Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1)}, "library", true)3737 items := first["items"].([]map[string]any)3838 if len(items) != 1 || items[0]["kind"] != "folder" || first["next_cursor"] == "" {3939 t.Fatalf("first page = %#v", first)4040 }41- second := (Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1), Cursor: first["next_cursor"].(string)}, "library", true)41+ second := (&Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1), Cursor: first["next_cursor"].(string)}, "library", true)4242 items = second["items"].([]map[string]any)4343 if len(items) != 1 || items[0]["kind"] != "file" || items[0]["name"] != "cover.png" {4444 t.Fatalf("second page = %#v", second)@@ -0,0 +1,121 @@
1+package daemon2+3+import (4+ "encoding/json"5+ "net/http"6+ "net/http/httptest"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/blob"11+)12+13+func TestEncryptedFileMetadataEndpointBindsBodyAndClaim(t *testing.T) {14+ _, tenant := testTenant(t)15+ ctx := t.Context()16+ entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("ciphertext"), Type: "application/octet-stream", Uploader: tenant.Policy().Owner})17+ if err != nil {18+ t.Fatal(err)19+ }20+ body := `{"hash":"` + entry.SHA256 + `","name":"Encrypted file ` + entry.SHA256[:12] + `","path":"Encrypted file ` + entry.SHA256[:12] + `","purpose":"file","size":104857600,"type":"application/octet-stream"}`21+ r := httptest.NewRequest(http.MethodPost, "http://relay.test/files/metadata", strings.NewReader(body))22+ r.Header.Set("Content-Type", "application/json")23+ signRequest(t, r, body)24+ w := httptest.NewRecorder()25+ tenant.ServeHTTP(w, r)26+ if w.Code != http.StatusOK {27+ t.Fatalf("metadata status=%d body=%s", w.Code, w.Body.String())28+ }29+ var purpose, typ string30+ var size int6431+ if err := tenant.store.DB().QueryRow("SELECT purpose,logical_size,logical_type FROM blob_claim_metadata WHERE sha256=? AND uploader=?", entry.SHA256, tenant.Policy().Owner).Scan(&purpose, &size, &typ); err != nil {32+ t.Fatal(err)33+ }34+ if purpose != "file" || size != 104857600 || typ != "application/octet-stream" {35+ t.Fatalf("metadata=%q %d %q", purpose, size, typ)36+ }37+ bad := strings.Replace(body, `"size":104857600`, `"size":104857601`, 1)38+ r = httptest.NewRequest(http.MethodPost, "http://relay.test/files/metadata", strings.NewReader(bad))39+ r.Header.Set("Content-Type", "application/json")40+ signRequest(t, r, body)41+ w = httptest.NewRecorder()42+ tenant.ServeHTTP(w, r)43+ if w.Code != http.StatusUnauthorized {44+ t.Fatalf("tampered metadata status=%d body=%s", w.Code, w.Body.String())45+ }46+ unknown := body[:len(body)-1] + `,"key":"must-not-store"}`47+ r = httptest.NewRequest(http.MethodPost, "http://relay.test/files/metadata", strings.NewReader(unknown))48+ r.Header.Set("Content-Type", "application/json")49+ signRequest(t, r, unknown)50+ w = httptest.NewRecorder()51+ tenant.ServeHTTP(w, r)52+ if w.Code != http.StatusBadRequest {53+ t.Fatalf("unknown metadata status=%d body=%s", w.Code, w.Body.String())54+ }55+}56+57+func TestEncryptedUploadFinalizesOneLogicalLibraryFile(t *testing.T) {58+ _, tenant := testTenant(t)59+ body := "an encrypted upload fixture"60+ r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload?purpose=chunk", strings.NewReader(body))61+ r.Header.Set("Content-Type", "application/octet-stream")62+ signRequest(t, r, body)63+ w := httptest.NewRecorder()64+ tenant.ServeHTTP(w, r)65+ if w.Code != http.StatusOK {66+ t.Fatalf("upload: %d %s", w.Code, w.Body.String())67+ }68+ var uploaded struct {69+ Hash string `json:"sha256"`70+ }71+ if err := json.Unmarshal(w.Body.Bytes(), &uploaded); err != nil {72+ t.Fatal(err)73+ }74+ query := []json.RawMessage{json.RawMessage(`{"view":"library"}`)}75+ library, err := tenant.Execute(t.Context(), tenant.Policy().Owner, "browsefiles", query)76+ if err != nil {77+ t.Fatal(err)78+ }79+ if len(fileItems(t, library)) != 0 {80+ t.Fatalf("internal chunk in library: %v", library)81+ }82+ metadata := `{"hash":"` + uploaded.Hash + `","name":"Encrypted file","purpose":"file","size":104857600,"type":"application/octet-stream"}`83+ r = httptest.NewRequest(http.MethodPost, "http://relay.test/files/metadata", strings.NewReader(metadata))84+ signRequest(t, r, metadata)85+ w = httptest.NewRecorder()86+ tenant.ServeHTTP(w, r)87+ if w.Code != http.StatusOK {88+ t.Fatalf("finalize: %d %s", w.Code, w.Body.String())89+ }90+ library, err = tenant.Execute(t.Context(), tenant.Policy().Owner, "browsefiles", query)91+ if err != nil {92+ t.Fatal(err)93+ }94+ items := fileItems(t, library)95+ if len(items) != 1 || items[0]["name"] != "Encrypted file" || items[0]["size"] != int64(104857600) {96+ t.Fatalf("logical file: %v", items)97+ }98+}99+100+func TestFileMetadataCannotChangeAnotherUploadersClaim(t *testing.T) {101+ _, tenant := testTenant(t)102+ entry, err := tenant.blobs.Put(t.Context(), blob.PutOptions{Reader: strings.NewReader("someone else's file"), Uploader: strings.Repeat("b", 64)})103+ if err != nil {104+ t.Fatal(err)105+ }106+ body := `{"hash":"` + entry.SHA256 + `","name":"Changed","purpose":"file"}`107+ r := httptest.NewRequest(http.MethodPost, "http://relay.test/files/metadata", strings.NewReader(body))108+ signRequest(t, r, body)109+ w := httptest.NewRecorder()110+ tenant.ServeHTTP(w, r)111+ if w.Code == http.StatusOK {112+ t.Fatal("owner must not edit another uploader's claim metadata")113+ }114+ var count int115+ if err := tenant.store.DB().QueryRow("SELECT count(*) FROM blob_claim_metadata WHERE sha256=?", entry.SHA256).Scan(&count); err != nil {116+ t.Fatal(err)117+ }118+ if count != 0 {119+ t.Fatal("metadata changed without claim ownership")120+ }121+}@@ -169,6 +169,9 @@ func (t *Tenant) mcpReadAttachment(ctx context.Context, call mcp.Call) (mcp.Resu
169169 if err := t.browseRead(ctx, call.Actor); err != nil {170170 return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil171171 }172+ if err := t.fileReadAccess(ctx, call.Actor, call.String("sha256")); err != nil {173+ return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil174+ }172175 if err := t.roomAttachmentAccess(ctx, call.Actor, call.String("sha256")); err != nil {173176 return mcp.Failure("attachment unavailable: "+err.Error(), nil), nil174177 }@@ -0,0 +1,135 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+)11+12+func TestMemberFileAccessAcrossHTTPAndBrowseSurfaces(t *testing.T) {13+ h := newRoomHarness(t)14+ ctx := context.Background()15+ const body = "members-only file bytes"16+ request := httptest.NewRequest(http.MethodPut, "http://relay.test/upload?access=members&filename=private.txt", strings.NewReader(body))17+ request.Header.Set("Content-Type", "text/plain")18+ signRequestWithSecret(t, request, body, h.secrets["alice"])19+ uploaded := httptest.NewRecorder()20+ h.tenant.ServeHTTP(uploaded, request)21+ if uploaded.Code != http.StatusOK {22+ t.Fatalf("member upload = %d: %s", uploaded.Code, uploaded.Body.String())23+ }24+ var descriptor struct {25+ Hash string `json:"sha256"`26+ }27+ if err := json.Unmarshal(uploaded.Body.Bytes(), &descriptor); err != nil || len(descriptor.Hash) != 64 {28+ t.Fatalf("upload descriptor = %s: %v", uploaded.Body.String(), err)29+ }30+31+ if rows, err := h.browse("bob", "browsefiles", map[string]any{"view": "library", "limit": 50}); err != nil {32+ t.Fatalf("member library: %v", err)33+ } else if !libraryContainsHash(rows, descriptor.Hash, "private.txt") {34+ t.Fatalf("member library omitted private file: %v", rows)35+ }36+ if rows, err := h.browse("stranger", "browsefiles", map[string]any{"view": "library", "limit": 50}); err != nil {37+ t.Fatalf("outsider library: %v", err)38+ } else if libraryContainsHash(rows, descriptor.Hash, "private.txt") {39+ t.Fatalf("outsider library exposed private file: %v", rows)40+ }41+42+ for _, route := range []string{"/" + descriptor.Hash, "/nip96/" + descriptor.Hash, "/files/raw?hash=" + descriptor.Hash, "/media/" + descriptor.Hash} {43+ for _, method := range []string{http.MethodGet, http.MethodHead} {44+ for _, actor := range []string{"owner", "bob"} {45+ t.Run(method+" "+actor+" "+route, func(t *testing.T) {46+ response := memberFileRequest(t, h, method, route, actor)47+ if response.Code != http.StatusOK {48+ t.Fatalf("%s response = %d: %s", actor, response.Code, response.Body.String())49+ }50+ if method == http.MethodGet && response.Body.String() != body {51+ t.Fatalf("%s body = %q", actor, response.Body.String())52+ }53+ })54+ }55+ t.Run(method+" guest "+route, func(t *testing.T) {56+ response := memberFileRequest(t, h, method, route, "")57+ if response.Code != http.StatusUnauthorized && response.Code != http.StatusForbidden {58+ t.Fatalf("guest response = %d: %s", response.Code, response.Body.String())59+ }60+ })61+ t.Run(method+" outsider "+route, func(t *testing.T) {62+ response := memberFileRequest(t, h, method, route, "stranger")63+ if response.Code != http.StatusForbidden {64+ t.Fatalf("outsider response = %d: %s", response.Code, response.Body.String())65+ }66+ })67+ }68+ }69+70+ for _, actor := range []string{"bob", "stranger"} {71+ request := httptest.NewRequest(http.MethodGet, "http://relay.test/file?hash="+descriptor.Hash, nil)72+ if actor != "" {73+ signRequestWithSecret(t, request, "", h.secrets[actor])74+ }75+ response := httptest.NewRecorder()76+ h.tenant.ServeHTTP(response, request)77+ page := response.Body.String()78+ if actor == "bob" {79+ if response.Code != http.StatusOK || !strings.Contains(page, "private.txt") {80+ t.Fatalf("member file page = %d: %s", response.Code, page)81+ }82+ } else if strings.Contains(page, "private.txt") || strings.Contains(page, body) {83+ t.Fatalf("outsider file page leaked private data: %d: %s", response.Code, page)84+ }85+ }86+87+ result, err := h.tenant.Execute(ctx, h.keys["bob"], "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": descriptor.Hash})})88+ if err != nil || !strings.Contains(string(rawJSON(result)), "private.txt") {89+ t.Fatalf("member browsefile = %v, %v", result, err)90+ }91+ if _, err := h.tenant.Execute(ctx, h.keys["stranger"], "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": descriptor.Hash})}); err == nil {92+ t.Fatal("outsider browsefile exposed private file")93+ }94+95+ if _, err := h.tenant.Execute(ctx, h.keys["owner"], "removemember", []json.RawMessage{rawJSON(h.keys["bob"])}); err != nil {96+ t.Fatalf("remove member: %v", err)97+ }98+ if response := memberFileRequest(t, h, http.MethodGet, "/files/raw?hash="+descriptor.Hash+"&check=revoked", "bob"); response.Code != http.StatusForbidden {99+ t.Fatalf("revoked member response = %d: %s", response.Code, response.Body.String())100+ }101+ if _, err := h.tenant.Execute(ctx, h.keys["owner"], "setmember", []json.RawMessage{rawJSON(h.keys["bob"]), rawJSON(map[string]any{"role": "member"})}); err != nil {102+ t.Fatalf("restore member: %v", err)103+ }104+ if _, err := h.tenant.Execute(ctx, h.keys["owner"], "banpubkey", []json.RawMessage{rawJSON(h.keys["bob"]), rawJSON("test")}); err != nil {105+ t.Fatalf("ban member: %v", err)106+ }107+ if response := memberFileRequest(t, h, http.MethodGet, "/files/raw?hash="+descriptor.Hash+"&check=banned", "bob"); response.Code != http.StatusForbidden {108+ t.Fatalf("banned member response = %d: %s", response.Code, response.Body.String())109+ }110+}111+112+func memberFileRequest(t *testing.T, h *roomHarness, method, route, actor string) *httptest.ResponseRecorder {113+ t.Helper()114+ request := httptest.NewRequest(method, "http://relay.test"+route, nil)115+ if actor != "" {116+ signRequestWithSecret(t, request, "", h.secrets[actor])117+ }118+ response := httptest.NewRecorder()119+ h.tenant.ServeHTTP(response, request)120+ return response121+}122+123+func libraryContainsHash(rows map[string]any, hash, name string) bool {124+ items, ok := rows["items"].([]any)125+ if !ok {126+ return false127+ }128+ for _, raw := range items {129+ item, ok := raw.(map[string]any)130+ if ok && item["sha256"] == hash && item["name"] == name {131+ return true132+ }133+ }134+ return false135+}@@ -69,6 +69,13 @@ func (t *Tenant) initServices(ctx context.Context) error {
6969 return t.community.SubmitReportTx(ctx, tx, reporter, target, targetType, reportType, content, t.Policy().ReportThreshold)7070 },7171 CanRead: func(ctx context.Context, hash string, pubkeys []string) bool {72+ access, accessErr := t.blobs.BlobAccess(ctx, hash)73+ if accessErr != nil {74+ return false75+ }76+ if access == blob.AccessMembers && len(pubkeys) == 0 {77+ return false78+ }7279 keys := []string{}7380 for _, pubkey := range pubkeys {7481 if pubkey == "" {@@ -78,8 +85,17 @@ func (t *Tenant) initServices(ctx context.Context) error {
7885 if err != nil || banned {7986 return false8087 }88+ if access == blob.AccessMembers {89+ role, roleErr := t.community.Role(ctx, pubkey)90+ if roleErr != nil || !t.memberBlobReader(ctx, pubkey, role) {91+ return false92+ }93+ }8194 keys = append(keys, pubkey)8295 }96+ if access == blob.AccessMembers && len(keys) == 0 {97+ return false98+ }8399 _, err := t.gate.Read(ctx, []event.Filter{{}}, relay.Session{PubKeys: keys, RelayURL: t.RelayURL()})84100 return err == nil85101 }, IsOwner: func(pubkey string) bool { return pubkey == t.Policy().Owner },@@ -94,6 +110,11 @@ func (t *Tenant) initServices(ctx context.Context) error {
94110 t.sites, err = sites.New(sites.Config{95111 Store: t.store,96112 GetBlob: func(ctx context.Context, hash string) (sites.Blob, error) {113+ if access, accessErr := t.blobs.BlobAccess(ctx, hash); accessErr != nil {114+ return sites.Blob{}, accessErr115+ } else if access == blob.AccessMembers {116+ return sites.Blob{}, errors.New("restricted: member files cannot be published as sites")117+ }97118 rooms, err := t.roomAttachmentRooms(ctx, hash)98119 if err != nil {99120 return sites.Blob{}, err@@ -172,6 +193,45 @@ func (t *Tenant) initServices(ctx context.Context) error {
172193 return nil173194 }174195 196+func (t *Tenant) memberBlobReader(ctx context.Context, pubkey, role string) bool {197+ if role == "owner" || role == "moderator" || role == "member" {198+ return true199+ }200+ if role != "agent" {201+ return false202+ }203+ _, err := t.agentUploadGrant(ctx, pubkey)204+ return err == nil205+}206+207+func (t *Tenant) fileReadAccess(ctx context.Context, actor, hash string) error {208+ access, err := t.blobs.BlobAccess(ctx, hash)209+ if err != nil {210+ return err211+ }212+ if access != blob.AccessMembers {213+ return nil214+ }215+ if actor == "" {216+ return errors.New("auth-required: member file requires sign in")217+ }218+ banned, err := t.community.IsBanned(ctx, actor)219+ if err != nil {220+ return fmt.Errorf("check file reader ban: %w", err)221+ }222+ if banned {223+ return errors.New("blocked: file reader is banned")224+ }225+ role, err := t.community.Role(ctx, actor)226+ if err != nil {227+ return fmt.Errorf("check file reader role: %w", err)228+ }229+ if !t.memberBlobReader(ctx, actor, role) {230+ return errors.New("restricted: relay membership required to read this file")231+ }232+ return nil233+}234+175235 // backend adapts the management method signature to webui's form-oriented API.176236 type backend struct{ tenant *Tenant }177237 @@ -440,14 +500,21 @@ func (t *Tenant) validateBlobUpload(r *http.Request, hash string) error {
440500 // signed request can be verified before parsing or fetching any content.441501 func blobAuthBody(r *http.Request, action blob.Action) (string, error) {442502 buffer := action == blob.ActionMirror || action == blob.ActionReport ||443- (action == blob.ActionUpload && r.Method == http.MethodPost && r.URL.Path == "/nip96")503+ (action == blob.ActionUpload && r.Method == http.MethodPost && (r.URL.Path == "/nip96" || r.URL.Path == "/files/metadata"))444504 if !buffer {445505 return "", nil446506 }447- body, err := io.ReadAll(r.Body)507+ var source io.Reader = r.Body508+ if r.URL.Path == "/files/metadata" {509+ source = io.LimitReader(r.Body, 16*1024+1)510+ }511+ body, err := io.ReadAll(source)448512 if err != nil {449513 return "", fmt.Errorf("read blob authorization body: %w", err)450514 }515+ if r.URL.Path == "/files/metadata" && len(body) > 16*1024 {516+ return "", errors.New("invalid: file metadata exceeds 16 KiB")517+ }451518 r.Body = io.NopCloser(bytes.NewReader(body))452519 return string(body), nil453520 }@@ -6,6 +6,8 @@
66 // executes that plan with retry, progress and cancellation support.77 88 const {sha256, hex} = globalThis.tiny.util;9+ const probeCache = new Map();10+ const DEFAULT_PROBE_CACHE_MS = 5 * 60 * 1000;911 const asBytes = async value => {1012 if (value instanceof Uint8Array) return value;1113 if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);@@ -30,6 +32,17 @@
3032 throw new Error("invalid Blossom upload descriptor");3133 return descriptor;3234 };35+ const withMetadata = (target, metadata) => {36+ if (metadata === undefined) return target;37+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) throw new Error("upload metadata must be a map");38+ const parsed = new URL(target, globalThis.location?.href || "https://blossom.invalid");39+ for (const [key, value] of Object.entries(metadata)) {40+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(key) || value === undefined || typeof value === "object")41+ throw new Error("invalid upload metadata");42+ parsed.searchParams.set(key, String(value));43+ }44+ return parsed.href;45+ };3346 3447 // resolveTarget checks the source and options and returns the blob URL3548 // the upload addresses. Both the live uploader and the background plan@@ -58,7 +71,7 @@
5871 const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path)5972 ? base6073 : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");61- return {bytes, hash, type, url: target};74+ return {bytes, hash, type, url: withMetadata(target, options.metadata), probeURL: base.replace(/\/$/, "")};6275 };6376 6477 // plan lists the signed chunk requests for one upload without sending@@ -89,13 +102,15 @@
89102 };90103 91104 const uploadTask = async (source, options = {}, controller) => {92- const {bytes, hash, type, url} = await resolveTarget(source, options);105+ const {bytes, hash, type, url, probeURL} = await resolveTarget(source, options);93106 const fetcher = options.fetch || globalThis.fetch;94107 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");95108 const chunkSize =96109 Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;97110 const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;98- const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 0;111+ const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 2;112+ const retryDelayMs = Number.isFinite(options.retryDelayMs) && options.retryDelayMs >= 0 ? options.retryDelayMs : 250;113+ const retryMaxDelayMs = Number.isFinite(options.retryMaxDelayMs) && options.retryMaxDelayMs >= 0 ? options.retryMaxDelayMs : 5000;99114 const state = options.state instanceof Map ? options.state : new Map();100115 const notify = (offset, length, status) => options.onState?.({offset, length, status, state});101116 const progress = sent =>@@ -159,6 +174,23 @@
159174 requestController.abort();160175 }161176 };177+ const waitForRetry = async attempt => {178+ const delay = Math.min(retryMaxDelayMs, retryDelayMs * 2 ** Math.max(0, attempt - 1));179+ if (!delay) return;180+ await new Promise((resolve, reject) => {181+ let timer;182+ const cancel = () => {183+ clearTimeout(timer);184+ controller.signal.removeEventListener("abort", cancel);185+ reject(abortError());186+ };187+ timer = setTimeout(() => {188+ controller.signal.removeEventListener("abort", cancel);189+ resolve();190+ }, delay);191+ controller.signal.addEventListener("abort", cancel, {once: true});192+ });193+ };162194 const retry = async (action, offset, length) => {163195 let attempt = 0;164196 while (true) {@@ -167,6 +199,7 @@
167199 const result = await action();168200 if (!result.ok && result.status >= 500 && attempt++ < retries) {169201 notify(offset, length, "retrying");202+ await waitForRetry(attempt);170203 continue;171204 }172205 return result;@@ -174,15 +207,21 @@
174207 if (controller.signal.aborted) throw abortError();175208 if (attempt++ >= retries) throw error;176209 notify(offset, length, "retrying");210+ await waitForRetry(attempt);177211 }178212 }179213 };180214 181- let supportsPatch = options.probe === false;182- if (options.probe !== false) {215+ const cacheMs = options.probeCache === false ? 0 :216+ Number.isFinite(options.probeCacheMs) && options.probeCacheMs >= 0 ? options.probeCacheMs : DEFAULT_PROBE_CACHE_MS;217+ const cachedProbe = cacheMs ? probeCache.get(probeURL) : null;218+ const cacheValid = Boolean(cachedProbe && cachedProbe.expiresAt > Date.now());219+ let supportsPatch = options.probe === false || (cacheValid && cachedProbe.supportsPatch);220+ if (options.probe !== false && !cacheValid) {183221 try {184222 const response = await request("OPTIONS", null, {});185223 supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");224+ if (cacheMs) probeCache.set(probeURL, {supportsPatch, expiresAt: Date.now() + cacheMs});186225 } catch {187226 supportsPatch = false;188227 }@@ -12,17 +12,17 @@
1212 {{end}}</section>1313 {{end}}1414 15-{{define "files"}}{{$data := asMap .Event}}{{$view := or (str (index $data "view")) (.Query.Get "view") "library"}}<section id="file-library">15+{{define "files"}}{{$data := asMap .Event}}{{$view := or (str (index $data "view")) (.Query.Get "view") "library"}}<section id="file-library" data-pubkey="{{.Actor}}">1616 <h1>Files</h1>1717 <nav id="file-views" aria-label="File collections"><a href="/files"{{if eq $view "library"}} aria-current="page"{{end}}>My files</a><a href="/files?view=sites"{{if eq $view "sites"}} aria-current="page"{{end}}>Sites</a><a href="/files?view=rooms"{{if eq $view "rooms"}} aria-current="page"{{end}}>Rooms</a>{{if index $data "can_manage"}}<a href="/files?view=storage"{{if eq $view "storage"}} aria-current="page"{{end}}>Storage</a>{{end}}</nav>18-<p id="file-context">{{if eq $view "sites"}}Published assets, organized by site.{{else if eq $view "rooms"}}Attachments from rooms you can access.{{else if eq $view "storage"}}All stored files, newest first.{{else}}Your uploads and folders. Site assets and room attachments have their own collections.{{end}}</p>18+<p id="file-context">{{if eq $view "sites"}}Published assets, organized by site.{{else if eq $view "rooms"}}Attachments from rooms you can access.{{else if eq $view "storage"}}All stored files, newest first.{{else}}Your uploads, folders and files shared with relay members.{{end}}</p>1919 <form method="get" action="/files" id="file-search"><input type="hidden" name="view" value="{{$view}}">{{with .Query.Get "path"}}<input type="hidden" name="path" value="{{.}}">{{end}}<label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="Name, type or hash" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="{{browsePageURL "/files" .Query "q" ""}}">Clear</a>{{end}}</form>2020 {{with index $data "breadcrumbs"}}<nav id="file-breadcrumbs" aria-label="Folder path">{{range $i, $raw := .}}{{$crumb := asMap $raw}}{{if $i}} / {{end}}<a href="{{str (index $crumb "open")}}">{{str (index $crumb "name")}}</a>{{end}}</nav>{{end}}2121 {{if .Error}}{{template "alert" .Error}}2222 {{else if .Feed}}<table id="rows">2323 <thead><tr><th scope="col">Name</th><th scope="col">Details</th><th scope="col">Added</th></tr></thead>2424 <tbody>25-{{range .Feed}}{{$f := asMap .}}{{$kind := str (index $f "kind")}}{{$hash := str (index $f "sha256")}}{{$name := or (str (index $f "name")) (shortID $hash)}}{{$open := or (str (index $f "open")) (printf "/file?hash=%s" (urlquery $hash))}}<tr data-kind="{{$kind}}"><td><a href="{{$open}}">{{if and $hash (hasPrefix (str (index $f "type")) "image/")}}<img loading="lazy" src="/files/raw?hash={{urlquery $hash}}" alt="">{{else}}<span data-file-icon aria-hidden="true">{{if or (eq $kind "folder") (eq $kind "site") (eq $kind "room")}}â–±{{else}}â–¤{{end}}</span>{{end}}<span>{{$name}}</span></a>{{with index $f "context_url"}}<small><a href="{{.}}">{{str (index $f "context")}}</a></small>{{end}}</td><td>{{if eq $kind "folder"}}Folder{{else if eq $kind "site"}}Site{{else if eq $kind "room"}}Room{{else}}{{fileSize (index $f "size")}}<small>{{str (index $f "type")}}</small>{{end}}</td><td>{{with index $f "uploaded"}}<time datetime="{{datetime .}}">{{when .}}</time>{{end}}</td></tr>25+{{range .Feed}}{{$f := asMap .}}{{$kind := str (index $f "kind")}}{{$hash := str (index $f "sha256")}}{{$name := or (str (index $f "name")) (shortID $hash)}}{{$open := or (str (index $f "open")) (printf "/file?hash=%s" (urlquery $hash))}}<tr data-kind="{{$kind}}" data-hash="{{$hash}}"><td><a href="{{$open}}">{{if and $hash (hasPrefix (str (index $f "type")) "image/")}}<img loading="lazy" src="/files/raw?hash={{urlquery $hash}}" alt="">{{else}}<span data-file-icon aria-hidden="true">{{if or (eq $kind "folder") (eq $kind "site") (eq $kind "room")}}â–±{{else}}â–¤{{end}}</span>{{end}}<span>{{$name}}</span></a>{{with index $f "context_url"}}<small><a href="{{.}}">{{str (index $f "context")}}</a></small>{{end}}</td><td>{{if eq $kind "folder"}}Folder{{else if eq $kind "site"}}Site{{else if eq $kind "room"}}Room{{else}}{{fileSize (index $f "size")}}<small>{{str (index $f "type")}}</small>{{if eq (str (index $f "access")) "members"}}<small>Relay members</small>{{else if eq (str (index $f "purpose")) "file"}}<small>Secret link</small>{{end}}{{end}}</td><td>{{with index $f "uploaded"}}<time datetime="{{datetime .}}">{{when .}}</time>{{end}}</td></tr>2626 {{end}}</tbody>2727 </table>2828 {{else}}<p id="file-empty">{{if .Query.Get "q"}}No files match this search.{{else if eq $view "sites"}}No site files here yet.{{else if eq $view "rooms"}}No room attachments here yet.{{else if .Query.Get "path"}}This folder is empty.{{else}}No files here yet. Use Upload to add files or a folder.{{end}}</p>@@ -31,18 +31,12 @@
3131 {{end}}3232 3333 {{define "file"}}{{if .Error}}<section><h1>File</h1>{{template "alert" .Error}}</section>34-{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}<section id="file-view">35-<h1>{{or (str (index $file "name")) "File"}}</h1>36-<table id="facts">37-<tr><th>sha256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>38-<tr><th>type</th><td>{{str (index $file "type")}}</td></tr>39-<tr><th>size</th><td>{{str (index $file "size")}} bytes</td></tr>40-{{with index $file "blossom_uri"}}<tr><th>blossom</th><td><code>{{.}}</code></td></tr>{{end}}41-<tr><th>download</th><td><a fx-ignore href="/files/raw?hash={{urlquery (str (index $file "sha256"))}}">raw</a></td></tr>42-</table>34+{{else}}{{$file := asMap .Event}}{{$hash := or (.Query.Get "hash") (.Query.Get "sha")}}<section id="file-view" data-pubkey="{{.Actor}}">35+<h1>{{or (str (index $file "name")) "File"}}</h1>{{if eq (str (index $file "access")) "members"}}<p>Relay members can view and download this file.</p>{{end}}36+4337 <file-workspace-root hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" hidden></file-workspace-root>4438 <noscript><p>Enable JavaScript to decrypt files and browse folder manifests locally. Raw downloads remain available above.</p></noscript>45-<file-tools hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" size="{{str (index $file "size")}}" name="{{or (str (index $file "name")) (shortID (index $file "sha256"))}}"></file-tools>39+4640 {{$type := str (index $file "type")}}{{$raw := printf "/files/raw?hash=%s" (urlquery (str (index $file "sha256")))}}{{if hasPrefix $type "image/"}}<div id="media"><img src="{{$raw}}" alt=""></div>4741 {{else if hasPrefix $type "video/"}}<div id="media"><video controls src="{{$raw}}"></video></div>4842 {{else if hasPrefix $type "audio/"}}<div id="media"><audio controls src="{{$raw}}"></audio></div>@@ -50,7 +44,13 @@
5044 {{else if index $file "binary"}}<p id="media">This is a binary file. Download it to view its contents.</p>5145 {{else if index $file "content"}}{{sourceHTML .Event}}5246 {{else}}<p id="media">This file is empty.</p>53-{{end}}{{if .Actor}}<details id="manage-file"><summary>Manage file</summary><rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form></details>{{end}}</section>47+{{end}}<file-tools hash="{{str (index $file "sha256")}}" type="{{str (index $file "type")}}" size="{{str (index $file "size")}}" name="{{or (str (index $file "name")) (shortID (index $file "sha256"))}}"></file-tools><details id="file-facts"><summary>File details</summary><table id="facts">48+<tr><th>sha256</th><td><code>{{str (index $file "sha256")}}</code></td></tr>49+<tr><th>type</th><td>{{str (index $file "type")}}</td></tr>50+<tr><th>size</th><td>{{fileSize (index $file "size")}}</td></tr>51+{{with index $file "blossom_uri"}}<tr><th>blossom</th><td><code>{{.}}</code></td></tr>{{end}}52+<tr><th>download</th><td><a fx-ignore href="/files/raw?hash={{urlquery (str (index $file "sha256"))}}">raw</a></td></tr>53+</table></details>{{if .Actor}}<details id="manage-file"><summary>Manage file</summary><rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form></details>{{end}}</section>5454 {{end}}{{end}}5555 5656 {{define "approvals"}}<section>@@ -298,6 +298,7 @@
298298 class FileTools extends HTMLElement {299299 disconnectedCallback() {300300 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);301+ if (this.previewURL && this.previewURL !== this.downloadURL) URL.revokeObjectURL(this.previewURL);301302 }302303 303304 connectedCallback() {@@ -359,10 +360,52 @@
359360 }360361 if (params.get("ox") && await tiny.sha256hex(plain) !== params.get("ox")) throw Error("plaintext hash mismatch");361362 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);362- const link = el("a"); this.downloadURL = URL.createObjectURL(new Blob([plain], {type: params.get("type") || this.type()})); link.href = this.downloadURL; link.download = params.get("name") || "decrypted-file"; link.textContent = "download decrypted file"; this.append(link); this.say("Decrypted locally.");363+ if (this.previewURL && this.previewURL !== this.downloadURL) URL.revokeObjectURL(this.previewURL);364+ const contentType = normalizePreviewType(params.get("type") || this.type());365+ const type = safeMediaPreviewType(contentType);366+ const name = params.get("name") || "decrypted-file";367+ this.previewURL = URL.createObjectURL(new Blob([plain], {type: type || "application/octet-stream"}));368+ this.renderPreview(contentType, name, plain);369+ const link = el("a"); this.downloadURL = this.previewURL; link.href = this.downloadURL; link.download = name; link.textContent = "download decrypted file"; this.append(link); this.say("Decrypted locally.");363370 } catch (error) { this.say("Could not decrypt this file: " + error.message, true); }364371 }365372 373+ renderPreview(type, name, bytes) {374+ this.querySelector("[data-decrypted-preview]")?.remove();375+ const text = safeTextPreviewType(type);376+ if (!this.previewURL || (!safeMediaPreviewType(type) && !text)) return;377+ const wrapper = el("div");378+ wrapper.setAttribute("data-decrypted-preview", "");379+ const heading = el("p", "Decrypted preview: " + name);380+ heading.setAttribute("data-preview-name", "");381+ let media;382+ if (text) {383+ const pre = el("pre");384+ const limit = 256 * 1024;385+ const truncated = bytes.byteLength > limit;386+ pre.textContent = new TextDecoder().decode(bytes.slice(0, limit)) + (truncated ? "\n\n[Preview truncated. Download the file to see the rest.]" : "");387+ wrapper.append(heading, pre);388+ this.append(wrapper);389+ return;390+ }391+ if (type.startsWith("image/")) {392+ media = el("img");393+ media.alt = name;394+ } else if (type.startsWith("video/")) {395+ media = el("video");396+ media.controls = true;397+ media.preload = "metadata";398+ } else if (type.startsWith("audio/")) {399+ media = el("audio");400+ media.controls = true;401+ media.preload = "metadata";402+ }403+ if (!media) return;404+ media.src = this.previewURL;405+ wrapper.append(heading, media);406+ this.append(wrapper);407+ }408+366409 async share(form) {367410 if (this.sharing) return;368411 await this.ready;@@ -384,6 +427,22 @@
384427 }385428 }386429 430+ const normalizePreviewType = value => String(value || "").toLowerCase().split(";", 1)[0].trim();431+ const safeMediaPreviewType = value => {432+ const type = normalizePreviewType(value);433+ if (/^image\/(?:avif|gif|jpeg|png|webp)$/.test(type)) return type;434+ if (/^video\/(?:mp4|ogg|webm)$/.test(type)) return type;435+ if (/^audio\/(?:aac|flac|mpeg|mp4|ogg|wav|webm)$/.test(type)) return type;436+ return "";437+ };438+ const safeTextPreviewType = value => {439+ const type = normalizePreviewType(value);440+ if (/^text\/(?:plain|markdown|css|javascript|x-(?:c|csrc|c\+\+src|java-source))$/.test(type)) return type;441+ if (/^application\/(?:json|javascript|xml|x-(?:javascript|yaml))$/.test(type)) return type;442+ if (type === "image/svg+xml" || type === "text/html" || type === "application/xhtml+xml") return type;443+ return "";444+ };445+387446 class PublishList extends FormElement {388447 async submit(form) {389448 const tag = this.getAttribute("tag");@@ -0,0 +1,164 @@
1+// Browser-local names and keys for encrypted files. Nothing in this catalog2+// is sent to the relay; the tenant and signed-in key are part of its scope.3+(() => {4+ "use strict";5+6+ const MAX_ENTRIES = 1000;7+ const MAX_NAME = 1024;8+ const HASH = /^[0-9a-f]{64}$/;9+ const PUBKEY = /^[0-9a-f]{64}$/;10+11+ const actorKey = value => {12+ const key = String(value || "").trim().toLowerCase();13+ return PUBKEY.test(key) ? key : "";14+ };15+ const tenantPath = () => {16+ try {17+ const local = globalThis.tiny?.localPath;18+ const value = new URL(typeof local === "function" ? local("/") : "/", location.href);19+ return value.pathname.replace(/\/+$/, "") || "/";20+ } catch {21+ return "/";22+ }23+ };24+ const storageKey = pubkey => `tiny.files.catalog.v1:${location.origin}:${tenantPath()}:${pubkey}`;25+ const storage = () => {26+ try { return globalThis.localStorage; } catch { return null; }27+ };28+ const read = pubkey => {29+ const key = actorKey(pubkey), store = storage();30+ if (!key || !store) return [];31+ try {32+ const value = JSON.parse(store.getItem(storageKey(key)) || "[]");33+ return Array.isArray(value) ? value.filter(entry => validEntry(entry, key)) : [];34+ } catch {35+ return [];36+ }37+ };38+ const validName = value =>39+ typeof value === "string" && value.length > 0 && value.length <= MAX_NAME &&40+ value.isWellFormed?.() !== false && !/[\u0000/\\]/.test(value);41+ const validPath = value => value === undefined ||42+ (typeof value === "string" && value.length <= MAX_NAME && !/[\u0000\\]/.test(value));43+ const validLink = (value, hash) => {44+ if (typeof value !== "string") return false;45+ try {46+ const url = new URL(value, location.href);47+ const expected = new URL(globalThis.tiny?.localPath?.("/file") || "/file", location.href);48+ if (url.origin !== location.origin || url.pathname !== expected.pathname) return false;49+ if (url.searchParams.get("hash") !== hash && url.searchParams.get("sha") !== hash) return false;50+ const params = new URLSearchParams(url.hash.slice(1));51+ return Boolean((params.get("key") && params.get("iv")) ||52+ (params.get("enc") === "chk-v1" && params.get("key")) ||53+ (params.get("manifest") === hash && params.get("key")));54+ } catch {55+ return false;56+ }57+ };58+ const validEntry = (entry, pubkey) => Boolean(entry && typeof entry === "object" &&59+ actorKey(pubkey) && HASH.test(entry.hash) && validLink(entry.link, entry.hash) &&60+ validName(entry.name) && (entry.type === undefined || typeof entry.type === "string") &&61+ (entry.size === undefined || (Number.isSafeInteger(entry.size) && entry.size >= 0)) &&62+ (entry.folder === undefined || typeof entry.folder === "boolean") && validPath(entry.path));63+ const write = (pubkey, entries) => {64+ const store = storage();65+ if (!store) return false;66+ try {67+ store.setItem(storageKey(pubkey), JSON.stringify(entries.slice(-MAX_ENTRIES)));68+ return true;69+ } catch {70+ return false;71+ }72+ };73+ const entryFor = (hash, pubkey) => {74+ const key = actorKey(pubkey);75+ return HASH.test(String(hash || "")) ? read(key).find(entry => entry.hash === hash) || null : null;76+ };77+ const formatSize = size => {78+ if (!Number.isSafeInteger(size) || size < 0) return "";79+ if (size < 1024) return `${size} B`;80+ const units = ["KiB", "MiB", "GiB"];81+ let value = size, unit = "B";82+ for (const next of units) {83+ value /= 1024;84+ unit = next;85+ if (value < 1024 || next === units.at(-1)) break;86+ }87+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;88+ };89+ const decorate = pubkey => {90+ const section = document.querySelector?.("#file-library[data-pubkey]");91+ if (!section) return 0;92+ let count = 0;93+ for (const row of section.querySelectorAll?.("tr[data-hash]") || []) {94+ const entry = entryFor(row.getAttribute("data-hash"), pubkey);95+ if (!entry) continue;96+ const link = row.querySelector("td:first-child > a") || row.querySelector("a");97+ if (link) {98+ link.href = entry.link;99+ link.setAttribute("fx-ignore", "");100+ const name = link.querySelector("span:last-child") || link;101+ name.textContent = entry.name;102+ }103+ const details = row.querySelector("td:nth-child(2)");104+ if (details && entry.size !== undefined) {105+ const access = /secret link/i.test(details.textContent || "");106+ details.textContent = access ? "Secret link" : formatSize(entry.size);107+ if (access) {108+ const size = document.createElement("small");109+ size.textContent = formatSize(entry.size);110+ details.append(size);111+ }112+ if (entry.type) {113+ const type = document.createElement("small");114+ type.textContent = entry.type;115+ details.append(type);116+ }117+ }118+ count++;119+ }120+ return count;121+ };122+ const restore = pubkey => {123+ const current = new URL(location.href);124+ if (!/(?:^|\/)file$/.test(current.pathname)) return null;125+ const hash = current.searchParams.get("hash") || current.searchParams.get("sha");126+ const fragment = new URLSearchParams(current.hash.slice(1));127+ if (!HASH.test(hash || "") || fragment.get("key") || fragment.get("iv")) return null;128+ const entry = entryFor(hash, pubkey);129+ if (!entry) return null;130+ const saved = new URL(entry.link);131+ if (saved.searchParams.get("hash") !== hash && saved.searchParams.get("sha") !== hash) return null;132+ history.replaceState?.(null, "", saved.pathname + saved.search + saved.hash);133+ return entry;134+ };135+ const save = (value, pubkey) => {136+ const key = actorKey(pubkey);137+ if (!key || !validEntry(value, key)) return false;138+ const entries = read(key).filter(entry => entry.hash !== value.hash);139+ entries.push({140+ hash: value.hash,141+ link: value.link,142+ name: value.name,143+ ...(value.type === undefined ? {} : {type: value.type}),144+ ...(value.size === undefined ? {} : {size: value.size}),145+ ...(value.folder === undefined ? {} : {folder: value.folder}),146+ ...(value.path === undefined ? {} : {path: value.path}),147+ savedAt: Date.now()148+ });149+ return write(key, entries);150+ };151+152+ const api = Object.freeze({save, get: entryFor, restore, decorate});153+ globalThis.tiny = globalThis.tiny || {};154+ globalThis.tiny.files = {...(globalThis.tiny.files || {}), catalog: api};155+ const sync = () => {156+ const surface = document.querySelector?.("#file-library[data-pubkey], #file-detail[data-pubkey], #file-view[data-pubkey]");157+ const actor = surface?.getAttribute("data-pubkey");158+ if (!actor) return;159+ restore(actor);160+ decorate(actor);161+ };162+ sync();163+ document.addEventListener?.("tiny:navigation", sync);164+})();@@ -15,6 +15,13 @@
1515 const hex = value => globalThis.tiny.util.hex(value);1616 const fromHex = value => globalThis.tiny.util.fromHex(value || "", "Invalid hash or encryption key.");1717 const element = globalThis.tiny.util.element;18+ const fileSize = value => {19+ let size = Number(value) || 0;20+ for (const unit of ["B", "KiB", "MiB", "GiB"]) {21+ if (size < 1024 || unit === "GiB") return (unit === "B" ? size : size.toFixed(1)) + " " + unit;22+ size /= 1024;23+ }24+ };1825 const checkAbort = signal => {1926 if (signal?.aborted)2027 throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});@@ -55,27 +62,37 @@
5562 return {root, name: commonRoot ? paths[0][0] : "folder"};5663 }5764 58- async function storeEncrypted(plaintext, type, signal) {65+ async function storeEncrypted(plaintext, type, signal, session) {5966 checkAbort(signal);6067 const encrypted = await encryption().encryptCHK(plaintext);6168 checkAbort(signal);69+ if (session?.completed.has(encrypted.hash)) return session.completed.get(encrypted.hash);6270 const url = new URL(tiny.localPath("/"), location.href).href;6371 const result = await upload().upload(encrypted.ciphertext, {6472 url,6573 hash: encrypted.hash,6674 type,6775 signal,76+ metadata: {purpose: "chunk"},77+ retries: 3,78+ state: session?.states.get(encrypted.hash) || (() => {79+ const state = new Map();80+ session?.states.set(encrypted.hash, state);81+ return state;82+ })(),6883 authorize: (target, method, body) => tiny.authorization(target, method, body)6984 });7085 if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length)7186 throw Error("Relay returned an unexpected blob descriptor.");72- return {hash: encrypted.hash, key: fromHex(encrypted.key)};87+ const stored = {hash: encrypted.hash, key: fromHex(encrypted.key)};88+ session?.completed.set(encrypted.hash, stored);89+ return stored;7390 }7491 7592 const manifestStore =76- signal =>93+ (signal, session) =>7794 async ({bytes, node}) =>78- storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal);95+ storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal, session);7996 const rootReference = details => {8097 const stored = details.manifests.at(-1);8198 return {@@ -86,13 +103,13 @@
86103 };87104 };88105 89- async function storeFile(file, signal, progress) {106+ async function storeFile(file, signal, progress, session) {90107 if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");91108 const chunks = [];92109 for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {93110 checkAbort(signal);94111 const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());95- const stored = await storeEncrypted(plain, "application/octet-stream", signal);112+ const stored = await storeEncrypted(plain, "application/octet-stream", signal, session);96113 chunks.push({...stored, size: plain.length});97114 progress?.(plain.length);98115 if (file.size === 0) break;@@ -100,16 +117,16 @@
100117 if (chunks.length === 1) return {...chunks[0], type: 0};101118 return rootReference(102119 await codec().buildFile(chunks, {103- store: manifestStore(signal),120+ store: manifestStore(signal, session),104121 returnDetails: true105122 })106123 );107124 }108125 109- async function storeDirectory(directory, signal, progress) {126+ async function storeDirectory(directory, signal, progress, session) {110127 const entries = [];111128 for (const [name, file] of directory.files) {112- const stored = await storeFile(file, signal, progress);129+ const stored = await storeFile(file, signal, progress, session);113130 entries.push({114131 ...stored,115132 name,@@ -117,12 +134,12 @@
117134 });118135 }119136 for (const [name, child] of directory.directories) {120- const stored = await storeDirectory(child, signal, progress);137+ const stored = await storeDirectory(child, signal, progress, session);121138 entries.push({...stored, name});122139 }123140 return rootReference(124141 await codec().buildDirectory(entries, {125- store: manifestStore(signal),142+ store: manifestStore(signal, session),126143 returnDetails: true127144 })128145 );@@ -157,11 +174,19 @@
157174 if (this.bound) return;158175 this.bound = true;159176 this.innerHTML =160- '<form><label>Files <input type="file" name="file" multiple></label><input type="file" name="folder" webkitdirectory multiple hidden><p data-drop><button type="button" data-pick-folder>Choose folder</button> or drop files or a folder here</p><label><input type="checkbox" name="encrypt"> encrypt</label><button>Upload</button></form><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><label data-share hidden>Share link <input data-share-url readonly></label><output role="status"></output>';177+ '<form><label>Files <input type="file" name="file" multiple></label><input type="file" name="folder" webkitdirectory multiple hidden><p data-drop><button type="button" data-pick-folder>Choose folder</button> or drop files or a folder here</p><label>Who can open <select name="access"><option value="public">Anyone with the link</option><option value="members">Relay members</option></select></label><details data-advanced><summary>Advanced</summary><label><input type="checkbox" name="encrypt"> Encrypt with a secret link</label><p><small>Only people with the complete link can decrypt it. Saved in this browser for your account.</small></p><label data-background hidden><input type="checkbox" name="background"> Continue in the background</label></details><button>Upload</button></form><progress max="1" value="0" hidden aria-label="Upload progress"></progress><output role="status"></output><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><div data-share hidden><p><a data-open>Open file</a> <button type="button" data-copy-link>Copy secret link</button></p><label>Secret link <input data-share-url readonly></label></div>';161178 this.out = this.querySelector("output");162179 const form = this.querySelector("form");163180 const files = form.elements.namedItem("file");164181 const folder = form.elements.namedItem("folder");182+ const access = form.elements.namedItem("access");183+ access.value = "public";184+ access.addEventListener("change", () => {185+ const encrypt = form.elements.namedItem("encrypt");186+ if (access.value === "members") encrypt.checked = false;187+ encrypt.disabled = access.value === "members";188+ });189+ this.querySelector("[data-background]").hidden = !backgroundAvailable();165190 files.addEventListener("change", () => this.choose([...files.files]));166191 folder.addEventListener("change", () => this.choose([...folder.files]));167192 this.querySelector("[data-pick-folder]").addEventListener("click", () => folder.click());@@ -181,6 +206,7 @@
181206 });182207 this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());183208 this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {}));209+ this.querySelector("[data-copy-link]").addEventListener("click", () => this.copyLink());184210 this.intake().catch(error => this.say(error.message, true));185211 this.resumeBackground().catch(() => {});186212 }@@ -210,7 +236,7 @@
210236 ];211237 }212238 const id = "tiny-upload-" + crypto.randomUUID();213- localStorage.setItem("tiny.bg." + id, JSON.stringify({hash, fragment: fragment || "", name, at: Date.now()}));239+ localStorage.setItem("tiny.bg." + id, JSON.stringify({hash, fragment: fragment || "", name, size: bytes.byteLength - (fragment ? 16 : 0), pubkey: this.getAttribute("pubkey") || "", root: tiny.root || "", at: Date.now()}));214240 const task = await registration.backgroundFetch.fetch(id, requests, {215241 title: "Uploading " + name,216242 icons: [@@ -271,8 +297,9 @@
271297 localStorage.removeItem(key);272298 continue;273299 }300+ if (info.pubkey !== (this.getAttribute("pubkey") || "") || info.root !== (tiny.root || "")) continue;274301 const task = await registration.backgroundFetch.get(id).catch(() => null);275- const finish = descriptor => {302+ const finish = async descriptor => {276303 if (!info.fragment) {277304 this.say("Background upload of " + info.name + " finished.");278305 return tiny.navigate?.(location.href);@@ -280,6 +307,8 @@
280307 const link = new URL(tiny.localPath("/file"), location.href);281308 link.search = "?hash=" + descriptor.sha256;282309 link.hash = info.fragment;310+ const params = new URLSearchParams(info.fragment);311+ await this.remember(link.href, [{name: info.name, size: info.size || Math.max(0, descriptor.size - 16), type: params.get("type") || "application/octet-stream"}], false);283312 return this.showLink(link.href);284313 };285314 try {@@ -381,6 +410,13 @@
381410 if (error) this.out.dataset.error = "";382411 else delete this.out.dataset.error;383412 }413+ progress(sent, total, phase = "Uploading") {414+ const fraction = total ? Math.min(1, sent / total) : 0;415+ const meter = this.querySelector("progress");416+ meter.hidden = false;417+ meter.value = fraction;418+ this.say(phase + " " + Math.round(fraction * 100) + "% — " + fileSize(sent) + " of " + fileSize(total));419+ }384420 controls(busy, completed) {385421 const retryable = Boolean(this.selection) && !completed;386422 this.querySelector("[data-controls]").hidden = !busy && !retryable;@@ -391,31 +427,41 @@
391427 if (this.busy) return;392428 if (form) {393429 if (!this.chosen) this.choose([...form.elements.namedItem("file").files]);394- this.selection = {...this.chosen, encrypt: Boolean(form.elements.namedItem("encrypt").checked)};430+ const access = form.elements.namedItem("access").value === "members" ? "members" : "public";431+ this.selection = {...this.chosen, access, encrypt: access !== "members" && Boolean(form.elements.namedItem("encrypt").checked), background: Boolean(form.elements.namedItem("background").checked)};395432 this.pending = null;433+ this.completedLink = null;434+ this.manifestSession = {completed: new Map(), states: new Map()};435+ this.plainStates = new Map();396436 }397- if (!this.selection?.files.length) throw Error("Choose a file or folder first.");437+ if (!this.selection?.files.length) { this.say("Choose a file or folder first.", true); return; }398438 this.busy = true;399439 this.controller = new AbortController();400440 this.controls(true, false);441+ this.querySelector("[data-share]").hidden = true;401442 let completed = false;402443 // Keep the phone awake for the length of the upload.403444 const wakeLock = await navigator.wakeLock?.request?.("screen").catch(() => null);404445 try {405446 const {files, folder, encrypt} = this.selection;406- let link;407- if (!encrypt) await this.storePlain(files);408- else if (folder || files.length > 1) link = await this.storeManifest(files, true);409- else if (files[0].size > LARGE_FILE_BYTES) link = await this.storeManifest(files, false);410- else link = await this.storeSealed(files[0]);411- completed = true;447+ let link = this.completedLink;448+ if (!link) {449+ if (!encrypt) await this.storePlain(files);450+ else if (folder || files.length > 1) link = await this.storeManifest(files, true);451+ else if (files[0].size > LARGE_FILE_BYTES) link = await this.storeManifest(files, false);452+ else link = await this.storeSealed(files[0]);453+ }412454 if (link) {455+ this.completedLink = link;456+ await this.remember(link, files, folder);413457 await this.showLink(link);414458 } else {415459 this.say("Stored " + files.length + (files.length === 1 ? " file." : " files."));416460 await tiny.navigate?.(location.href);417461 }418- return link;462+ completed = true;463+ if (link && this.catalogSaved) await tiny.navigate?.(location.href);464+ return link || undefined;419465 } catch (error) {420466 const canceled = this.controller.signal.aborted;421467 this.say(@@ -427,24 +473,44 @@
427473 wakeLock?.release?.().catch?.(() => {});428474 this.busy = false;429475 this.controls(false, completed);476+ if (completed) this.querySelector("progress").hidden = true;430477 if (completed) this.selection = this.chosen = null;431478 }432479 }433480 async storePlain(files) {434481 const query = new URL(location.href).searchParams;435482 const parent = (!query.get("view") || query.get("view") === "library") ? query.get("path") || "" : "";483+ const total = files.reduce((size, file) => size + file.size, 0);484+ let sent = 0;436485 for (const [index, file] of files.entries()) {437486 checkAbort(this.controller.signal);487+ if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");438488 this.say("Uploading " + file.name + " (" + (index + 1) + " of " + files.length + ")…");439489 const bytes = new Uint8Array(await file.arrayBuffer());440490 const type = file.type || "application/octet-stream";441491 const selectedPath = file.webkitRelativePath || file.name;442492 const metadata = {filename: file.name, path: parent ? parent + "/" + selectedPath : selectedPath};443- if (files.length === 1 && bytes.byteLength >= BACKGROUND_BYTES && backgroundAvailable()) {493+ if (this.selection?.access === "members") metadata.access = "members";494+ if (this.selection?.background && files.length === 1 && bytes.byteLength >= BACKGROUND_BYTES && backgroundAvailable()) {444495 await this.storeBackground(bytes, {hash: await tiny.sha256hex(bytes), type, name: file.name, metadata});445496 continue;446497 }447- await tiny.signedFetch(this.uploadTarget("/upload", metadata), "PUT", bytes, {contentType: type, signal: this.controller.signal});498+ this.progress(sent, total, "Uploading " + file.name);499+ if (bytes.byteLength >= BACKGROUND_BYTES && upload()?.upload) {500+ this.plainStates ||= new Map();501+ if (!this.plainStates.has(index)) this.plainStates.set(index, new Map());502+ await upload().upload(bytes, {503+ url: new URL(tiny.localPath("/"), location.href).href,504+ type, metadata, retries: 3, state: this.plainStates.get(index), signal: this.controller.signal,505+ authorize: (target, method, body) => tiny.authorization(target, method, body),506+ onProgress: progress => this.progress(sent + progress.sent, total, "Uploading " + file.name),507+ onState: state => { if (state.status === "retrying") this.say("Connection interrupted. Retrying " + file.name + "…"); }508+ });509+ } else {510+ await tiny.signedFetch(this.uploadTarget("/upload", metadata), "PUT", bytes, {contentType: type, signal: this.controller.signal});511+ }512+ sent += file.size;513+ this.progress(sent, total);448514 }449515 }450516 uploadTarget(target, metadata) {@@ -455,7 +521,7 @@
455521 async storeSealed(file) {456522 if (!this.pending) {457523 if (file.size > MAX_FILE_BYTES) throw Error("Encrypted browser uploads are limited to 256 MiB.");458- this.say("Encrypting…");524+ this.say("Encrypting " + file.name + " (" + fileSize(file.size) + ")…");459525 const plaintext = new Uint8Array(await file.arrayBuffer());460526 const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]);461527 const iv = crypto.getRandomValues(new Uint8Array(12));@@ -477,11 +543,13 @@
477543 checkAbort(this.controller.signal);478544 const {ciphertext, fragment, state} = this.pending;479545 const hash = await tiny.sha256hex(ciphertext);546+ this.progress(0, file.size);480547 let descriptor;481- if (ciphertext.byteLength >= BACKGROUND_BYTES && backgroundAvailable() && upload()?.plan) {548+ if (this.selection?.background && ciphertext.byteLength >= BACKGROUND_BYTES && backgroundAvailable() && upload()?.plan) {482549 descriptor = await this.storeBackground(ciphertext, {483550 hash,484551 type: "application/octet-stream",552+ metadata: {purpose: "chunk"},485553 name: file.name,486554 fragment487555 });@@ -490,14 +558,17 @@
490558 url: new URL(tiny.localPath("/"), location.href).href,491559 hash,492560 type: "application/octet-stream",561+ metadata: {purpose: "chunk"},562+ retries: 3,493563 state,494564 signal: this.controller.signal,495565 authorize: (target, method, body) => tiny.authorization(target, method, body),496- onProgress: progress => this.say("Uploading " + Math.round(progress.fraction * 100) + "%…")566+ onProgress: progress => this.progress(Math.min(file.size, progress.sent), file.size),567+ onState: state => { if (state.status === "retrying") this.say("Connection interrupted. Retrying " + file.name + "…"); }497568 });498569 descriptor = (await task).descriptor;499570 } else {500- const response = await tiny.signedFetch("/upload", "PUT", ciphertext, {571+ const response = await tiny.signedFetch("/upload?purpose=chunk", "PUT", ciphertext, {501572 contentType: "application/octet-stream",502573 signal: this.controller.signal503574 });@@ -512,36 +583,64 @@
512583 }513584 async storeManifest(files, folder) {514585 let sent = 0;586+ const total = files.reduce((size, file) => size + file.size, 0);515587 const progress = size => {516588 sent += size;517- this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…");589+ this.progress(sent, total, "Encrypting and uploading");518590 };519- this.say("Encrypting selected content…");591+ this.progress(0, total, "Encrypting and uploading");592+ this.manifestSession ||= {completed: new Map(), states: new Map()};520593 let reference, name, type;521594 if (folder) {522595 const selected = selectionTree(files);523596 name = selected.name || "files";524- reference = await storeDirectory(selected.root, this.controller.signal, progress);597+ reference = await storeDirectory(selected.root, this.controller.signal, progress, this.manifestSession);525598 } else {526599 const file = files[0];527600 name = file.name;528601 type = file.type;529- reference = await storeFile(file, this.controller.signal, progress);602+ reference = await storeFile(file, this.controller.signal, progress, this.manifestSession);530603 }531604 checkAbort(this.controller.signal);532605 return shareURL(reference, name, type);533606 }607+ async remember(link, files, folder) {608+ const url = new URL(link);609+ const params = new URLSearchParams(url.hash.slice(1));610+ const hash = url.searchParams.get("hash");611+ const name = params.get("name") || files[0].name;612+ const type = params.get("type") || files[0].type || "application/octet-stream";613+ const size = files.reduce((sum, file) => sum + file.size, 0);614+ const pubkey = this.getAttribute("pubkey");615+ const isFolder = folder || ["2", "3"].includes(params.get("node"));616+ this.catalogSaved = tiny.files?.catalog?.save({hash, link, name, type, size, folder: isFolder}, pubkey) || false;617+ // Keep the working key visible even if recording the library entry fails.618+ await this.showLink(link);619+ if (tiny.signedFetch) {620+ const label = (isFolder ? "Encrypted folder " : "Encrypted file ") + hash.slice(0, 12);621+ const response = await tiny.signedFetch("/files/metadata", "POST", JSON.stringify({hash, name: label, path: label, purpose: "file", size, type: "application/octet-stream"}), {contentType: "application/json", signal: this.controller?.signal});622+ if (response.ok === false) throw Error("File uploaded; its library entry could not be saved. Retry to finish.");623+ }624+ tiny.files?.catalog?.decorate(pubkey);625+ }534626 async showLink(link) {535627 const share = this.querySelector("[data-share-url]");536628 share.value = link;537629 this.querySelector("[data-share]").hidden = false;630+ const open = this.querySelector("[data-open]");631+ open.href = link;632+ open.setAttribute("fx-ignore", "");633+ this.say(this.catalogSaved ? "Stored. You can reopen this file from My files in this browser." : "Stored. Keep the secret link to reopen this encrypted file.");634+ }635+ async copyLink() {636+ const share = this.querySelector("[data-share-url]");538637 try {539638 if (!navigator.clipboard?.writeText) throw Error("Clipboard unavailable");540- await navigator.clipboard.writeText(link);541- this.say("Stored. Share link copied; the key is only in its fragment.");639+ await navigator.clipboard.writeText(share.value);640+ this.say("Secret link copied.");542641 } catch {543642 share.select?.();544- this.say("Stored. Copy the share link above; the key is only in its fragment.");643+ this.say("Copy the secret link from the field above.");545644 }546645 }547646 }@@ -638,18 +737,19 @@
638737 const root = codec().decodeManifest(data);639738 if (root.t !== type) throw Error("Manifest type does not match its reference.");640739 if (type === 1) {641- const button = element("button", "Download decrypted file");740+ const media = /^(video\/(mp4|webm|ogg)|audio\/(mpeg|mp4|ogg|wav|webm))$/.test(params.get("type") || "");741+ const button = element("button", media ? "Load media player" : "Prepare download");642742 button.type = "button";643- button.addEventListener("click", () =>644- this.download({743+ const open = () => this.download({645744 ...reference,646745 t: 1,647746 s: root.l.reduce((sum, link) => sum + link.s, 0),648747 n: params.get("name") || "file",649748 m: {type: params.get("type")}650- }).catch(error => this.fail(error.message))651- );749+ }).catch(error => this.fail(error.message));750+ button.addEventListener("click", open);652751 this.append(button);752+ if (media) await open();653753 return;654754 }655755 const entries = await codec().resolveDirectory(root, hash => fetchBlob(hash, this.controller.signal), {@@ -676,7 +776,7 @@
676776 open.href = url.href;677777 item.append(open);678778 } else {679- const button = element("button", link.n + " (" + link.s.toLocaleString() + " bytes)");779+ const button = element("button", link.n + " (" + fileSize(link.s) + ")");680780 button.type = "button";681781 button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));682782 item.append(button);@@ -687,11 +787,24 @@
687787 this.entries = entries;688788 }689789 async download(link) {790+ if (this.downloading) return;791+ this.downloading = true;792+ const status = this.querySelector("output") || element("output");793+ status.setAttribute("role", "status");794+ if (!status.parentNode) this.append(status);795+ status.textContent = "Loading " + link.n + " (" + fileSize(link.s) + ")…";796+ try {690797 let data;691798 if (link.t === 0) data = await plaintext(link, this.controller.signal);692799 else if (link.t === 1) {693800 const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));694- data = await codec().readFile(manifest, hash => fetchBlob(hash, this.controller.signal), {801+ let fetched = 0;802+ data = await codec().readFile(manifest, async (hash, part) => {803+ const raw = await fetchBlob(hash, this.controller.signal);804+ if (part.t === 0) fetched += part.s;805+ status.textContent = "Loading " + Math.min(100, Math.round(fetched / link.s * 100)) + "% — " + fileSize(fetched) + " of " + fileSize(link.s);806+ return raw;807+ }, {695808 decrypt,696809 maxDepth: MAX_DEPTH,697810 maxBytes: MAX_FILE_BYTES@@ -699,7 +812,9 @@
699812 } else throw Error("Unsupported file link.");700813 if (data.length !== link.s) throw Error("File size does not match its directory entry.");701814 this.offerDownload(data, link.n, link.m?.type);815+ status.textContent = "Ready. " + fileSize(data.length) + " decrypted in your browser.";702816 return data;817+ } finally { this.downloading = false; }703818 }704819 offerDownload(data, name, type) {705820 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);@@ -707,8 +822,26 @@
707822 const link = element("a", "Save " + name);708823 link.href = this.downloadURL;709824 link.download = name;710- this.append(link);711- link.click();825+ this.querySelector("[data-prepared-file]")?.remove();826+ const prepared = element("div");827+ prepared.setAttribute("data-prepared-file", "");828+ let media;829+ if (/^video\/(mp4|webm|ogg)$/.test(type || "")) media = element("video");830+ else if (/^audio\/(mpeg|mp4|ogg|wav|webm)$/.test(type || "")) media = element("audio");831+ else if (/^image\/(png|jpeg|gif|webp|avif)$/.test(type || "")) media = element("img");832+ if (media) {833+ media.src = this.downloadURL;834+ media.controls = true;835+ media.preload = "metadata";836+ if (media.tagName === "IMG") media.alt = name;837+ prepared.append(media);838+ } else if (/^(text\/|application\/(json|javascript|xml|x-(javascript|sh|python|ruby))|image\/svg\+xml$)/.test(type || "")) {839+ const preview = element("pre", new TextDecoder().decode(new Uint8Array(data).subarray(0, 256 * 1024)));840+ prepared.append(preview);841+ if (data.byteLength > 256 * 1024) prepared.append(element("p", "Preview shows the first 256 KiB. Download the file to read it all."));842+ }843+ prepared.append(link);844+ this.append(prepared);712845 }713846 }714847 customElements.define("file-upload", FileUpload);@@ -75,6 +75,7 @@ then feature modules are loaded for the current page. */}}
7575 <script src="{{scriptURL "tiny.js"}}"></script>7676 <script src="{{scriptURL "bridge.js"}}"></script>7777 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>78+<script src="{{scriptURL "file-catalog.js"}}"></script>7879 <script src="{{scriptURL "blossom-manifests.js"}}"></script>7980 <script src="{{scriptURL "blossom-upload.js"}}"></script>8081 <script src="{{scriptURL "file-messages.js"}}"></script>@@ -129,7 +130,7 @@ then feature modules are loaded for the current page. */}}
129130 <section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}}130131 131132 {{define "panel-files"}}<section><h4>Storage</h4><table><tr><th>protocol</th><td>Blossom</td></tr>{{if .Policy.Features.Files}}<tr><th>uploads</th><td>members</td></tr>{{end}}</table></section>132-{{if .Actor}}<section><h4>Upload</h4><file-upload></file-upload><p><small>Encrypt keeps the contents and key in your browser. Keep the complete share link.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}133+{{if .Actor}}<section><h4>Upload</h4><file-upload pubkey="{{.Actor}}"></file-upload><p><small>Members-only files require membership to view or download.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}133134 134135 {{define "panel-approvals"}}{{$counts := approvalCounts .Event}}{{$data := asMap .Event}}<section><h4>Waiting</h4><table><tr><th>open</th><td>{{index $counts "open"}}</td></tr><tr><th>oldest</th><td>{{with index $data "oldest_open"}}<time datetime="{{datetime .}}">{{when .}}</time>{{else}}none{{end}}</td></tr><tr><th>answered</th><td>{{index $counts "answered"}}</td></tr><tr><th>expired</th><td>{{index $counts "expired"}}</td></tr></table></section>135136 <section><h4>Devices</h4>{{if .Actor}}{{range $index, $device := approvalDevices .Event}}<p><i></i>device {{add $index 1}} | {{$device}}</p>{{else}}<p>No device receives notifications for this key. Enable them under Inbox.</p>{{end}}<p><small>Answers from the phone need the remembered signer.</small></p>{{else}}<p>Sign in to see which devices are notified.</p>{{end}}</section>{{end}}@@ -138,6 +138,13 @@ file-tools h3,file-upload h3{margin:0 0 4px}file-tools p,file-upload p{margin:6p
138138 rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,#collaboration-filter label,#file-search label,#wiki-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}139139 rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input,#wiki-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}140140 file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}141+file-upload form>label,file-upload [data-share] label{display:block;max-width:100%;margin-right:0}142+file-upload select,file-upload progress{display:block;width:100%;max-width:100%;margin:6px 0 10px}143+file-upload progress{height:8px;accent-color:var(--accent)}144+file-upload input[type=file],file-upload input[data-share-url]{min-width:0;max-width:100%;box-sizing:border-box}145+file-workspace-root video,file-workspace-root img,file-tools video,file-tools img{display:block;width:auto;max-width:100%;max-height:70vh;margin:12px 0}146+file-workspace-root video,file-tools video{width:100%;background:#000}147+file-workspace-root audio,file-tools audio{display:block;max-width:100%;margin:12px 0}141148 file-upload label:has([type=checkbox]),#panel file-upload label:has([type=checkbox]){display:inline-block;margin:8px 14px 8px 0}file-upload label:has([type=checkbox]) input,#panel file-upload label:has([type=checkbox]) input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}142149 #panel file-upload [hidden],file-upload [hidden],file-tools [hidden],#panel push-toggle [hidden]{display:none}143150 #panel push-toggle label{display:inline-block;margin:6px 12px 0 0}#panel push-toggle label input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}#panel push-toggle form{margin-top:4px}@@ -65,6 +65,9 @@ var blossomUploadJS string
6565 //go:embed file-workspace.js6666 var fileWorkspaceJS string6767 68+//go:embed file-catalog.js69+var fileCatalogJS string70+6871 // Installable app assets. The manifest is a template: NAME and BASE are6972 // replaced per request so tenant prefixes and relay names stay correct.7073 //@@ -222,7 +225,7 @@ func repoView(query url.Values) string {
222225 // scripts are served at /scripts/<name> and cached by the service worker.223226 // One version stamp covers them all, so a change to any file refreshes every224227 // cached copy together.225-var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}228+var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "rooms.js": roomsJS, "nostr-name.js": nostrNameJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS, "file-catalog.js": fileCatalogJS}226229 227230 var scriptsVersion = func() string {228231 names := make([]string, 0, len(scripts))@@ -141,7 +141,7 @@
141141 // Feature bundles load on the pages that use them and again after an142142 // in-place navigation lands on such a page. Each script loads once.143143 tiny.bundles = {144- files: ["blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],144+ files: ["file-catalog.js", "blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],145145 private: ["private-services.js"]146146 };147147 const loaded = new Map();@@ -27,6 +27,7 @@ test("uses BUD-14 PATCH chunks and signs each chunk payload", async () => {
2727 hash,2828 type: "text/plain",2929 chunkSize: 4,30+ probeCache: false,3031 fetch: async (url, init) => {3132 calls.push({url, init});3233 return calls.length === 1 ? response(204, {allow: "GET, PUT, PATCH"}) :@@ -52,6 +53,7 @@ test("falls back to BUD-13 PUT when PATCH is not advertised", async () => {
5253 url: "https://blossom.example/upload",5354 hash,5455 type: "text/plain",56+ probeCache: false,5557 fetch: async (url, init) => {5658 calls.push({url, init});5759 return calls.length === 1 ? response(204, {allow: "GET, PUT"}) : response(201, {}, {sha256: hash, size: bytes.length});@@ -69,7 +71,7 @@ test("retries transient chunks, reports local state, and can be canceled", async
6971 let attempts = 0;7072 const states = [];7173 const task = upload(bytes, {72- url: "https://blossom.example/upload", hash, chunkSize: 2, retries: 1,74+ url: "https://blossom.example/upload", hash, chunkSize: 2, retries: 1, probeCache: false,7375 fetch: async (url, init) => {7476 if (init.method === "OPTIONS") return response(204, {allow: "PATCH"});7577 attempts++;@@ -110,6 +112,25 @@ test("resumes recent local chunks, rejects stale state, and checks the descripto
110112 }), /invalid Blossom upload descriptor/);111113 });112114 115+test("caches endpoint capability and appends upload metadata", async () => {116+ const bytes = new Uint8Array([1, 2]);117+ const hash = await digest(bytes);118+ const calls = [];119+ const fetcher = async (url, init) => {120+ calls.push({url, init});121+ return init.method === "OPTIONS" ? response(204, {allow: "PATCH"}) : response(201, {}, {sha256: hash, size: bytes.length});122+ };123+ await upload(bytes, {url: "https://metadata.example/upload", hash, chunkSize: bytes.length, fetch: fetcher,124+ metadata: {access: "members", purpose: "file", filename: "secret.bin"}});125+ await upload(bytes, {url: "https://metadata.example/upload", hash, chunkSize: bytes.length, fetch: fetcher,126+ metadata: {access: "members", purpose: "chunk", filename: "secret.bin"}});127+ assert.equal(calls.filter(call => call.init.method === "OPTIONS").length, 1);128+ const patch = calls.find(call => call.init.method === "PATCH");129+ assert.match(patch.url, /access=members/);130+ assert.match(patch.url, /purpose=file/);131+ assert.match(patch.url, /filename=secret.bin/);132+});133+113134 test("keeps the response body readable after headers arrive", async () => {114135 const bytes = new Uint8Array([11, 12]);115136 const hash = await digest(bytes);@@ -0,0 +1,50 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+6+const source = fs.readFileSync("internal/webui/file-catalog.js", "utf8");7+const pubkey = "a".repeat(64);8+const hash = "b".repeat(64);9+10+function setup({path = "/r/test/files", actor = pubkey} = {}) {11+ const values = new Map();12+ const rows = [];13+ const document = {14+ querySelector(selector) {15+ if (selector === "[data-pubkey]") return {getAttribute: () => actor};16+ if (selector === "#file-library") return {querySelectorAll: () => rows};17+ return null;18+ },19+ createElement() { return {textContent: "", append() {}}; }20+ };21+ const location = {href: "https://relay.test" + path, origin: "https://relay.test", pathname: path};22+ const history = {replaceState(_state, _title, value) { location.href = "https://relay.test" + value; location.pathname = new URL(location.href).pathname; location.hash = new URL(location.href).hash; }};23+ const context = {24+ location, history, document,25+ localStorage: {getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value)},26+ URL, URLSearchParams, Date, Number, String, Array, Boolean, Object, RegExp, JSON,27+ globalThis: null, tiny: {localPath: value => "/r/test" + value}28+ };29+ context.globalThis = context;30+ vm.runInNewContext(source, context);31+ return {context, values, rows};32+}33+34+const link = () => `https://relay.test/r/test/file?hash=${hash}#key=${"c".repeat(64)}&iv=${"d".repeat(16)}&name=video.mp4&type=video%2Fmp4`;35+36+test("saves encrypted metadata per tenant and account and restores it after reload", () => {37+ const first = setup({path: "/r/test/file?hash=" + hash});38+ assert.equal(first.context.tiny.files.catalog.save({hash, link: link(), name: "video.mp4", type: "video/mp4", size: 104857600}, pubkey), true);39+ assert.equal(first.context.tiny.files.catalog.get(hash, pubkey).name, "video.mp4");40+ assert.equal(first.context.tiny.files.catalog.get(hash, "e".repeat(64)), null);41+ assert.equal(first.context.tiny.files.catalog.restore(pubkey).name, "video.mp4");42+ assert.match(first.context.location.href, /#key=/);43+});44+45+test("rejects unsafe links, names and cross-tenant links", () => {46+ const {context} = setup();47+ assert.equal(context.tiny.files.catalog.save({hash, link: "https://evil.test/r/test/file?hash=" + hash + "#key=x", name: "x"}, pubkey), false);48+ assert.equal(context.tiny.files.catalog.save({hash, link: link(), name: "../secret"}, pubkey), false);49+ assert.equal(context.tiny.files.catalog.save({hash, link: link().replace("/r/test/", "/r/other/"), name: "x"}, pubkey), false);50+});@@ -0,0 +1,141 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+6+class Node {7+ constructor(tag = "") { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = {}; this.listeners = {}; this.textContent = ""; this.dataset = {}; }8+ append(...nodes) { for (const node of nodes) if (node && typeof node !== "string") { this.children.push(node); node.parentNode = this; } }9+ remove() { this.parentNode?.children.splice(this.parentNode.children.indexOf(this), 1); }10+ setAttribute(name, value) { this.attributes[name] = String(value); }11+ getAttribute(name) { return this.attributes[name] ?? null; }12+ hasAttribute(name) { return Object.hasOwn(this.attributes, name); }13+ addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); }14+ click() {}15+ replaceChildren(...nodes) { this.children = []; this.append(...nodes); }16+ querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }17+ querySelectorAll(selector) {18+ const selectors = selector.split(",").map(value => value.trim());19+ const match = node => selectors.some(value => {20+ const attr = value.match(/^([^[]+)?\[([^=\]]+)(?:="([^"]+)")?\]$/);21+ if (attr) return (!attr[1] || node.tagName === attr[1].toUpperCase()) && node.hasAttribute(attr[2]) && (attr[3] === undefined || node.getAttribute(attr[2]) === attr[3]);22+ return node.tagName === value.toUpperCase();23+ });24+ const found = [];25+ const walk = node => { for (const child of node.children) { if (match(child)) found.push(child); walk(child); } };26+ walk(this); return found;27+ }28+ set innerHTML(value) {29+ this.children = [];30+ const stack = [this];31+ for (const token of value.match(/<[^>]+>|[^<]+/g) || []) {32+ if (!token.startsWith("<")) continue;33+ if (/^<\//.test(token)) { stack.pop(); continue; }34+ const match = token.match(/^<([\w-]+)([^>]*)>/); if (!match) continue;35+ const child = new Node(match[1]);36+ for (const [, name, quote, attrValue] of match[2].matchAll(/([\w-]+)(?:=(['"])(.*?)\2)?/g)) child.setAttribute(name, attrValue ?? "");37+ stack.at(-1).append(child);38+ if (!/^(input|br|meta|img)$/.test(match[1])) stack.push(child);39+ }40+ }41+}42+43+const setup = () => {44+ const document = new Node("document");45+ document.readyState = "complete";46+ document.documentElement = new Node("html");47+ document.body = new Node("body");48+ document.createElement = name => new Node(name);49+ document.addEventListener = () => {};50+ document.dispatchEvent = () => {};51+ document.querySelectorAll = () => [];52+ const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }};53+ const location = {href: "https://relay.test/file?hash=cipher", origin: "https://relay.test", pathname: "/file", hash: ""};54+ const window = {document, customElements, location, addEventListener: () => {}, tiny: {util: {}, blossom: {encryption: {}}}};55+ class HTMLElement extends Node {}56+ Object.assign(globalThis, {window, document, customElements, HTMLElement, location, Blob, URL, CustomEvent: class {}});57+ globalThis.tiny = window.tiny;58+ vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"));59+ window.tiny.localPath = path => "/r/test" + path;60+ window.tiny.require = async () => {};61+ window.tiny.sha256hex = async () => "cipher";62+ window.tiny.blossom.encryption.decryptCHK = async () => new TextEncoder().encode("video bytes");63+ vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8"));64+ return {FileTools: customElements.registry["file-tools"], window};65+};66+67+const makeTool = (FileTools, window, type = "video/mp4") => {68+ window.location.hash = "#enc=chk-v1&key=secret&name=clip.mp4&type=" + encodeURIComponent(type);69+ const tool = new FileTools();70+ tool.setAttribute("hash", "cipher");71+ tool.setAttribute("type", "application/octet-stream");72+ return tool;73+};74+75+test("encrypted MP4 renders a local video preview and replaces it on retry", async () => {76+ const {FileTools, window} = setup();77+ const revoked = [];78+ const originalCreate = URL.createObjectURL, originalRevoke = URL.revokeObjectURL;79+ URL.createObjectURL = blob => "blob:preview-" + blob.type + "-" + Math.random();80+ URL.revokeObjectURL = url => revoked.push(url);81+ const originalFetch = globalThis.fetch;82+ globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));83+ try {84+ const tool = makeTool(FileTools, window); tool.connectedCallback();85+ await new Promise(resolve => setTimeout(resolve, 0));86+ const first = tool.querySelector("[data-decrypted-preview]");87+ assert.ok(first);88+ assert.equal(first.querySelector("video").controls, true);89+ assert.equal(first.querySelector("video").preload, "metadata");90+ assert.equal(first.querySelector("video").src.startsWith("blob:preview-video/mp4-"), true);91+ await tool.decryptFromFragment();92+ assert.equal(tool.querySelectorAll("[data-decrypted-preview]").length, 1);93+ assert.equal(revoked.length, 1, "retry releases the previous object URL");94+ tool.disconnectedCallback();95+ assert.equal(revoked.length, 2, "disconnect releases the current object URL");96+ } finally {97+ globalThis.fetch = originalFetch;98+ URL.createObjectURL = originalCreate;99+ URL.revokeObjectURL = originalRevoke;100+ }101+});102+103+test("HTML and SVG render as inert bounded source text", async () => {104+ for (const type of ["image/svg+xml", "text/html"]) {105+ const {FileTools, window} = setup();106+ const originalFetch = globalThis.fetch;107+ globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));108+ try {109+ const tool = makeTool(FileTools, window, type); tool.connectedCallback();110+ await tool.decryptFromFragment();111+ const preview = tool.querySelector("[data-decrypted-preview]");112+ assert.ok(preview, type + " should have a source preview");113+ assert.equal(preview.querySelector("pre").textContent, "video bytes");114+ assert.equal(preview.querySelector("img"), null);115+ assert.equal(preview.querySelector("iframe"), null);116+ assert.ok(tool.querySelector("a[download]") || tool.querySelector("a"));117+ } finally { globalThis.fetch = originalFetch; }118+ }119+});120+121+test("plain text preview is bounded while download remains complete", async () => {122+ const {FileTools, window} = setup();123+ const originalFetch = globalThis.fetch;124+ const originalDecrypt = window.tiny.blossom.encryption.decryptCHK;125+ const source = "A".repeat(256 * 1024 + 20);126+ window.tiny.blossom.encryption.decryptCHK = async () => new TextEncoder().encode(source);127+ globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));128+ try {129+ const tool = makeTool(FileTools, window, "text/plain"); tool.connectedCallback();130+ await new Promise(resolve => setTimeout(resolve, 0));131+ const pre = tool.querySelector("pre");132+ assert.ok(pre);133+ assert.equal(pre.textContent.startsWith("A".repeat(256 * 1024)), true);134+ assert.match(pre.textContent, /Preview truncated/);135+ const downloaded = await (await originalFetch(tool.downloadURL)).text();136+ assert.equal(downloaded, source);137+ } finally {138+ window.tiny.blossom.encryption.decryptCHK = originalDecrypt;139+ globalThis.fetch = originalFetch;140+ }141+});@@ -9,6 +9,7 @@ class Node {
99 append(...nodes) { nodes.forEach(node => { if (node) { this.children.push(node); node.parentNode = this; } }); }1010 replaceChildren(...nodes) { this.children = []; this.append(...nodes); }1111 click() {}12+ remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); }1213 setAttribute(name, value) { this.attributes[name] = String(value); }1314 getAttribute(name) { return this.attributes[name] ?? null; }1415 hasAttribute(name) { return name in this.attributes; }@@ -120,7 +121,7 @@ const sealedUpload = async (window, FileUpload, file) => {
120121 delete window.tiny.blossom.upload;121122 window.tiny.localPath = path => path;122123 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");123- window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; };124+ window.tiny.signedFetch = async (_path, _method, body) => { if (_path === "/files/metadata") return {ok: true}; uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; };124125 const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();125126 const form = upload.querySelector("form");126127 form.elements.namedItem("file").files = [file];@@ -191,14 +192,20 @@ test("folder upload rejects unexpected server hashes", async t => {
191192 await assert.rejects(server.store([folderFile("private", "root/private.txt")]), /descriptor/);192193 });193194 194-test("encrypted upload keeps ciphertext and link when clipboard fails", async () => {195+test("encrypted upload never touches the clipboard until Copy is pressed", async () => {195196 const {window, FileUpload} = load(true);197+ let copies = 0;198+ navigator.clipboard.writeText = async () => { copies++; throw Error("denied"); };196199 const {upload, link, uploaded} = await sealedUpload(window, FileUpload, new File(["secret plaintext"], "secret.txt", {type: "text/plain"}));197200 assert.notEqual(new TextDecoder().decode(uploaded()), "secret plaintext");198201 assert.match(link, /\/file\?hash=[0-9a-f]{64}#key=.*&iv=/);199202 assert.equal(upload.querySelector("[data-share-url]").value, link);200203 assert.equal(upload.querySelector("[data-share]").hidden, false);201- assert.match(upload.out.textContent, /Copy the share link above/);204+ assert.equal(copies, 0);205+ assert.equal(upload.querySelector("[data-open]").href, link);206+ await upload.copyLink();207+ assert.equal(copies, 1);208+ assert.match(upload.out.textContent, /Copy the secret link from the field/);202209 });203210 204211 test("plain uploads store every chosen file and refresh the listing", async () => {@@ -308,6 +315,110 @@ test("retry keeps the original ciphertext and key after interrupted encrypted up
308315 assert.equal(upload.pending, null);309316 });310317 318+test("manifest retry keeps completed chunks and reports readable progress", async t => {319+ const server = workspaceServer(t);320+ const transport = globalThis.fetch;321+ let interrupted = true;322+ const attempts = new Map();323+ globalThis.fetch = async (url, init = {}) => {324+ if (init.method === "PATCH") {325+ const hash = new URL(url).pathname.split("/").at(-1);326+ attempts.set(hash, (attempts.get(hash) || 0) + 1);327+ if (interrupted && attempts.size === 2) return new Response(null, {status: 403});328+ }329+ return transport(url, init);330+ };331+ const bytes = new Uint8Array(5 * 1024 * 1024);332+ bytes[0] = 1; bytes[2 * 1024 * 1024] = 2; bytes[4 * 1024 * 1024] = 3;333+ const upload = new server.FileUpload(); upload.connectedCallback();334+ const form = upload.querySelector("form");335+ form.elements.namedItem("file").files = [folderFile(bytes, "video/clip.mp4")];336+ form.elements.namedItem("encrypt").checked = true;337+ await assert.rejects(upload.run(form), /403/);338+ const first = attempts.keys().next().value;339+ assert.equal(upload.manifestSession.completed.size, 1);340+ interrupted = false;341+ const link = await upload.run();342+ assert.equal(attempts.get(first), 1, "accepted first chunk must not be reuploaded");343+ const root = await server.open(link);344+ assert.deepEqual(await root.download(root.entries[0]), bytes);345+ upload.progress(3 * 1024 * 1024, 5 * 1024 * 1024);346+ assert.match(upload.out.textContent, /60%.*3.0 MiB.*5.0 MiB/);347+ assert.equal(upload.querySelector("progress").value, 0.6);348+ for (const request of server.requests.filter(r => r.init.method === "PATCH")) {349+ assert.equal(request.url.searchParams.get("purpose"), "chunk");350+ }351+});352+353+test("members upload keeps the MP4 name and type and never creates a secret link", async () => {354+ const {window, FileUpload} = load(true);355+ const sent = [];356+ window.tiny.signedFetch = async (path, method, bytes, options) => { sent.push({path, method, bytes, options}); };357+ window.tiny.navigate = async () => {};358+ const upload = new FileUpload(); upload.connectedCallback();359+ const form = upload.querySelector("form");360+ form.elements.namedItem("file").files = [new File(["mp4 bytes"], "holiday.mp4", {type: "video/mp4"})];361+ form.elements.namedItem("access").value = "members";362+ form.elements.namedItem("encrypt").checked = true;363+ await upload.run(form);364+ assert.equal(sent.length, 1);365+ const url = new URL(sent[0].path, location.href);366+ assert.equal(url.searchParams.get("access"), "members");367+ assert.equal(url.searchParams.get("filename"), "holiday.mp4");368+ assert.equal(sent[0].options.contentType, "video/mp4");369+ assert.equal(new TextDecoder().decode(sent[0].bytes), "mp4 bytes");370+ assert.equal(upload.querySelector("[data-share]").hidden, true);371+});372+373+test("retry finalizes the same encrypted root without reuploading or exposing its key", async () => {374+ const {window, FileUpload} = load(true);375+ window.tiny.localPath = path => path;376+ window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");377+ let uploads = 0, records = 0;378+ window.tiny.blossom.upload = {upload: async (bytes, options) => {379+ uploads++;380+ assert.equal(options.metadata.purpose, "chunk");381+ return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}};382+ }};383+ window.tiny.signedFetch = async (path, method, body) => {384+ assert.equal(path, "/files/metadata");385+ assert.equal(method, "POST");386+ const record = JSON.parse(body);387+ assert.match(record.name, /^Encrypted file [a-f0-9]{12}$/);388+ assert.equal(record.purpose, "file");389+ assert.equal(record.size, 7);390+ assert.equal(body.includes("secret-name.txt"), false);391+ assert.equal("key" in record, false);392+ if (++records === 1) throw Error("server restarting");393+ return {ok: true};394+ };395+ const upload = new FileUpload(); upload.connectedCallback();396+ const form = upload.querySelector("form");397+ form.elements.namedItem("file").files = [new File(["private"], "secret-name.txt")];398+ form.elements.namedItem("encrypt").checked = true;399+ await assert.rejects(upload.run(form), /server restarting/);400+ const saved = upload.querySelector("[data-share-url]").value;401+ assert.match(saved, /#key=/);402+ assert.equal(upload.querySelector("[data-retry]").disabled, false);403+ assert.equal(await upload.run(), saved);404+ assert.equal(uploads, 1);405+ assert.equal(records, 2);406+});407+408+test("a 100 MiB encrypted MP4 opens a media player after reconstruction", async t => {409+ const server = workspaceServer(t);410+ const bytes = new Uint8Array(100 * 1024 * 1024);411+ for (let index = 0; index < 50; index++) bytes[index * 2 * 1024 * 1024] = index;412+ const link = await server.store([new File([bytes], "large.mp4", {type: "video/mp4"})]);413+ const root = await server.open(link);414+ const video = root.querySelector("video");415+ assert.ok(video, "decrypted manifest MP4 must have a player");416+ assert.equal(video.controls, true);417+ const data = await server.originalFetch(video.src).then(response => response.arrayBuffer());418+ assert.deepEqual(new Uint8Array(data), bytes);419+ assert.match(root.querySelector("output").textContent, /100.0 MiB/);420+});421+311422 test("large uploads hand signed requests to Background Fetch and read the parked descriptor", async () => {312423 const {window, FileUpload} = load(true);313424 window.tiny.localPath = path => path;@@ -334,6 +445,7 @@ test("large uploads hand signed requests to Background Fetch and read the parked
334445 const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();335446 const form = upload.querySelector("form");336447 form.elements.namedItem("file").files = [new File([bytes], "big.bin", {type: "application/octet-stream"})];448+ form.elements.namedItem("background").checked = true;337449 const run = upload.run(form);338450 await new Promise(resolve => setTimeout(resolve, 50));339451 assert.ok(started, "background fetch was not started");