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>
This commit is contained in:
commit
fc1f7259c5
52 changed files with 5459 additions and 0 deletions
299
internal/handler/auth.go
Normal file
299
internal/handler/auth.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
// 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) {
|
||||
h.deps.Renderer.Page(w, r, "home", render.PageData{})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
416
internal/handler/fave.go
Normal file
416
internal/handler/fave.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kode.naiv.no/olemd/favoritter/internal/image"
|
||||
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
||||
"kode.naiv.no/olemd/favoritter/internal/render"
|
||||
"kode.naiv.no/olemd/favoritter/internal/store"
|
||||
)
|
||||
|
||||
const defaultPageSize = 24
|
||||
|
||||
// handleFaveList shows the current user's faves.
|
||||
func (h *Handler) handleFaveList(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
page := queryInt(r, "page", 1)
|
||||
offset := (page - 1) * defaultPageSize
|
||||
|
||||
faves, total, err := h.deps.Faves.ListByUser(user.ID, defaultPageSize, offset)
|
||||
if err != nil {
|
||||
slog.Error("list faves error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Load tags for each fave.
|
||||
if err := h.deps.Faves.LoadTags(faves); err != nil {
|
||||
slog.Error("load tags error", "error", err)
|
||||
}
|
||||
|
||||
totalPages := (total + defaultPageSize - 1) / defaultPageSize
|
||||
|
||||
h.deps.Renderer.Page(w, r, "fave_list", render.PageData{
|
||||
Title: "Mine favoritter",
|
||||
Data: map[string]any{
|
||||
"Faves": faves,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"Total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleFaveNew shows the form for creating a new fave.
|
||||
func (h *Handler) handleFaveNew(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
|
||||
h.deps.Renderer.Page(w, r, "fave_form", render.PageData{
|
||||
Title: "Ny favoritt",
|
||||
Data: map[string]any{
|
||||
"IsNew": true,
|
||||
"DefaultPrivacy": user.DefaultFavePrivacy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleFaveCreate processes the form for creating a new fave.
|
||||
func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
|
||||
if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil {
|
||||
h.flash(w, r, "fave_form", "Filen er for stor.", "error", map[string]any{"IsNew": true})
|
||||
return
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(r.FormValue("description"))
|
||||
url := strings.TrimSpace(r.FormValue("url"))
|
||||
privacy := r.FormValue("privacy")
|
||||
tagStr := r.FormValue("tags")
|
||||
|
||||
if description == "" {
|
||||
h.flash(w, r, "fave_form", "Beskrivelse er påkrevd.", "error", map[string]any{
|
||||
"IsNew": true, "Description": description, "URL": url, "Tags": tagStr, "Privacy": privacy,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if privacy != "public" && privacy != "private" {
|
||||
privacy = user.DefaultFavePrivacy
|
||||
}
|
||||
|
||||
// Handle image upload.
|
||||
var imagePath string
|
||||
file, header, err := r.FormFile("image")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
result, err := image.Process(file, header, h.deps.Config.UploadDir)
|
||||
if err != nil {
|
||||
slog.Error("image process error", "error", err)
|
||||
h.flash(w, r, "fave_form", "Kunne ikke behandle bildet. Sjekk at filen er et gyldig bilde (JPEG, PNG, GIF eller WebP).", "error", map[string]any{
|
||||
"IsNew": true, "Description": description, "URL": url, "Tags": tagStr, "Privacy": privacy,
|
||||
})
|
||||
return
|
||||
}
|
||||
imagePath = result.Filename
|
||||
}
|
||||
|
||||
// Create the fave.
|
||||
fave, err := h.deps.Faves.Create(user.ID, description, url, imagePath, privacy)
|
||||
if err != nil {
|
||||
slog.Error("create fave error", "error", err)
|
||||
h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": true})
|
||||
return
|
||||
}
|
||||
|
||||
// Set tags.
|
||||
if tagStr != "" {
|
||||
tags := parseTags(tagStr)
|
||||
if err := h.deps.Tags.SetFaveTags(fave.ID, tags); err != nil {
|
||||
slog.Error("set tags error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, h.deps.Config.BasePath+"/faves/"+strconv.FormatInt(fave.ID, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleFaveDetail shows a single fave.
|
||||
func (h *Handler) handleFaveDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
fave, err := h.deps.Faves.GetByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrFaveNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("get fave error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check access: private faves are only visible to their owner.
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
if fave.Privacy == "private" && (user == nil || user.ID != fave.UserID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Load tags.
|
||||
tags, err := h.deps.Tags.ForFave(fave.ID)
|
||||
if err != nil {
|
||||
slog.Error("load tags error", "error", err)
|
||||
}
|
||||
fave.Tags = tags
|
||||
|
||||
h.deps.Renderer.Page(w, r, "fave_detail", render.PageData{
|
||||
Title: fave.Description,
|
||||
Data: map[string]any{
|
||||
"Fave": fave,
|
||||
"IsOwner": user != nil && user.ID == fave.UserID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleFaveEdit shows the edit form for a fave.
|
||||
func (h *Handler) handleFaveEdit(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
fave, err := h.deps.Faves.GetByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrFaveNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("get fave error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Only the owner can edit.
|
||||
if user.ID != fave.UserID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
tags, _ := h.deps.Tags.ForFave(fave.ID)
|
||||
fave.Tags = tags
|
||||
|
||||
tagNames := make([]string, len(tags))
|
||||
for i, t := range tags {
|
||||
tagNames[i] = t.Name
|
||||
}
|
||||
|
||||
h.deps.Renderer.Page(w, r, "fave_form", render.PageData{
|
||||
Title: "Rediger favoritt",
|
||||
Data: map[string]any{
|
||||
"IsNew": false,
|
||||
"Fave": fave,
|
||||
"Description": fave.Description,
|
||||
"URL": fave.URL,
|
||||
"Privacy": fave.Privacy,
|
||||
"Tags": strings.Join(tagNames, ", "),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleFaveUpdate processes the edit form.
|
||||
func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
fave, err := h.deps.Faves.GetByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrFaveNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("get fave error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID != fave.UserID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(h.deps.Config.MaxUploadSize); err != nil {
|
||||
h.flash(w, r, "fave_form", "Filen er for stor.", "error", map[string]any{"IsNew": false, "Fave": fave})
|
||||
return
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(r.FormValue("description"))
|
||||
url := strings.TrimSpace(r.FormValue("url"))
|
||||
privacy := r.FormValue("privacy")
|
||||
tagStr := r.FormValue("tags")
|
||||
|
||||
if description == "" {
|
||||
h.flash(w, r, "fave_form", "Beskrivelse er påkrevd.", "error", map[string]any{"IsNew": false, "Fave": fave})
|
||||
return
|
||||
}
|
||||
|
||||
if privacy != "public" && privacy != "private" {
|
||||
privacy = fave.Privacy
|
||||
}
|
||||
|
||||
// Handle image upload (optional on edit).
|
||||
imagePath := fave.ImagePath
|
||||
file, header, err := r.FormFile("image")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
result, err := image.Process(file, header, h.deps.Config.UploadDir)
|
||||
if err != nil {
|
||||
slog.Error("image process error", "error", err)
|
||||
h.flash(w, r, "fave_form", "Kunne ikke behandle bildet. Sjekk at filen er et gyldig bilde (JPEG, PNG, GIF eller WebP).", "error", map[string]any{"IsNew": false, "Fave": fave})
|
||||
return
|
||||
}
|
||||
if fave.ImagePath != "" {
|
||||
if delErr := image.Delete(h.deps.Config.UploadDir, fave.ImagePath); delErr != nil {
|
||||
slog.Error("image delete error", "error", delErr)
|
||||
}
|
||||
}
|
||||
imagePath = result.Filename
|
||||
}
|
||||
|
||||
// Check if user wants to remove the existing image.
|
||||
if r.FormValue("remove_image") == "1" && imagePath != "" {
|
||||
if delErr := image.Delete(h.deps.Config.UploadDir, imagePath); delErr != nil {
|
||||
slog.Error("image delete error", "error", delErr)
|
||||
}
|
||||
imagePath = ""
|
||||
}
|
||||
|
||||
if err := h.deps.Faves.Update(id, description, url, imagePath, privacy); err != nil {
|
||||
slog.Error("update fave error", "error", err)
|
||||
h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": false, "Fave": fave})
|
||||
return
|
||||
}
|
||||
|
||||
// Update tags.
|
||||
tags := parseTags(tagStr)
|
||||
if err := h.deps.Tags.SetFaveTags(id, tags); err != nil {
|
||||
slog.Error("set tags error", "error", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, h.deps.Config.BasePath+"/faves/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleFaveDelete deletes a fave.
|
||||
func (h *Handler) handleFaveDelete(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.UserFromContext(r.Context())
|
||||
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
fave, err := h.deps.Faves.GetByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrFaveNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("get fave error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID != fave.UserID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if fave.ImagePath != "" {
|
||||
if delErr := image.Delete(h.deps.Config.UploadDir, fave.ImagePath); delErr != nil {
|
||||
slog.Error("image delete error", "error", delErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.deps.Faves.Delete(id); err != nil {
|
||||
slog.Error("delete fave error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If this was an HTMX request, return empty (the element is removed).
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, h.deps.Config.BasePath+"/faves", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleTagSearch handles tag autocomplete HTMX requests.
|
||||
func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
tags, err := h.deps.Tags.Search(q, 10)
|
||||
if err != nil {
|
||||
slog.Error("tag search error", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.deps.Renderer.Partial(w, "tag_suggestions", tags); err != nil {
|
||||
slog.Error("render tag suggestions error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTagBrowse shows all public faves with a given tag.
|
||||
func (h *Handler) handleTagBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
tagName := r.PathValue("name")
|
||||
page := queryInt(r, "page", 1)
|
||||
offset := (page - 1) * defaultPageSize
|
||||
|
||||
faves, total, err := h.deps.Faves.ListByTag(tagName, defaultPageSize, offset)
|
||||
if err != nil {
|
||||
slog.Error("list by tag error", "error", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.deps.Faves.LoadTags(faves); err != nil {
|
||||
slog.Error("load tags error", "error", err)
|
||||
}
|
||||
|
||||
totalPages := (total + defaultPageSize - 1) / defaultPageSize
|
||||
|
||||
h.deps.Renderer.Page(w, r, "tag_browse", render.PageData{
|
||||
Title: "Merkelapp: " + tagName,
|
||||
Data: map[string]any{
|
||||
"TagName": tagName,
|
||||
"Faves": faves,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"Total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseTags splits a comma-separated tag string into individual tag names.
|
||||
func parseTags(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
var tags []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
tags = append(tags, p)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// queryInt parses an integer query parameter with a default.
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
v := r.URL.Query().Get(key)
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
107
internal/handler/handler.go
Normal file
107
internal/handler/handler.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package handler contains HTTP handlers for all web and API routes.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"kode.naiv.no/olemd/favoritter/internal/config"
|
||||
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
||||
"kode.naiv.no/olemd/favoritter/internal/render"
|
||||
"kode.naiv.no/olemd/favoritter/internal/store"
|
||||
"kode.naiv.no/olemd/favoritter/web"
|
||||
)
|
||||
|
||||
// Deps bundles all dependencies injected into handlers.
|
||||
type Deps struct {
|
||||
Config *config.Config
|
||||
Users *store.UserStore
|
||||
Sessions *store.SessionStore
|
||||
Settings *store.SettingsStore
|
||||
Faves *store.FaveStore
|
||||
Tags *store.TagStore
|
||||
SignupRequests *store.SignupRequestStore
|
||||
Renderer *render.Renderer
|
||||
}
|
||||
|
||||
// Handler holds all HTTP handler methods and their dependencies.
|
||||
type Handler struct {
|
||||
deps Deps
|
||||
rateLimiter *middleware.RateLimiter
|
||||
}
|
||||
|
||||
// New creates a new Handler with the given dependencies.
|
||||
func New(deps Deps) *Handler {
|
||||
return &Handler{
|
||||
deps: deps,
|
||||
rateLimiter: middleware.NewRateLimiter(deps.Config.RateLimit),
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimiterCleanupLoop periodically evicts stale rate limiter entries.
|
||||
func (h *Handler) RateLimiterCleanupLoop(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.rateLimiter.Cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Routes registers all routes on a new ServeMux and returns it.
|
||||
func (h *Handler) Routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static files (served from embedded filesystem).
|
||||
staticFS, err := fs.Sub(web.StaticFS, "static")
|
||||
if err != nil {
|
||||
panic("embedded static filesystem missing: " + err.Error())
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Uploaded images (served from the filesystem upload directory).
|
||||
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/",
|
||||
http.FileServer(http.Dir(h.deps.Config.UploadDir))))
|
||||
|
||||
// Health check.
|
||||
mux.HandleFunc("GET /health", h.handleHealth)
|
||||
|
||||
// Auth routes (rate-limited).
|
||||
mux.Handle("POST /login", h.rateLimiter.Limit(http.HandlerFunc(h.handleLoginPost)))
|
||||
mux.Handle("POST /signup", h.rateLimiter.Limit(http.HandlerFunc(h.handleSignupPost)))
|
||||
|
||||
mux.HandleFunc("GET /login", h.handleLoginGet)
|
||||
mux.HandleFunc("GET /signup", h.handleSignupGet)
|
||||
mux.HandleFunc("POST /logout", h.handleLogout)
|
||||
|
||||
// Password reset (for must-reset-password flow).
|
||||
mux.HandleFunc("GET /reset-password", h.handleResetPasswordGet)
|
||||
mux.HandleFunc("POST /reset-password", h.handleResetPasswordPost)
|
||||
|
||||
// Home page.
|
||||
mux.HandleFunc("GET /{$}", h.handleHome)
|
||||
|
||||
// Faves — authenticated routes use requireLogin wrapper.
|
||||
requireLogin := middleware.RequireLogin(h.deps.Config.BasePath)
|
||||
mux.Handle("GET /faves", requireLogin(http.HandlerFunc(h.handleFaveList)))
|
||||
mux.Handle("GET /faves/new", requireLogin(http.HandlerFunc(h.handleFaveNew)))
|
||||
mux.Handle("POST /faves", requireLogin(http.HandlerFunc(h.handleFaveCreate)))
|
||||
mux.HandleFunc("GET /faves/{id}", h.handleFaveDetail)
|
||||
mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit)))
|
||||
mux.Handle("POST /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveUpdate)))
|
||||
mux.Handle("DELETE /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveDelete)))
|
||||
|
||||
// Tags.
|
||||
mux.HandleFunc("GET /tags/search", h.handleTagSearch)
|
||||
mux.HandleFunc("GET /tags/{name}", h.handleTagBrowse)
|
||||
|
||||
return mux
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue