clone

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

main
tinyrelay / tinygit / server.go
server.go 4.4K
1package tinygit23import (4 "context"5 "encoding/hex"6 "encoding/json"7 "errors"8 "io"9 "net/http"10 "net/url"11 "path/filepath"12 "strings"13 "sync"14 "time"1516 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"17)1819// ServerConfig configures the standalone public Git host. Use a dedicated20// DataDir, not a running tinyrelay tenant's database or Git directory.21type ServerConfig struct {22 DataDir string23 Owner string24 PublicURL string25}2627// Server owns a Git engine and durable SQLite metadata, without a relay daemon,28// WebSocket service, UI or replication workers. POST /events accepts signed29// repository metadata. Other paths are handled by Git smart HTTP.30type Server struct {31 git *GitRelay32 store Store33 publishMu sync.Mutex34}3536var errHostAdmission = errors.New("blocked: standalone host accepts only its owner's public repositories and their maintainers")3738// OpenServer opens the standalone host. Stop all HTTP requests before Close.39func OpenServer(ctx context.Context, cfg ServerConfig) (*Server, error) {40 owner, err := hex.DecodeString(cfg.Owner)41 if err != nil || len(owner) != 32 || cfg.Owner != strings.ToLower(cfg.Owner) {42 return nil, errors.New("tinygit: owner must be a 64-character lowercase hex public key")43 }44 if cfg.DataDir == "" {45 return nil, errors.New("tinygit: data directory is required")46 }47 if cfg.PublicURL != "" {48 u, err := url.Parse(cfg.PublicURL)49 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {50 return nil, errors.New("tinygit: public URL must be an HTTP(S) URL without credentials, query or fragment")51 }52 }53 cfg.DataDir, err = filepath.Abs(cfg.DataDir)54 if err != nil {55 return nil, err56 }57 store, err := OpenStore(ctx, filepath.Join(cfg.DataDir, "events.db"))58 if err != nil {59 return nil, err60 }61 g, err := New(Config{62 Store: store, Root: filepath.Join(cfg.DataDir, "git"), PublicURL: cfg.PublicURL,63 Authorize: func(_ context.Context, e Event, repo Repository) error {64 if repo.Owner != cfg.Owner || repo.Private || (e.PubKey != repo.Owner && !contains(repo.Maintainers, e.PubKey)) {65 return errHostAdmission66 }67 return nil68 },69 })70 if err != nil {71 return nil, errors.Join(err, store.Close())72 }73 return &Server{git: g, store: store}, nil74}7576// Close releases metadata storage. The caller must first stop serving requests.77func (s *Server) Close() error { return s.store.Close() }7879// Publish validates and persists a signed announcement (30617) or ref state80// (30618). Repeated events retry the Git transition after a partial failure.81func (s *Server) Publish(ctx context.Context, e Event) error {82 s.publishMu.Lock()83 defer s.publishMu.Unlock()84 repo, err := s.git.Validate(ctx, e)85 if err != nil {86 return err87 }88 if err := s.store.Save(ctx, e, time.Now().Unix()); err != nil && !errors.Is(err, ErrDuplicate) {89 return err90 }91 return s.git.CommitAfterStore(ctx, e, repo)92}9394func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {95 if r.URL.Path == "/healthz" {96 if r.Method != http.MethodGet && r.Method != http.MethodHead {97 w.Header().Set("Allow", "GET, HEAD")98 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)99 return100 }101 w.Header().Set("Content-Type", "text/plain; charset=utf-8")102 w.WriteHeader(http.StatusOK)103 if r.Method != http.MethodHead {104 _, _ = io.WriteString(w, "ok\n")105 }106 return107 }108 if r.URL.Path != "/events" {109 s.git.ServeHTTP(w, r)110 return111 }112 if r.Method != http.MethodPost {113 w.Header().Set("Allow", "POST")114 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)115 return116 }117 r.Body = http.MaxBytesReader(w, r.Body, 1<<20)118 defer r.Body.Close()119 body, err := io.ReadAll(r.Body)120 if err != nil {121 var tooLarge *http.MaxBytesError122 if errors.As(err, &tooLarge) {123 http.Error(w, "event exceeds 1 MiB", http.StatusRequestEntityTooLarge)124 } else {125 http.Error(w, "cannot read event", http.StatusBadRequest)126 }127 return128 }129 e, err := event.Parse(body)130 if err != nil {131 http.Error(w, err.Error(), http.StatusBadRequest)132 return133 }134 if err := s.Publish(r.Context(), e); err != nil {135 status := http.StatusBadRequest136 if errors.Is(err, errHostAdmission) {137 status = http.StatusForbidden138 }139 http.Error(w, err.Error(), status)140 return141 }142 w.Header().Set("Content-Type", "application/json")143 _ = json.NewEncoder(w).Encode(struct {144 ID string `json:"id"`145 Pending bool `json:"pending"`146 }{e.ID, s.git.IsPending(e.ID)})147}