clone

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

feat/ui-updates
tinyrelay / tinygit / repository.go
repository.go 10.2K
1package tinygit23import (4 "bytes"5 "context"6 "fmt"7 "os"8 "os/exec"9 "path/filepath"10 "sort"11 "strings"12 "time"1314 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"15)1617func (g *GitRelay) repoPath(r Repository) string {18 if r.Alternative {19 return filepath.Join(g.root, "prs", r.Owner, r.Identifier+".git")20 }21 return filepath.Join(g.root, r.Owner, r.Identifier+".git")22}23func (g *GitRelay) statePath(r Repository) string {24 return filepath.Join(g.repoPath(r), "tinyrelay.refs")25}26func (g *GitRelay) pendingStatePath(r Repository) string {27 return filepath.Join(g.repoPath(r), "tinyrelay.pending")28}2930func (g *GitRelay) pendingHeadPath(r Repository) string {31 return filepath.Join(g.repoPath(r), "tinyrelay.pending.head")32}3334func (g *GitRelay) stateObjectsPresent(ctx context.Context, r Repository) bool {35 oids := make([]string, 0, len(r.Refs))36 for _, oid := range r.Refs {37 if oid != "" {38 oids = append(oids, oid)39 }40 }41 return g.objectsPresent(ctx, r, oids)42}4344// objectsPresent checks all object IDs through one long-lived cat-file45// process. Repository state can contain hundreds of refs; starting one Git46// process per ref makes promotion latency grow linearly with process startup.47func (g *GitRelay) objectsPresent(ctx context.Context, r Repository, oids []string) bool {48 if len(oids) == 0 {49 return true50 }51 cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(r), "cat-file", "--batch-check")52 var out bytes.Buffer53 cmd.Stdin = strings.NewReader(strings.Join(oids, "\n") + "\n")54 cmd.Stdout = &out55 if err := cmd.Run(); err != nil {56 return false57 }58 lines := strings.Split(strings.TrimSpace(out.String()), "\n")59 if len(lines) != len(oids) {60 return false61 }62 for _, line := range lines {63 fields := strings.Fields(line)64 if len(fields) < 2 || fields[1] == "missing" {65 return false66 }67 }68 return true69}70func (g *GitRelay) ensureRepo(r Repository) error {71 p := g.repoPath(r)72 if _, err := os.Stat(filepath.Join(p, "HEAD")); err == nil {73 return g.configureRepo(r)74 }75 if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {76 return err77 }78 cmd := exec.Command("git", "init", "--bare", p)79 if out, err := cmd.CombinedOutput(); err != nil {80 return fmt.Errorf("git init: %w: %s", err, strings.TrimSpace(string(out)))81 }82 return g.configureRepo(r)83}8485func (g *GitRelay) configureRepo(r Repository) error {86 g.repoConfigMu.Lock()87 defer g.repoConfigMu.Unlock()88 if _, ok := g.configured[g.repoPath(r)]; ok {89 return nil90 }91 for _, pair := range [][2]string{{"http.receivepack", "true"}, {"http.uploadpack", "true"}} {92 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "config", pair[0], pair[1])93 if out, err := cmd.CombinedOutput(); err != nil {94 return fmt.Errorf("git config %s: %w: %s", pair[0], err, strings.TrimSpace(string(out)))95 }96 }97 if err := g.installHook(r); err != nil {98 return err99 }100 if g.configured == nil {101 g.configured = make(map[string]struct{})102 }103 g.configured[g.repoPath(r)] = struct{}{}104 return nil105}106107func (g *GitRelay) installHook(r Repository) error {108 hook := filepath.Join(g.repoPath(r), "hooks", "pre-receive")109 state, pending := g.statePath(r), g.pendingStatePath(r)110 // Load signed maps once per push, not one process per ref. The hook is111 // static; metadata changes only replace the signed ref files.112 script := "#!/bin/sh\nset -eu\nTINY_REFS=" + shellQuote(state) + " TINY_PENDING=" + shellQuote(pending) + ` awk '113BEGIN {114 while ((getline line < ENVIRON["TINY_REFS"]) > 0) { split(line, row, " "); expected[row[1]]=row[2] }115 while ((getline line < ENVIRON["TINY_PENDING"]) > 0) { split(line, row, " "); expected[row[1]]=row[2] }116}117NF != 3 || expected[$3] != $2 {118 print "tinyrelay: ref is not authorized by signed repository state" > "/dev/stderr"119 exit 1120}121'122`123 if err := atomicExecutable(hook, []byte(script)); err != nil {124 return err125 }126 post := filepath.Join(g.repoPath(r), "hooks", "post-receive")127 postScript := "#!/bin/sh\nset -eu\nif [ -f " + shellQuote(pending) + " ]; then\n checked=$(awk '{print $2}' " + shellQuote(pending) + " | git cat-file --batch-check) || exit 1\n if printf '%s\\n' \"$checked\" | awk 'NF != 3 || $2 == \"missing\" { bad=1 } END { exit bad }'; then mv -f " + shellQuote(pending) + " " + shellQuote(state) + "; fi\nfi\n"128 return atomicExecutable(post, []byte(postScript))129}130131func atomicExecutable(path string, data []byte) error {132 tmp, err := os.CreateTemp(filepath.Dir(path), ".tinyrelay-hook-")133 if err != nil {134 return err135 }136 tmpName := tmp.Name()137 defer os.Remove(tmpName)138 if err := tmp.Chmod(0700); err != nil {139 _ = tmp.Close()140 return err141 }142 if _, err := tmp.Write(data); err != nil {143 _ = tmp.Close()144 return err145 }146 if err := tmp.Sync(); err != nil {147 _ = tmp.Close()148 return err149 }150 if err := tmp.Close(); err != nil {151 return err152 }153 return os.Rename(tmpName, path)154}155156func shellQuote(value string) string {157 return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"158}159160func (g *GitRelay) writeState(r Repository) error {161 if err := g.ensureRepo(r); err != nil {162 return err163 }164 if err := g.projectNativeRefs(r, r.Refs); err != nil {165 return err166 }167 if err := g.writeRefsFile(g.statePath(r), r.Refs); err != nil {168 return err169 }170 if r.Head != "" {171 ref := strings.TrimPrefix(r.Head, "ref: ")172 if out, err := exec.Command("git", "--git-dir", g.repoPath(r), "symbolic-ref", "HEAD", ref).CombinedOutput(); err != nil {173 return fmt.Errorf("install Git HEAD: %w: %s", err, strings.TrimSpace(string(out)))174 }175 }176 return nil177}178179func (g *GitRelay) projectNativeRefs(r Repository, next map[string]string) error {180 if len(next) > 0 && !g.objectsPresent(context.Background(), r, refObjectIDs(next)) {181 // Alternative PR metadata may arrive before its object. Keep the182 // signed file authoritative for the receive hook, but do not create a183 // native ref Git cannot resolve until the object is present.184 return nil185 }186 previous, err := g.nativeRefs(r)187 if err != nil {188 return err189 }190 refs := make(map[string]struct{}, len(previous)+len(next))191 for ref := range previous {192 refs[ref] = struct{}{}193 }194 for ref := range next {195 if !internalRef(ref) {196 refs[ref] = struct{}{}197 }198 }199 keys := make([]string, 0, len(refs))200 for ref := range refs {201 keys = append(keys, ref)202 }203 sort.Strings(keys)204 var tx strings.Builder205 tx.WriteString("start\n")206 for _, ref := range keys {207 if oid, ok := next[ref]; ok && !internalRef(ref) {208 if previousOID, exists := previous[ref]; exists && previousOID == oid {209 continue210 }211 tx.WriteString("update ")212 tx.WriteString(ref)213 tx.WriteByte(' ')214 tx.WriteString(oid)215 tx.WriteByte('\n')216 continue217 }218 if _, ok := previous[ref]; ok {219 tx.WriteString("delete ")220 tx.WriteString(ref)221 tx.WriteByte('\n')222 }223 }224 tx.WriteString("prepare\ncommit\n")225 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", "--stdin")226 cmd.Stdin = strings.NewReader(tx.String())227 if out, err := cmd.CombinedOutput(); err != nil {228 return fmt.Errorf("project Git refs: %w: %s", err, strings.TrimSpace(string(out)))229 }230 return nil231}232233func (g *GitRelay) nativeRefs(r Repository) (map[string]string, error) {234 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "for-each-ref", "--format=%(refname) %(objectname)")235 out, err := cmd.Output()236 if err != nil {237 return nil, fmt.Errorf("read native Git refs: %w", err)238 }239 refs := make(map[string]string)240 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {241 fields := strings.Fields(line)242 if len(fields) == 2 && !internalRef(fields[0]) {243 refs[fields[0]] = fields[1]244 }245 }246 return refs, nil247}248249func refObjectIDs(refs map[string]string) []string {250 oids := make([]string, 0, len(refs))251 for _, oid := range refs {252 if oid != "" {253 oids = append(oids, oid)254 }255 }256 return oids257}258259func internalRef(ref string) bool { return strings.HasPrefix(ref, "refs/tinyrelay/") }260261func readRefsFile(path string) (map[string]string, error) {262 b, err := os.ReadFile(path)263 if err != nil {264 return nil, err265 }266 refs := make(map[string]string)267 for _, line := range strings.Split(string(b), "\n") {268 fields := strings.Fields(line)269 if len(fields) == 2 {270 refs[fields[0]] = fields[1]271 }272 }273 return refs, nil274}275276func (g *GitRelay) writePendingState(r Repository) error {277 if err := g.ensureRepo(r); err != nil {278 return err279 }280 if err := g.writeRefsFile(g.pendingStatePath(r), r.Refs); err != nil {281 return err282 }283 if r.Head == "" {284 return nil285 }286 return os.WriteFile(g.pendingHeadPath(r), []byte(r.Head+"\n"), 0600)287}288289func (g *GitRelay) writeRefsFile(path string, refs map[string]string) error {290 keys := make([]string, 0, len(refs))291 for ref := range refs {292 keys = append(keys, ref)293 }294 sort.Strings(keys)295 var b strings.Builder296 for _, ref := range keys {297 b.WriteString(ref)298 b.WriteByte(' ')299 b.WriteString(refs[ref])300 b.WriteByte('\n')301 }302 tmp := path + ".tmp"303 if err := os.WriteFile(tmp, []byte(b.String()), 0600); err != nil {304 return err305 }306 if err := syncFile(tmp); err != nil {307 return err308 }309 return os.Rename(tmp, path)310}311312func (g *GitRelay) lookup(owner, id string) (Repository, error) {313 g.mu.RLock()314 r, ok := g.repos[key(owner, id)]315 g.mu.RUnlock()316 if ok {317 return r, nil318 }319 q, err := g.store.Query(context.Background(), event.Filter{Authors: []string{owner}, Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 1)320 if err != nil {321 return Repository{}, err322 }323 if len(q) == 0 {324 return Repository{}, os.ErrNotExist325 }326 r, err = g.parseRepository(q[0])327 if err != nil {328 return Repository{}, err329 }330 // The event store may acknowledge a state before the asynchronous Git331 // work intent has run. Load the latest signed state here so an immediate332 // receive-pack request can stage its authorization hook synchronously.333 // A maintainer, including an agent the host vouches for, may have signed334 // it; parseRepository rejects any author who is not one.335 states, stateErr := g.store.Query(context.Background(), event.Filter{Kinds: []int{30618}, Tags: map[string][]string{"d": {id}}}, time.Now().Unix(), 8)336 if stateErr == nil {337 for _, candidate := range states {338 state, parseErr := g.parseRepository(candidate)339 if parseErr != nil || state.Owner != owner {340 continue341 }342 state.Clone, state.Relays, state.Maintainers = r.Clone, r.Relays, r.Maintainers343 r = state344 break345 }346 }347 g.mu.Lock()348 g.repos[key(owner, id)] = r349 g.mu.Unlock()350 return r, nil351}