tinyrelayfile
tinyrelay/internal/daemon/data_http.go/

internal/daemon/data_http.go raw
1package daemon
2
3import (
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"
17
18 "github.com/FelineStateMachine/tinyrelay/internal/event"
19 "github.com/FelineStateMachine/tinyrelay/internal/replication"
20)
21
22// tryDataHTTP handles owner-only import and portable data operations. The
23// 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 false
38 }
39 return true
40}
41
42func (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 err
48 }
49 if len(s.PubKeys) == 1 {
50 actor = s.PubKeys[0]
51 }
52 } else if cookieAllowed {
53 var err error
54 actor, err = t.cookieActor(r)
55 if err != nil {
56 return err
57 }
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 err
65 }
66 if banned {
67 return errors.New("blocked: this pubkey is banned")
68 }
69 return nil
70}
71
72func (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 return
77 }
78 if err := t.ownerDataAuth(r, body, false); err != nil {
79 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})
80 return
81 }
82 events, err := parseJSONLEvents(body)
83 if err != nil {
84 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
85 return
86 }
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 return
91 }
92 }
93 writeJSON(w, http.StatusOK, map[string]any{"result": map[string]any{"imported": len(events)}})
94}
95
96func readUnbounded(reader io.Reader) ([]byte, error) {
97 return io.ReadAll(reader)
98}
99
100func 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.Event
104 for line := 1; scanner.Scan(); line++ {
105 if strings.TrimSpace(scanner.Text()) == "" {
106 continue
107 }
108 var item event.Event
109 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, err
119 }
120 return out, nil
121}
122
123func (t *Tenant) readBackup(body []byte) (replication.BackupArchive, error) {
124 return replication.ReadCompatibleBackup(bytes.NewReader(body))
125}
126
127func (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 return
132 }
133 if err := t.ownerDataAuth(r, body, false); err != nil {
134 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})
135 return
136 }
137 archive, err := t.readBackup(body)
138 if err != nil {
139 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
140 return
141 }
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 return
146 }
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}
149
150func (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 return
155 }
156 if err := t.ownerDataAuth(r, body, false); err != nil {
157 writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})
158 return
159 }
160 archive, err := t.readBackup(body)
161 if err != nil {
162 writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
163 return
164 }
165 ctx, done, admissionErr := t.beginMaintenance(r.Context())
166 if admissionErr != nil {
167 http.Error(w, admissionErr.Error(), http.StatusServiceUnavailable)
168 return
169 }
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 return
176 }
177 fresh, err := t.restoreTargetFresh(r.Context())
178 if err != nil {
179 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
180 return
181 }
182 if !fresh {
183 writeJSON(w, http.StatusConflict, map[string]string{"error": "restore target is not fresh"})
184 return
185 }
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 return
190 }
191 writeJSON(w, http.StatusOK, map[string]any{"result": map[string]any{"restored": count}})
192}
193
194func (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 return
198 }
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 return
206 }
207 root, err := filepath.EvalSymlinks(t.meta.Paths.Root)
208 if err != nil {
209 http.NotFound(w, r)
210 return
211 }
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 return
217 }
218 handle, err := os.Open(resolved)
219 if err != nil {
220 http.NotFound(w, r)
221 return
222 }
223 defer handle.Close()
224 info, err := handle.Stat()
225 if err != nil {
226 http.NotFound(w, r)
227 return
228 }
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}
234
235func 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.Owner
244 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.Owner
252 }
253 if len(owner) != 64 || strings.Trim(owner, "0123456789abcdef") != "" {
254 return "", errors.New("backup owner is invalid")
255 }
256 return owner, nil
257}
258
259// 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 are
262// 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 bool
266 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 blobs
269 UNION ALL SELECT 1 FROM community_members WHERE pubkey<>?
270 )`, t.records.PublicKey(), t.Policy().Owner).Scan(&occupied); err != nil {
271 return false, err
272 }
273 if occupied {
274 return false, nil
275 }
276 for _, root := range []string{filepath.Join(t.meta.Paths.Root, "blobs"), t.meta.Paths.Git} {
277 found := false
278 err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
279 if os.IsNotExist(err) {
280 return nil
281 }
282 if err != nil {
283 return err
284 }
285 if path == filepath.Join(t.meta.Paths.Git, ".tinyrelay", "grasp-progress.json") {
286 return nil
287 }
288 if !entry.IsDir() {
289 found = true
290 return filepath.SkipAll
291 }
292 return nil
293 })
294 if err != nil {
295 return false, err
296 }
297 if found {
298 return false, nil
299 }
300 }
301 return true, nil
302}
303
304func withinPath(root, candidate string) bool {
305 rel, err := filepath.Rel(root, candidate)
306 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
307}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs