Add Go implementation: main.go, Dockerfile, docker-compose.yml
This commit is contained in:
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -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"]
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@@ -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
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module gitea-custom-api
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/go-sql-driver/mysql v1.8.1
|
||||
337
main.go
Normal file
337
main.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user