446 lines
13 KiB
Go
446 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Server struct {
|
|
engine *Engine
|
|
mux *http.ServeMux
|
|
password string
|
|
|
|
tokenMu sync.Mutex
|
|
tokens map[string]time.Time
|
|
}
|
|
|
|
func NewServer(e *Engine, static fs.FS, password string) *Server {
|
|
s := &Server{
|
|
engine: e,
|
|
mux: http.NewServeMux(),
|
|
password: password,
|
|
tokens: map[string]time.Time{},
|
|
}
|
|
mux := s.mux
|
|
mux.HandleFunc("GET /api/status", s.handleStatus)
|
|
mux.HandleFunc("GET /api/events", s.handleEvents)
|
|
mux.HandleFunc("POST /api/login", s.handleLogin)
|
|
mux.HandleFunc("POST /api/logout", s.handleLogout)
|
|
mux.HandleFunc("POST /api/repos", s.handleCreate)
|
|
mux.HandleFunc("GET /api/repos/{name}", s.wrapRepo(s.handleGet))
|
|
mux.HandleFunc("PUT /api/repos/{name}", s.wrapRepo(s.handleUpdate))
|
|
mux.HandleFunc("DELETE /api/repos/{name}", s.wrapRepo(s.handleDelete))
|
|
mux.HandleFunc("POST /api/repos/{name}/start", s.wrapRepo(s.startRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/stop", s.wrapRepo(s.stopRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/sync", s.wrapRepo(s.syncRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/action", s.wrapRepo(s.actionRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/force-push", s.wrapRepo(s.forcePushRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/abort-push", s.wrapRepo(s.abortPushRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/conflicts", s.wrapRepo(s.conflictsRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/log", s.wrapRepo(s.logRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/stats", s.wrapRepo(s.statsRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
|
|
mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo))
|
|
mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo))
|
|
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
|
|
mux.HandleFunc("GET /api/cron-next", s.handleCronNext)
|
|
mux.Handle("/", http.FileServer(http.FS(static)))
|
|
return s
|
|
}
|
|
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
|
|
r.URL.Path != "/api/login" && !s.validToken(r) {
|
|
writeErr(w, 401, "需要登录")
|
|
return
|
|
}
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) validToken(r *http.Request) bool {
|
|
c, err := r.Cookie("git_sync_token")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
s.tokenMu.Lock()
|
|
defer s.tokenMu.Unlock()
|
|
exp, ok := s.tokens[c.Value]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if time.Now().After(exp) {
|
|
delete(s.tokens, c.Value)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "请求体格式错误")
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 {
|
|
writeErr(w, 401, "密码错误")
|
|
return
|
|
}
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
writeErr(w, 500, "生成会话失败")
|
|
return
|
|
}
|
|
token := hex.EncodeToString(buf)
|
|
s.tokenMu.Lock()
|
|
s.tokens[token] = time.Now().Add(30 * 24 * time.Hour)
|
|
s.tokenMu.Unlock()
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "git_sync_token", Value: token, Path: "/",
|
|
HttpOnly: true, MaxAge: 30 * 24 * 3600,
|
|
})
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie("git_sync_token"); err == nil {
|
|
s.tokenMu.Lock()
|
|
delete(s.tokens, c.Value)
|
|
s.tokenMu.Unlock()
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "git_sync_token", Value: "", Path: "/", MaxAge: -1})
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
func (s *Server) wrapRepo(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if s.engine.get(name) == nil {
|
|
writeErr(w, 404, "配置不存在: "+name)
|
|
return
|
|
}
|
|
h(w, r, name)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"repos": s.engine.Snapshots(true)})
|
|
}
|
|
|
|
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, name string) {
|
|
if st := s.engine.get(name); st != nil {
|
|
writeJSON(w, 200, st.snapshot(true))
|
|
return
|
|
}
|
|
writeErr(w, 404, "配置不存在")
|
|
}
|
|
|
|
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
var cfg RepoConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
|
writeErr(w, 400, "请求体格式错误: "+err.Error())
|
|
return
|
|
}
|
|
if err := s.engine.Create(&cfg); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name})
|
|
}
|
|
|
|
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, name string) {
|
|
var cfg RepoConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
|
writeErr(w, 400, "请求体格式错误: "+err.Error())
|
|
return
|
|
}
|
|
cfg.Name = name
|
|
if err := s.engine.Update(&cfg); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := s.engine.Delete(name); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) startRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := s.engine.Start(name); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) stopRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := s.engine.Stop(name); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) syncRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := s.engine.SyncNow(name); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) actionRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "请求体格式错误")
|
|
return
|
|
}
|
|
switch req.Action {
|
|
case "abort":
|
|
if err := s.engine.ConflictAbort(name); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
case "resolve":
|
|
if err := s.engine.ConflictResolve(name); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
case "fetch", "pull", "push":
|
|
if err := s.engine.ManualGit(name, req.Action); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
default:
|
|
writeErr(w, 400, "未知操作: "+req.Action)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) conflictsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
writeJSON(w, 200, s.engine.ConflictDiffs(name))
|
|
}
|
|
|
|
func (s *Server) logRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
n := 300
|
|
if v, err := strconv.Atoi(r.URL.Query().Get("lines")); err == nil && v > 0 && v <= 5000 {
|
|
n = v
|
|
}
|
|
writeJSON(w, 200, map[string]any{"lines": s.engine.ReadLog(name, n)})
|
|
}
|
|
|
|
func (s *Server) statsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
writeJSON(w, 200, s.engine.ReadStats(name))
|
|
}
|
|
|
|
func (s *Server) commitsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
limit := 50
|
|
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 200 {
|
|
limit = v
|
|
}
|
|
writeJSON(w, 200, s.engine.ReadCommits(name, limit))
|
|
}
|
|
|
|
func (s *Server) commitDiffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
diff, err := s.engine.ReadCommitDiff(name, r.PathValue("hash"))
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]string{"diff": diff})
|
|
}
|
|
|
|
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|
fl, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
id, ch := s.engine.Bus().Subscribe()
|
|
defer s.engine.Bus().Unsubscribe(id)
|
|
|
|
if snap, err := json.Marshal(map[string]any{"type": "snapshot", "data": s.engine.Snapshots(true)}); err == nil {
|
|
fmt.Fprintf(w, "data: %s\n\n", snap)
|
|
fl.Flush()
|
|
}
|
|
|
|
tick := time.NewTicker(25 * time.Second)
|
|
defer tick.Stop()
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-tick.C:
|
|
fmt.Fprint(w, ": ping\n\n")
|
|
fl.Flush()
|
|
case data, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
fl.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) forcePushRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := s.engine.ForcePush(name); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := s.engine.AbortPush(name); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
|
|
func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
|
|
}
|
|
|
|
func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
rel := r.URL.Query().Get("path")
|
|
if rel == "" {
|
|
writeErr(w, 400, "缺少 path 参数")
|
|
return
|
|
}
|
|
fc, err := s.engine.ReadFileContent(name, rel)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, fc)
|
|
}
|
|
|
|
func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "请求体格式错误")
|
|
return
|
|
}
|
|
if req.Path == "" {
|
|
writeErr(w, 400, "缺少 path")
|
|
return
|
|
}
|
|
if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
Finish bool `json:"finish"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "请求体格式错误")
|
|
return
|
|
}
|
|
if req.Path == "" {
|
|
writeErr(w, 400, "缺少 path")
|
|
return
|
|
}
|
|
if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) commitFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "请求体格式错误")
|
|
return
|
|
}
|
|
if req.Path == "" {
|
|
writeErr(w, 400, "缺少 path")
|
|
return
|
|
}
|
|
if err := s.engine.CommitFile(name, req.Path, req.Message); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "working"
|
|
}
|
|
// mode = word 时返回 --word-diff=plain 结果(词级差异)
|
|
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode"))
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, res)
|
|
}
|
|
|
|
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
|
|
expr := r.URL.Query().Get("expr")
|
|
if expr == "" {
|
|
writeErr(w, 400, "缺少 expr 参数")
|
|
return
|
|
}
|
|
next, err := cronNextString(expr)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"next": next})
|
|
} |