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