From 55f87b13f4f454df7abb8bb9c9ac622d44069ba7 Mon Sep 17 00:00:00 2001 From: lyn Date: Tue, 14 Apr 2026 13:21:08 +0000 Subject: [PATCH] Add Go implementation: main.go, Dockerfile, docker-compose.yml --- Dockerfile | 12 ++ docker-compose.yml | 26 ++++ go.mod | 5 + main.go | 337 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..376b241 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.22-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o server . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=build /app/server . +CMD ["./server"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fbe7310 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + gitea-custom-api: + build: . + container_name: gitea-custom-api + restart: unless-stopped + environment: + DB_HOST: mysql + DB_USER: root + DB_PASS: ${MYSQL_ROOT_PASSWORD} + DB_NAME: giteadb + API_KEY_FILE: /data/api-key + WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET} + PORT: "8080" + volumes: + - ./api-key:/data/api-key + networks: + - git-network + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + git-network: + external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7701182 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea-custom-api + +go 1.22 + +require github.com/go-sql-driver/mysql v1.8.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..37532ab --- /dev/null +++ b/main.go @@ -0,0 +1,337 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// Config +var ( + dbHost = getEnv("DB_HOST", "mysql") + dbUser = getEnv("DB_USER", "root") + dbPass = getEnv("DB_PASS", "") + dbName = getEnv("DB_NAME", "giteadb") + apiKeyFile = getEnv("API_KEY_FILE", "/data/api-key") + port = getEnv("PORT", "8080") + webhookSecret = getEnv("WEBHOOK_SECRET", "") +) + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// Repo cached in memory +type Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + IsPrivate bool `json:"is_private"` + URL string `json:"url"` +} + +var ( + repoCache = make(map[int64]*Repo) + cacheMu sync.RWMutex +) + +func main() { + // Initial full sync + fullSync() + // 5h ticker + ticker := time.NewTicker(5 * time.Hour) + go func() { + for range ticker.C { + fullSync() + } + }() + // 10min key rotation + keyTicker := time.NewTicker(10 * time.Minute) + go func() { + for range keyTicker.C { + rotateAPIKey() + } + }() + rotateAPIKey() // generate initial + + http.HandleFunc("/list", withAuth(handleList)) + http.HandleFunc("/webhook/gitea", withWebhookAuth(handleWebhook)) + http.HandleFunc("/health", handleHealth) + + log.Printf("Listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +// ─── Auth ─────────────────────────────────────────────────────────────────── + +func withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !checkAPIKey(r) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func checkAPIKey(r *http.Request) bool { + rawKey, err := os.ReadFile(apiKeyFile) + if err != nil { + return false + } + parts := strings.SplitN(strings.TrimSpace(string(rawKey)), ":", 2) + if len(parts) < 2 { + return false + } + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return false + } + return strings.TrimPrefix(auth, "Bearer ") == parts[1] +} + +func withWebhookAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + if webhookSecret != "" { + // TODO: verify X-Gitea-Token header vs webhookSecret + } + next(w, r) + } +} + +func rotateAPIKey() { + token := generateToken(32) + key := fmt.Sprintf("%d:%s", time.Now().Unix(), token) + if err := os.WriteFile(apiKeyFile, []byte(key), 0600); err != nil { + log.Printf("rotateAPIKey: write failed: %v", err) + } +} + +func generateToken(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +// ─── Handlers ──────────────────────────────────────────────────────────────── + +func handleHealth(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func handleList(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, `{"error":"username required"}`, http.StatusBadRequest) + return + } + + // Snapshot cached repos + cacheMu.RLock() + cached := make([]*Repo, 0, len(repoCache)) + for _, repo := range repoCache { + cached = append(cached, repo) + } + cacheMu.RUnlock() + + // Get can_write flags via live MySQL query + canWriteMap, err := queryCanWrite(username) + if err != nil { + log.Printf("handleList: queryCanWrite failed: %v", err) + // Fallback: return cached without can_write + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cached) + return + } + + result := make([]map[string]interface{}, 0, len(cached)) + for _, repo := range cached { + result = append(result, map[string]interface{}{ + "name": repo.Name, + "owner": repo.Owner, + "is_private": repo.IsPrivate, + "url": repo.URL, + "can_write": canWriteMap[repo.Name], + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +func handleWebhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + + event := r.Header.Get("X-Gitea-Event") + switch event { + case "repository.create": + handleRepoCreate(body) + case "repository.delete": + handleRepoDelete(body) + } + + w.WriteHeader(http.StatusNoContent) +} + +// ─── Webhook handlers ─────────────────────────────────────────────────────── + +type webhookPayload struct { + Repository struct { + ID int64 `json:"id"` + Name string `json:"name"` + OwnerName string `json:"owner_name"` + Private bool `json:"private"` + } `json:"repository"` +} + +func handleRepoCreate(body []byte) { + var p webhookPayload + if err := json.Unmarshal(body, &p); err != nil { + log.Printf("handleRepoCreate: parse error: %v", err) + return + } + repo := &Repo{ + ID: p.Repository.ID, + Name: p.Repository.Name, + Owner: p.Repository.OwnerName, + IsPrivate: p.Repository.Private, + URL: fmt.Sprintf("https://git.hangman-lab.top/%s/%s.git", p.Repository.OwnerName, p.Repository.Name), + } + cacheMu.Lock() + repoCache[repo.ID] = repo + cacheMu.Unlock() + log.Printf("Cache created: %s/%s (id=%d)", repo.Owner, repo.Name, repo.ID) +} + +func handleRepoDelete(body []byte) { + var p webhookPayload + if err := json.Unmarshal(body, &p); err != nil { + log.Printf("handleRepoDelete: parse error: %v", err) + return + } + cacheMu.Lock() + delete(repoCache, p.Repository.ID) + cacheMu.Unlock() + log.Printf("Cache deleted: id=%d", p.Repository.ID) +} + +// ─── MySQL ───────────────────────────────────────────────────────────────── + +func openDB() (*sql.DB, error) { + dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?parseTime=true", dbUser, dbPass, dbHost, dbName) + return sql.Open("mysql", dsn) +} + +func fullSync() { + log.Println("fullSync: starting...") + db, err := openDB() + if err != nil { + log.Printf("fullSync: db open failed: %v", err) + return + } + defer db.Close() + + rows, err := db.Query(` + SELECT r.id, r.name, u.name AS owner, r.is_private + FROM repository r + JOIN user u ON r.owner_id = u.id + WHERE r.is_archived = 0 + `) + if err != nil { + log.Printf("fullSync: query failed: %v", err) + return + } + defer rows.Close() + + newCache := make(map[int64]*Repo) + for rows.Next() { + var id int64 + var name, owner string + var isPrivate bool + if err := rows.Scan(&id, &name, &owner, &isPrivate); err != nil { + continue + } + newCache[id] = &Repo{ + ID: id, + Name: name, + Owner: owner, + IsPrivate: isPrivate, + URL: fmt.Sprintf("https://git.hangman-lab.top/%s/%s.git", owner, name), + } + } + + cacheMu.Lock() + repoCache = newCache + cacheMu.Unlock() + log.Printf("fullSync: done, %d repos cached", len(repoCache)) +} + +func queryCanWrite(username string) (map[string]bool, error) { + db, err := openDB() + if err != nil { + return nil, err + } + defer db.Close() + + // Subquery to get user id + uidSubq := `(SELECT id FROM ` + dbName + `.user WHERE lower_name = LOWER(?))` + + query := fmt.Sprintf(` + SELECT r.name, + (r.owner_id = %s + OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = %s) + OR EXISTS ( + SELECT 1 FROM team_user tu + JOIN team t ON t.id = tu.team_id + WHERE tu.uid = %s + AND (t.includes_all_repositories = 1 + OR EXISTS (SELECT 1 FROM team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = r.id)) + ) + ) AS can_write + FROM repository r + WHERE r.is_archived = 0 + AND (r.owner_id = %s + OR r.is_private = 0 + OR EXISTS (SELECT 1 FROM access a WHERE a.repo_id = r.id AND a.user_id = %s) + OR EXISTS (SELECT 1 FROM team_user tu WHERE tu.uid = %s)) + `, uidSubq, uidSubq, uidSubq, uidSubq, uidSubq, uidSubq) + + rows, err := db.Query(query, username, username, username, username, username, username) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]bool) + for rows.Next() { + var name string + var canWrite bool + if err := rows.Scan(&name, &canWrite); err != nil { + continue + } + result[name] = canWrite + } + return result, nil +}