tinyrelaycommit

Commit 3d083407cd82

browse files at this commit | history

1diff --git a/internal/blob/blob.go b/internal/blob/blob.go
2index 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 == "" {
53diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
54index 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 }
69diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go
70index 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 }
125diff --git a/internal/daemon/media_preview_test.go b/internal/daemon/media_preview_test.go
126new file mode 100644
127index 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+}
181diff --git a/internal/webui/browse.html b/internal/webui/browse.html
182index 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}}
198diff --git a/internal/webui/components.js b/internal/webui/components.js
199index 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 }
211diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
212index 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+}
240diff --git a/internal/webui/style.css b/internal/webui/style.css
241index 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}
253diff --git a/internal/webui/templates.go b/internal/webui/templates.go
254index 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,

About

ownerc01503e8035f
visibilitypublic
head3d083407cd82

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs