clone

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

feat/ui-updates
tinyrelay / internal / daemon / custom_views_http.go
custom_views_http.go 3.4K
1package daemon23// GET /views/<name>/<hash>[.<svg|png>] serves a custom view artifact under a4// sandbox policy. Reads require both the view's audience and a visible source.56import (7 "crypto/sha256"8 "encoding/hex"9 "net/http"10 "strconv"11 "strings"1213 "github.com/FelineStateMachine/tinyrelay/internal/relay"14 "github.com/FelineStateMachine/tinyrelay/internal/views"15)1617func isViewArtifactPath(path string) bool {18 return strings.HasPrefix(path, "/views/")19}2021func (t *customViewService) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {22 ctx, finish := t.telemetry.Start(r.Context(), "view")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", http.StatusMethodNotAllowed)28 outcome = "invalid"29 return30 }31 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/views/"), "/")32 if len(parts) != 2 {33 http.NotFound(w, r)34 outcome = "invalid"35 return36 }37 name := parts[0]38 hash, extension, hasExtension := strings.Cut(parts[1], ".")39 if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (hasExtension && extension != "svg" && extension != "png") {40 http.NotFound(w, r)41 outcome = "invalid"42 return43 }44 actor, err := t.resolveActor(r)45 if err != nil {46 http.Error(w, err.Error(), http.StatusUnauthorized)47 outcome = "unauthorized"48 return49 }50 record, err := t.customViewArtifact(ctx, name, hash)51 if err != nil {52 if strings.HasPrefix(err.Error(), "not found:") {53 http.NotFound(w, r)54 outcome = "invalid"55 return56 }57 http.Error(w, err.Error(), http.StatusInternalServerError)58 return59 }60 wantType := map[string]string{"svg": "image/svg+xml", "png": "image/png"}[extension]61 if extension != "" && record.Type != wantType {62 http.NotFound(w, r)63 outcome = "invalid"64 return65 }66 if record.Audience == "members" {67 role, err := t.community.Role(ctx, actor)68 if err != nil {69 http.Error(w, err.Error(), http.StatusInternalServerError)70 return71 }72 if actor == "" {73 http.Error(w, "auth-required: this view is for members", http.StatusUnauthorized)74 outcome = "unauthorized"75 return76 }77 if role != "member" && role != "moderator" && role != "owner" {78 http.Error(w, "restricted: this view is for members", http.StatusForbidden)79 outcome = "unauthorized"80 return81 }82 } else if err := t.browseRead(ctx, actor); err != nil {83 browseHTTPError(w, err)84 outcome = "unauthorized"85 return86 }87 if !t.artifactHTTPSourceVisible(ctx, name, hash, relay.Session{PubKeys: []string{actor}, RelayURL: t.publicURL}) {88 http.NotFound(w, r)89 outcome = "invalid"90 return91 }92 body, err := record.body()93 if err != nil {94 http.Error(w, "artifact is unreadable", http.StatusInternalServerError)95 return96 }97 w.Header().Set("Content-Type", record.Type)98 w.Header().Set("Content-Length", strconv.Itoa(len(body)))99 w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")100 w.Header().Set("X-Content-Type-Options", "nosniff")101 etagBytes := sha256.Sum256(body)102 etag := `"` + hex.EncodeToString(etagBytes[:]) + `"`103 w.Header().Set("ETag", etag)104 w.Header().Set("Cache-Control", "private, no-cache")105 outcome = "ok"106 if r.Header.Get("If-None-Match") == etag {107 w.WriteHeader(http.StatusNotModified)108 return109 }110 if r.Method == http.MethodHead {111 w.WriteHeader(http.StatusOK)112 return113 }114 _, _ = w.Write(body)115}