clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

recognise media uploads and preview them

Dami committed 3d08340
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>
9 files changed, +146 -6
Minternal/blob/blob.go34++++++++++++++++++++-
Minternal/daemon/client_browse.go5+++
Minternal/daemon/client_browse_http.go31+++++++++++++++++--
Ainternal/daemon/media_preview_test.go50++++++++++++++++++++++++++++++
Minternal/webui/browse.html6+++-
Minternal/webui/components.js2+-
Minternal/webui/shell_test.go21+++++++++++++
Minternal/webui/style.css2+
Minternal/webui/templates.go1+
M internal/blob/blob.go +33 -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 == "" {
M internal/daemon/client_browse.go +5 -0
M internal/daemon/client_browse_http.go +28 -3
@@ -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 }
A internal/daemon/media_preview_test.go +50 -0
@@ -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+}
M internal/webui/browse.html +5 -1
M internal/webui/components.js +1 -1
M internal/webui/shell_test.go +21 -0
M internal/webui/style.css +2 -0
M internal/webui/templates.go +1 -0