fierj
PublicTiny personal git forge
package main
import (
"context"
"embed"
"html/template"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
//go:embed templates/*.html
var templateFS embed.FS
type contextKey string
// registerRoutes adds all fierj HTTP routes to the given mux.
// It is shared by main() and tests.
func registerRoutes(mux *http.ServeMux, cfg Config, users UserStore, cookieSecret []byte, tmpl *template.Template) {
mux.HandleFunc("GET /", Repos(cfg, tmpl))
mux.HandleFunc("GET /new", NewRepoGet(tmpl))
mux.HandleFunc("POST /new", NewRepoPost(cfg, tmpl))
mux.HandleFunc("GET /setup", SetupGet(tmpl))
mux.HandleFunc("POST /setup", SetupPost(users, cfg.UsersPath, cookieSecret, tmpl))
mux.HandleFunc("GET /login", LoginGet(tmpl))
mux.HandleFunc("POST /login", LoginPost(users, cookieSecret, tmpl))
mux.HandleFunc("POST /logout", Logout())
mux.HandleFunc("GET /users", UsersGet(users, cfg.UsersPath, tmpl))
mux.HandleFunc("POST /users", UsersPost(users, cfg.UsersPath, tmpl))
mux.HandleFunc("GET /{repo}", Tree(cfg, tmpl))
mux.HandleFunc("GET /{repo}/tree/{refpath...}", Tree(cfg, tmpl))
mux.HandleFunc("GET /{repo}/blob/{refpath...}", Blob(cfg, tmpl))
mux.HandleFunc("GET /{repo}/log/{refpath...}", Log(cfg, tmpl))
mux.HandleFunc("GET /{repo}/commit/{hash}", Diff(cfg, tmpl))
mux.HandleFunc("GET /{repo}/refs/", Refs(cfg, tmpl))
mux.HandleFunc("GET /{repo}/settings", SettingsGet(cfg, tmpl))
mux.HandleFunc("POST /{repo}/settings", SettingsPost(cfg, tmpl))
mux.HandleFunc("GET /{repo}/threads", ThreadsList(cfg, tmpl))
mux.HandleFunc("GET /{repo}/threads/new", ThreadNewGet(cfg, tmpl))
mux.HandleFunc("POST /{repo}/threads/new", ThreadNewPost(cfg, tmpl))
mux.HandleFunc("GET /{repo}/threads/{threadId}", ThreadView(cfg, tmpl))
mux.HandleFunc("POST /{repo}/threads/{threadId}/reply", ThreadReplyPost(cfg, tmpl))
mux.HandleFunc("POST /{repo}/threads/{threadId}/close", ThreadClosePost(cfg, tmpl))
mux.HandleFunc("POST /{repo}/threads/{threadId}/reopen", ThreadReopenPost(cfg, tmpl))
mux.HandleFunc("GET /{repo}/patches", PatchesList(cfg, tmpl))
mux.HandleFunc("GET /{repo}/patches/new", PatchNewGet(cfg, tmpl))
mux.HandleFunc("POST /{repo}/patches/new", PatchNewPost(cfg, tmpl))
mux.HandleFunc("GET /{repo}/patches/{patchId}", PatchView(cfg, tmpl))
mux.HandleFunc("POST /{repo}/patches/{patchId}/merge", PatchMergePost(cfg, tmpl))
mux.HandleFunc("POST /{repo}/patches/{patchId}/close", PatchClosePost(cfg, tmpl))
// ActivityPub
mux.HandleFunc("GET /.well-known/webfinger", WebFinger(cfg))
mux.HandleFunc("GET /{repo}/ap/actor", APActorHandler(cfg))
mux.HandleFunc("POST /{repo}/ap/inbox", APInbox(cfg))
mux.HandleFunc("GET /{repo}/ap/outbox", APOutbox(cfg))
mux.HandleFunc("GET /{repo}/ap/followers", APFollowers(cfg))
}
// newHandler creates a fully-wired HTTP handler from the given configuration.
func newHandler(cfg Config, users UserStore, cookieSecret []byte, tmpl *template.Template) http.Handler {
mux := http.NewServeMux()
registerRoutes(mux, cfg, users, cookieSecret, tmpl)
var h http.Handler = mux
if len(cookieSecret) > 0 {
h = SetupRedirect(users)(h)
h = authMiddleware(cookieSecret)(h)
}
// Request logging.
h = requestLogMiddleware(h)
return h
}
// requestLogMiddleware logs every incoming request with method, path, status,
// and which handler served it (or "unmatched" if nothing matched).
func requestLogMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lw := &loggingResponseWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(lw, r)
dur := time.Since(start)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery,
"status", lw.status,
"duration", dur.String(),
"host", r.Host,
"remote", r.RemoteAddr,
)
})
}
type loggingResponseWriter struct {
http.ResponseWriter
status int
}
func (lw *loggingResponseWriter) WriteHeader(code int) {
lw.status = code
lw.ResponseWriter.WriteHeader(code)
}
func main() {
if len(os.Args) >= 2 && os.Args[1] == "hook" {
os.Exit(hookCmd(os.Args[2:]))
}
cfgPath := "config.json"
if len(os.Args) > 1 {
cfgPath = os.Args[1]
}
cfg := LoadConfig(cfgPath)
os.MkdirAll(cfg.Dir, 0755)
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
// Load users and cookie secret for auth.
var users UserStore
var cookieSecret []byte
if cfg.UsersPath != "" && cfg.CookieSecret != "" {
var err error
users, err = LoadUsers(cfg.UsersPath)
if err != nil {
slog.Error("failed to load users", "path", cfg.UsersPath, "error", err)
os.Exit(1)
}
cookieSecret = []byte(cfg.CookieSecret)
if len(users) > 0 {
slog.Info("auth enabled", "users", len(users))
} else {
slog.Info("auth enabled, waiting for first-run setup")
}
}
handler := newHandler(cfg, users, cookieSecret, tmpl)
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
}
// Start SSH server if configured
if cfg.SSHAddr != "" {
sshSrv, err := NewSSHServer(cfg.Dir, cfg.SSHHostKey, users)
if err != nil {
slog.Error("failed to create SSH server", "error", err)
os.Exit(1)
}
go func() {
if err := sshSrv.ListenAndServe(cfg.SSHAddr); err != nil {
slog.Error("ssh server failed", "error", err)
}
}()
}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
slog.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
slog.Info("fierj started", "addr", cfg.Addr, "host", cfg.Host, "repos", cfg.Dir)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("failed to start server", "error", err)
}
slog.Info("stopped")
}
// hookCmd dispatches git hook invocations.
func hookCmd(args []string) int {
if len(args) < 1 {
return 0
}
switch args[0] {
case "pre-receive":
return runPreReceive()
}
return 0
}
// User returns the logged-in username from the request context, or "" if anonymous.
func User(r *http.Request) string {
u, _ := r.Context().Value(contextKey("user")).(string)
return u
}
func authMiddleware(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username := AuthUser(r, secret)
ctx := context.WithValue(r.Context(), contextKey("user"), username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}