New Sign in

fierj

Public

Tiny personal git forge

← fierj / main.go
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

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")
		}
	}

	mux := http.NewServeMux()

	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))

	// Wrap with CSRF, auth middleware, and setup redirect.
	var handler http.Handler = mux
	handler = csrfMiddleware(handler)
	if len(cookieSecret) > 0 {
		handler = SetupRedirect(users)(handler)
		handler = authMiddleware(cookieSecret)(handler)
	}

	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))
		})
	}
}