| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | type 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. |
| 30 | type Server struct { |
| 31 | git *GitRelay |
| 32 | store Store |
| 33 | publishMu sync.Mutex |
| 34 | } |
| 35 | |
| 36 | var 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. |
| 39 | func 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. |
| 77 | func (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. |
| 81 | func (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 | |
| 94 | func (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 | } |