clone

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

main
tinyrelay / internal / daemon / client_browse_http.go
client_browse_http.go 4.2K
1package daemon23import (4 "database/sql"5 "errors"6 "io"7 "mime"8 "net/http"9 "os"10 "path"11 "strings"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/blob"15 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"16)1718func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {19 if r.URL.Path != "/repo/raw" && r.URL.Path != "/files/raw" {20 return false21 }22 ctx, finish := t.app.telemetry.Start(r.Context(), "browse-download")23 r = r.WithContext(ctx)24 outcome := "error"25 defer func() { finish(outcome) }()26 if r.Method != http.MethodGet && r.Method != http.MethodHead {27 http.Error(w, "method not allowed", 405)28 return true29 }30 actor, err := t.resolveUIActor(r)31 if err == nil {32 err = t.browseRead(r.Context(), actor)33 }34 if err != nil {35 browseHTTPError(w, err)36 return true37 }38 w.Header().Set("Cache-Control", "private, no-store")39 w.Header().Set("Content-Security-Policy", "sandbox")40 w.Header().Set("X-Content-Type-Options", "nosniff")41 if r.URL.Path == "/repo/raw" {42 err = t.sourceDownload(w, r, actor)43 } else {44 err = t.fileDownload(w, r, actor)45 }46 if err != nil {47 browseHTTPError(w, err)48 } else {49 outcome = "success"50 }51 return true52}5354func browseHTTPError(w http.ResponseWriter, err error) {55 status := http.StatusBadRequest56 switch {57 case strings.HasPrefix(err.Error(), "conflict:"):58 status = http.StatusConflict59 case strings.HasPrefix(err.Error(), "auth-required:"):60 status = http.StatusUnauthorized61 case strings.HasPrefix(err.Error(), "restricted:"), strings.HasPrefix(err.Error(), "blocked:"):62 status = http.StatusForbidden63 case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows), strings.HasPrefix(err.Error(), "not found:"):64 status = http.StatusNotFound65 }66 http.Error(w, err.Error(), status)67}6869func (t *Tenant) sourceDownload(w http.ResponseWriter, r *http.Request, actor string) error {70 if t.git == nil {71 return os.ErrNotExist72 }73 values := r.URL.Query()74 q := gitrelay.BrowseRequest{Owner: values.Get("owner"), Repo: values.Get("repo"), Ref: values.Get("ref"), Path: values.Get("path")}75 repo, err := t.git.BrowseRepository(q.Owner, q.Repo)76 if err != nil {77 return err78 }79 if err := t.browseRepoAccess(r.Context(), actor, repo); err != nil {80 return err81 }82 w.Header().Set("Content-Type", "application/octet-stream")83 w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(q.Path)}))84 if r.Method == http.MethodHead {85 q.View = "file"86 _, err = t.git.Browse(r.Context(), q)87 return err88 }89 return t.git.WriteSource(r.Context(), q, w)90}9192// mediaInline reports whether a stored type is safe to render in the browser.93func mediaInline(typ string) bool {94 return strings.HasPrefix(typ, "image/") || strings.HasPrefix(typ, "video/") || strings.HasPrefix(typ, "audio/") || typ == "application/pdf"95}9697func (t *Tenant) fileDownload(w http.ResponseWriter, r *http.Request, actor string) error {98 if !t.Policy().Features.Files {99 return os.ErrNotExist100 }101 hash := r.URL.Query().Get("hash")102 if err := t.fileReadAccess(r.Context(), actor, hash); err != nil {103 return err104 }105 if err := t.roomAttachmentAccess(r.Context(), actor, hash); err != nil {106 return err107 }108 entry, body, err := t.blobs.Get(r.Context(), hash)109 if err != nil {110 return err111 }112 defer body.Close()113 // Recognised media is served inline under its own type so the browser can114 // show it. Everything else downloads as an attachment.115 typ := entry.Type116 seeker, seekable := body.(io.ReadSeeker)117 if typ == "application/octet-stream" && seekable {118 head := make([]byte, 512)119 n, _ := io.ReadFull(seeker, head)120 typ = blob.DetectMediaType(head[:n])121 if _, err := seeker.Seek(0, io.SeekStart); err != nil {122 return err123 }124 }125 if !mediaInline(typ) {126 typ = "application/octet-stream"127 }128 w.Header().Set("Content-Type", typ)129 if typ == "application/octet-stream" {130 w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": entry.SHA256}))131 } else {132 w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.SHA256}))133 }134 if seekable {135 http.ServeContent(w, r, entry.SHA256, time.Unix(entry.Uploaded, 0), seeker)136 return nil137 }138 if r.Method == http.MethodHead {139 return nil140 }141 _, err = io.Copy(w, body)142 return err143}