New Sign in

fierj

Public

Tiny personal git forge

75059c17edc2c50bbb40eabec0ed7c7c703c952b
diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..d192443
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,270 @@
+package main
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"net/http"
+	"os"
+	"strings"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+// UserStore holds username → bcrypt-hash mappings loaded from a JSON file.
+type UserStore map[string]string
+
+func LoadUsers(path string) (UserStore, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return UserStore{}, nil // first run, no users yet
+		}
+		return nil, err
+	}
+	var store UserStore
+	if err := json.Unmarshal(data, &store); err != nil {
+		return nil, err
+	}
+	return store, nil
+}
+
+func (u UserStore) Verify(username, password string) bool {
+	hash, ok := u[username]
+	if !ok {
+		return false
+	}
+	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+func (u UserStore) Save(path string) error {
+	data, err := json.MarshalIndent(u, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0644)
+}
+
+func (u UserStore) Add(username, password string) error {
+	if username == "" || password == "" {
+		return fmt.Errorf("username and password are required")
+	}
+	if _, exists := u[username]; exists {
+		return fmt.Errorf("user %s already exists", username)
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	u[username] = string(hash)
+	return nil
+}
+
+func (u UserStore) Remove(username string) {
+	delete(u, username)
+}
+
+func (u UserStore) ChangePassword(username, newPassword string) error {
+	if newPassword == "" {
+		return fmt.Errorf("password is required")
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	u[username] = string(hash)
+	return nil
+}
+
+// ----- Cookie signing -----
+
+func signCookie(username string, secret []byte) string {
+	mac := hmac.New(sha256.New, secret)
+	mac.Write([]byte(username))
+	sig := hex.EncodeToString(mac.Sum(nil))
+	return username + ":" + sig
+}
+
+func verifyCookie(value string, secret []byte) (string, bool) {
+	parts := strings.SplitN(value, ":", 2)
+	if len(parts) != 2 {
+		return "", false
+	}
+	expected := signCookie(parts[0], secret)
+	return parts[0], hmac.Equal([]byte(expected), []byte(value))
+}
+
+func setAuthCookie(w http.ResponseWriter, username string, secret []byte) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     "fierj_auth",
+		Value:    signCookie(username, secret),
+		Path:     "/",
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   7 * 24 * 3600, // 7 days
+	})
+}
+
+func clearAuthCookie(w http.ResponseWriter) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     "fierj_auth",
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		MaxAge:   -1,
+	})
+}
+
+// ----- Handlers -----
+
+func LoginGet(tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		tmpl.ExecuteTemplate(w, "login.html", map[string]any{"User": User(r)})
+	}
+}
+
+func LoginPost(users UserStore, secret []byte, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		username := strings.TrimSpace(r.FormValue("username"))
+		password := r.FormValue("password")
+
+		if !users.Verify(username, password) {
+			tmpl.ExecuteTemplate(w, "login.html", map[string]any{
+				"Error": "Invalid username or password.",
+				"User":  User(r),
+			})
+			return
+		}
+
+		setAuthCookie(w, username, secret)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
+}
+
+func Logout() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		clearAuthCookie(w)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
+}
+
+// ----- Middleware -----
+
+// AuthUser extracts the logged-in username from the cookie, or returns "".
+func AuthUser(r *http.Request, secret []byte) string {
+	if len(secret) == 0 {
+		return ""
+	}
+	cookie, err := r.Cookie("fierj_auth")
+	if err != nil {
+		return ""
+	}
+	username, ok := verifyCookie(cookie.Value, secret)
+	if !ok {
+		return ""
+	}
+	return username
+}
+
+// ----- Setup (first-run) -----
+
+func SetupGet(tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"User": User(r)})
+	}
+}
+
+func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		username := strings.TrimSpace(r.FormValue("username"))
+		password := r.FormValue("password")
+		confirm := r.FormValue("confirm")
+
+		if password != confirm {
+			tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"Error": "Passwords do not match.", "User": User(r)})
+			return
+		}
+		if err := users.Add(username, password); err != nil {
+			tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"Error": err.Error(), "User": User(r)})
+			return
+		}
+		if err := users.Save(usersPath); err != nil {
+			tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r)})
+			return
+		}
+		setAuthCookie(w, username, secret)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
+}
+
+// SetupRedirect middleware: if no users exist, all requests go to /setup.
+func SetupRedirect(users UserStore) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if len(users) == 0 && r.URL.Path != "/setup" {
+				http.Redirect(w, r, "/setup", http.StatusSeeOther)
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// ----- User management -----
+
+func UsersGet(users UserStore, usersPath string, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		userList := make([]string, 0, len(users))
+		for u := range users {
+			userList = append(userList, u)
+		}
+		tmpl.ExecuteTemplate(w, "users.html", map[string]any{
+			"Users": userList,
+			"User":  User(r),
+		})
+	}
+}
+
+func UsersPost(users UserStore, usersPath string, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		action := r.FormValue("action")
+		switch action {
+		case "add":
+			username := strings.TrimSpace(r.FormValue("username"))
+			password := r.FormValue("password")
+			if err := users.Add(username, password); err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			users.Save(usersPath)
+		case "remove":
+			username := strings.TrimSpace(r.FormValue("username"))
+			if username == User(r) {
+				http.Error(w, "cannot remove yourself", http.StatusBadRequest)
+				return
+			}
+			users.Remove(username)
+			users.Save(usersPath)
+		case "change-password":
+			currentUser := User(r)
+			newPassword := r.FormValue("password")
+			if err := users.ChangePassword(currentUser, newPassword); err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			users.Save(usersPath)
+		}
+		http.Redirect(w, r, "/users", http.StatusSeeOther)
+	}
+}
diff --git a/config.go b/config.go
index 2eeed51..0c869bd 100644
--- a/config.go
+++ b/config.go
@@ -10,11 +10,13 @@ import (
 const envPrefix = "FIERJ_"
 
 type Config struct {
-	Addr       string `json:"addr" env:"ADDR"`
-	Host       string `json:"host" env:"HOST"`
-	Dir        string `json:"dir" env:"DIR"`
-	SSHAddr    string `json:"ssh_addr" env:"SSH_ADDR"`
-	SSHHostKey string `json:"ssh_host_key" env:"SSH_HOST_KEY"`
+	Addr        string `json:"addr" env:"ADDR"`
+	Host        string `json:"host" env:"HOST"`
+	Dir         string `json:"dir" env:"DIR"`
+	SSHAddr     string `json:"ssh_addr" env:"SSH_ADDR"`
+	SSHHostKey  string `json:"ssh_host_key" env:"SSH_HOST_KEY"`
+	UsersPath   string `json:"users_path" env:"USERS_PATH"`
+	CookieSecret string `json:"cookie_secret" env:"COOKIE_SECRET"`
 }
 
 func LoadConfig(path string) Config {
diff --git a/handlers.go b/handlers.go
index 0be93de..85044e5 100644
--- a/handlers.go
+++ b/handlers.go
@@ -97,6 +97,14 @@ func Breadcrumb(path string) []BreadcrumbItem {
 	return items
 }
 
+func withUser(r *http.Request, data map[string]any) map[string]any {
+	if data == nil {
+		data = map[string]any{}
+	}
+	data["User"] = User(r)
+	return data
+}
+
 func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		repos, err := ListRepos(cfg)
@@ -106,6 +114,7 @@ func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		}
 		tmpl.ExecuteTemplate(w, "repos.html", map[string]any{
 			"Repos": repos,
+			"User":  User(r),
 		})
 	}
 }
@@ -229,6 +238,7 @@ func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			return
 		}
 		data := map[string]any{
+			"User":        User(r),
 			"Repo":        git.Repo(),
 			"Ref":         ref,
 			"CommitList":  commits,
@@ -269,12 +279,20 @@ func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
 
 func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		tmpl.ExecuteTemplate(w, "new_repo.html", nil)
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		tmpl.ExecuteTemplate(w, "new_repo.html", map[string]any{"User": User(r)})
 	}
 }
 
 func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
 		name := strings.TrimSpace(r.FormValue("name"))
 		desc := strings.TrimSpace(r.FormValue("description"))
 		importURL := strings.TrimSpace(r.FormValue("import_url"))
diff --git a/main.go b/main.go
index ba8abdf..652b902 100644
--- a/main.go
+++ b/main.go
@@ -15,6 +15,8 @@ import (
 //go:embed templates/*.html
 var templateFS embed.FS
 
+type contextKey string
+
 func main() {
 	cfgPath := "config.json"
 	if len(os.Args) > 1 {
@@ -26,11 +28,36 @@ func main() {
 
 	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/{ref}/{path...}", Tree(cfg, tmpl))
 	mux.HandleFunc("GET /{repo}/blob/{ref}/{path...}", Blob(cfg, tmpl))
@@ -38,9 +65,16 @@ func main() {
 	mux.HandleFunc("GET /{repo}/commit/{ref}", Diff(cfg, tmpl))
 	mux.HandleFunc("GET /{repo}/refs/", Refs(cfg, tmpl))
 
+	// Wrap with auth middleware and setup redirect.
+	var handler http.Handler = mux
+	if len(cookieSecret) > 0 {
+		handler = SetupRedirect(users)(handler)
+		handler = authMiddleware(cookieSecret)(handler)
+	}
+
 	srv := &http.Server{
 		Addr:    cfg.Addr,
-		Handler: mux,
+		Handler: handler,
 	}
 
 	// Start SSH server if configured
@@ -73,3 +107,19 @@ func main() {
 	}
 	slog.Info("stopped")
 }
+
+// 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))
+		})
+	}
+}
diff --git a/templates/layout.html b/templates/layout.html
index dbfa22e..9a123ca 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -898,6 +898,15 @@
         </a>
         <div class="spacer"></div>
         <a class="nav-link" href="/new">+ New</a>
+        {{if .User}}
+        <a class="nav-link" href="/users">Users</a>
+        <span class="nav-link" style="opacity:0.7;">{{.User}}</span>
+        <form method="post" action="/logout" style="display:inline;">
+            <button type="submit" class="nav-link" style="background:none;border:none;cursor:pointer;font-size:0.85rem;">Sign out</button>
+        </form>
+        {{else}}
+        <a class="nav-link" href="/login">Sign in</a>
+        {{end}}
         <button onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
             <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
                 <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 12.5V2.5a5.5 5.5 0 0 1 0 11Z" />
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..dc515d3
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,25 @@
+{{template "head" .}}
+{{define "title"}}sign in - fierj{{end}}
+
+<div style="max-width:360px;margin:var(--space-xl) auto;">
+  <h1 style="margin-bottom:var(--space-lg);text-align:center;">Sign in to fierj</h1>
+
+  {{if .Error}}
+  <div style="background:var(--danger);color:#fff;padding:var(--space-sm) var(--space-md);border-radius:var(--radius);margin-bottom:var(--space-md);font-size:0.85rem;">
+    {{.Error}}
+  </div>
+  {{end}}
+
+  <form method="post">
+    <div class="form-group">
+      <label for="username">Username</label>
+      <input type="text" id="username" name="username" autofocus required>
+    </div>
+    <div class="form-group">
+      <label for="password">Password</label>
+      <input type="password" id="password" name="password" required>
+    </div>
+    <button type="submit" class="btn" style="width:100%;">Sign in</button>
+  </form>
+</div>
+{{template "foot" .}}
diff --git a/templates/setup.html b/templates/setup.html
new file mode 100644
index 0000000..113e392
--- /dev/null
+++ b/templates/setup.html
@@ -0,0 +1,32 @@
+{{template "head" .}}
+{{define "title"}}setup - fierj{{end}}
+
+<div style="max-width:360px;margin:var(--space-xl) auto;">
+  <h1 style="margin-bottom:var(--space-sm);text-align:center;">Welcome to fierj</h1>
+  <p style="text-align:center;color:var(--text-secondary);margin-bottom:var(--space-lg);">
+    Create the first admin account to get started.
+  </p>
+
+  {{if .Error}}
+  <div style="background:var(--danger);color:#fff;padding:var(--space-sm) var(--space-md);border-radius:var(--radius);margin-bottom:var(--space-md);font-size:0.85rem;">
+    {{.Error}}
+  </div>
+  {{end}}
+
+  <form method="post">
+    <div class="form-group">
+      <label for="username">Username</label>
+      <input type="text" id="username" name="username" autofocus required>
+    </div>
+    <div class="form-group">
+      <label for="password">Password</label>
+      <input type="password" id="password" name="password" required minlength="6">
+    </div>
+    <div class="form-group">
+      <label for="confirm">Confirm password</label>
+      <input type="password" id="confirm" name="confirm" required minlength="6">
+    </div>
+    <button type="submit" class="btn" style="width:100%;">Create account</button>
+  </form>
+</div>
+{{template "foot" .}}
diff --git a/templates/users.html b/templates/users.html
new file mode 100644
index 0000000..585b009
--- /dev/null
+++ b/templates/users.html
@@ -0,0 +1,58 @@
+{{template "head" .}}
+{{define "title"}}users - fierj{{end}}
+
+<h1 style="margin-bottom:var(--space-lg);">Users</h1>
+
+<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-xl);">
+  <!-- Add user -->
+  <div style="border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-lg);">
+    <h2 style="font-size:1rem;margin-bottom:var(--space-md);">Add user</h2>
+    <form method="post">
+      <input type="hidden" name="action" value="add">
+      <div class="form-group">
+        <label for="username">Username</label>
+        <input type="text" id="username" name="username" required>
+      </div>
+      <div class="form-group">
+        <label for="password">Password</label>
+        <input type="password" id="password" name="password" required minlength="6">
+      </div>
+      <button type="submit" class="btn">Add</button>
+    </form>
+  </div>
+
+  <!-- Change own password -->
+  <div style="border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-lg);">
+    <h2 style="font-size:1rem;margin-bottom:var(--space-md);">Change your password</h2>
+    <form method="post">
+      <input type="hidden" name="action" value="change-password">
+      <div class="form-group">
+        <label for="new-password">New password</label>
+        <input type="password" id="new-password" name="password" required minlength="6">
+      </div>
+      <button type="submit" class="btn">Update</button>
+    </form>
+  </div>
+</div>
+
+<!-- User list -->
+<div style="margin-top:var(--space-xl);">
+  <h2 style="font-size:1rem;margin-bottom:var(--space-md);">All users ({{len .Users}})</h2>
+  <div style="border:1px solid var(--border);border-radius:var(--radius);">
+    {{range .Users}}
+    <div style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-sm) var(--space-md);border-bottom:1px solid var(--border-subtle);">
+      <span>{{.}}</span>
+      {{if ne . $.User}}
+      <form method="post" style="display:inline;" onsubmit="return confirm('Remove {{.}}?')">
+        <input type="hidden" name="action" value="remove">
+        <input type="hidden" name="username" value="{{.}}">
+        <button type="submit" class="btn-subtle" style="padding:2px 10px;font-size:0.8rem;">Remove</button>
+      </form>
+      {{else}}
+      <span style="font-size:0.8rem;color:var(--text-tertiary);">you</span>
+      {{end}}
+    </div>
+    {{end}}
+  </div>
+</div>
+{{template "foot" .}}