| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | |
| 12 | event "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 13 | ) |
| 14 | |
| 15 | // Repository is the resolved authority and Git state for one hosted project. |
| 16 | type Repository struct { |
| 17 | Owner string |
| 18 | Identifier string |
| 19 | EventID string |
| 20 | Private bool |
| 21 | Clone []string |
| 22 | Relays []string |
| 23 | Maintainers []string |
| 24 | Refs map[string]string |
| 25 | Head string |
| 26 | Alternative bool |
| 27 | } |
| 28 | |
| 29 | // Maintainer is one key that may sign repository state, push refs and change |
| 30 | // status. Role is "owner", "maintainer" for a key in the announcement's |
| 31 | // maintainers tag, or "agent" for a key the host vouches for under an agent |
| 32 | // grant; Name carries the grant's label when it has one. |
| 33 | type Maintainer struct { |
| 34 | PubKey string `json:"pubkey"` |
| 35 | Role string `json:"role"` |
| 36 | Name string `json:"name,omitempty"` |
| 37 | } |
| 38 | |
| 39 | // MaintainerSource answers whether a key maintains a repository beyond the |
| 40 | // owner and the announcement's maintainers tag. The host relay supplies it so |
| 41 | // authority it records elsewhere, such as an agent grant, counts in every |
| 42 | // place this package checks maintainers. |
| 43 | type MaintainerSource interface { |
| 44 | IsMaintainer(ctx context.Context, r Repository, pubkey string) bool |
| 45 | } |
| 46 | |
| 47 | // Config supplies the event store, Git root, authorization and transport hooks. |
| 48 | type Config struct { |
| 49 | Store Store |
| 50 | Root string |
| 51 | Policy func() Policy |
| 52 | Authorize func(context.Context, Event, Repository) error |
| 53 | // Maintainers extends maintainer checks with host-side authority. It is |
| 54 | // consulted only after the owner and the maintainers tag. |
| 55 | Maintainers MaintainerSource |
| 56 | // AuthorizeHTTP is called for private repositories and for operators that |
| 57 | // want signed/NIP-98 authorization at the Git HTTP boundary. |
| 58 | AuthorizeHTTP func(context.Context, *http.Request, Repository) error |
| 59 | // ServiceURL is used to reject recursive GRASP sources and to construct |
| 60 | // advertised clone URLs. It is optional for an embedded relay. |
| 61 | ServiceURL string |
| 62 | // PublicURL is the canonical HTTPS URL used in repository advertisements; |
| 63 | // ServiceURL is used when PublicURL is empty. |
| 64 | PublicURL string |
| 65 | // EnableGRASP06 enables the alternative PR repository surface. It is kept |
| 66 | // opt-in because publishing a capability creates an interoperability |
| 67 | // promise beyond the base GRASP-01 HTTP endpoint. |
| 68 | EnableGRASP06 bool |
| 69 | // AllowMissingObjects is ignored. |
| 70 | // Missing objects enter the durable pending path and stay hidden until |
| 71 | // receive-pack or repair supplies every advertised tip. |
| 72 | AllowMissingObjects bool |
| 73 | // AllowPrivateRelays permits operator-controlled private DNS targets for |
| 74 | // self-hosted networks. Public deployments should leave it disabled. |
| 75 | AllowPrivateRelays bool |
| 76 | // PrivatePeers are operator-configured GRASP-08 origins. Only these |
| 77 | // sources may receive signed outbound Git requests. |
| 78 | PrivatePeers []string |
| 79 | // HTTPAuth signs requests to configured private peers. It is never used for |
| 80 | // sources that are merely present in a repository announcement. |
| 81 | HTTPAuth HTTPAuthSigner |
| 82 | // GitSync and EventSync are operator-owned transport callbacks. Keeping |
| 83 | // transport outside this package allows native websocket/NIP-77, HTTP, or |
| 84 | // an internal queue without making GRASP depend on one client library. |
| 85 | GitSync func(context.Context, Repository) error |
| 86 | EventSync func(context.Context, Repository) error |
| 87 | // OnPromote is called after a pending state becomes object-complete and |
| 88 | // visible. The host relay uses it to release event visibility/live fanout. |
| 89 | OnPromote func(context.Context, string, Repository) error |
| 90 | } |
| 91 | |
| 92 | // GitRelay coordinates signed repository events with native bare repositories. |
| 93 | type GitRelay struct { |
| 94 | store Store |
| 95 | root string |
| 96 | policy func() Policy |
| 97 | authorize func(context.Context, event.Event, Repository) error |
| 98 | authorizeHTTP func(context.Context, *http.Request, Repository) error |
| 99 | maintainers MaintainerSource |
| 100 | serviceURL string |
| 101 | gitSync func(context.Context, Repository) error |
| 102 | eventSync func(context.Context, Repository) error |
| 103 | onPromote func(context.Context, string, Repository) error |
| 104 | grasp06 bool |
| 105 | allowMissing bool |
| 106 | allowPrivate bool |
| 107 | privatePeers []string |
| 108 | httpAuth HTTPAuthSigner |
| 109 | mu sync.RWMutex |
| 110 | repos map[string]Repository |
| 111 | pending map[string]struct{} |
| 112 | commitMu sync.Mutex |
| 113 | tickMu sync.Mutex |
| 114 | repoConfigMu sync.Mutex |
| 115 | configured map[string]struct{} |
| 116 | } |
| 117 | |
| 118 | func New(cfg Config) (*GitRelay, error) { |
| 119 | if cfg.Store == nil { |
| 120 | return nil, errors.New("git relay: store is required" ) |
| 121 | } |
| 122 | store := cfg.Store |
| 123 | if cfg.Root == "" { |
| 124 | return nil, errors.New("git relay: root is required" ) |
| 125 | } |
| 126 | if err := os.MkdirAll(cfg.Root, 0700); err != nil { |
| 127 | return nil, fmt.Errorf("git relay root: %w" , err) |
| 128 | } |
| 129 | p := cfg.Policy |
| 130 | if p == nil { |
| 131 | p = func() Policy { return DefaultPolicy("" ) } |
| 132 | } |
| 133 | serviceURL := cfg.PublicURL |
| 134 | if serviceURL == "" { |
| 135 | serviceURL = cfg.ServiceURL |
| 136 | } |
| 137 | g := &GitRelay{store: store, root: cfg.Root, policy: p, authorize: cfg.Authorize, authorizeHTTP: cfg.AuthorizeHTTP, maintainers: cfg.Maintainers, serviceURL: strings.TrimRight(serviceURL, "/" ), grasp06: cfg.EnableGRASP06, allowMissing: cfg.AllowMissingObjects, allowPrivate: cfg.AllowPrivateRelays, privatePeers: append([]string(nil), cfg.PrivatePeers...), httpAuth: cfg.HTTPAuth, gitSync: cfg.GitSync, eventSync: cfg.EventSync, onPromote: cfg.OnPromote, repos: make(map[string]Repository), pending: make(map[string]struct{})} |
| 138 | if err := g.recoverJournals(); err != nil { |
| 139 | return nil, err |
| 140 | } |
| 141 | if err := g.Reload(context.Background()); err != nil { |
| 142 | return nil, err |
| 143 | } |
| 144 | return g, nil |
| 145 | } |
| 146 | |
| 147 | func key(owner, identifier string) string { return owner + "\x00" + identifier } |
| 148 | |
| 149 | // IsMaintainer reports whether pubkey may sign state and push refs for a |
| 150 | // repository: the owner, a key in the announcement's maintainers tag, or a key |
| 151 | // the configured MaintainerSource vouches for. Every maintainer check in this |
| 152 | // package goes through here so the host's answer applies uniformly. |
| 153 | func (g *GitRelay) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool { |
| 154 | if pubkey == "" { |
| 155 | return false |
| 156 | } |
| 157 | if pubkey == r.Owner || contains(r.Maintainers, pubkey) { |
| 158 | return true |
| 159 | } |
| 160 | return g.maintainers != nil && g.maintainers.IsMaintainer(ctx, r, pubkey) |
| 161 | } |