clone

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

feat/standalone-relay
tinyrelay / internal / daemon / data_http.go
data_http.go 9.4K
1package daemon23import (4 "bufio"5 "bytes"6 "context"7 "encoding/json"8 "errors"9 "fmt"10 "io"11 "mime"12 "net/http"13 "os"14 "path/filepath"15 "strings"16 "time"1718 "github.com/FelineStateMachine/tinyrelay/internal/event"19 "github.com/FelineStateMachine/tinyrelay/internal/replication"20)2122// tryDataHTTP handles owner-only import and portable data operations. The23// caller should invoke it after tenant/session setup and before the public UI.24func (t *Tenant) tryDataHTTP(w http.ResponseWriter, r *http.Request) bool {25 switch {26 case r.Method == http.MethodPut && r.URL.Path == "/import":27 t.importHTTP(w, r)28 case r.Method == http.MethodPost && r.URL.Path == "/backups/preview":29 t.backupPreviewHTTP(w, r)30 case r.Method == http.MethodPost && r.URL.Path == "/backups/restore":31 t.backupRestoreHTTP(w, r)32 case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/dumps/"):33 t.downloadArtifactHTTP(w, r, ".jsonl")34 case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/backups/"):35 t.downloadArtifactHTTP(w, r, ".backup.json")36 default:37 return false38 }39 return true40}4142func (t *Tenant) ownerDataAuth(r *http.Request, body []byte, cookieAllowed bool) error {43 actor := ""44 if r.Header.Get("Authorization") != "" {45 s, err := t.session(r, body)46 if err != nil {47 return err48 }49 if len(s.PubKeys) == 1 {50 actor = s.PubKeys[0]51 }52 } else if cookieAllowed {53 var err error54 actor, err = t.cookieActor(r)55 if err != nil {56 return err57 }58 }59 if actor == "" || actor != t.Policy().Owner {60 return errors.New("restricted: owner required")61 }62 banned, err := t.community.IsBanned(r.Context(), actor)63 if err != nil {64 return err65 }66 if banned {67 return errors.New("blocked: this pubkey is banned")68 }69 return nil70}7172func (t *Tenant) importHTTP(w http.ResponseWriter, r *http.Request) {73 body, err := t.requestBody(w, r)74 if err != nil {75 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})76 return77 }78 if err := t.ownerDataAuth(r, body, false); err != nil {79 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})80 return81 }82 events, err := parseJSONLEvents(body)83 if err != nil {84 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})85 return86 }87 for i, item := range events {88 if err := t.ingest(r.Context(), item, replication.OriginImport); err != nil {89 writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "stored": i})90 return91 }92 }93 writeJSON(w, http.StatusOK, map[string]any{"result": map[string]any{"imported": len(events)}})94}9596func readUnbounded(reader io.Reader) ([]byte, error) {97 return io.ReadAll(reader)98}99100func parseJSONLEvents(raw []byte) ([]event.Event, error) {101 scanner := bufio.NewScanner(bytes.NewReader(raw))102 scanner.Buffer(make([]byte, 64*1024), len(raw)+1)103 var out []event.Event104 for line := 1; scanner.Scan(); line++ {105 if strings.TrimSpace(scanner.Text()) == "" {106 continue107 }108 var item event.Event109 if err := json.Unmarshal(scanner.Bytes(), &item); err != nil {110 return nil, fmt.Errorf("invalid import line %d: %w", line, err)111 }112 if err := event.Validate(item); err != nil {113 return nil, fmt.Errorf("invalid import line %d: %w", line, err)114 }115 out = append(out, item)116 }117 if err := scanner.Err(); err != nil {118 return nil, err119 }120 return out, nil121}122123func (t *Tenant) readBackup(body []byte) (replication.BackupArchive, error) {124 return replication.ReadCompatibleBackup(bytes.NewReader(body))125}126127func (t *Tenant) backupPreviewHTTP(w http.ResponseWriter, r *http.Request) {128 body, err := t.requestBody(w, r)129 if err != nil {130 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})131 return132 }133 if err := t.ownerDataAuth(r, body, false); err != nil {134 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})135 return136 }137 archive, err := t.readBackup(body)138 if err != nil {139 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})140 return141 }142 owner, err := backupOwner(archive)143 if err != nil || owner != t.Policy().Owner {144 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "backup owner does not match this relay"})145 return146 }147 writeJSON(w, http.StatusOK, map[string]any{"result": map[string]any{"format": archive.Format, "createdAt": archive.Created, "events": len(archive.Events), "hasState": len(archive.State.Database) > 0 || len(archive.State.Config) > 0}})148}149150func (t *Tenant) backupRestoreHTTP(w http.ResponseWriter, r *http.Request) {151 body, err := t.requestBody(w, r)152 if err != nil {153 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})154 return155 }156 if err := t.ownerDataAuth(r, body, false); err != nil {157 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})158 return159 }160 archive, err := t.readBackup(body)161 if err != nil {162 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})163 return164 }165 ctx, done, admissionErr := t.beginMaintenance(r.Context())166 if admissionErr != nil {167 http.Error(w, admissionErr.Error(), http.StatusServiceUnavailable)168 return169 }170 defer done()171 r = r.WithContext(ctx)172 owner, err := backupOwner(archive)173 if err != nil || owner != t.Policy().Owner {174 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "backup owner does not match this relay"})175 return176 }177 fresh, err := t.restoreTargetFresh(r.Context())178 if err != nil {179 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})180 return181 }182 if !fresh {183 writeJSON(w, http.StatusConflict, map[string]string{"error": "restore target is not fresh"})184 return185 }186 count, err := replication.RestoreBackupWithProvider(r.Context(), t.store, t.ReplicationBackupProvider(), archive, time.Now().Unix())187 if err != nil {188 writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "restored": count})189 return190 }191 writeJSON(w, http.StatusOK, map[string]any{"result": map[string]any{"restored": count}})192}193194func (t *Tenant) downloadArtifactHTTP(w http.ResponseWriter, r *http.Request, suffix string) {195 if err := t.ownerDataAuth(r, nil, true); err != nil {196 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})197 return198 }199 name := strings.TrimPrefix(r.URL.Path, "/dumps/")200 if suffix == ".backup.json" {201 name = strings.TrimPrefix(r.URL.Path, "/backups/")202 }203 if name != filepath.Base(name) || name == "." || name == "" || !strings.HasSuffix(name, suffix) || strings.Contains(name, "..") {204 http.NotFound(w, r)205 return206 }207 root, err := filepath.EvalSymlinks(t.meta.Paths.Root)208 if err != nil {209 http.NotFound(w, r)210 return211 }212 file := filepath.Join(root, name)213 resolved, err := filepath.EvalSymlinks(file)214 if err != nil || !withinPath(root, resolved) {215 http.NotFound(w, r)216 return217 }218 handle, err := os.Open(resolved)219 if err != nil {220 http.NotFound(w, r)221 return222 }223 defer handle.Close()224 info, err := handle.Stat()225 if err != nil {226 http.NotFound(w, r)227 return228 }229 w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))230 w.Header().Set("Cache-Control", "private, no-store")231 w.Header().Set("Content-Type", "application/octet-stream")232 http.ServeContent(w, r, name, info.ModTime(), handle)233}234235func backupOwner(archive replication.BackupArchive) (string, error) {236 var config struct {237 Owner string `json:"owner"`238 Format string `json:"format"`239 }240 if err := json.Unmarshal(archive.State.Config, &config); err != nil {241 return "", errors.New("backup owner is missing")242 }243 owner := config.Owner244 if strings.HasPrefix(config.Format, "bind.ws/relay-config/") {245 var identity struct {246 Owner string `json:"owner"`247 }248 if err := json.Unmarshal(archive.State.Identity, &identity); err != nil {249 return "", errors.New("backup owner is missing")250 }251 owner = identity.Owner252 }253 if len(owner) != 64 || strings.Trim(owner, "0123456789abcdef") != "" {254 return "", errors.New("backup owner is invalid")255 }256 return owner, nil257}258259// restoreTargetFresh deliberately counts stored rows, not visible query results:260// hidden, pending, private, or expired user events still make a target nonempty.261// Only the new relay's automatically signed discovery/membership records are262// exempt: permanent ownership creates those before the restore UI can open.263// The caller holds exclusive maintenance admission through the restore.264func (t *Tenant) restoreTargetFresh(ctx context.Context) (bool, error) {265 var occupied bool266 if err := t.store.DB().QueryRowContext(ctx, `SELECT EXISTS(267 SELECT 1 FROM events WHERE pubkey<>? OR kind NOT IN (0,30166,13534,30078,39000,39001,39002,39003,33534,8000,9000)268 UNION ALL SELECT 1 FROM blobs269 UNION ALL SELECT 1 FROM community_members WHERE pubkey<>?270 )`, t.records.PublicKey(), t.Policy().Owner).Scan(&occupied); err != nil {271 return false, err272 }273 if occupied {274 return false, nil275 }276 for _, root := range []string{filepath.Join(t.meta.Paths.Root, "blobs"), t.meta.Paths.Git} {277 found := false278 err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {279 if os.IsNotExist(err) {280 return nil281 }282 if err != nil {283 return err284 }285 if path == filepath.Join(t.meta.Paths.Git, ".tinyrelay", "grasp-progress.json") {286 return nil287 }288 if !entry.IsDir() {289 found = true290 return filepath.SkipAll291 }292 return nil293 })294 if err != nil {295 return false, err296 }297 if found {298 return false, nil299 }300 }301 return true, nil302}303304func withinPath(root, candidate string) bool {305 rel, err := filepath.Rel(root, candidate)306 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))307}