338 lines
8.6 KiB
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
|
|
}
|