tinyrelayfile
tinyrelay/tinygit/server.go/

tinygit/server.go raw
1package tinygit
2
3import (
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"
15
16 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
17)
18
19// ServerConfig configures the standalone public Git host. Use a dedicated
20// DataDir, not a running tinyrelay tenant's database or Git directory.
21type ServerConfig struct {
22 DataDir string
23 Owner string
24 PublicURL string
25}
26
27// Server owns a Git engine and durable SQLite metadata, without a relay daemon,
28// WebSocket service, UI or replication workers. POST /events accepts signed
29// repository metadata. Other paths are handled by Git smart HTTP.
30type Server struct {
31 git *GitRelay
32 store Store
33 publishMu sync.Mutex
34}
35
36var errHostAdmission = errors.New("blocked: standalone host accepts only its owner's public repositories and their maintainers")
37
38// 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, err
56 }
57 store, err := OpenStore(ctx, filepath.Join(cfg.DataDir, "events.db"))
58 if err != nil {
59 return nil, err
60 }
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 errHostAdmission
66 }
67 return nil
68 },
69 })
70 if err != nil {
71 return nil, errors.Join(err, store.Close())
72 }
73 return &Server{git: g, store: store}, nil
74}
75
76// Close releases metadata storage. The caller must first stop serving requests.
77func (s *Server) Close() error { return s.store.Close() }
78
79// Publish validates and persists a signed announcement (30617) or ref state
80// (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 err
87 }
88 if err := s.store.Save(ctx, e, time.Now().Unix()); err != nil && !errors.Is(err, ErrDuplicate) {
89 return err
90 }
91 return s.git.CommitAfterStore(ctx, e, repo)
92}
93
94func (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 return
100 }
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 return
107 }
108 if r.URL.Path != "/events" {
109 s.git.ServeHTTP(w, r)
110 return
111 }
112 if r.Method != http.MethodPost {
113 w.Header().Set("Allow", "POST")
114 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
115 return
116 }
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.MaxBytesError
122 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 return
128 }
129 e, err := event.Parse(body)
130 if err != nil {
131 http.Error(w, err.Error(), http.StatusBadRequest)
132 return
133 }
134 if err := s.Publish(r.Context(), e); err != nil {
135 status := http.StatusBadRequest
136 if errors.Is(err, errHostAdmission) {
137 status = http.StatusForbidden
138 }
139 http.Error(w, err.Error(), status)
140 return
141 }
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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs