Files
GiteaCustomApi/main.go

338 lines
8.6 KiB
Go

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
}