tinyrelayfile
tinyrelay/internal/blob/http.go/

internal/blob/http.go raw
1package blob
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "os"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/FelineStateMachine/tinyrelay/internal/event"
19)
20
21func (s *Service) serveHTTP(w http.ResponseWriter, r *http.Request) {
22 w.Header().Set("access-control-allow-origin", "*")
23 w.Header().Set("access-control-allow-headers", "authorization, content-type, x-sha-256, x-content-length, x-content-type, upload-type, upload-length, upload-offset")
24 w.Header().Set("access-control-allow-methods", "GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS")
25 w.Header().Set("Access-Control-Expose-Headers", "Allow, X-Reason")
26 if r.Method == http.MethodOptions {
27 if blobPathPattern.MatchString(r.URL.Path) {
28 w.Header().Set("Allow", "GET, HEAD, PUT, PATCH, DELETE, OPTIONS")
29 }
30 w.WriteHeader(http.StatusNoContent)
31 return
32 }
33 if r.URL.Path == "/.well-known/nostr/nip96.json" {
34 s.writeJSON(w, http.StatusOK, s.nip96Info(r))
35 return
36 }
37 if r.URL.Path == "/upload" && r.Method == http.MethodHead {
38 s.uploadHead(w, r)
39 return
40 }
41 if r.URL.Path == "/upload" && r.Method == http.MethodPut {
42 s.upload(w, r)
43 return
44 }
45 if r.URL.Path == "/mirror" && r.Method == http.MethodPut {
46 s.mirror(w, r)
47 return
48 }
49 if r.URL.Path == "/files/metadata" && r.Method == http.MethodPost {
50 s.metadata(w, r)
51 return
52 }
53 if match := blobPathPattern.FindStringSubmatch(r.URL.Path); match != nil && (r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodOptions) {
54 if r.Method == http.MethodPatch || r.Method == http.MethodOptions {
55 s.multipart(w, r, match[1])
56 return
57 }
58 s.pathUpload(w, r, match[1])
59 return
60 }
61 if r.URL.Path == "/report" && r.Method == http.MethodPut {
62 s.report(w, r)
63 return
64 }
65 if strings.HasPrefix(r.URL.Path, "/list/") && r.Method == http.MethodGet {
66 s.list(w, r, strings.TrimPrefix(r.URL.Path, "/list/"))
67 return
68 }
69 if r.URL.Path == "/nip96" {
70 s.nip96(w, r)
71 return
72 }
73 s.blob(w, r)
74}
75
76func (s *Service) metadata(w http.ResponseWriter, r *http.Request) {
77 uploader, err := s.authorize(r, ActionUpload)
78 if err != nil {
79 s.fail(w, http.StatusUnauthorized, err)
80 return
81 }
82 var input struct {
83 Hash string `json:"hash"`
84 Name string `json:"name"`
85 Path string `json:"path"`
86 Purpose string `json:"purpose"`
87 Size int64 `json:"size"`
88 Type string `json:"type"`
89 }
90 body, readErr := io.ReadAll(io.LimitReader(r.Body, 16*1024+1))
91 if readErr != nil || len(body) > 16*1024 {
92 s.fail(w, http.StatusBadRequest, errors.New("invalid: metadata body"))
93 return
94 }
95 decoder := json.NewDecoder(bytes.NewReader(body))
96 decoder.DisallowUnknownFields()
97 if err := decoder.Decode(&input); err != nil {
98 s.fail(w, http.StatusBadRequest, errors.New("invalid: metadata body"))
99 return
100 }
101 var extra any
102 if err := decoder.Decode(&extra); err != io.EOF {
103 s.fail(w, http.StatusBadRequest, errors.New("invalid: metadata body"))
104 return
105 }
106 if err := s.SetClaimMetadata(r.Context(), uploader, strings.ToLower(strings.TrimSpace(input.Hash)), input.Name, input.Path, input.Purpose, input.Type, input.Size); err != nil {
107 s.fail(w, statusFor(err), err)
108 return
109 }
110 s.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "hash": strings.ToLower(strings.TrimSpace(input.Hash))})
111}
112
113func (s *Service) upload(w http.ResponseWriter, r *http.Request) {
114 pubkey, err := s.authorize(r, ActionUpload)
115 if err != nil {
116 s.fail(w, http.StatusUnauthorized, err)
117 return
118 }
119 claimed := strings.ToLower(strings.TrimSpace(r.Header.Get("x-sha-256")))
120 if claimed != "" && !shaPattern.MatchString(claimed) {
121 s.fail(w, http.StatusBadRequest, errors.New("invalid: X-SHA-256 must be lowercase hex"))
122 return
123 }
124 name, metadataPath, err := claimMetadata(r.URL.Query().Get("filename"), r.URL.Query().Get("path"))
125 if err != nil {
126 s.fail(w, http.StatusBadRequest, err)
127 return
128 }
129 blob, created, err := s.putValidatedWithMetadata(r.Context(), r.Body, contentType(r.Header.Get("content-type")), pubkey, claimed, name, metadataPath, r.URL.Query().Get("access"), r.URL.Query().Get("purpose"), func(actual string) error {
130 if s.config.ValidateUpload != nil {
131 return s.config.ValidateUpload(r, actual)
132 }
133 return nil
134 }, nil)
135 if err != nil {
136 s.fail(w, statusFor(err), err)
137 return
138 }
139 _ = created
140 s.writeJSON(w, http.StatusOK, s.descriptor(r, blob))
141}
142
143func (s *Service) uploadHead(w http.ResponseWriter, r *http.Request) {
144 sha := strings.ToLower(strings.TrimSpace(r.Header.Get("x-sha-256")))
145 if !shaPattern.MatchString(sha) {
146 s.fail(w, http.StatusBadRequest, errors.New("invalid: X-SHA-256 must be lowercase hex"))
147 return
148 }
149 rawLength := strings.TrimSpace(r.Header.Get("x-content-length"))
150 if rawLength == "" {
151 s.fail(w, http.StatusLengthRequired, errors.New("invalid: X-Content-Length is required"))
152 return
153 }
154 length, err := strconv.ParseInt(rawLength, 10, 64)
155 if err != nil || length < 0 {
156 s.fail(w, http.StatusBadRequest, errors.New("invalid: X-Content-Length must be a nonnegative integer"))
157 return
158 }
159 pubkey, err := s.authorize(r, ActionUpload)
160 if err != nil {
161 s.fail(w, http.StatusUnauthorized, err)
162 return
163 }
164 typ := r.Header.Get("x-content-type")
165 if typ == "" {
166 typ = r.Header.Get("content-type")
167 }
168 if strings.TrimSpace(typ) == "" {
169 s.fail(w, http.StatusBadRequest, errors.New("invalid: X-Content-Type is required"))
170 return
171 }
172 if existing, lookupErr := s.lookup(r.Context(), sha); lookupErr == nil && existing.SHA256 != "" && s.hasClaim(r.Context(), sha, pubkey) {
173 w.WriteHeader(http.StatusOK)
174 return
175 }
176 limits := s.currentLimits()
177 if limits.MaxFileBytes > 0 && length > limits.MaxFileBytes {
178 s.fail(w, http.StatusRequestEntityTooLarge, ErrFileTooLarge)
179 return
180 }
181 // HEAD is advisory for deduplicated uploads, whose final physical size is
182 // already present, but it should still reflect the current quota budget.
183 if limits.UserStorageBytes > 0 {
184 if used, quotaErr := s.quotaUsage(r.Context(), pubkey); quotaErr != nil {
185 s.fail(w, http.StatusInternalServerError, quotaErr)
186 return
187 } else if length > limits.UserStorageBytes-used {
188 s.fail(w, http.StatusForbidden, ErrQuotaExceeded)
189 return
190 }
191 }
192 if existing, err := s.lookup(r.Context(), sha); err == nil && existing.SHA256 != "" {
193 w.WriteHeader(http.StatusOK)
194 return
195 }
196 w.WriteHeader(http.StatusOK)
197}
198
199func (s *Service) blob(w http.ResponseWriter, r *http.Request) {
200 path := r.URL.Path
201 if strings.HasPrefix(path, "/nip96/") {
202 path = strings.TrimPrefix(path, "/nip96")
203 }
204 match := blobPathPattern.FindStringSubmatch(path)
205 if match == nil {
206 s.fail(w, http.StatusNotFound, errors.New("not found"))
207 return
208 }
209 sha := match[1]
210 if r.Method == http.MethodDelete {
211 s.delete(w, r, sha)
212 return
213 }
214 if r.Method != http.MethodGet && r.Method != http.MethodHead {
215 s.fail(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
216 return
217 }
218 if s.config.CanRead != nil {
219 pubkey, err := s.authorize(r, ActionGet)
220 if err != nil {
221 s.fail(w, http.StatusUnauthorized, err)
222 return
223 }
224 if !s.config.CanRead(r.Context(), sha, []string{pubkey}) {
225 s.fail(w, http.StatusForbidden, errors.New("restricted: read access denied"))
226 return
227 }
228 }
229 entry, err := s.lookup(r.Context(), sha)
230 if errors.Is(err, sql.ErrNoRows) {
231 s.fail(w, http.StatusNotFound, errors.New("not found"))
232 return
233 }
234 if err != nil {
235 s.fail(w, http.StatusInternalServerError, err)
236 return
237 }
238 if s.blocked(r.Context(), sha) {
239 s.fail(w, http.StatusForbidden, ErrBlocked)
240 return
241 }
242 file, err := os.Open(filepath.Join(s.root, sha))
243 if err != nil {
244 s.fail(w, http.StatusNotFound, errors.New("not found"))
245 return
246 }
247 defer file.Close()
248 w.Header().Set("etag", `"`+sha+`"`)
249 w.Header().Set("Content-Type", entry.Type)
250 w.Header().Set("x-content-type-options", "nosniff")
251 w.Header().Set("accept-ranges", "bytes")
252 w.Header().Set("content-security-policy", "default-src 'none'")
253 if s.config.CanRead == nil {
254 w.Header().Set("cache-control", "public, max-age=31536000, immutable")
255 } else {
256 w.Header().Set("cache-control", "private, no-store")
257 }
258 http.ServeContent(w, r, sha, time.Unix(entry.Uploaded, 0), file)
259}
260
261func (s *Service) delete(w http.ResponseWriter, r *http.Request, sha string) {
262 pubkey, err := s.authorize(r, ActionDelete)
263 if err != nil {
264 s.fail(w, http.StatusUnauthorized, err)
265 return
266 }
267 _, err = s.lookup(r.Context(), sha)
268 if errors.Is(err, sql.ErrNoRows) {
269 s.fail(w, http.StatusNotFound, errors.New("not found"))
270 return
271 }
272 if err != nil {
273 s.fail(w, http.StatusInternalServerError, err)
274 return
275 }
276 if !s.hasClaim(r.Context(), sha, pubkey) && !s.isOwner(pubkey) {
277 s.fail(w, http.StatusForbidden, errors.New("restricted: not the uploader"))
278 return
279 }
280 if s.isOwner(pubkey) {
281 if err := s.Delete(r.Context(), sha); err != nil {
282 s.fail(w, http.StatusInternalServerError, err)
283 return
284 }
285 } else if err := s.DeleteForUploader(r.Context(), sha, pubkey); err != nil {
286 if errors.Is(err, sql.ErrNoRows) {
287 s.fail(w, http.StatusNotFound, errors.New("not found"))
288 return
289 }
290 s.fail(w, http.StatusInternalServerError, err)
291 return
292 }
293 // Owners and uploaders may remove their claim; a shared physical object is
294 // retained until its final claim disappears.
295 w.WriteHeader(http.StatusNoContent)
296}
297
298func (s *Service) list(w http.ResponseWriter, r *http.Request, pubkey string) {
299 if !shaPattern.MatchString(pubkey) {
300 s.fail(w, http.StatusBadRequest, errors.New("invalid: bad pubkey"))
301 return
302 }
303 viewer, err := s.authorize(r, ActionList)
304 if err != nil {
305 s.fail(w, http.StatusUnauthorized, err)
306 return
307 }
308 if viewer != pubkey && !s.isOwner(viewer) {
309 s.fail(w, http.StatusForbidden, errors.New("restricted: cannot list another uploader's blobs"))
310 return
311 }
312 limit, err := parseListLimit(r.URL.Query().Get("limit"))
313 if err != nil {
314 s.fail(w, http.StatusBadRequest, err)
315 return
316 }
317 entries, _, err := s.ListPage(r.Context(), pubkey, limit, strings.TrimSpace(r.URL.Query().Get("cursor")))
318 if err != nil {
319 if errors.Is(err, sql.ErrNoRows) {
320 s.fail(w, http.StatusBadRequest, errors.New("invalid: cursor does not identify a blob"))
321 return
322 }
323 s.fail(w, http.StatusInternalServerError, err)
324 return
325 }
326 descriptors := make([]map[string]any, 0, len(entries))
327 for _, b := range entries {
328 descriptors = append(descriptors, s.descriptor(r, b))
329 }
330 s.writeJSON(w, http.StatusOK, descriptors)
331}
332
333func (s *Service) report(w http.ResponseWriter, r *http.Request) {
334 reporter, err := s.authorize(r, ActionReport)
335 if err != nil {
336 s.fail(w, http.StatusUnauthorized, err)
337 return
338 }
339 var input event.Event
340 if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Kind != 1984 || input.PubKey != reporter {
341 s.fail(w, http.StatusBadRequest, errors.New("invalid: report must be a signed kind 1984 event"))
342 return
343 }
344 if err := event.Validate(input); err != nil || time.Since(time.Unix(input.CreatedAt, 0)) > time.Hour || time.Until(time.Unix(input.CreatedAt, 0)) > time.Hour {
345 s.fail(w, http.StatusBadRequest, errors.New("invalid: report is not a current signed kind 1984 event"))
346 return
347 }
348 filed := 0
349 type reportTarget struct {
350 hash string
351 uploader string
352 }
353 targets := make([]reportTarget, 0, len(input.Tags))
354 for _, tag := range input.Tags {
355 if len(tag) < 2 || tag[0] != "x" || !shaPattern.MatchString(tag[1]) {
356 continue
357 }
358 entry, lookupErr := s.lookup(r.Context(), tag[1])
359 if lookupErr != nil {
360 continue
361 }
362 id := input.ID
363 if filed > 0 {
364 id += "." + strconv.Itoa(filed)
365 }
366 targets = append(targets, reportTarget{hash: tag[1], uploader: entry.Uploader})
367 }
368 if len(targets) == 0 {
369 s.fail(w, http.StatusNotFound, errors.New("not found: relay holds none of the reported blobs"))
370 return
371 }
372 err = s.store.WithTx(r.Context(), func(tx *sql.Tx) error {
373 for i, target := range targets {
374 id := input.ID
375 if i > 0 {
376 id += "." + strconv.Itoa(i)
377 }
378 result, insertErr := tx.ExecContext(r.Context(), `INSERT OR IGNORE INTO blob_reports(id,reporter,target_pubkey,target_blob,type,content,at) VALUES(?,?,?,?,?,?,?)`, id, reporter, target.uploader, target.hash, "blob", input.Content, input.CreatedAt)
379 if insertErr != nil {
380 return insertErr
381 }
382 changed, rowsErr := result.RowsAffected()
383 if rowsErr != nil {
384 return rowsErr
385 }
386 if changed == 0 {
387 continue
388 }
389 if s.config.RecordReport != nil {
390 if err := s.config.RecordReport(r.Context(), tx, reporter, target.hash, "blob", "blob", input.Content); err != nil {
391 return err
392 }
393 }
394 filed++
395 }
396 return nil
397 })
398 if err != nil {
399 s.fail(w, http.StatusInternalServerError, err)
400 return
401 }
402 if filed == 0 {
403 // Repeating an identical report is idempotent and still successful.
404 filed = len(targets)
405 }
406 s.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "filed": filed})
407}
408
409// Block permanently prevents a hash from returning through any upload door.
410func (s *Service) Block(ctx context.Context, sha, reason string) error {
411 if !shaPattern.MatchString(sha) {
412 return errors.New("invalid: bad blob sha256")
413 }
414 _, err := s.store.DB().ExecContext(ctx, "INSERT INTO blob_blocks(sha256,reason,blocked_at) VALUES(?,?,?) ON CONFLICT(sha256) DO UPDATE SET reason=excluded.reason,blocked_at=excluded.blocked_at", sha, reason, time.Now().UTC().Unix())
415 if err != nil {
416 return fmt.Errorf("block blob: %w", err)
417 }
418 if _, err := s.store.DB().ExecContext(ctx, "INSERT INTO blob_tombstones(sha256,deleted_at) VALUES(?,?) ON CONFLICT(sha256) DO UPDATE SET deleted_at=excluded.deleted_at", sha, time.Now().UTC().Unix()); err != nil {
419 return fmt.Errorf("record blocked blob deletion: %w", err)
420 }
421 if _, err := s.store.DB().ExecContext(ctx, "DELETE FROM blobs WHERE sha256=?", sha); err != nil {
422 return fmt.Errorf("remove blocked blob metadata: %w", err)
423 }
424 if err := os.Remove(filepath.Join(s.root, sha)); err != nil && !errors.Is(err, os.ErrNotExist) {
425 return fmt.Errorf("remove blocked blob: %w", err)
426 }
427 return nil
428}
429
430func (s *Service) mirror(w http.ResponseWriter, r *http.Request) {
431 pubkey, err := s.authorize(r, ActionMirror)
432 if err != nil {
433 s.fail(w, http.StatusUnauthorized, err)
434 return
435 }
436 var input struct {
437 URL string `json:"url"`
438 }
439 if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.URL == "" {
440 s.fail(w, http.StatusBadRequest, errors.New("invalid: body needs a url"))
441 return
442 }
443 requestedURL := input.URL
444 expectedHash := ""
445 extension := ""
446 uriServers := []string{}
447 authors := []string{}
448 if strings.HasPrefix(strings.ToLower(requestedURL), "blossom:") {
449 uri, err := ParseBlossomURI(requestedURL)
450 if err != nil {
451 s.fail(w, http.StatusBadRequest, err)
452 return
453 }
454 expectedHash, extension = uri.Hash, uri.Extension
455 uriServers = append(uriServers, uri.Servers...)
456 authors = append(authors, uri.Authors...)
457 requestedURL = ""
458 } else if hash := BlobHashFromURL(requestedURL); hash != "" {
459 expectedHash = hash
460 }
461 client := s.config.HTTPClient
462 if client == nil {
463 client = s.safeClient()
464 }
465 candidates := []string{}
466 if requestedURL != "" {
467 candidates = append(candidates, requestedURL)
468 }
469 candidates = append(candidates, FallbackURLs(uriServers, expectedHash, extension)...)
470 if s.config.ResolveServers != nil && expectedHash != "" {
471 if len(authors) == 0 && pubkey != "" {
472 authors = []string{pubkey}
473 }
474 for _, author := range uniqueStrings(authors) {
475 servers, resolveErr := s.config.ResolveServers(r.Context(), author)
476 if resolveErr != nil {
477 continue
478 }
479 candidates = append(candidates, FallbackURLs(servers, expectedHash, extension)...)
480 }
481 }
482 var lastErr error
483 for _, candidate := range uniqueStrings(candidates) {
484 if err := validateMirrorURL(candidate, s.resolve); err != nil {
485 lastErr = err
486 continue
487 }
488 response, fetchErr := client.Get(candidate)
489 if fetchErr != nil {
490 lastErr = fetchErr
491 continue
492 }
493 if response.StatusCode < 200 || response.StatusCode >= 300 {
494 lastErr = fmt.Errorf("error: origin answered %d", response.StatusCode)
495 _ = response.Body.Close()
496 continue
497 }
498 typ := contentType(response.Header.Get("content-type"))
499 if typ == "application/octet-stream" {
500 typ = typeForExtension(candidate)
501 }
502 entry, created, putErr := s.putValidated(r.Context(), response.Body, typ, pubkey, expectedHash, func(actual string) error {
503 if s.config.ValidateUpload != nil {
504 return s.config.ValidateUpload(r, actual)
505 }
506 return nil
507 })
508 _ = response.Body.Close()
509 if putErr == nil {
510 s.writeJSON(w, chooseStatus(created, http.StatusCreated), s.descriptor(r, entry))
511 return
512 }
513 lastErr = putErr
514 if !errors.Is(putErr, ErrHashMismatch) {
515 s.fail(w, statusFor(putErr), putErr)
516 return
517 }
518 }
519 if lastErr == nil {
520 lastErr = errors.New("error: no mirror origin available")
521 }
522 s.fail(w, statusFor(lastErr), lastErr)
523}
524
525func (s *Service) nip96(w http.ResponseWriter, r *http.Request) {
526 if r.Method == http.MethodGet {
527 pubkey, err := s.authorize(r, ActionList)
528 if err != nil {
529 s.fail(w, http.StatusUnauthorized, err)
530 return
531 }
532 s.nip96List(w, r, pubkey)
533 return
534 }
535 if r.Method != http.MethodPost {
536 s.fail(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
537 return
538 }
539 pubkey, err := s.authorize(r, ActionUpload)
540 if err != nil {
541 s.fail(w, http.StatusUnauthorized, err)
542 return
543 }
544 if err := r.ParseMultipartForm(0); err != nil {
545 s.fail(w, http.StatusBadRequest, errors.New("invalid: body is not multipart form data"))
546 return
547 }
548 defer r.MultipartForm.RemoveAll()
549 file, header, err := r.FormFile("file")
550 if err != nil {
551 s.fail(w, http.StatusBadRequest, errors.New("invalid: form needs a file field"))
552 return
553 }
554 defer file.Close()
555 typ := contentType(header.Header.Get("Content-Type"))
556 if typ == "application/octet-stream" {
557 typ = contentType(r.FormValue("content_type"))
558 }
559 entry, created, err := s.put(r.Context(), file, typ, pubkey, "")
560 if err != nil {
561 s.fail(w, statusFor(err), err)
562 return
563 }
564 s.writeJSON(w, chooseStatus(created, http.StatusCreated), map[string]any{"status": "success", "message": "Upload successful.", "url": s.blobURL(r, entry), "nip94_event": map[string]any{"tags": s.nip94Tags(r, entry), "content": r.FormValue("caption")}})
565}
566
567func (s *Service) nip96List(w http.ResponseWriter, r *http.Request, pubkey string) {
568 count := parsePageCount(r.URL.Query().Get("count"))
569 page := parsePage(r.URL.Query().Get("page"))
570 var total int
571 if err := s.store.DB().QueryRowContext(r.Context(), "SELECT count(*) FROM blob_claims WHERE uploader=?", pubkey).Scan(&total); err != nil {
572 s.fail(w, http.StatusInternalServerError, err)
573 return
574 }
575 rows, err := s.store.DB().QueryContext(r.Context(), `SELECT b.sha256,b.size,b.type,c.uploader,b.uploaded
576 FROM blobs b JOIN blob_claims c ON c.sha256=b.sha256 WHERE c.uploader=?
577 ORDER BY b.uploaded DESC,b.sha256 DESC LIMIT ? OFFSET ?`, pubkey, count, page*count)
578 if err != nil {
579 s.fail(w, http.StatusInternalServerError, err)
580 return
581 }
582 defer rows.Close()
583 files := make([]map[string]any, 0)
584 for rows.Next() {
585 var b Blob
586 if err := rows.Scan(&b.SHA256, &b.Size, &b.Type, &b.Uploader, &b.Uploaded); err != nil {
587 s.fail(w, http.StatusInternalServerError, err)
588 return
589 }
590 files = append(files, map[string]any{"tags": s.nip94Tags(r, b), "content": "", "created_at": b.Uploaded})
591 }
592 s.writeJSON(w, http.StatusOK, map[string]any{"count": count, "total": total, "page": page, "files": files})
593}
594
595func (s *Service) authorize(r *http.Request, action Action) (string, error) {
596 if s.config.Authorize == nil {
597 return "", errors.New("auth-required: authorization is required")
598 }
599 return s.config.Authorize(r, action)
600}
601func (s *Service) isOwner(pubkey string) bool {
602 return s.config.IsOwner != nil && s.config.IsOwner(pubkey)
603}
604func (s *Service) descriptor(r *http.Request, b Blob) map[string]any {
605 return map[string]any{"url": s.blobURL(r, b), "sha256": b.SHA256, "size": b.Size, "type": b.Type, "uploaded": b.Uploaded, "nip94": s.nip94Tags(r, b)}
606}
607func (s *Service) nip94Tags(r *http.Request, b Blob) [][]string {
608 return [][]string{{"url", s.blobURL(r, b)}, {"m", b.Type}, {"x", b.SHA256}, {"ox", b.SHA256}, {"size", strconv.FormatInt(b.Size, 10)}}
609}
610func (s *Service) blobURL(r *http.Request, b Blob) string {
611 return s.origin(r) + "/" + b.SHA256 + extensionSuffix(b.Type)
612}
613func (s *Service) nip96Info(r *http.Request) map[string]any {
614 return map[string]any{"api_url": s.origin(r) + "/nip96", "download_url": s.origin(r), "supported_nips": []int{94, 98}, "content_types": contentTypes(), "plans": map[string]any{"self-hosted": map[string]any{"name": "Self-hosted", "is_nip98_required": true, "max_byte_size": 0, "file_expiration": []int{0, 0}, "media_transformations": map[string]any{}}}}
615}
616func (s *Service) writeJSON(w http.ResponseWriter, status int, value any) {
617 w.Header().Set("content-type", "application/json")
618 w.WriteHeader(status)
619 _ = json.NewEncoder(w).Encode(value)
620}
621func (s *Service) fail(w http.ResponseWriter, status int, err error) {
622 w.Header().Set("x-reason", err.Error())
623 s.writeJSON(w, status, map[string]string{"error": err.Error()})
624}
625func origin(r *http.Request) string {
626 scheme := r.URL.Scheme
627 if scheme == "" {
628 scheme = "http"
629 if r.TLS != nil {
630 scheme = "https"
631 }
632 }
633 return scheme + "://" + r.Host
634}
635
636func (s *Service) origin(r *http.Request) string {
637 if s.config.PublicURL != "" {
638 return strings.TrimRight(s.config.PublicURL, "/")
639 }
640 return origin(r)
641}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs