fierj
PublicTiny 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" .}}