1package archesrv
2
3import (
4 "embed"
5 "encoding/hex"
6 "flag"
7 "fmt"
8 "html/template"
9 "log/slog"
10 "net/http"
11 "os"
12 "path/filepath"
13 "runtime/debug"
14 "strings"
15
16 "arche/internal/markdown"
17 "arche/internal/object"
18 "arche/internal/repo"
19)
20
21//go:embed templates/*.html
22var tmplFS embed.FS
23
24type forgeServer struct {
25 db *DB
26 cfg Config
27 log *slog.Logger
28}
29
30func (s *forgeServer) dataDir() string { return s.cfg.Storage.DataDir }
31
32func Run() error {
33 configPath := flag.String("config", "server.toml", "path to server.toml")
34 flag.Parse()
35
36 cfg, err := LoadConfig(*configPath)
37 if err != nil {
38 return err
39 }
40
41 if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
42 return fmt.Errorf("create data dir: %w", err)
43 }
44
45 dbPath := filepath.Join(cfg.Storage.DataDir, "server.db")
46 db, err := openDB(dbPath)
47 if err != nil {
48 return err
49 }
50 defer db.Close()
51
52 logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
53 s := &forgeServer{db: db, cfg: cfg, log: logger}
54
55 for repoName, repoCfg := range cfg.Repo {
56 rec, err := db.GetRepo(repoName)
57 if err != nil || rec == nil {
58 continue
59 }
60 _ = db.SetRepoAllowShellHooks(rec.ID, repoCfg.AllowShellHooks, repoCfg.PostReceive)
61 }
62 addr := cfg.Server.ListenHTTP
63 if addr == "" {
64 addr = ":8080"
65 }
66
67 if sshAddr := cfg.Server.ListenSSH; sshAddr != "" {
68 go func() {
69 if err := s.RunSSH(sshAddr); err != nil {
70 s.log.Error("SSH listener failed", "err", err)
71 }
72 }()
73 }
74
75 if mtlsAddr := cfg.Server.ListenMTLS; mtlsAddr != "" {
76 go func() {
77 if err := s.RunMTLS(mtlsAddr, cfg.Server.TLSCert, cfg.Server.TLSKey); err != nil {
78 s.log.Error("mTLS listener failed", "err", err)
79 }
80 }()
81 }
82
83 s.log.Info("listening", "addr", "http://"+addr)
84 return http.ListenAndServe(addr, s.routes())
85}
86
87func (s *forgeServer) routes() http.Handler {
88 mux := http.NewServeMux()
89
90 mux.HandleFunc("GET /setup", s.handleSetup)
91 mux.HandleFunc("POST /setup", s.handleSetupPost)
92
93 mux.HandleFunc("GET /login", s.handleLoginPage)
94 mux.HandleFunc("POST /login", s.handleLoginPost)
95 mux.HandleFunc("GET /logout", s.handleLogout)
96
97 if s.cfg.Auth.Registration == "open" || s.cfg.Auth.Registration == "invite" {
98 mux.HandleFunc("GET /register", s.handleRegisterPage)
99 mux.HandleFunc("POST /register", s.handleRegisterPost)
100 }
101
102 mux.HandleFunc("GET /settings/keys", s.handleSettingsKeys)
103 mux.HandleFunc("POST /settings/keys", s.handleSettingsAddKey)
104 mux.HandleFunc("DELETE /settings/keys/{id}", s.handleSettingsDeleteKey)
105
106 mux.HandleFunc("GET /settings/mtls", s.handleSettingsMTLSCerts)
107 mux.HandleFunc("POST /settings/mtls", s.handleSettingsAddMTLSCert)
108 mux.HandleFunc("DELETE /settings/mtls/{id}", s.handleSettingsDeleteMTLSCert)
109
110 mux.HandleFunc("GET /settings/token", s.handleSettingsToken)
111 mux.HandleFunc("POST /settings/tokens", s.handleSettingsCreateToken)
112 mux.HandleFunc("DELETE /settings/tokens/{id}", s.handleSettingsDeleteToken)
113
114 mux.HandleFunc("POST /admin/repos", s.handleAdminCreateRepo)
115 mux.HandleFunc("DELETE /admin/repos/{name}", s.handleAdminDeleteRepo)
116
117 mux.HandleFunc("GET /admin/users", s.handleAdminUsers)
118 mux.HandleFunc("POST /admin/users", s.handleAdminCreateUser)
119 mux.HandleFunc("DELETE /admin/users/{id}", s.handleAdminDeleteUser)
120
121 mux.HandleFunc("GET /admin/invites", s.handleAdminInvites)
122 mux.HandleFunc("POST /admin/invites", s.handleAdminCreateInvite)
123 mux.HandleFunc("DELETE /admin/invites/{id}", s.handleAdminDeleteInvite)
124
125 mux.HandleFunc("/{repo}/arche/v1/", s.handleSyncProxy)
126
127 mux.HandleFunc("GET /{repo}/issues", s.handleRepoIssues)
128 mux.HandleFunc("POST /{repo}/issues", s.handleRepoCreateIssue)
129 mux.HandleFunc("GET /{repo}/issue", s.handleRepoIssue)
130 mux.HandleFunc("POST /{repo}/issue/comment", s.handleRepoAddComment)
131 mux.HandleFunc("POST /{repo}/issue/status", s.handleRepoSetStatus)
132
133 mux.HandleFunc("GET /{repo}/wiki", s.handleRepoWikiList)
134 mux.HandleFunc("GET /{repo}/wiki/{title}", s.handleRepoWikiPage)
135 mux.HandleFunc("POST /{repo}/wiki/{title}", s.handleRepoWikiSave)
136
137 mux.HandleFunc("GET /{repo}/settings", s.handleRepoSettingsPage)
138 mux.HandleFunc("POST /{repo}/settings", s.handleRepoUpdateSettings)
139 mux.HandleFunc("POST /{repo}/settings/collaborators", s.handleRepoAddCollaborator)
140 mux.HandleFunc("DELETE /{repo}/settings/collaborators/{id}", s.handleRepoRemoveCollaborator)
141 mux.HandleFunc("POST /{repo}/settings/delete", s.handleRepoDeleteRepo)
142
143 mux.HandleFunc("GET /{repo}/settings/webhooks", s.handleRepoWebhooks)
144 mux.HandleFunc("POST /{repo}/settings/webhooks", s.handleRepoCreateWebhook)
145 mux.HandleFunc("DELETE /{repo}/settings/webhooks/{id}", s.handleRepoDeleteWebhook)
146 mux.HandleFunc("GET /{repo}/settings/webhooks/{id}/deliveries", s.handleWebhookDeliveries)
147 mux.HandleFunc("POST /{repo}/settings/webhooks/{id}/deliveries/{delivery}/replay", s.handleWebhookReplay)
148 mux.HandleFunc("GET /{repo}/log", s.handleRepoLog)
149 mux.HandleFunc("GET /{repo}/commit", s.handleRepoCommit)
150 mux.HandleFunc("GET /{repo}/tree", s.handleRepoTree)
151 mux.HandleFunc("GET /{repo}/file", s.handleRepoFile)
152 mux.HandleFunc("GET /{repo}/stacks", s.handleRepoStacks)
153 mux.HandleFunc("GET /{repo}/stacks/{stackid}", s.handleRepoStackDetail)
154 mux.HandleFunc("POST /{repo}/stacks/{stackid}/reviews/{changeid}", s.handleStackSetReview)
155 mux.HandleFunc("GET /{repo}", s.handleRepoHome)
156
157 mux.HandleFunc("GET /{$}", s.handleIndex)
158
159 return s.recoverMiddleware(s.logMiddleware(mux))
160}
161
162type statusRecorder struct {
163 http.ResponseWriter
164 code int
165}
166
167func (r *statusRecorder) WriteHeader(code int) {
168 r.code = code
169 r.ResponseWriter.WriteHeader(code)
170}
171
172func (s *forgeServer) logMiddleware(next http.Handler) http.Handler {
173 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174 rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
175 next.ServeHTTP(rec, r)
176 s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.code)
177 })
178}
179
180func (s *forgeServer) recoverMiddleware(next http.Handler) http.Handler {
181 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182 defer func() {
183 if rec := recover(); rec != nil {
184 s.log.Error("handler panic",
185 "panic", rec,
186 "method", r.Method,
187 "path", r.URL.Path,
188 "stack", string(debug.Stack()),
189 )
190 http.Error(w, "internal server error", http.StatusInternalServerError)
191 }
192 }()
193 next.ServeHTTP(w, r)
194 })
195}
196
197func (s *forgeServer) render(w http.ResponseWriter, page string, data any) {
198 funcs := template.FuncMap{
199 "markdown": markdown.Render,
200 "registrationOpen": func() bool { return s.cfg.Auth.Registration == "open" },
201 }
202 t, err := template.New("").Funcs(funcs).ParseFS(tmplFS,
203 "templates/srv_base.html",
204 "templates/"+page,
205 )
206 if err != nil {
207 http.Error(w, "template: "+err.Error(), http.StatusInternalServerError)
208 return
209 }
210
211 w.Header().Set("Content-Type", "text/html; charset=utf-8")
212 if err := t.ExecuteTemplate(w, page, data); err != nil {
213 s.log.Error("render failed", "page", page, "err", err)
214 }
215}
216
217func fullHex(id [32]byte) string { return hex.EncodeToString(id[:]) }
218func shortHex(id [32]byte) string { return hex.EncodeToString(id[:])[:8] }
219func phaseClass(p object.Phase) string { return strings.ToLower(p.String()) }
220
221func bookmarkMap(r *repo.Repo) map[string][]string {
222 bms, _ := r.Store.ListBookmarks()
223 m := make(map[string][]string, len(bms))
224 for _, b := range bms {
225 k := fullHex(b.CommitID)
226 m[k] = append(m[k], b.Name)
227 }
228 return m
229}