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:
Ole-Morten Duesund 2026-03-29 15:55:22 +02:00
commit fc1f7259c5
52 changed files with 5459 additions and 0 deletions

View file

@ -0,0 +1,73 @@
{{define "head"}}
{{with .Data}}{{with .Fave}}
{{if eq .Privacy "public"}}
<meta property="og:title" content="{{truncate 70 .Description}}">
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
<meta property="og:type" content="article">
{{if $.ExternalURL}}
<meta property="og:url" content="{{$.ExternalURL}}/faves/{{.ID}}">
{{if .ImagePath}}
<meta property="og:image" content="{{$.ExternalURL}}/uploads/{{.ImagePath}}">
<meta name="twitter:card" content="summary_large_image">
{{else}}
<meta name="twitter:card" content="summary">
{{end}}
{{end}}
<meta property="og:site_name" content="{{$.SiteName}}">
{{range .Tags}}
<meta property="article:tag" content="{{.Name}}">
{{end}}
{{end}}
{{end}}{{end}}
{{end}}
{{define "content"}}
{{with .Data}}
<article>
{{with .Fave}}
{{if .ImagePath}}
<img src="{{basePath}}/uploads/{{.ImagePath}}"
alt="Bilde for: {{.Description}}">
{{end}}
<header>
<h1>{{.Description}}</h1>
<p>
Av <a href="{{basePath}}/u/{{.Username}}">{{.DisplayName}}</a>
{{if eq .Privacy "private"}}
<small class="badge-private" aria-label="Privat">Privat</small>
{{end}}
</p>
</header>
{{if .URL}}
<p><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></p>
{{end}}
{{if .Tags}}
<p>
{{range .Tags}}
<a href="{{basePath}}/tags/{{.Name}}" class="tag-chip">{{.Name}}</a>
{{end}}
</p>
{{end}}
<footer>
<small>Lagt til {{.CreatedAt.Format "02.01.2006"}}</small>
{{if $.IsOwner}}
<nav class="fave-actions">
<a href="{{basePath}}/faves/{{.ID}}/edit" role="button" class="outline">Rediger</a>
<button
hx-delete="{{basePath}}/faves/{{.ID}}"
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
hx-headers='{"X-CSRF-Token": "{{$.CSRFToken}}"}'
class="outline secondary"
data-redirect="{{basePath}}/faves"
>Slett</button>
</nav>
{{end}}
</footer>
{{end}}
</article>
{{end}}
{{end}}

View file

@ -0,0 +1,92 @@
{{define "content"}}
<article>
{{with .Data}}
{{if .IsNew}}
<h1>Ny favoritt</h1>
{{else}}
<h1>Rediger favoritt</h1>
{{end}}
<form method="POST"
enctype="multipart/form-data"
{{if .IsNew}}
action="{{basePath}}/faves"
{{else}}
action="{{basePath}}/faves/{{.Fave.ID}}"
{{end}}>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label for="description">
Beskrivelse *
<input type="text" id="description" name="description"
value="{{.Description}}" required autofocus
placeholder="F.eks. «Blade Runner 2049» eller «Rød tulipan»">
</label>
<label for="url">
Lenke (valgfri)
<input type="url" id="url" name="url"
value="{{.URL}}"
placeholder="https://...">
</label>
<label for="image">
Bilde (valgfri)
<input type="file" id="image" name="image"
accept="image/jpeg,image/png,image/gif,image/webp">
</label>
{{if not .IsNew}}
{{if .Fave.ImagePath}}
<div class="current-image">
<img src="{{basePath}}/uploads/{{.Fave.ImagePath}}"
alt="Nåværende bilde"
style="max-width: 200px; max-height: 200px;">
<label>
<input type="checkbox" name="remove_image" value="1">
Fjern bilde
</label>
</div>
{{end}}
{{end}}
<label for="tags">
Merkelapper (kommaseparert)
<input type="text" id="tags" name="tags"
value="{{.Tags}}"
placeholder="film, sci-fi, favoritt"
autocomplete="off"
hx-get="{{basePath}}/tags/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#tag-suggestions"
hx-params="*"
hx-vals='{"q": ""}'
aria-describedby="tags-help"
aria-autocomplete="list"
aria-controls="tag-suggestions">
<small id="tags-help">Skriv for å søke i eksisterende merkelapper. Maks {{maxTags}} stk.</small>
</label>
<ul id="tag-suggestions" role="listbox" class="tag-suggestions" aria-label="Merkelappforslag"></ul>
<fieldset>
<legend>Synlighet</legend>
<label>
<input type="radio" name="privacy" value="public"
{{if eq .Privacy "public"}}checked{{end}}
{{if .IsNew}}{{if eq .DefaultPrivacy "public"}}checked{{end}}{{end}}>
Offentlig
</label>
<label>
<input type="radio" name="privacy" value="private"
{{if eq .Privacy "private"}}checked{{end}}
{{if .IsNew}}{{if eq .DefaultPrivacy "private"}}checked{{end}}{{end}}>
Privat
</label>
</fieldset>
<button type="submit">
{{if .IsNew}}Legg til{{else}}Lagre{{end}}
</button>
</form>
{{end}}
</article>
{{end}}

View file

@ -0,0 +1,56 @@
{{define "content"}}
<header>
<hgroup>
<h1>Mine favoritter</h1>
<p>{{with .Data}}{{.Total}} favoritter totalt{{end}}</p>
</hgroup>
<a href="{{basePath}}/faves/new" role="button">+ Ny favoritt</a>
</header>
{{with .Data}}
{{if .Faves}}
<div class="fave-grid" role="list">
{{range .Faves}}
<article class="fave-card" role="listitem">
{{if .ImagePath}}
<img src="{{basePath}}/uploads/{{.ImagePath}}"
alt="Bilde for: {{.Description}}"
loading="lazy">
{{end}}
<header>
<a href="{{basePath}}/faves/{{.ID}}">
<strong>{{.Description}}</strong>
</a>
{{if eq .Privacy "private"}}
<small class="badge-private" aria-label="Privat">Privat</small>
{{end}}
</header>
{{if .Tags}}
<footer>
{{range .Tags}}
<a href="{{basePath}}/tags/{{.Name}}" class="tag-chip">{{.Name}}</a>
{{end}}
</footer>
{{end}}
</article>
{{end}}
</div>
{{if gt .TotalPages 1}}
<nav aria-label="Sidenavigasjon">
<ul>
{{if gt .Page 1}}
<li><a href="{{basePath}}/faves?page={{subtract .Page 1}}">← Forrige</a></li>
{{end}}
<li>Side {{.Page}} av {{.TotalPages}}</li>
{{if lt .Page .TotalPages}}
<li><a href="{{basePath}}/faves?page={{add .Page 1}}">Neste →</a></li>
{{end}}
</ul>
</nav>
{{end}}
{{else}}
<p>Du har ingen favoritter ennå. <a href="{{basePath}}/faves/new">Legg til din første!</a></p>
{{end}}
{{end}}
{{end}}

View file

@ -0,0 +1,13 @@
{{define "content"}}
<hgroup>
<h1>Velkommen til {{.SiteName}}</h1>
<p>Del dine favoritter med verden — eller behold dem for deg selv.</p>
</hgroup>
{{if not .User}}
<div class="grid">
<a href="{{basePath}}/login" role="button">Logg inn</a>
<a href="{{basePath}}/signup" role="button" class="outline">Registrer deg</a>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,24 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<article>
<h1>Logg inn</h1>
<form method="POST" action="{{basePath}}/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">
Brukernavn
<input type="text" id="username" name="username" required autofocus
autocomplete="username" autocapitalize="none">
</label>
<label for="password">
Passord
<input type="password" id="password" name="password" required
autocomplete="current-password">
</label>
<button type="submit">Logg inn</button>
</form>
<p><a href="{{basePath}}/signup">Har du ikke konto? Registrer deg</a></p>
</article>
{{end}}

View file

@ -0,0 +1,24 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<article>
<h1>Endre passord</h1>
<p>Du må velge et nytt passord før du kan fortsette.</p>
<form method="POST" action="{{basePath}}/reset-password">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="password">
Nytt passord
<input type="password" id="password" name="password" required autofocus
autocomplete="new-password" minlength="8">
</label>
<label for="password_confirm">
Bekreft nytt passord
<input type="password" id="password_confirm" name="password_confirm" required
autocomplete="new-password">
</label>
<button type="submit">Lagre nytt passord</button>
</form>
</article>
{{end}}

View file

@ -0,0 +1,62 @@
{{define "head"}}
<meta name="robots" content="noindex">
{{end}}
{{define "content"}}
<article>
{{with .Data}}
{{if eq .Mode "closed"}}
<h1>Registrering stengt</h1>
<p>Nye registreringer er for øyeblikket ikke tilgjengelig. Kontakt administrator for tilgang.</p>
{{else if eq .Mode "requests"}}
<h1>Be om tilgang</h1>
<p>Nye brukere må godkjennes av en administrator.</p>
<form method="POST" action="{{basePath}}/signup">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label for="username">
Brukernavn
<input type="text" id="username" name="username" required autofocus
autocomplete="username" autocapitalize="none"
pattern="[a-zA-Z0-9_-]+" title="Bare bokstaver, tall, bindestrek og understrek"
minlength="2" maxlength="30">
</label>
<label for="password">
Passord
<input type="password" id="password" name="password" required
autocomplete="new-password" minlength="8">
</label>
<label for="password_confirm">
Bekreft passord
<input type="password" id="password_confirm" name="password_confirm" required
autocomplete="new-password">
</label>
<button type="submit">Send forespørsel</button>
</form>
{{else}}
<h1>Registrer deg</h1>
<form method="POST" action="{{basePath}}/signup">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label for="username">
Brukernavn
<input type="text" id="username" name="username" required autofocus
autocomplete="username" autocapitalize="none"
pattern="[a-zA-Z0-9_-]+" title="Bare bokstaver, tall, bindestrek og understrek"
minlength="2" maxlength="30">
</label>
<label for="password">
Passord
<input type="password" id="password" name="password" required
autocomplete="new-password" minlength="8">
</label>
<label for="password_confirm">
Bekreft passord
<input type="password" id="password_confirm" name="password_confirm" required
autocomplete="new-password">
</label>
<button type="submit">Registrer</button>
</form>
{{end}}
{{end}}
<p><a href="{{basePath}}/login">Har du allerede konto? Logg inn</a></p>
</article>
{{end}}

View file

@ -0,0 +1,51 @@
{{define "content"}}
{{with .Data}}
<hgroup>
<h1>Merkelapp: {{.TagName}}</h1>
<p>{{.Total}} offentlige favoritter</p>
</hgroup>
{{if .Faves}}
<div class="fave-grid" role="list">
{{range .Faves}}
<article class="fave-card" role="listitem">
{{if .ImagePath}}
<img src="{{basePath}}/uploads/{{.ImagePath}}"
alt="Bilde for: {{.Description}}"
loading="lazy">
{{end}}
<header>
<a href="{{basePath}}/faves/{{.ID}}">
<strong>{{.Description}}</strong>
</a>
<small>av <a href="{{basePath}}/u/{{.Username}}">{{.DisplayName}}</a></small>
</header>
{{if .Tags}}
<footer>
{{range .Tags}}
<a href="{{basePath}}/tags/{{.Name}}" class="tag-chip">{{.Name}}</a>
{{end}}
</footer>
{{end}}
</article>
{{end}}
</div>
{{if gt .TotalPages 1}}
<nav aria-label="Sidenavigasjon">
<ul>
{{if gt .Page 1}}
<li><a href="{{basePath}}/tags/{{.TagName}}?page={{subtract .Page 1}}">← Forrige</a></li>
{{end}}
<li>Side {{.Page}} av {{.TotalPages}}</li>
{{if lt .Page .TotalPages}}
<li><a href="{{basePath}}/tags/{{.TagName}}?page={{add .Page 1}}">Neste →</a></li>
{{end}}
</ul>
</nav>
{{end}}
{{else}}
<p>Ingen offentlige favoritter med denne merkelappen.</p>
{{end}}
{{end}}
{{end}}