| 1 | diff --git a/internal/blob/blob.go b/internal/blob/blob.go |
| 2 | index e2b1a66..57e300a 100644 |
| 3 | --- a/internal/blob/blob.go |
| 4 | +++ b/internal/blob/blob.go |
| 5 | @@ -569,7 +569,11 @@ func (s *Service) putValidated(ctx context.Context, source io.Reader, typ, uploa |
| 6 | return Blob{}, false, fmt.Errorf("sync blob directory: %w", err) |
| 7 | } |
| 8 | now := time.Now().UTC().Unix() |
| 9 | - entry := Blob{SHA256: sha, Size: count, Type: contentType(typ), Uploader: uploader, Uploaded: now} |
| 10 | + typ = contentType(typ) |
| 11 | + if typ == "application/octet-stream" { |
| 12 | + typ = detectFileType(final) |
| 13 | + } |
| 14 | + entry := Blob{SHA256: sha, Size: count, Type: typ, Uploader: uploader, Uploaded: now} |
| 15 | err = s.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 16 | if _, err := tx.ExecContext(ctx, "DELETE FROM blob_tombstones WHERE sha256=?", entry.SHA256); err != nil { |
| 17 | return fmt.Errorf("clear blob deletion tombstone: %w", err) |
| 18 | @@ -1031,6 +1035,34 @@ func (s *Service) origin(r *http.Request) string { |
| 19 | } |
| 20 | return origin(r) |
| 21 | } |
| 22 | + |
| 23 | +// DetectMediaType recognises images, video, audio and PDF from the first |
| 24 | +// bytes of a blob. Anything else stays an octet stream so uploads can never |
| 25 | +// be reinterpreted as a page or a script. |
| 26 | +func DetectMediaType(head []byte) string { |
| 27 | + detected := contentType(http.DetectContentType(head)) |
| 28 | + for _, prefix := range []string{"image/", "video/", "audio/"} { |
| 29 | + if strings.HasPrefix(detected, prefix) { |
| 30 | + return detected |
| 31 | + } |
| 32 | + } |
| 33 | + if detected == "application/pdf" { |
| 34 | + return detected |
| 35 | + } |
| 36 | + return "application/octet-stream" |
| 37 | +} |
| 38 | + |
| 39 | +func detectFileType(name string) string { |
| 40 | + file, err := os.Open(name) |
| 41 | + if err != nil { |
| 42 | + return "application/octet-stream" |
| 43 | + } |
| 44 | + defer file.Close() |
| 45 | + head := make([]byte, 512) |
| 46 | + n, _ := io.ReadFull(file, head) |
| 47 | + return DetectMediaType(head[:n]) |
| 48 | +} |
| 49 | + |
| 50 | func contentType(raw string) string { |
| 51 | raw = strings.ToLower(strings.TrimSpace(strings.Split(raw, ";")[0])) |
| 52 | if raw == "" { |
| 53 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 54 | index 2bd39bd..e3e2da6 100644 |
| 55 | --- a/internal/daemon/client_browse.go |
| 56 | +++ b/internal/daemon/client_browse.go |
| 57 | @@ -277,6 +277,11 @@ func (t *Tenant) browseFile(ctx context.Context, actor, hash string) (any, error |
| 58 | binary := bytes.IndexByte(data, 0) >= 0 || (!utf8.Valid(data) && !truncated) |
| 59 | result := t.browseBlobMetadata(entry) |
| 60 | result["binary"], result["truncated"] = binary, truncated |
| 61 | + if entry.Type == "application/octet-stream" { |
| 62 | + if detected := blob.DetectMediaType(data); detected != "application/octet-stream" { |
| 63 | + result["type"] = detected |
| 64 | + } |
| 65 | + } |
| 66 | if actor == "" { |
| 67 | delete(result, "uploader") |
| 68 | } |
| 69 | diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go |
| 70 | index a1a0460..662abbd 100644 |
| 71 | --- a/internal/daemon/client_browse_http.go |
| 72 | +++ b/internal/daemon/client_browse_http.go |
| 73 | @@ -10,6 +10,7 @@ import ( |
| 74 | "strings" |
| 75 | "time" |
| 76 | |
| 77 | + "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 78 | "github.com/FelineStateMachine/tinyrelay/internal/gitrelay" |
| 79 | ) |
| 80 | |
| 81 | @@ -85,6 +86,11 @@ func (t *Tenant) sourceDownload(w http.ResponseWriter, r *http.Request, actor st |
| 82 | return t.git.WriteSource(r.Context(), q, w) |
| 83 | } |
| 84 | |
| 85 | +// mediaInline reports whether a stored type is safe to render in the browser. |
| 86 | +func mediaInline(typ string) bool { |
| 87 | + return strings.HasPrefix(typ, "image/") || strings.HasPrefix(typ, "video/") || strings.HasPrefix(typ, "audio/") || typ == "application/pdf" |
| 88 | +} |
| 89 | + |
| 90 | func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request) error { |
| 91 | if !t.Policy().Features.Files { |
| 92 | return os.ErrNotExist |
| 93 | @@ -95,9 +101,28 @@ func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request) error { |
| 94 | return err |
| 95 | } |
| 96 | defer body.Close() |
| 97 | - w.Header().Set("Content-Type", "application/octet-stream") |
| 98 | - w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": entry.SHA256})) |
| 99 | - if seeker, ok := body.(io.ReadSeeker); ok { |
| 100 | + // Recognised media is served inline under its own type so the browser can |
| 101 | + // show it. Everything else downloads as an attachment. |
| 102 | + typ := entry.Type |
| 103 | + seeker, seekable := body.(io.ReadSeeker) |
| 104 | + if typ == "application/octet-stream" && seekable { |
| 105 | + head := make([]byte, 512) |
| 106 | + n, _ := io.ReadFull(seeker, head) |
| 107 | + typ = blob.DetectMediaType(head[:n]) |
| 108 | + if _, err := seeker.Seek(0, io.SeekStart); err != nil { |
| 109 | + return err |
| 110 | + } |
| 111 | + } |
| 112 | + if !mediaInline(typ) { |
| 113 | + typ = "application/octet-stream" |
| 114 | + } |
| 115 | + w.Header().Set("Content-Type", typ) |
| 116 | + if typ == "application/octet-stream" { |
| 117 | + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": entry.SHA256})) |
| 118 | + } else { |
| 119 | + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.SHA256})) |
| 120 | + } |
| 121 | + if seekable { |
| 122 | http.ServeContent(w, r, entry.SHA256, time.Unix(entry.Uploaded, 0), seeker) |
| 123 | return nil |
| 124 | } |
| 125 | diff --git a/internal/daemon/media_preview_test.go b/internal/daemon/media_preview_test.go |
| 126 | new file mode 100644 |
| 127 | index 0000000..7f15908 |
| 128 | --- /dev/null |
| 129 | +++ b/internal/daemon/media_preview_test.go |
| 130 | @@ -0,0 +1,50 @@ |
| 131 | +package daemon |
| 132 | + |
| 133 | +import ( |
| 134 | + "context" |
| 135 | + "encoding/json" |
| 136 | + "net/http" |
| 137 | + "net/http/httptest" |
| 138 | + "strings" |
| 139 | + "testing" |
| 140 | + |
| 141 | + "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 142 | +) |
| 143 | + |
| 144 | +// pngHeader is the start of a valid PNG file: enough for type detection. |
| 145 | +var pngHeader = append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 64)...) |
| 146 | + |
| 147 | +func TestUntypedImageUploadsAreRecognisedAndServedInline(t *testing.T) { |
| 148 | + _, tenant := testTenant(t) |
| 149 | + ctx := context.Background() |
| 150 | + entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader(string(pngHeader)), Type: "application/octet-stream", Uploader: tenant.Policy().Owner}) |
| 151 | + if err != nil { |
| 152 | + t.Fatal(err) |
| 153 | + } |
| 154 | + if entry.Type != "image/png" { |
| 155 | + t.Fatalf("stored type = %q, want image/png", entry.Type) |
| 156 | + } |
| 157 | + value, err := tenant.Execute(ctx, tenant.Policy().Owner, "browsefile", []json.RawMessage{json.RawMessage(`{"hash":"` + entry.SHA256 + `"}`)}) |
| 158 | + if err != nil { |
| 159 | + t.Fatal(err) |
| 160 | + } |
| 161 | + if page, _ := value.(map[string]any); page["type"] != "image/png" || page["binary"] != true { |
| 162 | + t.Fatalf("browsefile = %v", value) |
| 163 | + } |
| 164 | + req := httptest.NewRequest(http.MethodGet, "http://relay.test/files/raw?hash="+entry.SHA256, nil) |
| 165 | + res := httptest.NewRecorder() |
| 166 | + tenant.ServeHTTP(res, req) |
| 167 | + if res.Code != 200 || res.Header().Get("Content-Type") != "image/png" || !strings.HasPrefix(res.Header().Get("Content-Disposition"), "inline") { |
| 168 | + t.Fatalf("image download: %d type=%q disposition=%q", res.Code, res.Header().Get("Content-Type"), res.Header().Get("Content-Disposition")) |
| 169 | + } |
| 170 | + // Text that merely claims to be an image is still not rendered as a page. |
| 171 | + html, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("<html>hi</html>"), Type: "application/octet-stream", Uploader: tenant.Policy().Owner}) |
| 172 | + if err != nil { |
| 173 | + t.Fatal(err) |
| 174 | + } |
| 175 | + res = httptest.NewRecorder() |
| 176 | + tenant.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "http://relay.test/files/raw?hash="+html.SHA256, nil)) |
| 177 | + if res.Header().Get("Content-Type") != "application/octet-stream" || !strings.HasPrefix(res.Header().Get("Content-Disposition"), "attachment") { |
| 178 | + t.Fatalf("html blob served as %q %q", res.Header().Get("Content-Type"), res.Header().Get("Content-Disposition")) |
| 179 | + } |
| 180 | +} |
| 181 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 182 | index 39192ed..efa808c 100644 |
| 183 | --- a/internal/webui/browse.html |
| 184 | +++ b/internal/webui/browse.html |
| 185 | @@ -37,7 +37,11 @@ |
| 186 | <tr><th>download</th><td><a href="/files/raw?hash={{str (index $file "sha256")}}">raw</a></td></tr> |
| 187 | </table> |
| 188 | </section> |
| 189 | -{{if index $file "binary"}}<section><p>This is a binary file. Download it to view its contents.</p></section> |
| 190 | +{{$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> |
| 191 | +{{else if hasPrefix $type "video/"}}<section id="media"><video controls src="{{$raw}}"></video></section> |
| 192 | +{{else if hasPrefix $type "audio/"}}<section id="media"><audio controls src="{{$raw}}"></audio></section> |
| 193 | +{{else if eq $type "application/pdf"}}<section id="media"><a href="{{$raw}}">Open the PDF</a></section> |
| 194 | +{{else if index $file "binary"}}<section><p>This is a binary file. Download it to view its contents.</p></section> |
| 195 | {{else}}{{sourceHTML .Event}} |
| 196 | {{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}} |
| 197 | {{end}}{{end}} |
| 198 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 199 | index aa82d9e..7b000c6 100644 |
| 200 | --- a/internal/webui/components.js |
| 201 | +++ b/internal/webui/components.js |
| 202 | @@ -150,7 +150,7 @@ |
| 203 | const file = form.querySelector("input[type=file]")?.files[0]; |
| 204 | if (!file) { this.report("Choose a file first."); return; } |
| 205 | this.report("Signing…"); |
| 206 | - const response = await tiny.signedFetch(action, method, await file.arrayBuffer()); |
| 207 | + const response = await tiny.signedFetch(action, method, await file.arrayBuffer(), {contentType: file.type || "application/octet-stream"}); |
| 208 | this.report("Done: " + await response.text()); |
| 209 | } |
| 210 | } |
| 211 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 212 | index 83004d2..3045313 100644 |
| 213 | --- a/internal/webui/shell_test.go |
| 214 | +++ b/internal/webui/shell_test.go |
| 215 | @@ -135,3 +135,24 @@ func TestPromptPathSpellsOutRepositoriesAndFiles(t *testing.T) { |
| 216 | t.Fatalf("manage prompt = %q", got) |
| 217 | } |
| 218 | } |
| 219 | + |
| 220 | +type imageBackend struct{ fakeBackend } |
| 221 | + |
| 222 | +func (b *imageBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) { |
| 223 | + if method == "browsefile" { |
| 224 | + return map[string]any{"sha256": strings.Repeat("9", 64), "type": "image/png", "size": 12, "binary": true}, nil |
| 225 | + } |
| 226 | + return nil, nil |
| 227 | +} |
| 228 | + |
| 229 | +func TestFilePagePreviewsImages(t *testing.T) { |
| 230 | + app, err := New(&imageBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}, Options{}) |
| 231 | + if err != nil { |
| 232 | + t.Fatal(err) |
| 233 | + } |
| 234 | + recorder := httptest.NewRecorder() |
| 235 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file?sha="+strings.Repeat("9", 64), nil)) |
| 236 | + if body := recorder.Body.String(); !strings.Contains(body, `<img src="/files/raw?hash=`+strings.Repeat("9", 64)+`"`) { |
| 237 | + t.Fatalf("no image preview: %s", body) |
| 238 | + } |
| 239 | +} |
| 240 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 241 | index aa133df..9cff895 100644 |
| 242 | --- a/internal/webui/style.css |
| 243 | +++ b/internal/webui/style.css |
| 244 | @@ -90,6 +90,8 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem} |
| 245 | #identity>header small code{font-size:12px} |
| 246 | #tags a{display:inline-block;font:12px var(--mono);margin-right:8px} |
| 247 | |
| 248 | +#media img,#media video{max-width:100%;max-height:70vh;border:1px solid var(--line);background:var(--rail)} |
| 249 | + |
| 250 | /* README and other rendered markdown. */ |
| 251 | #readme{max-width:720px}#readme h1{font-size:24px;border-bottom:1px solid var(--line);padding-bottom:8px}#readme h2{font-size:17px} |
| 252 | #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} |
| 253 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 254 | index 566a446..8e13df0 100644 |
| 255 | --- a/internal/webui/templates.go |
| 256 | +++ b/internal/webui/templates.go |
| 257 | @@ -123,6 +123,7 @@ func parseTemplates() (*template.Template, error) { |
| 258 | "datetime": datetime, |
| 259 | "when": when, |
| 260 | "markdown": renderMarkdown, |
| 261 | + "hasPrefix": strings.HasPrefix, |
| 262 | "npub": identityNpub, |
| 263 | "short": short, |
| 264 | "prompt": promptPath, |