// sia-share serves files that people share from their own Sia account at // navigate.st/sia//, so anyone can open them in a browser. // // Pithos makes a Sia shared-object URL for a file (it can read that one // object and nothing else in the account) and registers it here; this // service keeps a short id for it and streams the file from Sia hosts on // request, with byte ranges so audio and video can seek. The data stays in // the sharer's account; reading it from hosts is paid by this service's own // app on Silent Mode's indexer account. // // sia-share register --data /var/lib/sia-share one-time: prints an approval link // sia-share serve --data /var/lib/sia-share the HTTP service package main import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "html" "io" "log" "mime" "net" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "go.sia.tech/core/types" sdk "go.sia.tech/siastorage" "go.uber.org/zap" ) // The app's identity at the indexer. NEVER change it: keys registered under // it stop working. var appMeta = sdk.AppMetadata{ ID: mustHash("ed4ccccf5e8912c4e91e37ce5842ded282ffde68accd39fe1265a46903749dc2"), Name: "Pithos share", Description: "Serves files shared from Pithos at navigate.st/sia", ServiceURL: "https://navigate.st/sia", } func mustHash(s string) (h types.Hash256) { b, err := hex.DecodeString(s) if err != nil || len(b) != 32 { panic("bad app id") } copy(h[:], b) return } type config struct { Listen string `json:"listen"` Indexer string `json:"indexer"` PublicBase string `json:"publicBase"` // https://navigate.st/sia AllowedIndexers []string `json:"allowedIndexers"` // hosts share URLs may point at PerIPPerDay int `json:"perIpPerDay"` // new links per client address per day DailyBytes int64 `json:"dailyBytesPerLink"` MaxFiles int `json:"maxFilesPerFolder"` } type file struct { Name string `json:"name"` URL string `json:"url"` // shared-object URL, with #encryption_key Size int64 `json:"size"` } type link struct { ID string `json:"id"` Name string `json:"name"` Files []file `json:"files"` // one entry for a single file Folder bool `json:"folder"` Created int64 `json:"created"` DeleteHash string `json:"deleteHash"` Day string `json:"day"` DayBytes int64 `json:"dayBytes"` } type store struct { mu sync.Mutex file string links map[string]*link ips map[string][]int64 } func (s *store) load() error { s.links = map[string]*link{} s.ips = map[string][]int64{} b, err := os.ReadFile(s.file) if errors.Is(err, os.ErrNotExist) { return nil } else if err != nil { return err } return json.Unmarshal(b, &s.links) } func (s *store) save() error { b, _ := json.MarshalIndent(s.links, "", " ") tmp := s.file + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, s.file) } func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: sia-share register|serve --data [--config ]") os.Exit(2) } fs := flag.NewFlagSet(os.Args[1], flag.ExitOnError) dataDir := fs.String("data", "/var/lib/sia-share", "state folder (app key, links)") cfgFile := fs.String("config", "/etc/sia-share.json", "config file") fs.Parse(os.Args[2:]) cfg := config{Listen: "127.0.0.1:8730", Indexer: "https://sia.storage", PublicBase: "https://navigate.st/sia", AllowedIndexers: []string{"sia.storage"}, PerIPPerDay: 30, DailyBytes: 20 << 30, MaxFiles: 1000} if b, err := os.ReadFile(*cfgFile); err == nil { if err := json.Unmarshal(b, &cfg); err != nil { log.Fatalf("config: %v", err) } } switch os.Args[1] { case "register": register(cfg, *dataDir) case "serve": serve(cfg, *dataDir) default: log.Fatalf("unknown command %q", os.Args[1]) } } // register connects this service to the indexer as its own app. The person // who owns the paying account opens the printed link once and approves it. func register(cfg config, dataDir string) { keyFile := filepath.Join(dataDir, "app.key") if _, err := os.Stat(keyFile); err == nil { log.Fatalf("%s exists; delete it first to register again", keyFile) } if err := os.MkdirAll(dataDir, 0o700); err != nil { log.Fatal(err) } ctx := context.Background() b := sdk.NewBuilder(cfg.Indexer, appMeta) approvalURL, err := b.RequestConnection(ctx) if err != nil { log.Fatalf("request connection: %v", err) } fmt.Printf("Open this link, sign in to the account that pays for downloads, and approve \"%s\":\n\n %s\n\nWaiting for approval…\n", appMeta.Name, approvalURL) if err := b.WaitForApproval(ctx); err != nil { log.Fatalf("approval: %v", err) } phrase := sdk.NewSeedPhrase() s, err := b.Register(ctx, phrase) if err != nil { log.Fatalf("register: %v", err) } key := s.AppKey() if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(key[:])), 0o600); err != nil { log.Fatal(err) } fmt.Printf("Registered. App key saved to %s (keep it private; it pays for downloads).\n", keyFile) } func loadAppKey(dataDir string) types.PrivateKey { b, err := os.ReadFile(filepath.Join(dataDir, "app.key")) if err != nil { log.Fatalf("no app key: run 'sia-share register' first (%v)", err) } raw, err := hex.DecodeString(strings.TrimSpace(string(b))) if err != nil || len(raw) != 64 { log.Fatal("app.key is not a 64-byte hex key") } return types.PrivateKey(raw) } var ( sharedPath = regexp.MustCompile(`^/objects/[0-9a-f]{64}/shared$`) idRe = regexp.MustCompile(`^[A-Za-z0-9_-]{6,32}$`) ) func serve(cfg config, dataDir string) { appKey := loadAppKey(dataDir) client, err := sdk.NewBuilder(cfg.Indexer, appMeta).SDK(appKey, sdk.WithLogger(zap.NewNop())) if err != nil { log.Fatalf("connect to indexer: %v", err) } st := &store{file: filepath.Join(dataDir, "links.json")} if err := st.load(); err != nil { log.Fatalf("links: %v", err) } h := &handler{cfg: cfg, sdk: client, st: st} mux := http.NewServeMux() mux.HandleFunc("POST /api/sia/links", h.create) mux.HandleFunc("DELETE /api/sia/links/{id}", h.remove) mux.HandleFunc("GET /sia/{id}", h.get) mux.HandleFunc("HEAD /sia/{id}", h.get) mux.HandleFunc("GET /sia/{id}/{rest...}", h.get) mux.HandleFunc("HEAD /sia/{id}/{rest...}", h.get) srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 20 * time.Second} log.Printf("sia-share on %s for %s", cfg.Listen, cfg.PublicBase) log.Fatal(srv.ListenAndServe()) } type handler struct { cfg config sdk *sdk.SDK st *store } func jsonOut(w http.ResponseWriter, status int, v any) { w.Header().Set("content-type", "application/json; charset=utf-8") w.Header().Set("cache-control", "no-store") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func clientIP(r *http.Request) string { if xf := r.Header.Get("X-Forwarded-For"); xf != "" { return strings.TrimSpace(strings.Split(xf, ",")[0]) } host, _, _ := net.SplitHostPort(r.RemoteAddr) return host } func randomID(n int) string { b := make([]byte, n) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func cleanName(s string) string { s = strings.TrimSpace(strings.ReplaceAll(s, "\\", "/")) s = path.Base("/" + s) if s == "/" || s == "." || s == ".." || len(s) > 255 { return "" } return s } // sizeOf checks a shared-object URL against its indexer and returns the // object's size. Only URLs on an allowed indexer, in the shared-object form // and carrying a key, are accepted. func (h *handler) sizeOf(ctx context.Context, raw string) (int64, error) { u, err := url.Parse(raw) if err != nil || u.Scheme != "https" || !sharedPath.MatchString(u.Path) { return 0, errors.New("not a Sia shared-object URL") } allowed := false for _, a := range h.cfg.AllowedIndexers { if strings.EqualFold(u.Host, a) { allowed = true } } if !allowed { return 0, fmt.Errorf("indexer %q is not accepted here", u.Host) } if v, _ := url.ParseQuery(u.Fragment); len(v.Get("encryption_key")) < 40 { return 0, errors.New("the URL has no encryption key") } q := *u q.Fragment = "" req, _ := http.NewRequestWithContext(ctx, http.MethodGet, q.String(), nil) req.Header.Set("accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return 0, fmt.Errorf("indexer: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return 0, fmt.Errorf("indexer refused the link (%s); it may have expired", resp.Status) } var obj struct { Slabs []struct { Length int64 `json:"length"` } `json:"slabs"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&obj); err != nil { return 0, fmt.Errorf("indexer: %w", err) } var n int64 for _, s := range obj.Slabs { n += s.Length } return n, nil } // POST /api/sia/links {name, url} for one file, or {name, folder: true, // files: [{name, url}]} for a folder. Answers {id, url, deleteToken}. func (h *handler) create(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` URL string `json:"url"` Folder bool `json:"folder"` Files []file `json:"files"` } if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&body); err != nil { jsonOut(w, 400, map[string]string{"error": "invalid JSON"}) return } ip := clientIP(r) now := time.Now().Unix() h.st.mu.Lock() recent := []int64{} for _, t := range h.st.ips[ip] { if now-t < 86400 { recent = append(recent, t) } } if h.cfg.PerIPPerDay > 0 && len(recent) >= h.cfg.PerIPPerDay { h.st.mu.Unlock() jsonOut(w, 429, map[string]string{"error": "too many new links from this address today"}) return } h.st.mu.Unlock() files := body.Files if !body.Folder { files = []file{{Name: body.Name, URL: body.URL}} } if len(files) == 0 || len(files) > h.cfg.MaxFiles { jsonOut(w, 400, map[string]string{"error": fmt.Sprintf("between 1 and %d files", h.cfg.MaxFiles)}) return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() seen := map[string]bool{} for i := range files { files[i].Name = cleanName(files[i].Name) if files[i].Name == "" || seen[files[i].Name] { jsonOut(w, 400, map[string]string{"error": "every file needs a distinct name"}) return } seen[files[i].Name] = true n, err := h.sizeOf(ctx, files[i].URL) if err != nil { jsonOut(w, 400, map[string]string{"error": fmt.Sprintf("%s: %v", files[i].Name, err)}) return } files[i].Size = n } name := cleanName(body.Name) if name == "" { name = files[0].Name } token := randomID(24) sum := sha256.Sum256([]byte(token)) l := &link{ID: randomID(6), Name: name, Files: files, Folder: body.Folder, Created: now, DeleteHash: hex.EncodeToString(sum[:])} h.st.mu.Lock() for h.st.links[l.ID] != nil { l.ID = randomID(6) } h.st.links[l.ID] = l h.st.ips[ip] = append(recent, now) err := h.st.save() h.st.mu.Unlock() if err != nil { jsonOut(w, 500, map[string]string{"error": "could not save the link"}) return } public := h.cfg.PublicBase + "/" + l.ID + "/" if !l.Folder { public += url.PathEscape(files[0].Name) } log.Printf("[%s] new %s link %q (%d file(s))", l.ID, map[bool]string{true: "folder", false: "file"}[l.Folder], name, len(files)) jsonOut(w, 200, map[string]any{"id": l.ID, "url": public, "deleteToken": token}) } // DELETE /api/sia/links/{id} with header X-Delete-Token: the link stops working here. func (h *handler) remove(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") sum := sha256.Sum256([]byte(r.Header.Get("X-Delete-Token"))) h.st.mu.Lock() defer h.st.mu.Unlock() l := h.st.links[id] if l == nil || l.DeleteHash != hex.EncodeToString(sum[:]) { jsonOut(w, 404, map[string]string{"error": "no such link"}) return } delete(h.st.links, id) h.st.save() jsonOut(w, 200, map[string]bool{"ok": true}) } func (h *handler) get(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !idRe.MatchString(id) { http.NotFound(w, r) return } h.st.mu.Lock() l := h.st.links[id] h.st.mu.Unlock() if l == nil { notFound(w) return } rest := r.PathValue("rest") if l.Folder { if rest == "" { if !strings.HasSuffix(r.URL.Path, "/") { http.Redirect(w, r, id+"/", http.StatusMovedPermanently) return } folderPage(w, l) return } for _, f := range l.Files { if f.Name == rest { h.stream(w, r, l, f) return } } notFound(w) return } h.stream(w, r, l, l.Files[0]) } func notFound(w http.ResponseWriter) { w.Header().Set("content-type", "text/html; charset=utf-8") w.WriteHeader(404) io.WriteString(w, `Not found

This shared link does not exist, or its owner switched it off.

`) } func parseRange(hdr string, size int64) (off, length int64, partial, ok bool) { if hdr == "" { return 0, size, false, true } m := regexp.MustCompile(`^bytes=(\d*)-(\d*)$`).FindStringSubmatch(strings.TrimSpace(hdr)) if m == nil { return 0, size, false, true // unsupported forms: whole file } if m[1] == "" { // suffix: last N bytes n, err := strconv.ParseInt(m[2], 10, 64) if err != nil || n <= 0 { return 0, 0, false, false } if n > size { n = size } return size - n, n, true, true } start, err := strconv.ParseInt(m[1], 10, 64) if err != nil || start >= size { return 0, 0, false, false } end := size - 1 if m[2] != "" { if e, err := strconv.ParseInt(m[2], 10, 64); err == nil && e < end { end = e } } if end < start { return 0, 0, false, false } return start, end - start + 1, true, true } // stream sends one file from Sia, honouring Range, under the link's daily cap. // Every file is served sandboxed: a shared HTML or SVG file cannot run script // on navigate.st. func (h *handler) stream(w http.ResponseWriter, r *http.Request, l *link, f file) { off, length, partial, ok := parseRange(r.Header.Get("Range"), f.Size) if !ok { w.Header().Set("content-range", fmt.Sprintf("bytes */%d", f.Size)) w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } today := time.Now().UTC().Format("2006-01-02") h.st.mu.Lock() if l.Day != today { l.Day, l.DayBytes = today, 0 } over := h.cfg.DailyBytes > 0 && l.DayBytes+length > h.cfg.DailyBytes if !over && r.Method != http.MethodHead { l.DayBytes += length } h.st.mu.Unlock() if over { w.Header().Set("retry-after", "3600") http.Error(w, "this link has used today's download allowance; try again tomorrow", http.StatusTooManyRequests) return } ct := mime.TypeByExtension(strings.ToLower(path.Ext(f.Name))) if ct == "" { ct = "application/octet-stream" } hd := w.Header() hd.Set("content-type", ct) hd.Set("accept-ranges", "bytes") hd.Set("content-length", strconv.FormatInt(length, 10)) hd.Set("content-disposition", mime.FormatMediaType("inline", map[string]string{"filename": f.Name})) hd.Set("content-security-policy", "sandbox") hd.Set("x-content-type-options", "nosniff") hd.Set("cache-control", "public, max-age=300") status := http.StatusOK if partial { hd.Set("content-range", fmt.Sprintf("bytes %d-%d/%d", off, off+length-1, f.Size)) status = http.StatusPartialContent } if r.Method == http.MethodHead || length == 0 { w.WriteHeader(status) return } rc, err := h.sdk.DownloadSharedObject(r.Context(), f.URL, sdk.WithDownloadRange(uint64(off), uint64(length))) if err != nil { log.Printf("[%s] %s: %v", l.ID, f.Name, err) http.Error(w, "could not read the file from Sia right now", http.StatusBadGateway) return } defer rc.Close() w.WriteHeader(status) if _, err := io.Copy(w, rc); err != nil && !errors.Is(err, context.Canceled) { log.Printf("[%s] %s: stream: %v", l.ID, f.Name, err) } } func fmtSize(n int64) string { units := []string{"B", "KiB", "MiB", "GiB", "TiB"} f := float64(n) i := 0 for f >= 1024 && i < len(units)-1 { f /= 1024 i++ } if i == 0 { return fmt.Sprintf("%d B", n) } return fmt.Sprintf("%.1f %s", f, units[i]) } func folderPage(w http.ResponseWriter, l *link) { kind := func(n string) string { switch strings.ToLower(path.Ext(n)) { case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif": return "img" case ".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".flac", ".wav": return "audio" case ".mp4", ".webm", ".mov", ".m4v": return "video" } return "" } var rows strings.Builder for _, f := range l.Files { href := url.PathEscape(f.Name) media := "" switch k := kind(f.Name); k { case "img": media = fmt.Sprintf(``, href) case "audio", "video": media = fmt.Sprintf(``, href, k) } fmt.Fprintf(&rows, `
  • %s%s%s
  • `, media, href, html.EscapeString(f.Name), fmtSize(f.Size)) } w.Header().Set("content-type", "text/html; charset=utf-8") w.Header().Set("content-security-policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self'; media-src 'self'; frame-ancestors 'none'") w.Header().Set("x-content-type-options", "nosniff") fmt.Fprintf(w, ` %s · shared from Sia
    Shared from Sia

    %s

      %s
    Stored on the Sia network and shared with Pithos.
    `, html.EscapeString(l.Name), html.EscapeString(l.Name), rows.String()) }