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 }