feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation
Go backend with server-rendered HTML/HTMX frontend, SQLite database,
and filesystem image storage. Self-hostable single-binary architecture.
Phase 1 — Authentication & project foundation:
- Argon2id password hashing with timing-attack prevention
- Session management with cookie-based auth and periodic cleanup
- Login, signup (open/requests/closed modes), logout, forced password reset
- CSRF double-submit cookie pattern with HTMX auto-inclusion
- Proxy-aware real IP extraction (WireGuard/Tailscale support)
- Configurable base path for subdomain and subpath deployment
- Rate limiting on auth endpoints with background cleanup
- Security headers (CSP, X-Frame-Options, Referrer-Policy)
- Structured logging with slog, graceful shutdown
- Pico CSS + HTMX vendored and embedded via go:embed
Phase 2 — Faves CRUD with tags and images:
- Full CRUD for favorites with ownership checks
- Image upload with EXIF stripping, resize to 1920px, UUID filenames
- Tag system with HTMX autocomplete (prefix search, popularity-sorted)
- Privacy controls (public/private per fave, user-configurable default)
- Tag browsing, pagination, batch tag loading (avoids N+1)
- OpenGraph meta tags on public fave detail pages
Includes code quality pass: extracted shared helpers, fixed signup
request persistence bug, plugged rate limiter memory leak, removed
dead code, and logged previously-swallowed errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:55:22 +02:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
|
|
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
|
|
|
|
"kode.naiv.no/olemd/favoritter/internal/render"
|
|
|
|
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var usernameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_-]{2,30}$`)
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// If already logged in, redirect to home.
|
|
|
|
|
if middleware.UserFromContext(r.Context()) != nil {
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.deps.Renderer.Page(w, r, "login", render.PageData{
|
|
|
|
|
Title: "Logg inn",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
h.flash(w, r, "login", "Ugyldig forespørsel.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
username := strings.TrimSpace(r.FormValue("username"))
|
|
|
|
|
password := r.FormValue("password")
|
|
|
|
|
|
|
|
|
|
user, err := h.deps.Users.Authenticate(username, password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrInvalidCredentials) || errors.Is(err, store.ErrUserDisabled) {
|
|
|
|
|
h.flash(w, r, "login", "Feil brukernavn eller passord.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
slog.Error("login authentication error", "error", err)
|
|
|
|
|
h.flash(w, r, "login", "Noe gikk galt. Prøv igjen.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
token, err := h.deps.Sessions.Create(user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("session create error", "error", err)
|
|
|
|
|
h.flash(w, r, "login", "Noe gikk galt. Prøv igjen.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.setSessionCookie(w, r, token)
|
|
|
|
|
|
|
|
|
|
// If user must reset password, redirect there.
|
|
|
|
|
if user.MustResetPassword {
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/reset-password", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/", http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleSignupGet(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if middleware.UserFromContext(r.Context()) != nil {
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
settings, err := h.deps.Settings.Get()
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("get settings error", "error", err)
|
|
|
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.deps.Renderer.Page(w, r, "signup", render.PageData{
|
|
|
|
|
Title: "Registrer",
|
|
|
|
|
Data: map[string]string{"Mode": settings.SignupMode},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleSignupPost(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
h.flash(w, r, "signup", "Ugyldig forespørsel.", "error", signupData("open"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
settings, err := h.deps.Settings.Get()
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("get settings error", "error", err)
|
|
|
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if settings.SignupClosed() {
|
|
|
|
|
h.flash(w, r, "signup", "Registrering er stengt.", "error", signupData("closed"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
username := strings.TrimSpace(r.FormValue("username"))
|
|
|
|
|
password := r.FormValue("password")
|
|
|
|
|
passwordConfirm := r.FormValue("password_confirm")
|
|
|
|
|
|
|
|
|
|
// Validate input.
|
|
|
|
|
if !usernameRegexp.MatchString(username) {
|
|
|
|
|
h.flash(w, r, "signup", "Ugyldig brukernavn. Bruk 2-30 tegn: bokstaver, tall, bindestrek, understrek.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(password) < 8 {
|
|
|
|
|
h.flash(w, r, "signup", "Passordet må være minst 8 tegn.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if password != passwordConfirm {
|
|
|
|
|
h.flash(w, r, "signup", "Passordene er ikke like.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if settings.SignupRequests() {
|
|
|
|
|
// Create a signup request for admin approval.
|
|
|
|
|
err := h.createSignupRequest(username, password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrUserExists) {
|
|
|
|
|
h.flash(w, r, "signup", "Brukernavnet er allerede i bruk.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
slog.Error("signup request error", "error", err)
|
|
|
|
|
h.flash(w, r, "signup", "Noe gikk galt. Prøv igjen.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.flash(w, r, "login", "Forespørselen din er sendt. En administrator vil gjennomgå den.", "info", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Open signup — create user directly.
|
|
|
|
|
user, err := h.deps.Users.Create(username, password, "user")
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrUserExists) {
|
|
|
|
|
h.flash(w, r, "signup", "Brukernavnet er allerede i bruk.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
slog.Error("user create error", "error", err)
|
|
|
|
|
h.flash(w, r, "signup", "Noe gikk galt. Prøv igjen.", "error", signupData(settings.SignupMode))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-login after signup.
|
|
|
|
|
token, err := h.deps.Sessions.Create(user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("session create error", "error", err)
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.setSessionCookie(w, r, token)
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/", http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cookie, err := r.Cookie(middleware.SessionCookieName)
|
|
|
|
|
if err == nil {
|
|
|
|
|
if delErr := h.deps.Sessions.Delete(cookie.Value); delErr != nil {
|
|
|
|
|
slog.Error("session delete error", "error", delErr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
middleware.ClearSessionCookie(w)
|
|
|
|
|
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleResetPasswordGet(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user := middleware.UserFromContext(r.Context())
|
|
|
|
|
if user == nil {
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !user.MustResetPassword {
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.deps.Renderer.Page(w, r, "reset_password", render.PageData{
|
|
|
|
|
Title: "Endre passord",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleResetPasswordPost(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user := middleware.UserFromContext(r.Context())
|
|
|
|
|
if user == nil {
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
h.flash(w, r, "reset_password", "Ugyldig forespørsel.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
password := r.FormValue("password")
|
|
|
|
|
passwordConfirm := r.FormValue("password_confirm")
|
|
|
|
|
|
|
|
|
|
if len(password) < 8 {
|
|
|
|
|
h.flash(w, r, "reset_password", "Passordet må være minst 8 tegn.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if password != passwordConfirm {
|
|
|
|
|
h.flash(w, r, "reset_password", "Passordene er ikke like.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := h.deps.Users.UpdatePassword(user.ID, password); err != nil {
|
|
|
|
|
slog.Error("update password error", "error", err)
|
|
|
|
|
h.flash(w, r, "reset_password", "Noe gikk galt. Prøv igjen.", "error", nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if delErr := h.deps.Sessions.DeleteAllForUser(user.ID); delErr != nil {
|
|
|
|
|
slog.Error("session cleanup error", "error", delErr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a fresh session.
|
|
|
|
|
token, err := h.deps.Sessions.Create(user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("session create error", "error", err)
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.setSessionCookie(w, r, token)
|
|
|
|
|
http.Redirect(w, r, h.deps.Config.BasePath+"/", http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleHome(w http.ResponseWriter, r *http.Request) {
|
feat: add profiles, public views, settings, and code quality fixes
Phase 3 — Profiles & Public Views:
- Public profile page (/u/{username}) with OG meta tags
- User settings page (display name, bio, visibility, default privacy)
- Avatar upload with image processing
- Password change from settings (verifies current password)
- Home page shows public fave feed for logged-in users
- Must-reset-password guard redirects to /reset-password
- Profile visibility: public (full) or limited (username only)
Code quality improvements from /simplify review:
- Fix signup request persistence bug (was silently discarding data)
- Fix health check to use configured listen address, not hardcoded :8080
- Add rate limiter cleanup goroutine (was leaking memory)
- Extract shared helpers: ClearSessionCookie, IsSecureRequest, scanTags,
scanUserFrom (scanner interface), SignupRequestStore
- Replace hand-rolled joinPlaceholders with strings.Join
- Remove dead _method hidden field, redundant devMode field
- Simplify rate-limited route registration (remove double-mux)
- Log previously-swallowed errors (session delete, image delete)
- Stop leaking internal error messages to users in image upload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:01:41 +02:00
|
|
|
user := middleware.UserFromContext(r.Context())
|
|
|
|
|
|
|
|
|
|
var data map[string]any
|
|
|
|
|
if user != nil {
|
|
|
|
|
page := queryInt(r, "page", 1)
|
|
|
|
|
offset := (page - 1) * defaultPageSize
|
|
|
|
|
faves, total, err := h.deps.Faves.ListPublic(defaultPageSize, offset)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("list public faves error", "error", err)
|
|
|
|
|
} else {
|
|
|
|
|
h.deps.Faves.LoadTags(faves)
|
|
|
|
|
totalPages := (total + defaultPageSize - 1) / defaultPageSize
|
|
|
|
|
data = map[string]any{
|
|
|
|
|
"Faves": faves,
|
|
|
|
|
"Page": page,
|
|
|
|
|
"TotalPages": totalPages,
|
|
|
|
|
"Total": total,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.deps.Renderer.Page(w, r, "home", render.PageData{Data: data})
|
feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation
Go backend with server-rendered HTML/HTMX frontend, SQLite database,
and filesystem image storage. Self-hostable single-binary architecture.
Phase 1 — Authentication & project foundation:
- Argon2id password hashing with timing-attack prevention
- Session management with cookie-based auth and periodic cleanup
- Login, signup (open/requests/closed modes), logout, forced password reset
- CSRF double-submit cookie pattern with HTMX auto-inclusion
- Proxy-aware real IP extraction (WireGuard/Tailscale support)
- Configurable base path for subdomain and subpath deployment
- Rate limiting on auth endpoints with background cleanup
- Security headers (CSP, X-Frame-Options, Referrer-Policy)
- Structured logging with slog, graceful shutdown
- Pico CSS + HTMX vendored and embedded via go:embed
Phase 2 — Faves CRUD with tags and images:
- Full CRUD for favorites with ownership checks
- Image upload with EXIF stripping, resize to 1920px, UUID filenames
- Tag system with HTMX autocomplete (prefix search, popularity-sorted)
- Privacy controls (public/private per fave, user-configurable default)
- Tag browsing, pagination, batch tag loading (avoids N+1)
- OpenGraph meta tags on public fave detail pages
Includes code quality pass: extracted shared helpers, fixed signup
request persistence bug, plugged rate limiter memory leak, removed
dead code, and logged previously-swallowed errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:55:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// setSessionCookie sets the session cookie with appropriate flags for
|
|
|
|
|
// both local and remote proxy deployments.
|
|
|
|
|
func (h *Handler) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
|
|
|
|
cookie := &http.Cookie{
|
|
|
|
|
Name: "session",
|
|
|
|
|
Value: token,
|
|
|
|
|
Path: "/",
|
|
|
|
|
MaxAge: int(h.deps.Config.SessionLifetime.Seconds()),
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: middleware.IsSecureRequest(r, h.deps.Config),
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set domain from external URL if configured.
|
|
|
|
|
if hostname := h.deps.Config.ExternalHostname(); hostname != "" {
|
|
|
|
|
cookie.Domain = hostname
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
http.SetCookie(w, cookie)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// flash renders a page with a flash message.
|
|
|
|
|
func (h *Handler) flash(w http.ResponseWriter, r *http.Request, page, message, flashType string, data any) {
|
|
|
|
|
h.deps.Renderer.Page(w, r, page, render.PageData{
|
|
|
|
|
Flash: message,
|
|
|
|
|
FlashType: flashType,
|
|
|
|
|
Data: data,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func signupData(mode string) map[string]string {
|
|
|
|
|
return map[string]string{"Mode": mode}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) createSignupRequest(username, password string) error {
|
|
|
|
|
// Check if username is already taken by an existing user.
|
|
|
|
|
_, err := h.deps.Users.GetByUsername(username)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return store.ErrUserExists
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = h.deps.SignupRequests.Create(username, password)
|
|
|
|
|
if errors.Is(err, store.ErrSignupRequestExists) {
|
|
|
|
|
return store.ErrUserExists
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|