Uploads that arrive as octet streams are sniffed for image, video, audio and PDF types at upload and read time. Recognised media is served inline under its own type, the file page previews it, and the browser upload sends the file's type. Anything else still downloads as an attachment.
- commit
- 3d083407cd82c97a9ec79fc4eb3ac75a0803f34b
- parent
- ecf0fe310403170cca41e62316f8981940e51bec
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 34 | ++++++++++++++++++++- |
| M | internal/ | 5 | +++ |
| M | internal/ | 31 | +++++++++++++++++-- |
| A | internal/ | 50 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 6 | +++- |
| M | internal/ | 2 | +- |
| M | internal/ | 21 | +++++++++++++ |
| M | internal/ | 2 | + |
| M | internal/ | 1 | + |
@@ -569,7 +569,11 @@ func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploa
569569 return Blob{}, false, fmt.Errorf("sync blob directory: %w", err)570570 }571571 now := time.Now().UTC().Unix()572- entry := Blob{SHA256: sha, Size: count, Type: contentType(typ), Uploader: uploader, Uploaded: now}572+ typ = contentType(typ)573+ if typ == "application/octet-stream" {574+ typ = detectFileType(final)575+ }576+ entry := Blob{SHA256: sha, Size: count, Type: typ, Uploader: uploader, Uploaded: now}573577 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {574578 if _, err := tx.ExecContext(ctx, "DELETE FROM blob_tombstones WHERE sha256=?", entry.SHA256); err != nil {575579 return fmt.Errorf("clear blob deletion tombstone: %w", err)@@ -1031,6 +1035,34 @@ func (s *Service) origin(r *http.Request) string {
10311035 }10321036 return origin(r)10331037 }1038+1039+// DetectMediaType recognises images, video, audio and PDF from the first1040+// bytes of a blob. Anything else stays an octet stream so uploads can never1041+// be reinterpreted as a page or a script.1042+func DetectMediaType(head []byte) string {1043+ detected := contentType(http.DetectContentType(head))1044+ for _, prefix := range []string{"image/", "video/", "audio/"} {1045+ if strings.HasPrefix(detected, prefix) {1046+ return detected1047+ }1048+ }1049+ if detected == "application/pdf" {1050+ return detected1051+ }1052+ return "application/octet-stream"1053+}1054+1055+func detectFileType(name string) string {1056+ file, err := os.Open(name)1057+ if err != nil {1058+ return "application/octet-stream"1059+ }1060+ defer file.Close()1061+ head := make([]byte, 512)1062+ n, _ := io.ReadFull(file, head)1063+ return DetectMediaType(head[:n])1064+}1065+10341066 func contentType(raw string) string {10351067 raw = strings.ToLower(strings.TrimSpace(strings.Split(raw, ";")[0]))10361068 if raw == "" {@@ -277,6 +277,11 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error
277277 binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated)278278 result := t.browseBlobMetadata(entry)279279 result["binary"], result["truncated"] = binary, truncated280+ if entry.Type == "application/octet-stream" {281+ if detected := blob.DetectMediaType(data); detected != "application/octet-stream" {282+ result["type"] = detected283+ }284+ }280285 if actor == "" {281286 delete(result, "uploader")282287 }@@ -10,6 +10,7 @@ import (
1010 "strings"1111 "time"1212 13+ "github.com/FelineStateMachine/tinyrelay/internal/blob"1314 "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"1415 )1516 @@ -85,6 +86,11 @@ func (t *Tenant) sourceDownload(w http.ResponseWriter, r *http.Request, actor st
8586 return t.git.WriteSource(r.Context(), q, w)8687 }8788 89+// mediaInline reports whether a stored type is safe to render in the browser.90+func mediaInline(typ string) bool {91+ return strings.HasPrefix(typ, "image/") || strings.HasPrefix(typ, "video/") || strings.HasPrefix(typ, "audio/") || typ == "application/pdf"92+}93+8894 func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request) error {8995 if !t.Policy().Features.Files {9096 return os.ErrNotExist@@ -95,9 +101,28 @@ func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request) error {
95101 return err96102 }97103 defer body.Close()98- w.Header().Set("Content-Type", "application/octet-stream")99- w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": entry.SHA256}))100- if seeker, ok := body.(io.ReadSeeker); ok {104+ // Recognised media is served inline under its own type so the browser can105+ // show it. Everything else downloads as an attachment.106+ typ := entry.Type107+ seeker, seekable := body.(io.ReadSeeker)108+ if typ == "application/octet-stream" && seekable {109+ head := make([]byte, 512)110+ n, _ := io.ReadFull(seeker, head)111+ typ = blob.DetectMediaType(head[:n])112+ if _, err := seeker.Seek(0, io.SeekStart); err != nil {113+ return err114+ }115+ }116+ if !mediaInline(typ) {117+ typ = "application/octet-stream"118+ }119+ w.Header().Set("Content-Type", typ)120+ if typ == "application/octet-stream" {121+ w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": entry.SHA256}))122+ } else {123+ w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.SHA256}))124+ }125+ if seekable {101126 http.ServeContent(w, r, entry.SHA256, time.Unix(entry.Uploaded, 0), seeker)102127 return nil103128 }@@ -0,0 +1,50 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/blob"12+)13+14+// pngHeader is the start of a valid PNG file: enough for type detection.15+var pngHeader = append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 64)...)16+17+func TestUntypedImageUploadsAreRecognisedAndServedInline(t *testing.T) {18+ _, tenant := testTenant(t)19+ ctx := context.Background()20+ entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader(string(pngHeader)), Type: "application/octet-stream", Uploader: tenant.Policy().Owner})21+ if err != nil {22+ t.Fatal(err)23+ }24+ if entry.Type != "image/png" {25+ t.Fatalf("stored type = %q, want image/png", entry.Type)26+ }27+ value, err := tenant.Execute(ctx, tenant.Policy().Owner, "browsefile", []json.RawMessage{json.RawMessage(`{"hash":"` + entry.SHA256 + `"}`)})28+ if err != nil {29+ t.Fatal(err)30+ }31+ if page, _ := value.(map[string]any); page["type"] != "image/png" || page["binary"] != true {32+ t.Fatalf("browsefile = %v", value)33+ }34+ req := httptest.NewRequest(http.MethodGet, "http://relay.test/files/raw?hash="+entry.SHA256, nil)35+ res := httptest.NewRecorder()36+ tenant.ServeHTTP(res, req)37+ if res.Code != 200 || res.Header().Get("Content-Type") != "image/png" || !strings.HasPrefix(res.Header().Get("Content-Disposition"), "inline") {38+ t.Fatalf("image download: %d type=%q disposition=%q", res.Code, res.Header().Get("Content-Type"), res.Header().Get("Content-Disposition"))39+ }40+ // Text that merely claims to be an image is still not rendered as a page.41+ html, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("<html>hi</html>"), Type: "application/octet-stream", Uploader: tenant.Policy().Owner})42+ if err != nil {43+ t.Fatal(err)44+ }45+ res = httptest.NewRecorder()46+ tenant.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "http://relay.test/files/raw?hash="+html.SHA256, nil))47+ if res.Header().Get("Content-Type") != "application/octet-stream" || !strings.HasPrefix(res.Header().Get("Content-Disposition"), "attachment") {48+ t.Fatalf("html blob served as %q %q", res.Header().Get("Content-Type"), res.Header().Get("Content-Disposition"))49+ }50+}@@ -37,7 +37,11 @@
3737 <tr><th>download</th><td><a href="/files/raw?hash={{str (index $file "sha256")}}">raw</a></td></tr>3838 </table>3939 </section>40-{{if index $file "binary"}}<section><p>This is a binary file. Download it to view its contents.</p></section>40+{{$type := str (index $file "type")}}{{$raw := printf "/files/raw?hash=%s" (str (index $file "sha256"))}}{{if hasPrefix $type "image/"}}<section id="media"><img src="{{$raw}}" alt=""></section>41+{{else if hasPrefix $type "video/"}}<section id="media"><video controls src="{{$raw}}"></video></section>42+{{else if hasPrefix $type "audio/"}}<section id="media"><audio controls src="{{$raw}}"></audio></section>43+{{else if eq $type "application/pdf"}}<section id="media"><a href="{{$raw}}">Open the PDF</a></section>44+{{else if index $file "binary"}}<section><p>This is a binary file. Download it to view its contents.</p></section>4145 {{else}}{{sourceHTML .Event}}4246 {{end}}{{if .Actor}}<section><details><summary>Manage file</summary><rpc-form method="deleteblob"><input type="hidden" name="param" value="{{$hash}}"><button>Delete file</button></rpc-form></details></section>{{end}}4347 {{end}}{{end}}@@ -150,7 +150,7 @@
150150 const file = form.querySelector("input[type=file]")?.files[0];151151 if (!file) { this.report("Choose a file first."); return; }152152 this.report("Signing…");153- const response = await tiny.signedFetch(action, method, await file.arrayBuffer());153+ const response = await tiny.signedFetch(action, method, await file.arrayBuffer(), {contentType: file.type || "application/octet-stream"});154154 this.report("Done: " + await response.text());155155 }156156 }@@ -135,3 +135,24 @@ func TestPromptPathSpellsOutRepositoriesAndFiles(t *testing.T) {
135135 t.Fatalf("manage prompt = %q", got)136136 }137137 }138+139+type imageBackend struct{ fakeBackend }140+141+func (b *imageBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {142+ if method == "browsefile" {143+ return map[string]any{"sha256": strings.Repeat("9", 64), "type": "image/png", "size": 12, "binary": true}, nil144+ }145+ return nil, nil146+}147+148+func TestFilePagePreviewsImages(t *testing.T) {149+ app, err := New(&imageBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}, Options{})150+ if err != nil {151+ t.Fatal(err)152+ }153+ recorder := httptest.NewRecorder()154+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file?sha="+strings.Repeat("9", 64), nil))155+ if body := recorder.Body.String(); !strings.Contains(body, `<img src="/files/raw?hash=`+strings.Repeat("9", 64)+`"`) {156+ t.Fatalf("no image preview: %s", body)157+ }158+}@@ -90,6 +90,8 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
9090 #identity>header small code{font-size:12px}9191 #tags a{display:inline-block;font:12px var(--mono);margin-right:8px}9292 93+#media img,#media video{max-width:100%;max-height:70vh;border:1px solid var(--line);background:var(--rail)}94+9395 /* README and other rendered markdown. */9496 #readme{max-width:720px}#readme h1{font-size:24px;border-bottom:1px solid var(--line);padding-bottom:8px}#readme h2{font-size:17px}9597 #readme pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre}@@ -123,6 +123,7 @@ func parseTemplates() (*template.Template, error) {
123123 "datetime": datetime,124124 "when": when,125125 "markdown": renderMarkdown,126+ "hasPrefix": strings.HasPrefix,126127 "npub": identityNpub,127128 "short": short,128129 "prompt": promptPath,