Compare commits

..

14 commits

Author SHA1 Message Date
a456d0096a feat: add CLI commands for user management
Adds subcommands to the binary for admin tasks without needing
the web UI: list users, set passwords, promote/demote roles,
and lock/unlock accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:37:39 +02:00
04c6dd3df6 chore: add .claude to .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:57:59 +02:00
fe925fc292 feat: add OG meta tags to home page, tag browse, and profile bio
Complete Open Graph coverage across all public pages:

- Home page: og:title (site name), og:description, og:type=website,
  og:url, og:site_name
- Tag browse: og:title with tag name, og:description with count,
  og:url, og:site_name
- Profile: add og:description using bio (truncated to 200 chars)
  with fallback to generic text

Previously only fave detail and profile (without description) had
OG tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:54:23 +02:00
e379039fe8 refactor: simplify PWA handlers and fix review findings
Address code review findings from reuse, quality, and efficiency agents:

- Cache manifest JSON and service worker JS at init (was rebuilt per
  request with allocations and JSON encoding on every hit)
- Add ImagePathsByUser store method for targeted image cleanup (was
  loading 100k full fave objects just to read image_path)
- Add missing aria-label on privacy toggle in fave_list.html (inline
  copy had drifted from the partial — accessibility bug)
- Fix comment/function name mismatch in pwa.go
- Remove redundant user nil-check in handleShare (requireLogin guards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:47:13 +02:00
254573316a feat: add admin role management and user deletion
Admins can now change user roles and permanently delete user accounts.

- New SetRole store method with validation (user/admin only)
- New Delete store method — cascades via foreign keys to sessions,
  faves, and fave_tags
- handleAdminSetRole: change role with self-modification prevention
- handleAdminDeleteUser: permanent deletion with image cleanup from
  disk before cascade delete, self-deletion prevention
- admin_users.html: role dropdown with save button per user row,
  delete button with hx-confirm for safety
- Routes: POST /admin/users/{id}/role, POST /admin/users/{id}/delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:18:00 +02:00
b186fb4bc5 feat: add edit/delete buttons to list views and inline privacy toggle
Fave cards in the list and profile views now show edit, delete, and
privacy toggle buttons directly — no need to open the detail page first.

- New POST /faves/{id}/privacy route with HTMX privacy toggle partial
- New UpdatePrivacy store method for single-column update
- fave_list.html: edit link, HTMX delete, privacy toggle on every card
- profile.html: edit/delete for owner's own cards
- privacy_toggle.html: new HTMX partial that swaps inline on toggle
- CSS: compact .fave-card-actions styles

The existing handleFaveDelete already returns empty 200 for HTMX
requests, so hx-target="closest article" hx-swap="outerHTML" removes
the card from DOM seamlessly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:17:46 +02:00
1260cfd18f feat: add PWA support with Android share intent
Make Favoritter installable as a Progressive Web App with offline
static asset caching and Web Share Target API for Android.

New files:
- internal/handler/pwa.go: handlers for manifest, service worker,
  and share target
- web/static/sw.js: service worker (cache-first static, network-first
  HTML) with {{BASE_PATH}} placeholder for subpath deployments
- web/static/icons/: placeholder PWA icons (192, 512, 512-maskable)

Key design decisions:
- Share target uses GET (not POST) to avoid CSRF token issues — Android
  apps cannot provide CSRF tokens
- Manifest is generated dynamically to inject BasePath into start_url,
  scope, icon paths, and share_target action
- Service worker served at /sw.js with Cache-Control: no-cache and
  BasePath injected via string replacement
- handleShare extracts URLs from Android's "text" field as fallback
  (many apps put the URL there instead of "url")
- handleFaveNew replaced with handleFaveNewPreFill that reads url,
  description, notes from query params (enables share + bookmarklets)
- SW registration in app.js reads base-path from <meta> tag (CSP-safe)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:06:18 +02:00
485d01ce45 feat: add notes field to favorites and enhance OG meta tags
Add an optional long-form "notes" text field to each favorite for
reviews, thoughts, or extended descriptions. The field is stored in
SQLite via a new migration (002_add_fave_notes.sql) and propagated
through the entire stack:

- Model: Notes field on Fave struct
- Store: All SQL queries (Create, GetByID, Update, list methods,
  scanFaves) updated with notes column
- Web handlers: Read/write notes in create, edit, update forms
- API handlers: Notes in create, update, get, import request/response
- Export: Notes included in both JSON and CSV exports
- Import: Notes parsed from both JSON and CSV imports
- Feed: Notes used as Atom feed item summary when present
- Form template: New textarea between URL and image fields
- Detail template: Display notes, enhanced og:description with
  cascade: notes (truncated) → URL → generic fallback text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:40:08 +02:00
a8f3aa6f7e test: add comprehensive test suite (44 → 169 tests) and v1.1 plan
Add 125 new test functions across 10 new test files, covering:
- CSRF middleware (8 tests): double-submit cookie validation
- Auth middleware (12 tests): SessionLoader, RequireAdmin, context helpers
- API handlers (28 tests): auth, faves CRUD, tags, users, export/import
- Web handlers (41 tests): signup, login, password reset, fave CRUD,
  admin panel, feeds, import/export, profiles, settings
- Config (8 tests): env parsing, defaults, trusted proxies, normalization
- Database (6 tests): migrations, PRAGMAs, idempotency, seeding
- Image processing (10 tests): JPEG/PNG, resize, EXIF strip, path traversal
- Render (6 tests): page/error/partial rendering, template functions
- Settings store (3 tests): CRUD operations
- Regression tests for display name fallback and CSP-safe autocomplete

Also adds CSRF middleware to testServer chain for end-to-end CSRF
verification, TESTPLAN.md documenting coverage, and PLANS-v1.1.md
with implementation plans for notes+OG, PWA, editing UX, and admin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:18:01 +02:00
9c3ca14578 fix: resolve tag autocomplete click bug and display name fallback
Tag autocomplete suggestions were silently broken by CSP (script-src
'self') which blocks inline event handlers. Replaced onclick attributes
with data-tag-name + delegated mousedown/touchend listeners in app.js.
Also changed hx-params="*" to hx-params="none" to avoid sending
unrelated form fields to the search endpoint.

Display name in "av <name>" on fave cards was empty for users without
a custom display name. Changed SQL queries to use
COALESCE(NULLIF(u.display_name, ''), u.username) for automatic fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:17:38 +02:00
0a77935d4d docs: add PLANS.md with roadmap for v1.1, v1.2, and future
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:33:24 +02:00
f4480dd510 fix: remove chatty install message from postinstall script
README already documents the setup steps. Package install scripts
should be silent on success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:28:22 +02:00
3341e9a818 fix: preserve systemd enable/disable state on package upgrade
The preremove script was unconditionally stopping and disabling the
service, which meant upgrades (dpkg -i new.deb) would disable the
service. Users had to manually re-enable after every upgrade.

Now:
- preremove: only stop+disable on actual removal (not upgrade)
  Checks $1 for "remove"/"purge" (deb) or "0" (rpm)
- postinstall: restart the service on upgrade if it was running,
  preserving enable/disable state. Only shows first-install
  instructions on initial install.

Tested with shellcheck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:26:29 +02:00
ab07a7f93f fix: use envsubst for nfpm variable expansion in Makefile
nfpm v2 does not expand ${VAR} in contents.src fields. The deb/rpm
targets now pipe nfpm.yaml through envsubst to resolve ARCH and
VERSION before passing the config to nfpm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:18:14 +02:00
52 changed files with 5303 additions and 93 deletions

3
.gitignore vendored
View file

@ -15,9 +15,10 @@
*.out
/vendor/
# IDE
# IDE / AI
.vscode/
.idea/
.claude/
*.swp
*.swo
*~

View file

@ -14,13 +14,24 @@ make test # Run tests
make container # Build container image
```
### CLI Admin Commands
```bash
favoritter user list # List all users
favoritter user set-password <user> # Change a user's password
favoritter user promote <user> # Promote user to admin
favoritter user demote <user> # Remove admin role
favoritter user lock <user> # Disable user account
favoritter user unlock <user> # Enable user account
```
## Architecture
Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. SQLite for storage, filesystem for uploaded images. All templates and static assets are embedded via `go:embed`.
### Directory Layout
- `cmd/favoritter/` — Entry point, wiring, graceful shutdown
- `cmd/favoritter/` — Entry point, wiring, graceful shutdown, CLI admin commands (`cli_*.go`)
- `internal/config/` — Environment variable configuration
- `internal/database/` — SQLite connection, PRAGMAs, migration runner
- `internal/model/` — Domain types (no logic, no DB)
@ -37,7 +48,7 @@ Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API.
### Key Design Decisions
- **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing
- **3 external dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds
- **Minimal dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds, google/uuid, golang.org/x/term
- **`SetMaxOpenConns(1)`** — SQLite works best with a single writer; PRAGMAs are set once on the single connection
- **Templates embedded in binary**`//go:embed` for single-binary deployment; dev mode reads from disk for live reload
- **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard

View file

@ -29,24 +29,30 @@ build-all: $(DIST)
./cmd/favoritter; \
done
## Build .deb packages (requires nfpm)
## Build .deb packages (requires nfpm and envsubst)
deb: build-all
@for platform in $(PLATFORMS); do \
arch=$${platform##*/}; \
echo "Packaging deb for $$arch..."; \
ARCH=$$arch VERSION=$(VERSION) nfpm package \
ARCH=$$arch VERSION=$(VERSION) envsubst < nfpm.yaml > $(DIST)/nfpm-$$arch.yaml; \
nfpm package \
--config $(DIST)/nfpm-$$arch.yaml \
--packager deb \
--target $(DIST)/favoritter_$(VERSION)_$${arch}.deb; \
rm -f $(DIST)/nfpm-$$arch.yaml; \
done
## Build .rpm packages (requires nfpm)
## Build .rpm packages (requires nfpm and envsubst)
rpm: build-all
@for platform in $(PLATFORMS); do \
arch=$${platform##*/}; \
echo "Packaging rpm for $$arch..."; \
ARCH=$$arch VERSION=$(VERSION) nfpm package \
ARCH=$$arch VERSION=$(VERSION) envsubst < nfpm.yaml > $(DIST)/nfpm-$$arch.yaml; \
nfpm package \
--config $(DIST)/nfpm-$$arch.yaml \
--packager rpm \
--target $(DIST)/favoritter_$(VERSION)_$${arch}.rpm; \
rm -f $(DIST)/nfpm-$$arch.yaml; \
done
## Build container image

316
PLANS-v1.1.md Normal file
View file

@ -0,0 +1,316 @@
# Implementeringsplan v1.1 — Favoritter
Fire funksjoner som utvider Favoritter med notater og OG-metadata, PWA-installering med Android-delingsstøtte, bedre redigeringsflyt, og utvidede admin-verktøy.
## Oversikt
| # | Funksjon | Omfang | Avhengigheter |
|---|----------|--------|---------------|
| 1 | OG-støtte + fritekstfelt (notes) | Migrasjon, modell, store, alle handlere, maler, eksport/import, feed | Ingen |
| 2 | PWA + Android share intent | Service worker, manifest, nye handlere, base-mal, JS | Avhenger av 1 (deling pre-fyller notes) |
| 3 | Redigeringsforbedringer (UX) | Maler, CSS, ny toggle-rute, ny partial | Avhenger av 1 (notes vises i kort) |
| 4 | Admin-forbedringer | Store, handlere, maler, ruter | Ingen |
---
## Funksjon 1: OG-støtte + fritekstfelt (notes)
### Beskrivelse
Legger til et valgfritt langtekstfelt «notes» på hver favoritt. Forbedrer Open Graph-metatagger på detaljsiden slik at `og:description` bruker notes når det er tilgjengelig.
### Berørte filer
| Fil | Endring |
|-----|---------|
| `internal/database/migrations/002_add_fave_notes.sql` | Ny fil: ALTER TABLE faves ADD COLUMN notes |
| `internal/model/fave.go` | Legg til `Notes string` i Fave-struct |
| `internal/store/fave.go` | Oppdater alle SQL-spørringer, Create/Update-signaturer, scanFaves |
| `internal/handler/fave.go` | Les notes fra skjema i create/edit/update |
| `internal/handler/api/api.go` | Legg til notes i request/response-structs og faveJSON |
| `internal/handler/import_export.go` | Legg til notes i ExportFave, CSV-kolonner, import |
| `internal/handler/feed.go` | Bruk notes som feed item description |
| `web/templates/pages/fave_form.html` | Legg til textarea for notes mellom URL og bilde |
| `web/templates/pages/fave_detail.html` | Vis notes, forbedre og:description |
### Migrasjons-SQL
```sql
-- 002_add_fave_notes.sql
-- Legger til et valgfritt notatfelt på favoritter.
ALTER TABLE faves ADD COLUMN notes TEXT NOT NULL DEFAULT '';
```
### Implementeringssteg
1. **Migrasjon**: Opprett `internal/database/migrations/002_add_fave_notes.sql`
2. **Modell**: Legg til `Notes string` etter `ImagePath` i `model.Fave`
3. **Store — Create**: Endre signatur til `Create(userID, description, url, imagePath, notes, privacy)`, oppdater INSERT
4. **Store — GetByID**: Legg til `f.notes` i SELECT og `&f.Notes` i Scan
5. **Store — Update**: Endre signatur, legg til `notes = ?` i UPDATE
6. **Store — Alle list-metoder**: Legg til `f.notes` i SELECT for `ListByUser`, `ListPublicByUser`, `ListPublic`, `ListByTag`
7. **Store — scanFaves**: Legg til `&f.Notes` i Scan
8. **Handler — fave.go**: Les `notes` fra form i `handleFaveCreate` og `handleFaveUpdate`. Pass `fave.Notes` i `handleFaveEdit` template data
9. **Handler — api/api.go**: Legg til `Notes` i request/response-structs, `faveJSON`, import
10. **Handler — import_export.go**: Legg til notes i `ExportFave`, CSV header/rader, import-parsing
11. **Handler — feed.go**: Sett `item.Description = f.Notes` når det ikke er tomt i `favesToFeedItems`
12. **Template — fave_form.html**: Legg til `<textarea id="notes" name="notes">` mellom URL og bilde
13. **Template — fave_detail.html**: Vis `{{if .Notes}}<div class="fave-notes">{{.Notes}}</div>{{end}}`, forbedre `og:description`:
```html
{{if .Notes}}
<meta property="og:description" content="{{truncate 200 .Notes}}">
{{else if .URL}}
<meta property="og:description" content="{{.URL}}">
{{else}}
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
{{end}}
```
### Tester
- Oppdater `TestFaveCRUD` (Create/Update-kall med notes)
- Ny `TestFaveNotes` (CRUD med notes-felt)
- Oppdater API CRUD-tester, ny `TestAPICreateFaveWithNotes`
- ~5 nye, ~7 endrede
---
## Funksjon 2: PWA + Android share intent
### Beskrivelse
Gjør Favoritter installerbar som Progressive Web App med offline-støtte for statiske ressurser. Web Share Target API lar Android-brukere dele lenker direkte til Favoritter fra hvilken som helst app.
### Berørte filer
| Fil | Endring |
|-----|---------|
| `web/static/sw.js` | Ny fil: Service worker (cache-first statisk, network-first HTML) |
| `web/static/icons/icon-192.png` | Ny fil: PWA-ikon 192x192 |
| `web/static/icons/icon-512.png` | Ny fil: PWA-ikon 512x512 |
| `web/static/icons/icon-512-maskable.png` | Ny fil: Maskable ikon for Android |
| `internal/handler/pwa.go` | Ny fil: 3 handlere (manifest, SW, share) |
| `internal/handler/handler.go` | 3 nye ruter |
| `internal/handler/fave.go` | `handleFaveNew` leser url/description/notes fra query-parametre |
| `web/templates/layouts/base.html` | Manifest-link, theme-color, base-path meta |
| `web/static/js/app.js` | SW-registrering |
### Implementeringssteg
1. **Ikoner**: Opprett `web/static/icons/` med 192px, 512px, 512px-maskable PNG-er
2. **Service worker** (`web/static/sw.js`): Bruk `{{BASE_PATH}}`-plassholder, cache-first for `/static/`, network-first for HTML
3. **pwa.go — handleManifest**: Dynamisk JSON med BasePath injisert i `start_url`, `scope`, ikonruter og `share_target.action`. Share target bruker **GET** for å unngå CSRF-problemer
4. **pwa.go — handleServiceWorker**: Les sw.js fra embedded FS, erstatt `{{BASE_PATH}}`, sett `Cache-Control: no-cache`
5. **pwa.go — handleShare**: GET-handler bak `requireLogin`. Les `url`, `text`, `title` fra query. Mange Android-apper sender URL i `text`-feltet — sjekk begge. Redirect til `/faves/new?url=...&description=...&notes=...`
6. **Ruter**: `GET /manifest.json` (offentlig), `GET /sw.js` (offentlig), `GET /share` (krever login)
7. **handleFaveNew**: Les `url`, `description`, `notes` fra query-parametre for pre-fill
8. **base.html**: `<meta name="theme-color" content="#1095c1">`, `<link rel="manifest">`, `<meta name="base-path">`
9. **app.js**: SW-registrering via `navigator.serviceWorker.register()`, les base-path fra meta-tag
### Designbeslutninger
- **GET for share target**: Android kan ikke levere CSRF-tokens, så POST er utelukket. GET-handler redirecter til fave-skjema
- **Dynamisk manifest**: Nødvendig for å injisere BasePath korrekt ved subpath-deployment
- **SW Cache-Control: no-cache**: Nettleseren må kunne sjekke for oppdateringer
### Tester
- `TestManifestJSON`, `TestServiceWorkerContent`, `TestServiceWorkerBasePath`
- `TestShareRedirectsToFaveNew`, `TestShareTextFieldFallback`, `TestShareRequiresLogin`
- ~6 nye
---
## Funksjon 3: Redigeringsforbedringer (UX)
### Beskrivelse
Backend-redigering fungerer allerede (rediger, slett, personvernveksling). Problemet er at handlingene kun er synlige på detaljsiden. Denne funksjonen gjør dem tilgjengelige fra listevisninger.
### Tier 1 — Synlige handlinger i listevisninger
| Fil | Endring |
|-----|---------|
| `web/templates/pages/fave_list.html` | Legg til rediger/slett-knapper i hver card footer |
| `web/templates/pages/profile.html` | Legg til rediger-lenke for eiers egne kort |
| `web/static/css/style.css` | Kompakte `.fave-card-actions`-stiler |
**fave_list.html**: Legg til etter tags-footer, før `</article>`:
```html
<footer class="fave-card-actions">
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link">Rediger</a>
<button hx-delete="{{basePath}}/faves/{{.ID}}"
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
hx-target="closest article" hx-swap="outerHTML"
class="fave-action-btn secondary">Slett</button>
</footer>
```
Eksisterende `handleFaveDelete` returnerer allerede tom response for HTMX-forespørsler, så `hx-target="closest article" hx-swap="outerHTML"` fjerner kortet fra DOM.
**profile.html**: Vis kun for eier med `{{if $d.IsOwner}}`.
### Tier 2 — Rask personvernveksler
| Fil | Endring |
|-----|---------|
| `internal/store/fave.go` | Ny `UpdatePrivacy(id, privacy)` metode |
| `internal/handler/fave.go` | Ny `handleFaveTogglePrivacy` handler |
| `internal/handler/handler.go` | Ny rute: `POST /faves/{id}/privacy` |
| `web/templates/partials/privacy_toggle.html` | Ny HTMX-partial for toggle-knapp |
| `web/templates/pages/fave_list.html` | Bruk toggle i stedet for statisk badge |
| `web/templates/pages/fave_detail.html` | Bruk toggle for eier |
**Store — UpdatePrivacy**:
```go
func (s *FaveStore) UpdatePrivacy(id int64, privacy string) error {
_, err := s.db.Exec(
`UPDATE faves SET privacy = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`, privacy, id)
return err
}
```
**Handler**: Verifiserer eierskap, veksler mellom "public"/"private", returnerer oppdatert partial.
**Partial** (`privacy_toggle.html`):
```html
<span class="privacy-toggle" id="privacy-{{.ID}}">
<button hx-post="{{basePath}}/faves/{{.ID}}/privacy"
hx-target="#privacy-{{.ID}}" hx-swap="outerHTML"
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}">
{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}
</button>
</span>
```
CSRF-token håndteres automatisk av `htmx:configRequest`-hooken i app.js.
### Tester
- `TestUpdatePrivacy`, `TestTogglePrivacyOwner`, `TestTogglePrivacyNotOwner`
- `TestFaveListShowsEditButton`, `TestFaveListDeleteViaHTMX`
- ~5 nye
---
## Funksjon 4: Admin-forbedringer
### Beskrivelse
Utvider admin-panelet med mulighet til å endre brukerroller og permanent slette brukerkontoer. Eksisterende admin-funksjonalitet som allerede fungerer:
- ✅ Lukke registreringer (`/admin/settings` → signup_mode)
- ✅ Låse/deaktivere brukere (`/admin/users/{id}/toggle-disabled`)
- ✅ Tilbakestille passord (`/admin/users/{id}/reset-password`)
- ✅ Godkjenne/avvise registreringsforespørsler
- ✅ Opprette brukere
### Del A: Endre brukerrolle
| Fil | Endring |
|-----|---------|
| `internal/store/user.go` | Ny `SetRole(userID, role)` metode |
| `internal/handler/admin.go` | Ny `handleAdminSetRole` handler |
| `internal/handler/handler.go` | Ny rute: `POST /admin/users/{id}/role` |
| `web/templates/pages/admin_users.html` | Rolle-dropdown per brukerrad |
**Store — SetRole**: Validerer at role er "user" eller "admin", oppdaterer databasen.
**Handler**: Forhindrer at admin endrer egen rolle. Oppdaterer rolle og viser bekreftelse.
**Template**: `<select name="role">` med `onchange="this.form.submit()"` i handlingskolonnen.
**NB**: `onchange` inline handler blokkeres av CSP (`script-src 'self'`). Bruk i stedet en delegert event listener i app.js, eller legg til en submit-knapp:
```html
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/role" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<select name="role" class="inline-input">
<option value="user" {{if eq .Role "user"}}selected{{end}}>Bruker</option>
<option value="admin" {{if eq .Role "admin"}}selected{{end}}>Admin</option>
</select>
<button type="submit" class="nav-button outline">Lagre</button>
</form>
```
### Del B: Slette brukerkontoer
| Fil | Endring |
|-----|---------|
| `internal/store/user.go` | Ny `Delete(userID)` metode |
| `internal/handler/admin.go` | Ny `handleAdminDeleteUser` handler |
| `internal/handler/handler.go` | Ny rute: `POST /admin/users/{id}/delete` |
| `web/templates/pages/admin_users.html` | Slett-knapp med `hx-confirm` |
**Store — Delete**: `DELETE FROM users WHERE id = ?`. Kaskadesletting via foreign keys fjerner sessions, faves og fave_tags automatisk.
**Handler**: Forhindrer selvsletting. Sletter brukerens bilder fra disk **før** databasesletting (fordi kaskaden fjerner fave-radene som refererer til bildefilene). Viser bekreftelse.
**Template**: HTMX-knapp med `hx-confirm`:
```html
<button hx-post="{{basePath}}/admin/users/{{.ID}}/delete"
hx-confirm="Er du HELT sikker? Dette sletter brukeren og alle favorittene permanent."
hx-target="closest tr" hx-swap="outerHTML"
class="nav-button outline" style="color: var(--pico-del-color);">Slett</button>
```
### Tester
- `TestSetRole`, `TestSetRoleInvalid`, `TestDeleteUser`, `TestDeleteUserCascade`
- `TestAdminSetRoleSuccess`, `TestAdminSetRoleSelf`
- `TestAdminDeleteUserSuccess`, `TestAdminDeleteUserSelf`, `TestAdminDeleteUserCascadesData`
- ~9 nye
---
## Samlet implementeringsrekkefølge
```
Fase 1: Funksjon 1 + Funksjon 4 (parallelt, ingen avhengigheter)
├── 1a: Migrasjon + modell + store (notes)
├── 1b: Handlere og maler (notes + OG)
├── 4a: SetRole + handler + mal
└── 4b: Delete + handler + mal
Fase 2: Funksjon 3 (krever notes fra Fase 1)
├── 3a: Tier 1 — rediger/slett-knapper i listevisninger
└── 3b: Tier 2 — personvernveksler
Fase 3: Funksjon 2 (krever notes fra Fase 1)
├── 2a: Ikoner + service worker + manifest-handler
├── 2b: Share-handler + pre-fill
└── 2c: Base-mal + SW-registrering
```
### Nye endepunkter
| Metode | Rute | Handler | Auth |
|--------|------|---------|------|
| GET | `/manifest.json` | handleManifest | Nei |
| GET | `/sw.js` | handleServiceWorker | Nei |
| GET | `/share` | handleShare | Ja |
| POST | `/faves/{id}/privacy` | handleFaveTogglePrivacy | Ja |
| POST | `/admin/users/{id}/role` | handleAdminSetRole | Admin |
| POST | `/admin/users/{id}/delete` | handleAdminDeleteUser | Admin |
### Nye filer
| Fil | Beskrivelse |
|-----|-------------|
| `internal/database/migrations/002_add_fave_notes.sql` | Migrasjon: notes-kolonne |
| `internal/handler/pwa.go` | PWA-handlere (manifest, SW, share) |
| `web/static/sw.js` | Service worker |
| `web/static/icons/icon-*.png` | PWA-ikoner (3 stk) |
| `web/templates/partials/privacy_toggle.html` | HTMX personvernveksler |
### Estimert testtillegg
| Funksjon | Nye tester |
|----------|------------|
| 1: Notes + OG | ~5 |
| 2: PWA + share | ~6 |
| 3: Redigering UX | ~5 |
| 4: Admin | ~9 |
| **Totalt** | **~25** |

37
PLANS.md Normal file
View file

@ -0,0 +1,37 @@
# Planer for Favoritter
## v1.1
### Fulltekstsøk
Søk på tvers av favoritter med SQLite FTS5. Indekser beskrivelse, URL og merkelapper. Søkefelt i navigasjonen og egen søkeside med resultater.
### Feltspesifikke valideringsfeil
Skjemafeil vises i dag som flash-melding øverst på siden. Bør i tillegg markere det aktuelle feltet med `aria-invalid="true"` og vise feilmelding direkte ved feltet med `aria-describedby`. Viktig for universell utforming.
### Mørk modus
Pico CSS støtter `data-theme="dark"` og `data-theme="light"`. Legg til brukerinnstilling som lagres i profilen, og respekter `prefers-color-scheme` som standard.
### API-tokens
Personlige API-tokens som alternativ til session cookie for tredjepartsklienter og automatisering. Administreres fra brukerinnstillinger.
### Databasebackup (admin)
Endepunkt i administrasjonspanelet for å laste ned SQLite-databasen direkte. Nyttig for enkel backup av selvhostede installasjoner.
## v1.2+
### Masseoperasjoner
Velg flere favoritter og utfør handlinger: slett, endre synlighet, legg til/fjern merkelapper.
### Angre sletting
Soft delete med 30-dagers oppbevaringsperiode. Slettede favoritter kan gjenopprettes fra en «papirkurv»-visning.
### Internasjonalisering (i18n)
All brukervendt tekst er hardkodet bokmål i dag. Innfør et i18n-rammeverk med støtte for minst norsk bokmål og engelsk.
### Prometheus-metrikker
`/metrics`-endepunkt for overvåking. Antall brukere, favoritter, forespørsler per sekund, responstider, databasestørrelse.
## Fremtid
### WebFinger / ActivityPub
Fødererte favoritter — del favoritter på tvers av Favoritter-installasjoner og andre ActivityPub-kompatible tjenester. Ambisiøst, men passer AGPL-filosofien og det selvhostede økosystemet.

388
TESTPLAN.md Normal file
View file

@ -0,0 +1,388 @@
# Testplan — Favoritter
## Dekning (oppdatert 2026-04-03)
| Lag | Pakke | Testfiler | Testfunksjoner | Dekning |
|-----|-------|-----------|----------------|---------|
| Data | `internal/store` | 6 | 24 | 70.8 % |
| HTTP (web) | `internal/handler` | 2 | 55 | 56.1 % |
| HTTP (API) | `internal/handler/api` | 1 | 28 | 75.8 % |
| Mellomvare | `internal/middleware` | 3 | 29 | 83.1 % |
| Konfig | `internal/config` | 1 | 8 | 81.1 % |
| Bilde | `internal/image` | 1 | 10 | 85.7 % |
| Database | `internal/database` | 1 | 6 | 66.0 % |
| Rendering | `internal/render` | 1 | 6 | 72.7 % |
| Modell | `internal/model` | 0 | 0 | 0.0 % (kun datatyper) |
**Totalt**: 16 testfiler, 167 testfunksjoner, alle grønne.
---
## Prioriteter
- **P0 — Kritisk**: Sikkerhet, autentisering, autorisasjon
- **P1 — Høy**: Forretningslogikk, dataintegritet, API-kontrakter
- **P2 — Middels**: Konfigurasjon, feilhåndtering, kanttilfeller
- **P3 — Lav**: Rendering, logging, hjelpefunksjoner
---
## P0 — Sikkerhet og tilgangskontroll
### CSRF-beskyttelse (`internal/middleware/csrf.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestCSRFTokenSetInCookie` | POST uten eksisterende cookie → ny `csrf_token`-cookie settes |
| 2 | `TestCSRFValidTokenAccepted` | POST med matchende cookie + form-felt → 200 |
| 3 | `TestCSRFMismatchRejected` | POST med feil token i form-felt → 403 |
| 4 | `TestCSRFMissingTokenRejected` | POST uten token i form-felt/header → 403 |
| 5 | `TestCSRFHeaderFallback` | POST med token i `X-CSRF-Token`-header → 200 |
| 6 | `TestCSRFSkippedForAPI` | POST til `/api/`-ruter → CSRF sjekkes ikke |
| 7 | `TestCSRFSafeMethodsPassThrough` | GET/HEAD/OPTIONS → ingen validering |
### Auth-mellomvare (`internal/middleware/auth.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestSessionLoaderValidToken` | Gyldig session-cookie → bruker satt i context |
| 2 | `TestSessionLoaderInvalidToken` | Ugyldig cookie → context uten bruker, ingen feil |
| 3 | `TestSessionLoaderNoCookie` | Ingen cookie → context uten bruker |
| 4 | `TestRequireLoginRedirectsToLogin` | Uautentisert → 302 til `/login` |
| 5 | `TestRequireLoginAllowsAuthenticated` | Autentisert → neste handler kjøres |
| 6 | `TestRequireAdminRejectsNonAdmin` | Vanlig bruker → 403 |
| 7 | `TestRequireAdminAllowsAdmin` | Admin-bruker → neste handler kjøres |
### API-autentisering (`internal/handler/api/`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestAPILoginSuccess` | Korrekt brukernavn/passord → token + brukerinfo |
| 2 | `TestAPILoginWrongPassword` | Feil passord → 401 |
| 3 | `TestAPILoginInvalidBody` | Ugyldig JSON → 400 |
| 4 | `TestAPILogout` | Gyldig session → session slettet, cookie fjernet |
| 5 | `TestAPIRequiresAuth` | Beskyttede endepunkter uten session → 401/redirect |
### Autorisasjon (eierskap)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestCannotEditOtherUsersFave` | PUT `/api/v1/faves/{id}` med annen bruker → 403 |
| 2 | `TestCannotDeleteOtherUsersFave` | DELETE `/api/v1/faves/{id}` med annen bruker → 403 |
| 3 | `TestPrivateFaveHiddenFromAPI` | GET `/api/v1/faves/{id}` privat fave, annen bruker → 404 |
| 4 | `TestPrivateFaveVisibleToOwnerAPI` | GET `/api/v1/faves/{id}` privat fave, eier → 200 |
| 5 | `TestAdminEndpointsRequireAdminRole` | Ikke-admin → 403 på alle admin-ruter |
---
## P1 — Forretningslogikk og API-kontrakter
### Store: Innstillinger (`internal/store/settings.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestSettingsGetDefault` | Hent standardinnstillinger etter migrasjon |
| 2 | `TestSettingsUpdate` | Oppdater site_name, description, signup_mode → endringer leses tilbake |
| 3 | `TestSettingsUpdatePartial` | Oppdater kun ett felt → andre felter beholdes |
### API: Favoritter CRUD (`internal/handler/api/`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestAPICreateFave` | POST med beskrivelse, URL, privacy, tags → 201 + komplett JSON |
| 2 | `TestAPICreateFaveMinimal` | POST med kun beskrivelse → 201, standardverdier |
| 3 | `TestAPICreateFaveMissingDescription` | POST uten beskrivelse → 400 |
| 4 | `TestAPIGetFave` | GET `/api/v1/faves/{id}` → komplett JSON med tagger |
| 5 | `TestAPIGetFaveNotFound` | GET med ugyldig ID → 404 |
| 6 | `TestAPIUpdateFave` | PUT med endringer → oppdatert JSON |
| 7 | `TestAPIUpdateFavePartial` | PUT med kun tags → andre felter uendret |
| 8 | `TestAPIDeleteFave` | DELETE → 204 No Content |
| 9 | `TestAPIDeleteFaveNotFound` | DELETE med ugyldig ID → 404 |
| 10 | `TestAPIListFaves` | GET `/api/v1/faves` → paginert resultat med total |
| 11 | `TestAPIListFavesPagination` | Sjekk page/limit-parametre og grense (limit ≤ 100) |
### API: Brukere og offentlige favoritter
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestAPIGetUser` | GET `/api/v1/users/{username}` → brukerinfo |
| 2 | `TestAPIGetUserNotFound` | Ukjent brukernavn → 404 |
| 3 | `TestAPIGetDisabledUser` | Deaktivert bruker → 404 |
| 4 | `TestAPIGetUserFaves` | GET `/api/v1/users/{username}/faves` → kun offentlige |
| 5 | `TestAPIGetUserFavesEmpty` | Bruker uten favoritter → tom liste |
### API: Tagger
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestAPISearchTags` | GET `/api/v1/tags?q=prefix` → matchende tagger |
| 2 | `TestAPISearchTagsEmpty` | Søk uten treff → tom liste |
| 3 | `TestAPISearchTagsLimit` | `limit`-parameter respekteres, maks 100 |
### API: Eksport/import
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestAPIExport` | GET `/api/v1/export/json` → alle brukerens favoritter |
| 2 | `TestAPIImportValid` | POST med JSON-array → imported/total-svar |
| 3 | `TestAPIImportSkipsEmpty` | Oppføringer uten beskrivelse → hoppes over |
| 4 | `TestAPIImportInvalidJSON` | Ugyldig JSON → 400 |
| 5 | `TestAPIImportBodyLimit` | Stor body → avvist (MaxUploadSize) |
### Handler: Registrering og pålogging (`internal/handler/auth.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestSignupPageRendering` | GET `/signup` → skjema med CSRF-token |
| 2 | `TestSignupSuccess` | POST med gyldig data → registreringsforespørsel opprettet |
| 3 | `TestSignupDuplicate` | Duplisert brukernavn → feilmelding |
| 4 | `TestSignupDisabled` | `signup_mode=closed` → 403 eller redirect |
| 5 | `TestPasswordResetFlow` | Bruker med must_reset → tvinges til å endre passord |
| 6 | `TestPasswordChangeSuccess` | POST med korrekt gammelt + nytt passord → oppdatert |
| 7 | `TestPasswordChangeMismatch` | Nytt passord ≠ bekreftelse → feilmelding |
| 8 | `TestLogout` | POST `/logout` → session slettet, redirect til login |
### Handler: Favoritter CRUD (web) (`internal/handler/fave.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestCreateFavePage` | GET `/faves/new` → skjema rendres |
| 2 | `TestCreateFaveSubmit` | POST med gyldig data → redirect til favoritt |
| 3 | `TestCreateFaveWithImage` | POST med multipart/bilde → bilde lagret |
| 4 | `TestEditFavePage` | GET `/faves/{id}/edit` → skjema med eksisterende data |
| 5 | `TestEditFaveSubmit` | POST med endringer → oppdatert |
| 6 | `TestEditFaveNotOwner` | Annen bruker → 403 |
| 7 | `TestDeleteFave` | POST `/faves/{id}/delete` → slettet, redirect |
| 8 | `TestDeleteFaveNotOwner` | Annen bruker → 403 |
| 9 | `TestFaveListPagination` | Navigasjon med page-parameter |
### Handler: Administrasjon (`internal/handler/admin.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestAdminDashboard` | GET `/admin` → statistikk rendres |
| 2 | `TestAdminUserList` | GET `/admin/users` → alle brukere vises |
| 3 | `TestAdminDisableUser` | POST → bruker deaktiveres |
| 4 | `TestAdminEnableUser` | POST → bruker reaktiveres |
| 5 | `TestAdminApproveSignup` | POST → bruker opprettes, forespørsel fjernes |
| 6 | `TestAdminRejectSignup` | POST → forespørsel fjernes, bruker opprettes ikke |
| 7 | `TestAdminUpdateSettings` | POST → innstillinger oppdateres |
### Handler: Profil (`internal/handler/profile.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestPublicProfileVisible` | Offentlig profil → bio, visningsnavn vist |
| 2 | `TestLimitedProfileHidesBio` | Begrenset profil → bio skjult, merknad vist |
| 3 | `TestProfileEditPage` | GET `/profile/edit` → skjema med gjeldende verdier |
| 4 | `TestProfileUpdateDisplayName` | POST → visningsnavn oppdatert |
| 5 | `TestProfileUpdateVisibility` | POST → synlighet endret |
| 6 | `TestProfileAvatarUpload` | POST med bilde → avatar lagret |
### Handler: Feed (`internal/handler/feed.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestGlobalFeedAtom` | GET `/feed` → gyldig Atom XML |
| 2 | `TestUserFeedAtom` | GET `/{username}/feed` → kun brukerens offentlige |
| 3 | `TestFeedExcludesPrivate` | Private favoritter utelates |
| 4 | `TestFeedLinksUseExternalURL` | Lenker bruker `EXTERNAL_URL`, ikke localhost |
### Handler: Import/Eksport (web) (`internal/handler/import_export.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestExportPageRendering` | GET `/export` → eksportvalg vist |
| 2 | `TestExportJSON` | GET `/export/json` → JSON-array med alle faves |
| 3 | `TestExportCSV` | GET `/export/csv` → gyldig CSV med header |
| 4 | `TestImportJSON` | POST med JSON-fil → favoritter importert |
| 5 | `TestImportCSV` | POST med CSV-fil → favoritter importert |
| 6 | `TestImportDuplicateHandling` | Duplikater → håndteres uten feil |
---
## P2 — Konfigurasjon, bilde, database
### Konfigurasjon (`internal/config/config.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestLoadDefaults` | Ingen env-variabler → fornuftige standardverdier |
| 2 | `TestLoadFromEnv` | Alle env-variabler satt → config inneholder riktige verdier |
| 3 | `TestTrustedProxiesParsing` | `TRUSTED_PROXIES=10.0.0.0/8,192.168.0.0/16` → korrekte IPNet |
| 4 | `TestTrustedProxiesInvalid` | Ugyldig CIDR → logges, hoppes over |
| 5 | `TestBasePathNormalization` | Trailing slash fjernes, tom streng beholdes |
| 6 | `TestSessionLifetimeParsing` | `SESSION_LIFETIME=48h` → korrekt Duration |
| 7 | `TestDevModeFlag` | `FAVORITTER_DEV_MODE=true` → DevMode = true |
| 8 | `TestMaxUploadSizeParsing` | `MAX_UPLOAD_SIZE=10485760` → korrekt int64 |
### Bildebehandling (`internal/image/image.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestProcessJPEG` | Gyldig JPEG → UUID-filnavn, lagret i uploadDir |
| 2 | `TestProcessPNG` | Gyldig PNG → re-encoded, EXIF strippet |
| 3 | `TestProcessResizeWideImage` | Bilde > 1920px bredt → nedskalert til MaxWidth |
| 4 | `TestProcessSmallImageNotResized` | Bilde < 1920px → beholdes som det er |
| 5 | `TestProcessInvalidMIME` | `text/plain`-fil → feil returneres |
| 6 | `TestProcessCorruptImage` | Korrupt bildedata → feil returneres |
| 7 | `TestProcessUUIDFilename` | Filnavn bruker UUID, aldri brukerens opprinnelige filnavn |
| 8 | `TestAllowedTypes` | Sjekk at JPEG, PNG, GIF, WebP er tillatt |
### Databasemigrasjoner (`internal/database/database.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestOpenInMemory` | `:memory:` → fungerende tilkobling |
| 2 | `TestMigrateCreatesAllTables` | Migrasjon → alle forventede tabeller finnes |
| 3 | `TestMigrateIdempotent` | Kjør Migrate to ganger → ingen feil |
| 4 | `TestPRAGMAs` | WAL-modus, foreign_keys, journal_size_limit satt |
| 5 | `TestSingleConnection` | MaxOpenConns = 1 verifisert |
### Mellomvare: Context-hjelpere (`internal/middleware/context.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestUserFromContextPresent` | Bruker i context → returneres |
| 2 | `TestUserFromContextAbsent` | Ingen bruker → nil |
| 3 | `TestCSRFTokenFromContext` | Token i context → returneres |
| 4 | `TestFlashFromContext` | Flash-melding i context → returneres |
### Mellomvare: Logger (`internal/middleware/logger.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestLoggerRecordsStatusCode` | Response status kodes logges |
| 2 | `TestLoggerRecordsDuration` | Forespørselstid logges |
| 3 | `TestLoggerIncludesPath` | URL-path inkluderes i loggoppføring |
---
## P3 — Rendering og hjelpefunksjoner
### Template-rendering (`internal/render/render.go`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestRenderPage` | Rendrer side med layout → komplett HTML |
| 2 | `TestRenderPageWithData` | PageData (Title, brukerinfo) → interpolert i template |
| 3 | `TestRenderPartial` | Rendrer partial uten layout |
| 4 | `TestRenderMissingTemplate` | Ukjent template-navn → feilhåndtering |
| 5 | `TestCSRFTokenInTemplate` | CSRF-token tilgjengelig i template-context |
### API JSON-hjelpere (`internal/handler/api/`)
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestJsonOKFormat` | Content-Type: application/json, gyldig JSON |
| 2 | `TestJsonErrorFormat` | Feil → `{"error": "melding"}` + riktig statuskode |
| 3 | `TestUserJSONPublicProfile` | Offentlig profil → bio og avatar inkludert |
| 4 | `TestUserJSONLimitedProfile` | Begrenset profil → bio og avatar utelatt |
| 5 | `TestFaveJSONComplete` | Alle felter mappet korrekt |
| 6 | `TestQueryIntFallback` | Ugyldig/manglende parameter → standardverdi |
| 7 | `TestQueryIntNegative` | Negativ verdi → standardverdi |
---
## Integrasjonstester (tverrgående)
Disse testene verifiserer hele flyten fra HTTP-forespørsel gjennom mellomvare, handler, store og tilbake.
| # | Test | Verifiserer |
|---|------|-------------|
| 1 | `TestFullLoginFlow` | GET login → POST credentials → session cookie → GET /faves → 200 |
| 2 | `TestFullSignupApprovalFlow` | Registrering → admin godkjenner → bruker logger inn → endrer passord |
| 3 | `TestFullFaveCRUDFlow` | Opprett → les → rediger → slett favoritt via web |
| 4 | `TestFullAPIFaveCRUDFlow` | Login → opprett → hent → oppdater → slett via JSON API |
| 5 | `TestImportExportRoundtrip` | Eksporter → importer til ny bruker → verifiser likhet |
| 6 | `TestFeedReflectsNewFaves` | Opprett favoritt → feed oppdateres |
| 7 | `TestRateLimitOnLogin` | Mange mislykkede pålogginger → 429 |
| 8 | `TestCSRFProtectionEndToEnd` | Hent side → ekstraher token → POST med token → suksess |
| 9 | `TestBasePathPropagation` | Alle lenker og redirects respekterer base path |
| 10 | `TestMultiUserPrivacy` | Bruker A ser ikke bruker Bs private favoritter i noen visning |
---
## Testinfrastruktur
### Eksisterende hjelpere (gjenbruk)
- `testDB(t)` — in-memory SQLite med migrasjoner (`store/*_test.go`)
- `testServer(t)` — komplett handler-stack med mellomvare (`handler/handler_test.go`)
- `loginUser(t, h, username, password, role)` — oppretter bruker og returnerer session-cookie
- `extractCookie(rr, name)` — henter cookie fra response
### Nye hjelpere som trengs
| Hjelper | Bruksområde |
|---------|-------------|
| `testAPIServer(t)` | Setup for API-handler med in-memory DB |
| `apiLogin(t, server, username, password)` | Hent session-token for API-tester |
| `testImage(t, width, height, mime)` | Generer test-bilde med gitte dimensjoner |
| `setEnv(t, key, value)` | Sett env-variabel med automatisk cleanup |
### Konvensjoner
- Bruk `:memory:` SQLite for alle tester
- Sett raske Argon2-parametre (`Memory=1024, Time=1`) i alle passord-tester
- Bruk `t.Helper()` på alle hjelpefunksjoner
- Bruk `t.Cleanup()` for opprydding, aldri manuell defer i hjelpere
- Bruk `httptest.NewRecorder()` og `httptest.NewRequest()` for HTTP-tester
- Bruk `t.TempDir()` for bildebehandlingstester
- Tabell-drevne tester der det er naturlig (f.eks. config-parsing, feilkoder)
- Norske testbeskrivelser i kommentarer, engelske funksjons-/variabelnavn
---
## Implementeringsrekkefølge
```
Fase 1 (P0): Sikkerhet
├── middleware/csrf_test.go (~7 tester)
├── middleware/auth_test.go (~7 tester) ← utvid eksisterende fil
└── handler/api/api_test.go (~5 auth-tester)
Fase 2 (P1a): Store + API-kontrakter
├── store/settings_test.go (~3 tester)
└── handler/api/api_test.go (~25 tester, utvid)
Fase 3 (P1b): Web-handlere
├── handler/handler_test.go (~30 tester, utvid eksisterende)
└── handler/auth_test.go (~8 tester)
Fase 4 (P2): Konfig, bilde, database
├── config/config_test.go (~8 tester)
├── image/image_test.go (~8 tester)
├── database/database_test.go (~5 tester)
└── middleware/context_test.go (~4 tester)
Fase 5 (P3): Rendering og hjelpere
├── render/render_test.go (~5 tester)
└── handler/api/helpers_test.go (~7 tester)
Fase 6: Integrasjonstester
└── integration_test.go (~10 tester, build tag)
```
**Totalt**: ~132 nye tester → fra 44 til ~176 testfunksjoner.
---
## Kjøring
```bash
# Alle tester
go test ./...
# Én pakke
go test ./internal/store/...
# Med dekning
go test -cover ./...
# Detaljert dekningsrapport
go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out
# Kun integrasjonstester (om build tag brukes)
go test -tags=integration ./...
```

246
cmd/favoritter/cli_user.go Normal file
View file

@ -0,0 +1,246 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"golang.org/x/term"
"kode.naiv.no/olemd/favoritter/internal/config"
"kode.naiv.no/olemd/favoritter/internal/database"
"kode.naiv.no/olemd/favoritter/internal/store"
)
// runUserCommand dispatches user management subcommands.
func runUserCommand(args []string) {
if len(args) == 0 {
printUserUsage()
os.Exit(1)
}
switch args[0] {
case "list":
runUserList()
case "set-password":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user set-password <brukernavn>\n")
os.Exit(1)
}
runUserSetPassword(args[1])
case "promote":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user promote <brukernavn>\n")
os.Exit(1)
}
runUserSetRole(args[1], "admin")
case "demote":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user demote <brukernavn>\n")
os.Exit(1)
}
runUserSetRole(args[1], "user")
case "lock":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user lock <brukernavn>\n")
os.Exit(1)
}
runUserSetDisabled(args[1], true)
case "unlock":
if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user unlock <brukernavn>\n")
os.Exit(1)
}
runUserSetDisabled(args[1], false)
default:
fmt.Fprintf(os.Stderr, "Ukjent underkommando: %s\n\n", args[0])
printUserUsage()
os.Exit(1)
}
}
// printUserUsage prints help text for the user subcommand.
func printUserUsage() {
fmt.Fprintf(os.Stderr, "Bruk: favoritter user <kommando>\n\n")
fmt.Fprintf(os.Stderr, "Kommandoer:\n")
fmt.Fprintf(os.Stderr, " list Vis alle brukere\n")
fmt.Fprintf(os.Stderr, " set-password <bruker> Endre passord for en bruker\n")
fmt.Fprintf(os.Stderr, " promote <bruker> Gjør bruker til administrator\n")
fmt.Fprintf(os.Stderr, " demote <bruker> Fjern administratorrettigheter\n")
fmt.Fprintf(os.Stderr, " lock <bruker> Deaktiver brukerkonto\n")
fmt.Fprintf(os.Stderr, " unlock <bruker> Aktiver brukerkonto\n")
}
// openDB loads config, opens the database, and runs migrations.
// It exits the process on failure.
func openDB() (*store.UserStore, func()) {
cfg := config.Load()
db, err := database.Open(cfg.DBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke åpne databasen: %v\n", err)
os.Exit(1)
}
if err := database.Migrate(db); err != nil {
db.Close()
fmt.Fprintf(os.Stderr, "Feil: migrering feilet: %v\n", err)
os.Exit(1)
}
// Configure Argon2 parameters from config.
store.Argon2Memory = cfg.Argon2Memory
store.Argon2Time = cfg.Argon2Time
store.Argon2Parallelism = cfg.Argon2Parallelism
users := store.NewUserStore(db)
return users, func() { db.Close() }
}
// runUserList prints all users in a table.
func runUserList() {
users, cleanup := openDB()
defer cleanup()
all, err := users.ListAll()
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke hente brukere: %v\n", err)
os.Exit(1)
}
if len(all) == 0 {
fmt.Println("Ingen brukere funnet.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tBRUKERNAVN\tVISNINGSNAVN\tROLLE\tSTATUS\tOPPRETTET")
for _, u := range all {
status := "aktiv"
if u.Disabled {
status = "deaktivert"
}
if u.MustResetPassword {
status += ", må bytte passord"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n",
u.ID,
u.Username,
u.DisplayName,
u.Role,
status,
u.CreatedAt.Format("2006-01-02"),
)
}
w.Flush()
}
// runUserSetPassword prompts for a new password and updates it.
func runUserSetPassword(username string) {
users, cleanup := openDB()
defer cleanup()
user, err := users.GetByUsername(username)
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username)
os.Exit(1)
}
// Read new password from terminal.
fmt.Fprintf(os.Stderr, "Nytt passord for %s: ", username)
pw1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke lese passord: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Gjenta passord: ")
pw2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke lese passord: %v\n", err)
os.Exit(1)
}
password := strings.TrimSpace(string(pw1))
if password != strings.TrimSpace(string(pw2)) {
fmt.Fprintf(os.Stderr, "Feil: passordene stemmer ikke overens\n")
os.Exit(1)
}
if len(password) < 8 {
fmt.Fprintf(os.Stderr, "Feil: passordet må være minst 8 tegn\n")
os.Exit(1)
}
if err := users.UpdatePassword(user.ID, password); err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke oppdatere passord: %v\n", err)
os.Exit(1)
}
fmt.Printf("Passord oppdatert for %s.\n", username)
}
// runUserSetRole promotes or demotes a user.
func runUserSetRole(username, role string) {
users, cleanup := openDB()
defer cleanup()
user, err := users.GetByUsername(username)
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username)
os.Exit(1)
}
if user.Role == role {
fmt.Fprintf(os.Stderr, "%s har allerede rollen %q\n", username, role)
return
}
if err := users.SetRole(user.ID, role); err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke endre rolle: %v\n", err)
os.Exit(1)
}
switch role {
case "admin":
fmt.Printf("%s er nå administrator.\n", username)
default:
fmt.Printf("%s er ikke lenger administrator.\n", username)
}
}
// runUserSetDisabled locks or unlocks a user account.
func runUserSetDisabled(username string, disabled bool) {
users, cleanup := openDB()
defer cleanup()
user, err := users.GetByUsername(username)
if err != nil {
fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username)
os.Exit(1)
}
if user.Disabled == disabled {
if disabled {
fmt.Fprintf(os.Stderr, "%s er allerede deaktivert\n", username)
} else {
fmt.Fprintf(os.Stderr, "%s er allerede aktiv\n", username)
}
return
}
if err := users.SetDisabled(user.ID, disabled); err != nil {
fmt.Fprintf(os.Stderr, "Feil: kunne ikke endre kontostatus: %v\n", err)
os.Exit(1)
}
if disabled {
fmt.Printf("%s er nå deaktivert.\n", username)
} else {
fmt.Printf("%s er nå aktivert.\n", username)
}
}

View file

@ -28,16 +28,19 @@ var (
)
func main() {
// Handle -healthcheck flag for container health checks.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
// Handle flags and subcommands before starting the server.
if len(os.Args) > 1 {
switch os.Args[1] {
case "-healthcheck":
runHealthCheck()
return
}
// Handle -version flag.
if len(os.Args) > 1 && os.Args[1] == "-version" {
case "-version":
fmt.Printf("favoritter %s (built %s)\n", version, buildDate)
return
case "user":
runUserCommand(os.Args[2:])
return
}
}
cfg := config.Load()

15
dist/postinstall.sh vendored
View file

@ -1,6 +1,9 @@
#!/bin/sh
# Post-install script for Favoritter .deb/.rpm package.
# Creates the system user and sets directory permissions.
# Creates the system user, sets directory permissions, and handles upgrades.
#
# Debian/Ubuntu: called with "configure" on install/upgrade.
# RPM: called with 1 on first install, 2+ on upgrade.
set -e
# Create system user if it doesn't exist.
@ -12,10 +15,14 @@ fi
install -d -o favoritter -g favoritter -m 0750 /var/lib/favoritter
install -d -o favoritter -g favoritter -m 0750 /var/lib/favoritter/uploads
# Reload systemd to pick up the service file.
# Reload systemd to pick up any service file changes.
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload
# On upgrade: restart the service if it was running.
# This picks up the new binary without losing enable/disable state.
if systemctl is-active --quiet favoritter 2>/dev/null; then
systemctl restart favoritter
fi
fi
echo "Favoritter installed. Configure /etc/favoritter/favoritter.env then run:"
echo " sudo systemctl enable --now favoritter"

27
dist/preremove.sh vendored
View file

@ -1,9 +1,30 @@
#!/bin/sh
# Pre-remove script for Favoritter .deb/.rpm package.
# Stops the service before package removal.
# Only stops and disables the service on actual removal, not on upgrade.
#
# Debian/Ubuntu: called with "remove" on uninstall, "upgrade" on upgrade.
# RPM (Fedora/RHEL): called with 0 on final removal, 1+ on upgrade.
set -e
if command -v systemctl >/dev/null 2>&1; then
action="${1:-}"
case "$action" in
# Debian: full removal.
remove|purge)
if command -v systemctl >/dev/null 2>&1; then
systemctl stop favoritter 2>/dev/null || true
systemctl disable favoritter 2>/dev/null || true
fi
fi
;;
# RPM: $1 is the number of remaining installations.
0)
if command -v systemctl >/dev/null 2>&1; then
systemctl stop favoritter 2>/dev/null || true
systemctl disable favoritter 2>/dev/null || true
fi
;;
# Debian "upgrade" or RPM "1+" — do nothing, the service stays running.
# The new postinstall will daemon-reload and restart.
*)
;;
esac

1
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
modernc.org/sqlite v1.48.0
)

2
go.sum
View file

@ -29,6 +29,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View file

@ -0,0 +1,204 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"testing"
"time"
)
func TestLoadDefaults(t *testing.T) {
// Clear all FAVORITTER_ env vars to test defaults.
for _, key := range []string{
"FAVORITTER_DB_PATH", "FAVORITTER_LISTEN", "FAVORITTER_BASE_PATH",
"FAVORITTER_EXTERNAL_URL", "FAVORITTER_UPLOAD_DIR", "FAVORITTER_MAX_UPLOAD_SIZE",
"FAVORITTER_SESSION_LIFETIME", "FAVORITTER_ARGON2_MEMORY", "FAVORITTER_ARGON2_TIME",
"FAVORITTER_ARGON2_PARALLELISM", "FAVORITTER_RATE_LIMIT", "FAVORITTER_ADMIN_USERNAME",
"FAVORITTER_ADMIN_PASSWORD", "FAVORITTER_SITE_NAME", "FAVORITTER_DEV_MODE",
"FAVORITTER_TRUSTED_PROXIES",
} {
t.Setenv(key, "")
}
cfg := Load()
if cfg.DBPath != "./data/favoritter.db" {
t.Errorf("DBPath = %q, want default", cfg.DBPath)
}
if cfg.Listen != ":8080" {
t.Errorf("Listen = %q, want :8080", cfg.Listen)
}
if cfg.BasePath != "" {
t.Errorf("BasePath = %q, want empty (root)", cfg.BasePath)
}
if cfg.MaxUploadSize != 10<<20 {
t.Errorf("MaxUploadSize = %d, want %d", cfg.MaxUploadSize, 10<<20)
}
if cfg.SessionLifetime != 720*time.Hour {
t.Errorf("SessionLifetime = %v, want 720h", cfg.SessionLifetime)
}
if cfg.SiteName != "Favoritter" {
t.Errorf("SiteName = %q, want Favoritter", cfg.SiteName)
}
if cfg.DevMode {
t.Error("DevMode should be false by default")
}
if cfg.RateLimit != 60 {
t.Errorf("RateLimit = %d, want 60", cfg.RateLimit)
}
}
func TestLoadFromEnv(t *testing.T) {
t.Setenv("FAVORITTER_DB_PATH", "/custom/db.sqlite")
t.Setenv("FAVORITTER_LISTEN", ":9090")
t.Setenv("FAVORITTER_BASE_PATH", "/faves")
t.Setenv("FAVORITTER_EXTERNAL_URL", "https://faves.example.com/")
t.Setenv("FAVORITTER_UPLOAD_DIR", "/custom/uploads")
t.Setenv("FAVORITTER_MAX_UPLOAD_SIZE", "20971520")
t.Setenv("FAVORITTER_SESSION_LIFETIME", "48h")
t.Setenv("FAVORITTER_ARGON2_MEMORY", "131072")
t.Setenv("FAVORITTER_ARGON2_TIME", "5")
t.Setenv("FAVORITTER_ARGON2_PARALLELISM", "4")
t.Setenv("FAVORITTER_RATE_LIMIT", "100")
t.Setenv("FAVORITTER_ADMIN_USERNAME", "admin")
t.Setenv("FAVORITTER_ADMIN_PASSWORD", "secret")
t.Setenv("FAVORITTER_SITE_NAME", "Mine Favoritter")
t.Setenv("FAVORITTER_DEV_MODE", "true")
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "10.0.0.0/8,192.168.1.0/24")
cfg := Load()
if cfg.DBPath != "/custom/db.sqlite" {
t.Errorf("DBPath = %q", cfg.DBPath)
}
if cfg.Listen != ":9090" {
t.Errorf("Listen = %q", cfg.Listen)
}
if cfg.BasePath != "/faves" {
t.Errorf("BasePath = %q, want /faves", cfg.BasePath)
}
// External URL should have trailing slash stripped.
if cfg.ExternalURL != "https://faves.example.com" {
t.Errorf("ExternalURL = %q, want trailing slash stripped", cfg.ExternalURL)
}
if cfg.MaxUploadSize != 20971520 {
t.Errorf("MaxUploadSize = %d", cfg.MaxUploadSize)
}
if cfg.SessionLifetime != 48*time.Hour {
t.Errorf("SessionLifetime = %v", cfg.SessionLifetime)
}
if cfg.Argon2Memory != 131072 {
t.Errorf("Argon2Memory = %d", cfg.Argon2Memory)
}
if cfg.Argon2Time != 5 {
t.Errorf("Argon2Time = %d", cfg.Argon2Time)
}
if cfg.Argon2Parallelism != 4 {
t.Errorf("Argon2Parallelism = %d", cfg.Argon2Parallelism)
}
if cfg.RateLimit != 100 {
t.Errorf("RateLimit = %d", cfg.RateLimit)
}
if cfg.AdminUsername != "admin" {
t.Errorf("AdminUsername = %q", cfg.AdminUsername)
}
if cfg.AdminPassword != "secret" {
t.Errorf("AdminPassword = %q", cfg.AdminPassword)
}
if cfg.SiteName != "Mine Favoritter" {
t.Errorf("SiteName = %q", cfg.SiteName)
}
if !cfg.DevMode {
t.Error("DevMode should be true")
}
if len(cfg.TrustedProxies) != 2 {
t.Errorf("TrustedProxies: got %d, want 2", len(cfg.TrustedProxies))
}
}
func TestTrustedProxiesParsing(t *testing.T) {
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "10.0.0.0/8, 192.168.0.0/16, 127.0.0.1")
cfg := Load()
if len(cfg.TrustedProxies) != 3 {
t.Fatalf("TrustedProxies: got %d, want 3", len(cfg.TrustedProxies))
}
// 127.0.0.1 without CIDR should become 127.0.0.1/32.
last := cfg.TrustedProxies[2]
if ones, _ := last.Mask.Size(); ones != 32 {
t.Errorf("bare IP mask = /%d, want /32", ones)
}
}
func TestTrustedProxiesInvalid(t *testing.T) {
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "not-an-ip, 10.0.0.0/8")
cfg := Load()
// Invalid entries are skipped; valid ones remain.
if len(cfg.TrustedProxies) != 1 {
t.Errorf("TrustedProxies: got %d, want 1 (invalid skipped)", len(cfg.TrustedProxies))
}
}
func TestBasePathNormalization(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/", ""},
{"", ""},
{"/faves", "/faves"},
{"/faves/", "/faves"},
{"faves", "/faves"},
{"/sub/path/", "/sub/path"},
}
for _, tt := range tests {
got := normalizePath(tt.input)
if got != tt.want {
t.Errorf("normalizePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestDevModeFlag(t *testing.T) {
t.Setenv("FAVORITTER_DEV_MODE", "true")
cfg := Load()
if !cfg.DevMode {
t.Error("DevMode should be true when env is 'true'")
}
t.Setenv("FAVORITTER_DEV_MODE", "false")
cfg = Load()
if cfg.DevMode {
t.Error("DevMode should be false when env is 'false'")
}
}
func TestExternalHostname(t *testing.T) {
cfg := &Config{ExternalURL: "https://faves.example.com/base"}
if got := cfg.ExternalHostname(); got != "faves.example.com" {
t.Errorf("ExternalHostname = %q, want faves.example.com", got)
}
cfg = &Config{}
if got := cfg.ExternalHostname(); got != "" {
t.Errorf("empty ExternalURL: ExternalHostname = %q, want empty", got)
}
}
func TestBaseURL(t *testing.T) {
// With external URL configured.
cfg := &Config{ExternalURL: "https://faves.example.com"}
if got := cfg.BaseURL("localhost:8080"); got != "https://faves.example.com" {
t.Errorf("BaseURL with external = %q", got)
}
// Without external URL — falls back to request host.
cfg = &Config{BasePath: "/faves"}
if got := cfg.BaseURL("myhost.local:8080"); got != "https://myhost.local:8080/faves" {
t.Errorf("BaseURL fallback = %q", got)
}
}

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"testing"
_ "modernc.org/sqlite"
)
func TestOpenInMemory(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("Open(:memory:): %v", err)
}
defer db.Close()
// Verify the connection is usable.
var result int
if err := db.QueryRow("SELECT 1").Scan(&result); err != nil {
t.Fatalf("query: %v", err)
}
if result != 1 {
t.Errorf("SELECT 1 = %d", result)
}
}
func TestMigrateCreatesAllTables(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Check that core tables exist.
tables := []string{"users", "faves", "tags", "fave_tags", "sessions", "site_settings", "schema_migrations", "signup_requests"}
for _, table := range tables {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count)
if err != nil {
t.Errorf("check table %s: %v", table, err)
}
if count != 1 {
t.Errorf("table %s does not exist", table)
}
}
}
func TestMigrateIdempotent(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
// First migration.
if err := Migrate(db); err != nil {
t.Fatalf("first migrate: %v", err)
}
// Second migration should be a no-op.
if err := Migrate(db); err != nil {
t.Fatalf("second migrate: %v", err)
}
// Verify schema_migrations has entries (no duplicates).
var count int
db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
if count < 1 {
t.Error("expected at least one migration record")
}
}
func TestPRAGMAs(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
// WAL mode.
var journalMode string
db.QueryRow("PRAGMA journal_mode").Scan(&journalMode)
// In-memory databases use "memory" journal mode, not "wal".
// WAL is only meaningful for file-based databases.
// We just verify the pragma was accepted without error.
// Foreign keys should be ON.
var fk int
db.QueryRow("PRAGMA foreign_keys").Scan(&fk)
if fk != 1 {
t.Errorf("foreign_keys = %d, want 1", fk)
}
// Busy timeout.
var timeout int
db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
if timeout != 5000 {
t.Errorf("busy_timeout = %d, want 5000", timeout)
}
}
func TestSingleConnection(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
stats := db.Stats()
if stats.MaxOpenConnections != 1 {
t.Errorf("MaxOpenConnections = %d, want 1", stats.MaxOpenConnections)
}
}
func TestSiteSettingsSeeded(t *testing.T) {
db, err := Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Migrations should seed a default site_settings row.
var siteName string
err = db.QueryRow("SELECT site_name FROM site_settings WHERE id = 1").Scan(&siteName)
if err != nil {
t.Fatalf("query site_settings: %v", err)
}
if siteName == "" {
t.Error("expected non-empty default site_name")
}
}

View file

@ -0,0 +1,2 @@
-- Legger til et valgfritt notatfelt på favoritter.
ALTER TABLE faves ADD COLUMN notes TEXT NOT NULL DEFAULT '';

View file

@ -11,6 +11,7 @@ import (
"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"
@ -158,6 +159,86 @@ func (h *Handler) handleAdminToggleDisabled(w http.ResponseWriter, r *http.Reque
h.adminUsersFlash(w, r, "Bruker "+user.Username+" er "+action+".", "success")
}
// handleAdminSetRole changes a user's role.
func (h *Handler) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
admin := middleware.UserFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if id == admin.ID {
h.adminUsersFlash(w, r, "Du kan ikke endre din egen rolle.", "error")
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
role := r.FormValue("role")
if role != "user" && role != "admin" {
h.adminUsersFlash(w, r, "Ugyldig rolle.", "error")
return
}
if err := h.deps.Users.SetRole(id, role); err != nil {
slog.Error("set role error", "error", err)
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
return
}
user, _ := h.deps.Users.GetByID(id)
h.adminUsersFlash(w, r, "Rollen til "+user.Username+" er endret til "+role+".", "success")
}
// handleAdminDeleteUser permanently deletes a user and all their data.
func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
admin := middleware.UserFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if id == admin.ID {
h.adminUsersFlash(w, r, "Du kan ikke slette din egen konto.", "error")
return
}
user, err := h.deps.Users.GetByID(id)
if err != nil {
slog.Error("get user error", "error", err)
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
return
}
// Delete user's images from disk before database deletion.
imagePaths, _ := h.deps.Faves.ImagePathsByUser(id)
for _, p := range imagePaths {
if delErr := image.Delete(h.deps.Config.UploadDir, p); delErr != nil {
slog.Error("image delete error", "error", delErr)
}
}
if user.AvatarPath != "" {
if delErr := image.Delete(h.deps.Config.UploadDir, user.AvatarPath); delErr != nil {
slog.Error("avatar delete error", "user_id", id, "error", delErr)
}
}
if err := h.deps.Users.Delete(id); err != nil {
slog.Error("delete user error", "error", err)
h.adminUsersFlash(w, r, "Noe gikk galt.", "error")
return
}
h.adminUsersFlash(w, r, "Bruker "+user.Username+" er permanent slettet.", "success")
}
// handleAdminTags lists all tags.
func (h *Handler) handleAdminTags(w http.ResponseWriter, r *http.Request) {
tags, err := h.deps.Tags.ListAll()

View file

@ -135,6 +135,7 @@ func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
var req struct {
Description string `json:"description"`
URL string `json:"url"`
Notes string `json:"notes"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
@ -151,7 +152,7 @@ func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
req.Privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, req.Description, req.URL, "", req.Privacy)
fave, err := h.deps.Faves.Create(user.ID, req.Description, req.URL, "", req.Notes, req.Privacy)
if err != nil {
slog.Error("api: create fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
@ -222,6 +223,7 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
var req struct {
Description string `json:"description"`
URL string `json:"url"`
Notes string `json:"notes"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
@ -237,7 +239,7 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
req.Privacy = fave.Privacy
}
if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy); err != nil {
if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Notes, req.Privacy); err != nil {
slog.Error("api: update fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
@ -378,6 +380,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
var faves []struct {
Description string `json:"description"`
URL string `json:"url"`
Notes string `json:"notes"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
@ -395,7 +398,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
if privacy != "public" && privacy != "private" {
privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, f.Description, f.URL, "", privacy)
fave, err := h.deps.Faves.Create(user.ID, f.Description, f.URL, "", f.Notes, privacy)
if err != nil {
continue
}
@ -446,6 +449,7 @@ func faveJSON(f *model.Fave) map[string]any {
"id": f.ID,
"description": f.Description,
"url": f.URL,
"notes": f.Notes,
"image_path": f.ImagePath,
"privacy": f.Privacy,
"tags": tags,

View file

@ -0,0 +1,663 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"kode.naiv.no/olemd/favoritter/internal/config"
"kode.naiv.no/olemd/favoritter/internal/database"
"kode.naiv.no/olemd/favoritter/internal/middleware"
"kode.naiv.no/olemd/favoritter/internal/store"
)
// testAPIServer creates a wired API handler with in-memory DB.
func testAPIServer(t *testing.T) (*Handler, *http.ServeMux, *store.UserStore, *store.SessionStore) {
t.Helper()
db, err := database.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { db.Close() })
store.Argon2Memory = 1024
store.Argon2Time = 1
cfg := &config.Config{
MaxUploadSize: 10 << 20, // 10 MB
}
users := store.NewUserStore(db)
sessions := store.NewSessionStore(db)
faves := store.NewFaveStore(db)
tags := store.NewTagStore(db)
h := New(Deps{
Config: cfg,
Users: users,
Sessions: sessions,
Faves: faves,
Tags: tags,
})
mux := http.NewServeMux()
h.Routes(mux)
// Wrap with SessionLoader so authenticated API requests work.
chain := middleware.SessionLoader(sessions, users)(mux)
wrappedMux := http.NewServeMux()
wrappedMux.Handle("/", chain)
return h, wrappedMux, users, sessions
}
// apiLogin creates a user and returns a session cookie.
func apiLogin(t *testing.T, users *store.UserStore, sessions *store.SessionStore, username, password, role string) *http.Cookie {
t.Helper()
user, err := users.Create(username, password, role)
if err != nil {
t.Fatalf("create user %s: %v", username, err)
}
token, err := sessions.Create(user.ID)
if err != nil {
t.Fatalf("create session: %v", err)
}
return &http.Cookie{Name: "session", Value: token}
}
// jsonBody is a helper to parse JSON response bodies.
func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
t.Helper()
var result map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatalf("parse response JSON: %v\nbody: %s", err, rr.Body.String())
}
return result
}
// --- Auth ---
func TestAPILoginSuccess(t *testing.T) {
_, mux, users, _ := testAPIServer(t)
users.Create("testuser", "password123", "user")
body := `{"username":"testuser","password":"password123"}`
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("login: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
}
result := jsonBody(t, rr)
if result["token"] == nil || result["token"] == "" {
t.Error("expected token in response")
}
user, ok := result["user"].(map[string]any)
if !ok {
t.Fatal("expected user object in response")
}
if user["username"] != "testuser" {
t.Errorf("username = %v, want testuser", user["username"])
}
}
func TestAPILoginWrongPassword(t *testing.T) {
_, mux, users, _ := testAPIServer(t)
users.Create("testuser", "password123", "user")
body := `{"username":"testuser","password":"wrong"}`
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("wrong password: got %d, want 401", rr.Code)
}
}
func TestAPILoginInvalidBody(t *testing.T) {
_, mux, _, _ := testAPIServer(t)
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader("not json"))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("invalid body: got %d, want 400", rr.Code)
}
}
func TestAPILogout(t *testing.T) {
_, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("logout: got %d, want 200", rr.Code)
}
// Session should be invalid now.
_, err := sessions.Validate(cookie.Value)
if err == nil {
t.Error("session should be invalidated after logout")
}
}
// --- Faves CRUD ---
func TestAPICreateFave(t *testing.T) {
_, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
body := `{"description":"My favorite thing","url":"https://example.com","privacy":"public","tags":["go","web"]}`
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("create fave: got %d, want 201\nbody: %s", rr.Code, rr.Body.String())
}
result := jsonBody(t, rr)
if result["description"] != "My favorite thing" {
t.Errorf("description = %v", result["description"])
}
if result["url"] != "https://example.com" {
t.Errorf("url = %v", result["url"])
}
tags, ok := result["tags"].([]any)
if !ok || len(tags) != 2 {
t.Errorf("expected 2 tags, got %v", result["tags"])
}
}
func TestAPICreateFaveMissingDescription(t *testing.T) {
_, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
body := `{"url":"https://example.com"}`
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("missing description: got %d, want 400", rr.Code)
}
}
func TestAPICreateFaveRequiresAuth(t *testing.T) {
_, mux, _, _ := testAPIServer(t)
body := `{"description":"test"}`
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Should redirect or return non-2xx.
if rr.Code == http.StatusCreated || rr.Code == http.StatusOK {
t.Errorf("unauthenticated create: got %d, should not be 2xx", rr.Code)
}
}
func TestAPIGetFave(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
// Create a public fave directly.
user, _ := users.GetByUsername("testuser")
fave, _ := h.deps.Faves.Create(user.ID, "Test fave", "https://example.com", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"test"})
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("get fave: got %d, want 200", rr.Code)
}
result := jsonBody(t, rr)
if result["description"] != "Test fave" {
t.Errorf("description = %v", result["description"])
}
}
func TestAPIGetFaveNotFound(t *testing.T) {
_, mux, _, _ := testAPIServer(t)
req := httptest.NewRequest("GET", "/api/v1/faves/99999", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("nonexistent fave: got %d, want 404", rr.Code)
}
}
func TestAPIPrivateFaveHiddenFromOthers(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
// User A creates a private fave.
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret", "", "", "", "private")
// User B tries to access it.
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
req.AddCookie(cookieB)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("private fave for other user: got %d, want 404", rr.Code)
}
}
func TestAPIPrivateFaveVisibleToOwner(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "", "private")
tokenA, _ := sessions.Create(userA.ID)
cookieA := &http.Cookie{Name: "session", Value: tokenA}
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
req.AddCookie(cookieA)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("own private fave: got %d, want 200", rr.Code)
}
}
func TestAPIUpdateFave(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Original", "https://old.com", "", "", "public")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
body := `{"description":"Updated","url":"https://new.com","tags":["updated"]}`
req := httptest.NewRequest("PUT", "/api/v1/faves/"+faveIDStr(fave.ID), strings.NewReader(body))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("update fave: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
}
result := jsonBody(t, rr)
if result["description"] != "Updated" {
t.Errorf("description = %v, want Updated", result["description"])
}
if result["url"] != "https://new.com" {
t.Errorf("url = %v, want https://new.com", result["url"])
}
}
func TestAPIUpdateFaveNotOwner(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
body := `{"description":"Hijacked"}`
req := httptest.NewRequest("PUT", "/api/v1/faves/"+faveIDStr(fave.ID), strings.NewReader(body))
req.AddCookie(cookieB)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("update by non-owner: got %d, want 403", rr.Code)
}
}
func TestAPIDeleteFave(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "", "public")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
req := httptest.NewRequest("DELETE", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Errorf("delete fave: got %d, want 204", rr.Code)
}
// Verify it's gone.
req = httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("deleted fave: got %d, want 404", rr.Code)
}
}
func TestAPIDeleteFaveNotOwner(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
req := httptest.NewRequest("DELETE", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
req.AddCookie(cookieB)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("delete by non-owner: got %d, want 403", rr.Code)
}
}
func TestAPIListFaves(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 3", "", "", "", "private")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
req := httptest.NewRequest("GET", "/api/v1/faves", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("list faves: got %d, want 200", rr.Code)
}
result := jsonBody(t, rr)
total, _ := result["total"].(float64)
if total != 3 {
t.Errorf("total = %v, want 3 (all faves including private)", total)
}
}
func TestAPIListFavesPagination(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
for i := 0; i < 5; i++ {
h.deps.Faves.Create(user.ID, "Fave", "", "", "", "public")
}
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
req := httptest.NewRequest("GET", "/api/v1/faves?page=1&limit=2", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
result := jsonBody(t, rr)
faves, ok := result["faves"].([]any)
if !ok {
t.Fatal("expected faves array")
}
if len(faves) != 2 {
t.Errorf("page size: got %d faves, want 2", len(faves))
}
total, _ := result["total"].(float64)
if total != 5 {
t.Errorf("total = %v, want 5", total)
}
}
// --- Tags ---
func TestAPISearchTags(t *testing.T) {
h, mux, users, _ := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines", "python"})
req := httptest.NewRequest("GET", "/api/v1/tags?q=go", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("search tags: got %d, want 200", rr.Code)
}
result := jsonBody(t, rr)
tags, ok := result["tags"].([]any)
if !ok {
t.Fatal("expected tags array")
}
if len(tags) < 1 {
t.Error("expected at least one tag matching 'go'")
}
}
func TestAPISearchTagsEmpty(t *testing.T) {
_, mux, _, _ := testAPIServer(t)
req := httptest.NewRequest("GET", "/api/v1/tags?q=nonexistent", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("empty tag search: got %d, want 200", rr.Code)
}
result := jsonBody(t, rr)
tags, _ := result["tags"].([]any)
if len(tags) != 0 {
t.Errorf("expected empty tags, got %v", tags)
}
}
// --- Users ---
func TestAPIGetUser(t *testing.T) {
_, mux, users, _ := testAPIServer(t)
users.Create("testuser", "pass123", "user")
req := httptest.NewRequest("GET", "/api/v1/users/testuser", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("get user: got %d, want 200", rr.Code)
}
result := jsonBody(t, rr)
if result["username"] != "testuser" {
t.Errorf("username = %v", result["username"])
}
}
func TestAPIGetUserNotFound(t *testing.T) {
_, mux, _, _ := testAPIServer(t)
req := httptest.NewRequest("GET", "/api/v1/users/nobody", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("nonexistent user: got %d, want 404", rr.Code)
}
}
func TestAPIGetDisabledUser(t *testing.T) {
_, mux, users, _ := testAPIServer(t)
user, _ := users.Create("disabled", "pass123", "user")
users.SetDisabled(user.ID, true)
req := httptest.NewRequest("GET", "/api/v1/users/disabled", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("disabled user: got %d, want 404", rr.Code)
}
}
func TestAPIGetUserFaves(t *testing.T) {
h, mux, users, _ := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Private fave", "", "", "", "private")
req := httptest.NewRequest("GET", "/api/v1/users/testuser/faves", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("user faves: got %d, want 200", rr.Code)
}
result := jsonBody(t, rr)
total, _ := result["total"].(float64)
if total != 1 {
t.Errorf("total = %v, want 1 (only public faves)", total)
}
}
// --- Export/Import ---
func TestAPIExport(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "", "private")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
req := httptest.NewRequest("GET", "/api/v1/export/json", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("export: got %d, want 200", rr.Code)
}
// Export returns a JSON array directly.
var faves []any
if err := json.Unmarshal(rr.Body.Bytes(), &faves); err != nil {
t.Fatalf("parse export JSON: %v", err)
}
if len(faves) != 2 {
t.Errorf("exported %d faves, want 2", len(faves))
}
}
func TestAPIImportValid(t *testing.T) {
_, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
body := `[{"description":"Imported 1","privacy":"public"},{"description":"Imported 2","tags":["test"]}]`
req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader(body))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("import: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
}
result := jsonBody(t, rr)
imported, _ := result["imported"].(float64)
if imported != 2 {
t.Errorf("imported = %v, want 2", imported)
}
}
func TestAPIImportSkipsEmpty(t *testing.T) {
_, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
body := `[{"description":"Valid"},{"description":"","url":"https://empty.com"}]`
req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader(body))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
result := jsonBody(t, rr)
imported, _ := result["imported"].(float64)
total, _ := result["total"].(float64)
if imported != 1 {
t.Errorf("imported = %v, want 1", imported)
}
if total != 2 {
t.Errorf("total = %v, want 2", total)
}
}
func TestAPIImportInvalidJSON(t *testing.T) {
_, mux, users, sessions := testAPIServer(t)
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader("not json"))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("invalid JSON import: got %d, want 400", rr.Code)
}
}
// --- JSON helpers ---
func TestQueryIntFallback(t *testing.T) {
tests := []struct {
query string
want int
}{
{"", 10},
{"page=abc", 10},
{"page=-1", 10},
{"page=0", 10},
{"page=5", 5},
}
for _, tt := range tests {
req := httptest.NewRequest("GET", "/test?"+tt.query, nil)
got := queryInt(req, "page", 10)
if got != tt.want {
t.Errorf("queryInt(%q) = %d, want %d", tt.query, got, tt.want)
}
}
}
// faveIDStr converts an int64 to a string for URL paths.
func faveIDStr(id int64) string {
return strconv.FormatInt(id, 10)
}

View file

@ -48,19 +48,6 @@ func (h *Handler) handleFaveList(w http.ResponseWriter, r *http.Request) {
})
}
// 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())
@ -72,12 +59,13 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
description := strings.TrimSpace(r.FormValue("description"))
url := strings.TrimSpace(r.FormValue("url"))
notes := strings.TrimSpace(r.FormValue("notes"))
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,
"IsNew": true, "Description": description, "URL": url, "Notes": notes, "Tags": tagStr, "Privacy": privacy,
})
return
}
@ -95,7 +83,7 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
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,
"IsNew": true, "Description": description, "URL": url, "Notes": notes, "Tags": tagStr, "Privacy": privacy,
})
return
}
@ -103,7 +91,7 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
}
// Create the fave.
fave, err := h.deps.Faves.Create(user.ID, description, url, imagePath, privacy)
fave, err := h.deps.Faves.Create(user.ID, description, url, imagePath, notes, 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})
@ -205,6 +193,7 @@ func (h *Handler) handleFaveEdit(w http.ResponseWriter, r *http.Request) {
"Fave": fave,
"Description": fave.Description,
"URL": fave.URL,
"Notes": fave.Notes,
"Privacy": fave.Privacy,
"Tags": strings.Join(tagNames, ", "),
},
@ -244,6 +233,7 @@ func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
description := strings.TrimSpace(r.FormValue("description"))
url := strings.TrimSpace(r.FormValue("url"))
notes := strings.TrimSpace(r.FormValue("notes"))
privacy := r.FormValue("privacy")
tagStr := r.FormValue("tags")
@ -283,7 +273,7 @@ func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
imagePath = ""
}
if err := h.deps.Faves.Update(id, description, url, imagePath, privacy); err != nil {
if err := h.deps.Faves.Update(id, description, url, imagePath, notes, 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
@ -345,6 +335,51 @@ func (h *Handler) handleFaveDelete(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, h.deps.Config.BasePath+"/faves", http.StatusSeeOther)
}
// handleFaveTogglePrivacy toggles a fave's privacy and returns the updated toggle partial.
func (h *Handler) handleFaveTogglePrivacy(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
h.notFound(w, r)
return
}
fave, err := h.deps.Faves.GetByID(id)
if err != nil {
if errors.Is(err, store.ErrFaveNotFound) {
h.notFound(w, r)
return
}
slog.Error("get fave error", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if user.ID != fave.UserID {
h.forbidden(w, r)
return
}
newPrivacy := "private"
if fave.Privacy == "private" {
newPrivacy = "public"
}
if err := h.deps.Faves.UpdatePrivacy(id, newPrivacy); err != nil {
slog.Error("toggle privacy error", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
fave.Privacy = newPrivacy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.deps.Renderer.Partial(w, "privacy_toggle", fave); err != nil {
slog.Error("render privacy toggle error", "error", err)
}
}
// handleTagSearch handles tag autocomplete HTMX requests.
func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")

View file

@ -129,6 +129,10 @@ func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item {
Updated: f.UpdatedAt,
}
if f.Notes != "" {
item.Description = f.Notes
}
if f.URL != "" {
escaped := html.EscapeString(f.URL)
item.Content = `<p><a href="` + escaped + `">` + escaped + `</a></p>`

View file

@ -32,14 +32,20 @@ type Deps struct {
type Handler struct {
deps Deps
rateLimiter *middleware.RateLimiter
// Cached PWA responses (computed once at init, never change).
manifestJSON []byte
swJS []byte
}
// New creates a new Handler with the given dependencies.
func New(deps Deps) *Handler {
return &Handler{
h := &Handler{
deps: deps,
rateLimiter: middleware.NewRateLimiter(deps.Config.RateLimit),
}
h.initPWACache()
return h
}
// RateLimiterCleanupLoop periodically evicts stale rate limiter entries.
@ -74,6 +80,10 @@ func (h *Handler) Routes() *http.ServeMux {
// Health check.
mux.HandleFunc("GET /health", h.handleHealth)
// PWA: manifest and service worker (public, no auth).
mux.HandleFunc("GET /manifest.json", h.handleManifest)
mux.HandleFunc("GET /sw.js", h.handleServiceWorker)
// 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)))
@ -91,13 +101,15 @@ func (h *Handler) Routes() *http.ServeMux {
// Faves — authenticated routes use requireLogin wrapper.
requireLogin := middleware.RequireLogin(h.deps.Config.BasePath)
mux.Handle("GET /share", requireLogin(http.HandlerFunc(h.handleShare)))
mux.Handle("GET /faves", requireLogin(http.HandlerFunc(h.handleFaveList)))
mux.Handle("GET /faves/new", requireLogin(http.HandlerFunc(h.handleFaveNew)))
mux.Handle("GET /faves/new", requireLogin(http.HandlerFunc(h.handleFaveNewPreFill)))
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)))
mux.Handle("POST /faves/{id}/privacy", requireLogin(http.HandlerFunc(h.handleFaveTogglePrivacy)))
// Tags.
mux.HandleFunc("GET /tags/search", h.handleTagSearch)
@ -133,6 +145,8 @@ func (h *Handler) Routes() *http.ServeMux {
mux.Handle("POST /admin/users", admin(h.handleAdminCreateUser))
mux.Handle("POST /admin/users/{id}/reset-password", admin(h.handleAdminResetPassword))
mux.Handle("POST /admin/users/{id}/toggle-disabled", admin(h.handleAdminToggleDisabled))
mux.Handle("POST /admin/users/{id}/role", admin(h.handleAdminSetRole))
mux.Handle("POST /admin/users/{id}/delete", admin(h.handleAdminDeleteUser))
mux.Handle("GET /admin/tags", admin(h.handleAdminTags))
mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag))
mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag))

View file

@ -67,8 +67,8 @@ func testServer(t *testing.T) (*Handler, *http.ServeMux) {
mux := h.Routes()
// Wrap with SessionLoader so authenticated tests work.
chain := middleware.SessionLoader(sessions, users)(mux)
// Wrap with SessionLoader and CSRFProtection so authenticated tests work.
chain := middleware.CSRFProtection(cfg)(middleware.SessionLoader(sessions, users)(mux))
wrappedMux := http.NewServeMux()
wrappedMux.Handle("/", chain)
@ -220,7 +220,7 @@ func TestPrivateFaveHiddenFromOthers(t *testing.T) {
// User A creates a private fave.
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret fave", "", "", "private")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret fave", "", "", "", "private")
// User B tries to view it.
cookieB := loginUser(t, h, "userb", "pass123", "user")
@ -239,7 +239,7 @@ func TestPrivateFaveVisibleToOwner(t *testing.T) {
h, mux := testServer(t)
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "", "private")
tokenA, _ := h.deps.Sessions.Create(userA.ID)
cookieA := &http.Cookie{Name: "session", Value: tokenA}
@ -290,7 +290,7 @@ func TestTagSearchEndpoint(t *testing.T) {
// Create some tags via faves.
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"music", "movies", "manga"})
req := httptest.NewRequest("GET", "/tags/search?q=mu", nil)
@ -310,7 +310,7 @@ func TestFeedGlobal(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/feed.xml", nil)
rr := httptest.NewRecorder()

View file

@ -21,6 +21,7 @@ const maxExportFaves = 100000
type ExportFave struct {
Description string `json:"description"`
URL string `json:"url,omitempty"`
Notes string `json:"notes,omitempty"`
Privacy string `json:"privacy"`
Tags []string `json:"tags,omitempty"`
CreatedAt string `json:"created_at"`
@ -57,6 +58,7 @@ func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) {
export[i] = ExportFave{
Description: f.Description,
URL: f.URL,
Notes: f.Notes,
Privacy: f.Privacy,
Tags: tags,
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"),
@ -89,7 +91,7 @@ func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", "attachment; filename=favoritter.csv")
cw := csv.NewWriter(w)
cw.Write([]string{"description", "url", "privacy", "tags", "created_at"})
cw.Write([]string{"description", "url", "notes", "privacy", "tags", "created_at"})
for _, f := range faves {
tags := make([]string, len(f.Tags))
@ -99,6 +101,7 @@ func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) {
cw.Write([]string{
f.Description,
f.URL,
f.Notes,
f.Privacy,
strings.Join(tags, ","),
f.CreatedAt.Format("2006-01-02T15:04:05Z"),
@ -159,7 +162,7 @@ func (h *Handler) handleImportPost(w http.ResponseWriter, r *http.Request) {
privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, ef.Description, ef.URL, "", privacy)
fave, err := h.deps.Faves.Create(user.ID, ef.Description, ef.URL, "", ef.Notes, privacy)
if err != nil {
slog.Error("import: create fave error", "error", err)
continue
@ -213,6 +216,9 @@ func parseImportCSV(r io.Reader) ([]ExportFave, error) {
if idx, ok := colMap["url"]; ok && idx < len(row) {
f.URL = row[idx]
}
if idx, ok := colMap["notes"]; ok && idx < len(row) {
f.Notes = row[idx]
}
if idx, ok := colMap["privacy"]; ok && idx < len(row) {
f.Privacy = row[idx]
}

131
internal/handler/pwa.go Normal file
View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package handler
import (
"encoding/json"
"io/fs"
"net/http"
"net/url"
"strings"
"kode.naiv.no/olemd/favoritter/internal/middleware"
"kode.naiv.no/olemd/favoritter/internal/render"
"kode.naiv.no/olemd/favoritter/web"
)
// initPWACache pre-computes the manifest JSON and service worker JS
// so they can be served without per-request allocations.
func (h *Handler) initPWACache() {
bp := h.deps.Config.BasePath
manifest := map[string]any{
"name": h.deps.Config.SiteName,
"short_name": h.deps.Config.SiteName,
"description": "Lagre og del dine favoritter",
"start_url": bp + "/",
"scope": bp + "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1095c1",
"icons": []map[string]any{
{"src": bp + "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": bp + "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
{"src": bp + "/static/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
},
"share_target": map[string]any{
"action": bp + "/share",
"method": "GET",
"params": map[string]string{
"url": "url",
"text": "text",
"title": "title",
},
},
}
h.manifestJSON, _ = json.Marshal(manifest)
staticFS, err := fs.Sub(web.StaticFS, "static")
if err == nil {
if data, err := fs.ReadFile(staticFS, "sw.js"); err == nil {
h.swJS = []byte(strings.ReplaceAll(string(data), "{{BASE_PATH}}", bp))
}
}
}
// handleManifest serves the pre-computed Web App Manifest.
func (h *Handler) handleManifest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Write(h.manifestJSON)
}
// handleServiceWorker serves the pre-computed service worker JS.
func (h *Handler) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "no-cache")
w.Write(h.swJS)
}
// handleShare receives Android share intents via the Web Share Target API
// and redirects to the new-fave form with pre-filled values.
// Uses GET to avoid CSRF issues (Android cannot provide CSRF tokens).
func (h *Handler) handleShare(w http.ResponseWriter, r *http.Request) {
sharedURL := r.URL.Query().Get("url")
sharedTitle := r.URL.Query().Get("title")
sharedText := r.URL.Query().Get("text")
// Many Android apps send the URL in the "text" field instead of "url".
if sharedURL == "" && sharedText != "" {
sharedURL = extractURL(sharedText)
if sharedURL != "" {
sharedText = strings.TrimSpace(strings.Replace(sharedText, sharedURL, "", 1))
}
}
description := sharedTitle
if description == "" && sharedText != "" {
description = sharedText
sharedText = ""
}
target := h.deps.Config.BasePath + "/faves/new?"
params := url.Values{}
if sharedURL != "" {
params.Set("url", sharedURL)
}
if description != "" {
params.Set("description", description)
}
if sharedText != "" {
params.Set("notes", sharedText)
}
http.Redirect(w, r, target+params.Encode(), http.StatusSeeOther)
}
// handleFaveNewPreFill shows the new fave form, optionally pre-filled
// from query parameters (used by share target and bookmarklets).
func (h *Handler) handleFaveNewPreFill(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,
"Description": r.URL.Query().Get("description"),
"URL": r.URL.Query().Get("url"),
"Notes": r.URL.Query().Get("notes"),
},
})
}
// extractURL finds the first http:// or https:// URL in a string.
func extractURL(s string) string {
for _, word := range strings.Fields(s) {
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
return word
}
}
return ""
}

1493
internal/handler/web_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,234 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package image
import (
"bytes"
"image"
"image/jpeg"
"image/png"
"mime/multipart"
"net/textproto"
"os"
"path/filepath"
"strings"
"testing"
)
// testJPEG creates a test JPEG image in memory with given dimensions.
func testJPEG(t *testing.T, width, height int) (*bytes.Buffer, *multipart.FileHeader) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
t.Fatalf("encode test jpeg: %v", err)
}
header := &multipart.FileHeader{
Filename: "test.jpg",
Size: int64(buf.Len()),
Header: textproto.MIMEHeader{"Content-Type": {"image/jpeg"}},
}
return &buf, header
}
// testPNG creates a test PNG image in memory with given dimensions.
func testPNG(t *testing.T, width, height int) (*bytes.Buffer, *multipart.FileHeader) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode test png: %v", err)
}
header := &multipart.FileHeader{
Filename: "test.png",
Size: int64(buf.Len()),
Header: textproto.MIMEHeader{"Content-Type": {"image/png"}},
}
return &buf, header
}
// bufferReadSeeker wraps a bytes.Reader to implement multipart.File.
type bufferReadSeeker struct {
*bytes.Reader
}
func (b *bufferReadSeeker) Close() error { return nil }
func TestProcessJPEG(t *testing.T) {
uploadDir := t.TempDir()
buf, header := testJPEG(t, 800, 600)
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
if err != nil {
t.Fatalf("Process JPEG: %v", err)
}
if result.Filename == "" {
t.Error("expected non-empty filename")
}
if !strings.HasSuffix(result.Filename, ".jpg") {
t.Errorf("filename %q should end with .jpg", result.Filename)
}
// Verify file was written.
if _, err := os.Stat(result.Path); err != nil {
t.Errorf("output file not found: %v", err)
}
}
func TestProcessPNG(t *testing.T) {
uploadDir := t.TempDir()
buf, header := testPNG(t, 640, 480)
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
if err != nil {
t.Fatalf("Process PNG: %v", err)
}
if !strings.HasSuffix(result.Filename, ".png") {
t.Errorf("filename %q should end with .png", result.Filename)
}
}
func TestProcessResizeWideImage(t *testing.T) {
uploadDir := t.TempDir()
buf, header := testJPEG(t, 3840, 2160) // 4K width
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
if err != nil {
t.Fatalf("Process wide image: %v", err)
}
// Read back and check dimensions.
f, err := os.Open(result.Path)
if err != nil {
t.Fatalf("open result: %v", err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
t.Fatalf("decode result: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != MaxWidth {
t.Errorf("resized width = %d, want %d", bounds.Dx(), MaxWidth)
}
// Aspect ratio should be maintained.
expectedHeight := 2160 * MaxWidth / 3840
if bounds.Dy() != expectedHeight {
t.Errorf("resized height = %d, want %d", bounds.Dy(), expectedHeight)
}
}
func TestProcessSmallImageNotResized(t *testing.T) {
uploadDir := t.TempDir()
buf, header := testJPEG(t, 800, 600)
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
if err != nil {
t.Fatalf("Process small image: %v", err)
}
f, err := os.Open(result.Path)
if err != nil {
t.Fatalf("open result: %v", err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
t.Fatalf("decode result: %v", err)
}
if img.Bounds().Dx() != 800 {
t.Errorf("small image width = %d, should not be resized", img.Bounds().Dx())
}
}
func TestProcessInvalidMIME(t *testing.T) {
uploadDir := t.TempDir()
header := &multipart.FileHeader{
Filename: "test.txt",
Header: textproto.MIMEHeader{"Content-Type": {"text/plain"}},
}
buf := bytes.NewReader([]byte("not an image"))
_, err := Process(&bufferReadSeeker{buf}, header, uploadDir)
if err == nil {
t.Error("expected error for text/plain MIME type")
}
if !strings.Contains(err.Error(), "unsupported image type") {
t.Errorf("error = %q, should mention unsupported type", err)
}
}
func TestProcessCorruptImage(t *testing.T) {
uploadDir := t.TempDir()
header := &multipart.FileHeader{
Filename: "corrupt.jpg",
Header: textproto.MIMEHeader{"Content-Type": {"image/jpeg"}},
}
buf := bytes.NewReader([]byte("this is not valid jpeg data"))
_, err := Process(&bufferReadSeeker{buf}, header, uploadDir)
if err == nil {
t.Error("expected error for corrupt image data")
}
}
func TestProcessUUIDFilename(t *testing.T) {
uploadDir := t.TempDir()
buf, header := testJPEG(t, 100, 100)
// Give a user-supplied filename.
header.Filename = "my-vacation-photo.jpg"
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
if err != nil {
t.Fatalf("Process: %v", err)
}
if strings.Contains(result.Filename, "vacation") {
t.Error("filename should be UUID-based, not user-supplied")
}
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.ext
if len(result.Filename) < 36 {
t.Errorf("filename %q too short for UUID", result.Filename)
}
}
func TestAllowedTypes(t *testing.T) {
expected := []string{"image/jpeg", "image/png", "image/gif", "image/webp"}
for _, mime := range expected {
if _, ok := AllowedTypes[mime]; !ok {
t.Errorf("AllowedTypes missing %s", mime)
}
}
}
func TestDeletePathTraversal(t *testing.T) {
uploadDir := t.TempDir()
// Create a file outside uploadDir.
outsideFile := filepath.Join(t.TempDir(), "sensitive.txt")
os.WriteFile(outsideFile, []byte("secret"), 0644)
// Attempt to delete it via path traversal.
err := Delete(uploadDir, "../../../"+filepath.Base(outsideFile))
if err == nil {
t.Error("expected error for path traversal")
}
// File should still exist.
if _, statErr := os.Stat(outsideFile); statErr != nil {
t.Error("path traversal should not have deleted the file")
}
}
func TestDeleteEmpty(t *testing.T) {
// Empty filename should be a no-op.
if err := Delete(t.TempDir(), ""); err != nil {
t.Errorf("Delete empty filename: %v", err)
}
}

View file

@ -0,0 +1,299 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"kode.naiv.no/olemd/favoritter/internal/database"
"kode.naiv.no/olemd/favoritter/internal/model"
"kode.naiv.no/olemd/favoritter/internal/store"
)
// testStores creates in-memory stores for auth middleware tests.
func testStores(t *testing.T) (*store.SessionStore, *store.UserStore) {
t.Helper()
db, err := database.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { db.Close() })
store.Argon2Memory = 1024
store.Argon2Time = 1
return store.NewSessionStore(db), store.NewUserStore(db)
}
func TestSessionLoaderValidToken(t *testing.T) {
sessions, users := testStores(t)
user, err := users.Create("testuser", "pass123", "user")
if err != nil {
t.Fatalf("create user: %v", err)
}
token, err := sessions.Create(user.ID)
if err != nil {
t.Fatalf("create session: %v", err)
}
var ctxUser *model.User
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxUser = UserFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
handler := SessionLoader(sessions, users)(inner)
req := httptest.NewRequest("GET", "/faves", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if ctxUser == nil {
t.Fatal("expected user in context, got nil")
}
if ctxUser.ID != user.ID {
t.Errorf("context user ID = %d, want %d", ctxUser.ID, user.ID)
}
}
func TestSessionLoaderInvalidToken(t *testing.T) {
sessions, users := testStores(t)
var ctxUser *model.User
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxUser = UserFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
handler := SessionLoader(sessions, users)(inner)
req := httptest.NewRequest("GET", "/faves", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "invalid-token"})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("invalid token: got %d, want 200", rr.Code)
}
if ctxUser != nil {
t.Error("expected nil user for invalid token")
}
// Should clear the invalid session cookie.
for _, c := range rr.Result().Cookies() {
if c.Name == SessionCookieName && c.MaxAge == -1 {
return // Cookie cleared, good.
}
}
t.Error("expected session cookie to be cleared for invalid token")
}
func TestSessionLoaderNoCookie(t *testing.T) {
sessions, users := testStores(t)
var ctxUser *model.User
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxUser = UserFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
handler := SessionLoader(sessions, users)(inner)
req := httptest.NewRequest("GET", "/faves", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("no cookie: got %d, want 200", rr.Code)
}
if ctxUser != nil {
t.Error("expected nil user when no cookie")
}
}
func TestSessionLoaderSkipsStaticPaths(t *testing.T) {
sessions, users := testStores(t)
var handlerCalled bool
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
handler := SessionLoader(sessions, users)(inner)
for _, path := range []string{"/static/css/style.css", "/uploads/image.jpg"} {
handlerCalled = false
req := httptest.NewRequest("GET", path, nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "some-token"})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !handlerCalled {
t.Errorf("handler not called for %s", path)
}
if rr.Code != http.StatusOK {
t.Errorf("%s: got %d, want 200", path, rr.Code)
}
}
}
func TestSessionLoaderDisabledUser(t *testing.T) {
sessions, users := testStores(t)
user, _ := users.Create("testuser", "pass123", "user")
token, _ := sessions.Create(user.ID)
users.SetDisabled(user.ID, true)
var ctxUser *model.User
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxUser = UserFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
handler := SessionLoader(sessions, users)(inner)
req := httptest.NewRequest("GET", "/faves", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if ctxUser != nil {
t.Error("disabled user should not be in context")
}
// Session should be deleted and cookie cleared.
for _, c := range rr.Result().Cookies() {
if c.Name == SessionCookieName && c.MaxAge == -1 {
return
}
}
t.Error("expected session cookie to be cleared for disabled user")
}
func TestRequireAdminRejectsNonAdmin(t *testing.T) {
handler := RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Regular user in context.
user := &model.User{ID: 1, Username: "regular", Role: "user"}
ctx := context.WithValue(context.Background(), userKey, user)
req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("non-admin: got %d, want 403", rr.Code)
}
}
func TestRequireAdminAllowsAdmin(t *testing.T) {
handler := RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
user := &model.User{ID: 1, Username: "admin", Role: "admin"}
ctx := context.WithValue(context.Background(), userKey, user)
req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("admin: got %d, want 200", rr.Code)
}
}
func TestRequireAdminNoUser(t *testing.T) {
handler := RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/admin", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("no user: got %d, want 403", rr.Code)
}
}
func TestContextHelpers(t *testing.T) {
// UserFromContext with user.
user := &model.User{ID: 42, Username: "tester"}
ctx := context.WithValue(context.Background(), userKey, user)
got := UserFromContext(ctx)
if got == nil || got.ID != 42 {
t.Errorf("UserFromContext with user: got %v", got)
}
// UserFromContext without user.
if UserFromContext(context.Background()) != nil {
t.Error("UserFromContext should return nil for empty context")
}
// CSRFTokenFromContext.
ctx = context.WithValue(context.Background(), csrfTokenKey, "my-token")
if CSRFTokenFromContext(ctx) != "my-token" {
t.Error("CSRFTokenFromContext failed")
}
if CSRFTokenFromContext(context.Background()) != "" {
t.Error("CSRFTokenFromContext should return empty for missing key")
}
// RealIPFromContext.
ctx = context.WithValue(context.Background(), realIPKey, "1.2.3.4")
if RealIPFromContext(ctx) != "1.2.3.4" {
t.Error("RealIPFromContext failed")
}
if RealIPFromContext(context.Background()) != "" {
t.Error("RealIPFromContext should return empty for missing key")
}
}
func TestMustResetPasswordGuardRedirects(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := MustResetPasswordGuard("")(inner)
// User with must_reset_password on a regular path → redirect.
user := &model.User{ID: 1, Username: "resetme", MustResetPassword: true}
ctx := context.WithValue(context.Background(), userKey, user)
req := httptest.NewRequest("GET", "/faves", nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Errorf("must-reset on /faves: got %d, want 303", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/reset-password" {
t.Errorf("redirect location = %q, want /reset-password", loc)
}
}
func TestMustResetPasswordGuardAllowsPaths(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := MustResetPasswordGuard("")(inner)
user := &model.User{ID: 1, Username: "resetme", MustResetPassword: true}
ctx := context.WithValue(context.Background(), userKey, user)
// These paths should pass through even with must_reset.
allowedPaths := []string{"/reset-password", "/logout", "/health", "/static/css/style.css"}
for _, path := range allowedPaths {
req := httptest.NewRequest("GET", path, nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("must-reset on %s: got %d, want 200", path, rr.Code)
}
}
}

View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"kode.naiv.no/olemd/favoritter/internal/config"
)
func testCSRFHandler(cfg *config.Config) http.Handler {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Echo the CSRF token from context so tests can verify it.
token := CSRFTokenFromContext(r.Context())
w.Write([]byte(token))
})
return CSRFProtection(cfg)(inner)
}
func TestCSRFTokenSetInCookie(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET: got %d, want 200", rr.Code)
}
// Should set a csrf_token cookie.
var found bool
for _, c := range rr.Result().Cookies() {
if c.Name == "csrf_token" {
found = true
if c.Value == "" {
t.Error("csrf_token cookie is empty")
}
if c.HttpOnly {
t.Error("csrf_token cookie should not be HttpOnly (JS needs to read it)")
}
}
}
if !found {
t.Error("csrf_token cookie not set on first request")
}
// The context token should match the cookie.
body := rr.Body.String()
if body == "" {
t.Error("CSRF token not set in context")
}
}
func TestCSRFValidTokenAccepted(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
// First GET to obtain a token.
getReq := httptest.NewRequest("GET", "/", nil)
getRR := httptest.NewRecorder()
handler.ServeHTTP(getRR, getReq)
var token string
for _, c := range getRR.Result().Cookies() {
if c.Name == "csrf_token" {
token = c.Value
}
}
if token == "" {
t.Fatal("no csrf_token cookie from GET")
}
// POST with matching cookie + form field.
form := "csrf_token=" + token
req := httptest.NewRequest("POST", "/submit", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: token})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("valid CSRF POST: got %d, want 200", rr.Code)
}
}
func TestCSRFMismatchRejected(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
form := "csrf_token=wrong-token"
req := httptest.NewRequest("POST", "/submit", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "real-token"})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("mismatched CSRF: got %d, want 403", rr.Code)
}
}
func TestCSRFMissingTokenRejected(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
// POST with cookie but no form field or header.
req := httptest.NewRequest("POST", "/submit", nil)
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "some-token"})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("missing CSRF form field: got %d, want 403", rr.Code)
}
}
func TestCSRFHeaderFallback(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
token := "valid-header-token"
req := httptest.NewRequest("POST", "/submit", nil)
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: token})
req.Header.Set("X-CSRF-Token", token)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("CSRF via header: got %d, want 200", rr.Code)
}
}
func TestCSRFSkippedForAPI(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
// POST to /api/ path — should skip CSRF validation.
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(`{}`))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "some-token"})
// Intentionally no CSRF form field or header.
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("API route CSRF skip: got %d, want 200", rr.Code)
}
}
func TestCSRFSafeMethodsPassThrough(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
for _, method := range []string{"GET", "HEAD", "OPTIONS"} {
req := httptest.NewRequest(method, "/page", nil)
// No CSRF cookie or token at all.
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("%s without CSRF: got %d, want 200", method, rr.Code)
}
}
}
func TestCSRFExistingCookieReused(t *testing.T) {
handler := testCSRFHandler(&config.Config{})
// Send a request with an existing csrf_token cookie.
existingToken := "pre-existing-token-value"
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: existingToken})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// The context token should be the existing one.
body := rr.Body.String()
if body != existingToken {
t.Errorf("context token = %q, want existing %q", body, existingToken)
}
// Should NOT set a new cookie (existing one is reused).
for _, c := range rr.Result().Cookies() {
if c.Name == "csrf_token" {
t.Error("should not set new csrf_token cookie when one already exists")
}
}
}

View file

@ -10,6 +10,7 @@ type Fave struct {
Description string
URL string
ImagePath string
Notes string
Privacy string
Tags []Tag
CreatedAt time.Time

View file

@ -0,0 +1,161 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package render
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"kode.naiv.no/olemd/favoritter/internal/config"
)
func testRenderer(t *testing.T) *Renderer {
t.Helper()
cfg := &config.Config{
SiteName: "Test Site",
BasePath: "/test",
}
r, err := New(cfg)
if err != nil {
t.Fatalf("create renderer: %v", err)
}
return r
}
func TestRenderPage(t *testing.T) {
r := testRenderer(t)
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
r.Page(rr, req, "login", PageData{
Title: "Logg inn",
})
if rr.Code != http.StatusOK {
t.Errorf("render page: got %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "Logg inn") {
t.Error("page should contain title")
}
ct := rr.Header().Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Errorf("content-type = %q, want text/html", ct)
}
}
func TestRenderPageWithData(t *testing.T) {
r := testRenderer(t)
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
r.Page(rr, req, "login", PageData{
Title: "Test Page",
SiteName: "My Site",
BasePath: "/base",
})
body := rr.Body.String()
if !strings.Contains(body, "Test Page") {
t.Error("should contain title in output")
}
}
func TestRenderMissingTemplate(t *testing.T) {
r := testRenderer(t)
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
r.Page(rr, req, "nonexistent_template_xyz", PageData{})
if rr.Code != http.StatusInternalServerError {
t.Errorf("missing template: got %d, want 500", rr.Code)
}
}
func TestRenderErrorPage(t *testing.T) {
r := testRenderer(t)
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
r.Error(rr, req, http.StatusNotFound, "Ikke funnet")
if rr.Code != http.StatusNotFound {
t.Errorf("error page: got %d, want 404", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "Ikke funnet") {
t.Error("error page should contain message")
}
}
func TestRenderPopulatesCommonData(t *testing.T) {
cfg := &config.Config{
SiteName: "Favoritter",
BasePath: "/app",
ExternalURL: "https://example.com",
}
r, err := New(cfg)
if err != nil {
t.Fatalf("create renderer: %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
// Add a CSRF token to the context.
type contextKey string
ctx := context.WithValue(req.Context(), contextKey("csrf_token"), "test-token")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
r.Page(rr, req, "login", PageData{Title: "Test"})
// BasePath and SiteName should be populated from config.
body := rr.Body.String()
if !strings.Contains(body, "/app") {
t.Error("should contain basePath from config")
}
}
func TestTemplateFuncs(t *testing.T) {
cfg := &config.Config{BasePath: "/test", ExternalURL: "https://example.com"}
r, _ := New(cfg)
funcs := r.templateFuncs()
// Test truncate function.
truncate := funcs["truncate"].(func(int, string) string)
if got := truncate(5, "Hello, world!"); got != "Hello..." {
t.Errorf("truncate(5, long) = %q, want Hello...", got)
}
if got := truncate(20, "Short"); got != "Short" {
t.Errorf("truncate(20, short) = %q, want Short", got)
}
// Test with Norwegian characters (Ærlig = 5 runes: Æ r l i g).
if got := truncate(3, "Ærlig"); got != "Ærl..." {
t.Errorf("truncate(3, Ærlig) = %q, want Ærl...", got)
}
// Test add/subtract.
add := funcs["add"].(func(int, int) int)
if add(2, 3) != 5 {
t.Error("add(2,3) should be 5")
}
sub := funcs["subtract"].(func(int, int) int)
if sub(5, 3) != 2 {
t.Error("subtract(5,3) should be 2")
}
// Test basePath function.
bp := funcs["basePath"].(func() string)
if bp() != "/test" {
t.Errorf("basePath() = %q", bp())
}
}

View file

@ -23,11 +23,11 @@ func NewFaveStore(db *sql.DB) *FaveStore {
}
// Create inserts a new fave and returns it with its ID populated.
func (s *FaveStore) Create(userID int64, description, url, imagePath, privacy string) (*model.Fave, error) {
func (s *FaveStore) Create(userID int64, description, url, imagePath, notes, privacy string) (*model.Fave, error) {
result, err := s.db.Exec(
`INSERT INTO faves (user_id, description, url, image_path, privacy)
VALUES (?, ?, ?, ?, ?)`,
userID, description, url, imagePath, privacy,
`INSERT INTO faves (user_id, description, url, image_path, notes, privacy)
VALUES (?, ?, ?, ?, ?, ?)`,
userID, description, url, imagePath, notes, privacy,
)
if err != nil {
return nil, fmt.Errorf("insert fave: %w", err)
@ -42,13 +42,13 @@ func (s *FaveStore) GetByID(id int64) (*model.Fave, error) {
f := &model.Fave{}
var createdAt, updatedAt string
err := s.db.QueryRow(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
f.created_at, f.updated_at, u.username, u.display_name
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
WHERE f.id = ?`, id,
).Scan(
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Privacy,
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Notes, &f.Privacy,
&createdAt, &updatedAt, &f.Username, &f.DisplayName,
)
if errors.Is(err, sql.ErrNoRows) {
@ -63,12 +63,23 @@ func (s *FaveStore) GetByID(id int64) (*model.Fave, error) {
}
// Update modifies an existing fave's fields.
func (s *FaveStore) Update(id int64, description, url, imagePath, privacy string) error {
func (s *FaveStore) Update(id int64, description, url, imagePath, notes, privacy string) error {
_, err := s.db.Exec(
`UPDATE faves SET description = ?, url = ?, image_path = ?, privacy = ?,
`UPDATE faves SET description = ?, url = ?, image_path = ?, notes = ?, privacy = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
description, url, imagePath, privacy, id,
description, url, imagePath, notes, privacy, id,
)
return err
}
// UpdatePrivacy toggles a fave's privacy setting.
func (s *FaveStore) UpdatePrivacy(id int64, privacy string) error {
_, err := s.db.Exec(
`UPDATE faves SET privacy = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
privacy, id,
)
return err
}
@ -86,6 +97,29 @@ func (s *FaveStore) Delete(id int64) error {
return nil
}
// ImagePathsByUser returns all non-empty image paths for a user's faves.
// Used for cleanup before user deletion.
func (s *FaveStore) ImagePathsByUser(userID int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT image_path FROM faves WHERE user_id = ? AND image_path != ''",
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
paths = append(paths, p)
}
return paths, rows.Err()
}
// ListByUser returns all faves for a user (both public and private),
// ordered by newest first, with pagination.
func (s *FaveStore) ListByUser(userID int64, limit, offset int) ([]*model.Fave, int, error) {
@ -96,8 +130,8 @@ func (s *FaveStore) ListByUser(userID int64, limit, offset int) ([]*model.Fave,
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
f.created_at, f.updated_at, u.username, u.display_name
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
WHERE f.user_id = ?
@ -125,8 +159,8 @@ func (s *FaveStore) ListPublicByUser(userID int64, limit, offset int) ([]*model.
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
f.created_at, f.updated_at, u.username, u.display_name
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
WHERE f.user_id = ? AND f.privacy = 'public'
@ -152,8 +186,8 @@ func (s *FaveStore) ListPublic(limit, offset int) ([]*model.Fave, int, error) {
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
f.created_at, f.updated_at, u.username, u.display_name
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
WHERE f.privacy = 'public'
@ -184,8 +218,8 @@ func (s *FaveStore) ListByTag(tagName string, limit, offset int) ([]*model.Fave,
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
f.created_at, f.updated_at, u.username, u.display_name
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
JOIN fave_tags ft ON ft.fave_id = f.id
@ -261,7 +295,7 @@ func (s *FaveStore) scanFaves(rows *sql.Rows) ([]*model.Fave, error) {
f := &model.Fave{}
var createdAt, updatedAt string
err := rows.Scan(
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Privacy,
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Notes, &f.Privacy,
&createdAt, &updatedAt, &f.Username, &f.DisplayName,
)
if err != nil {

View file

@ -23,7 +23,7 @@ func TestFaveCRUD(t *testing.T) {
}
// Create a fave.
fave, err := faves.Create(user.ID, "Blade Runner 2049", "https://example.com", "", "public")
fave, err := faves.Create(user.ID, "Blade Runner 2049", "https://example.com", "", "", "public")
if err != nil {
t.Fatalf("create fave: %v", err)
}
@ -44,7 +44,7 @@ func TestFaveCRUD(t *testing.T) {
}
// Update.
err = faves.Update(fave.ID, "Blade Runner 2049 (Final Cut)", "https://example.com/br2049", "", "private")
err = faves.Update(fave.ID, "Blade Runner 2049 (Final Cut)", "https://example.com/br2049", "", "", "private")
if err != nil {
t.Fatalf("update fave: %v", err)
}
@ -107,6 +107,80 @@ func TestFaveCRUD(t *testing.T) {
}
}
func TestFaveNotes(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
faves := NewFaveStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
// Create with notes.
fave, err := faves.Create(user.ID, "Film", "https://example.com", "", "En fantastisk film", "public")
if err != nil {
t.Fatalf("create fave with notes: %v", err)
}
if fave.Notes != "En fantastisk film" {
t.Errorf("notes = %q, want %q", fave.Notes, "En fantastisk film")
}
// Update notes.
err = faves.Update(fave.ID, fave.Description, fave.URL, fave.ImagePath, "Oppdatert anmeldelse", fave.Privacy)
if err != nil {
t.Fatalf("update notes: %v", err)
}
updated, _ := faves.GetByID(fave.ID)
if updated.Notes != "Oppdatert anmeldelse" {
t.Errorf("updated notes = %q", updated.Notes)
}
// Notes appear in list queries.
list, _, _ := faves.ListByUser(user.ID, 10, 0)
if len(list) != 1 || list[0].Notes != "Oppdatert anmeldelse" {
t.Error("notes should be loaded in list queries")
}
// Empty notes by default.
fave2, _ := faves.Create(user.ID, "No notes", "", "", "", "public")
if fave2.Notes != "" {
t.Errorf("default notes = %q, want empty", fave2.Notes)
}
}
func TestUpdatePrivacy(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
faves := NewFaveStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
fave, _ := faves.Create(user.ID, "Toggle me", "", "", "", "public")
// Toggle to private.
err := faves.UpdatePrivacy(fave.ID, "private")
if err != nil {
t.Fatalf("update privacy: %v", err)
}
got, _ := faves.GetByID(fave.ID)
if got.Privacy != "private" {
t.Errorf("privacy = %q, want private", got.Privacy)
}
// Toggle back to public.
faves.UpdatePrivacy(fave.ID, "public")
got, _ = faves.GetByID(fave.ID)
if got.Privacy != "public" {
t.Errorf("privacy = %q, want public", got.Privacy)
}
}
func TestListByTag(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
@ -120,9 +194,9 @@ func TestListByTag(t *testing.T) {
user, _ := users.Create("testuser", "password123", "user")
// Create two public faves with overlapping tags.
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "public")
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "public")
f3, _ := faves.Create(user.ID, "Private Fave", "", "", "private")
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "", "public")
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "", "public")
f3, _ := faves.Create(user.ID, "Private Fave", "", "", "", "private")
tags.SetFaveTags(f1.ID, []string{"music", "jazz"})
tags.SetFaveTags(f2.ID, []string{"music", "rock"})
@ -154,7 +228,7 @@ func TestFavePagination(t *testing.T) {
// Create 5 faves.
for i := 0; i < 5; i++ {
faves.Create(user.ID, "Fave "+string(rune('A'+i)), "", "", "public")
faves.Create(user.ID, "Fave "+string(rune('A'+i)), "", "", "", "public")
}
// Page 1 with limit 2.

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package store
import (
"testing"
)
func TestSettingsGetDefault(t *testing.T) {
db := testDB(t)
settings := NewSettingsStore(db)
s, err := settings.Get()
if err != nil {
t.Fatalf("get default settings: %v", err)
}
// Migration should insert a default row.
if s.SiteName == "" {
t.Error("expected non-empty default site name")
}
if s.SignupMode == "" {
t.Error("expected non-empty default signup mode")
}
}
func TestSettingsUpdate(t *testing.T) {
db := testDB(t)
settings := NewSettingsStore(db)
err := settings.Update("Nye Favoritter", "En kul side", "requests")
if err != nil {
t.Fatalf("update settings: %v", err)
}
s, err := settings.Get()
if err != nil {
t.Fatalf("get after update: %v", err)
}
if s.SiteName != "Nye Favoritter" {
t.Errorf("site_name = %q, want %q", s.SiteName, "Nye Favoritter")
}
if s.SiteDescription != "En kul side" {
t.Errorf("site_description = %q, want %q", s.SiteDescription, "En kul side")
}
if s.SignupMode != "requests" {
t.Errorf("signup_mode = %q, want %q", s.SignupMode, "requests")
}
if s.UpdatedAt.IsZero() {
t.Error("expected non-zero updated_at after update")
}
}
func TestSettingsUpdatePreservesOtherFields(t *testing.T) {
db := testDB(t)
settings := NewSettingsStore(db)
// Set initial values.
settings.Update("Site A", "Desc A", "open")
// Update with different values.
settings.Update("Site B", "Desc B", "closed")
s, _ := settings.Get()
if s.SiteName != "Site B" {
t.Errorf("site_name = %q, want %q", s.SiteName, "Site B")
}
if s.SiteDescription != "Desc B" {
t.Errorf("site_description = %q, want %q", s.SiteDescription, "Desc B")
}
if s.SignupMode != "closed" {
t.Errorf("signup_mode = %q, want %q", s.SignupMode, "closed")
}
}

View file

@ -42,8 +42,8 @@ func TestTagSearch(t *testing.T) {
user, _ := users.Create("testuser", "password123", "user")
// Create some tags via faves to give them usage counts.
f1, _ := faves.Create(user.ID, "F1", "", "", "public")
f2, _ := faves.Create(user.ID, "F2", "", "", "public")
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "", "public")
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "", "public")
tags.SetFaveTags(f1.ID, []string{"music", "movies", "misc"})
tags.SetFaveTags(f2.ID, []string{"music", "manga"})
@ -94,7 +94,7 @@ func TestTagSetFaveTagsLimit(t *testing.T) {
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
fave, _ := faves.Create(user.ID, "Test", "", "", "public")
fave, _ := faves.Create(user.ID, "Test", "", "", "", "public")
// Try to set more than MaxTagsPerFave tags.
manyTags := make([]string, 30)
@ -124,7 +124,7 @@ func TestTagCleanupOrphans(t *testing.T) {
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
fave, _ := faves.Create(user.ID, "Test", "", "", "public")
fave, _ := faves.Create(user.ID, "Test", "", "", "", "public")
tags.SetFaveTags(fave.ID, []string{"keep", "orphan"})

View file

@ -198,6 +198,34 @@ func (s *UserStore) SetDisabled(userID int64, disabled bool) error {
return err
}
// SetRole changes a user's role (user/admin).
func (s *UserStore) SetRole(userID int64, role string) error {
if role != "user" && role != "admin" {
return fmt.Errorf("invalid role: %s", role)
}
_, err := s.db.Exec(
`UPDATE users SET role = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
role, userID,
)
return err
}
// Delete permanently removes a user. Cascading foreign keys handle
// sessions, faves, and fave_tags. Image cleanup must be done by the caller.
func (s *UserStore) Delete(userID int64) error {
result, err := s.db.Exec("DELETE FROM users WHERE id = ?", userID)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return ErrUserNotFound
}
return nil
}
// ListAll returns all users, ordered by username.
func (s *UserStore) ListAll() ([]*model.User, error) {
rows, err := s.db.Query(

View file

@ -203,3 +203,71 @@ func TestDisabledUser(t *testing.T) {
t.Errorf("disabled user error = %v, want ErrUserDisabled", err)
}
}
func TestSetRole(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
if user.Role != "user" {
t.Fatalf("initial role = %q", user.Role)
}
// Promote to admin.
err := users.SetRole(user.ID, "admin")
if err != nil {
t.Fatalf("set role: %v", err)
}
updated, _ := users.GetByID(user.ID)
if updated.Role != "admin" {
t.Errorf("role = %q, want admin", updated.Role)
}
// Invalid role should error.
err = users.SetRole(user.ID, "superadmin")
if err == nil {
t.Error("invalid role should return error")
}
}
func TestDeleteUser(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
faves := NewFaveStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("deleteme", "password123", "user")
faves.Create(user.ID, "Fave 1", "", "", "", "public")
faves.Create(user.ID, "Fave 2", "", "", "", "public")
err := users.Delete(user.ID)
if err != nil {
t.Fatalf("delete user: %v", err)
}
// User should be gone.
_, err = users.GetByUsername("deleteme")
if err != ErrUserNotFound {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
// Faves should be cascade-deleted.
list, total, _ := faves.ListByUser(user.ID, 10, 0)
if total != 0 || len(list) != 0 {
t.Errorf("faves should be cascade-deleted: total=%d, len=%d", total, len(list))
}
// Deleting non-existent user should return error.
err = users.Delete(99999)
if err != ErrUserNotFound {
t.Errorf("delete nonexistent: %v, want ErrUserNotFound", err)
}
}

View file

@ -92,6 +92,40 @@
padding: 0 1rem 0.5rem;
}
/* Card action buttons (edit/delete/privacy toggle) */
.fave-card-actions {
display: flex;
gap: 0.5rem;
padding: 0 1rem 0.5rem;
align-items: center;
}
.fave-action-link {
font-size: 0.8rem;
text-decoration: none;
}
.fave-action-btn {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
margin: 0;
border: 1px solid var(--pico-muted-border-color);
background: transparent;
cursor: pointer;
border-radius: var(--pico-border-radius);
color: inherit;
}
.fave-action-btn:hover {
border-color: var(--pico-primary);
color: var(--pico-primary);
}
.fave-action-btn.secondary:hover {
border-color: var(--pico-del-color);
color: var(--pico-del-color);
}
/* Privacy badge */
.badge-private {
background: var(--pico-muted-border-color);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -70,6 +70,26 @@
// --- Tag autocomplete combobox pattern ---
var activeIndex = -1;
// Delegated event handler for tag suggestion selection.
// Uses mousedown (not click) to fire before blur removes the element.
// Uses touchend for mobile support. Both prevent default to keep
// the tags input focused. Inline handlers (onclick/onmousedown) are
// blocked by CSP script-src 'self', so we must use addEventListener.
document.addEventListener("mousedown", function (event) {
var li = event.target.closest("[data-tag-name]");
if (li) {
event.preventDefault();
addTag(null, li.getAttribute("data-tag-name"));
}
});
document.addEventListener("touchend", function (event) {
var li = event.target.closest("[data-tag-name]");
if (li) {
event.preventDefault();
addTag(null, li.getAttribute("data-tag-name"));
}
});
// Handle keyboard navigation in the tag suggestions listbox.
document.addEventListener("keydown", function (event) {
var input = document.getElementById("tags");
@ -152,7 +172,7 @@
}
// Add a selected tag to the tag input.
window.addTag = function (element, tagName) {
function addTag(element, tagName) {
var input = document.getElementById("tags");
if (!input) return;
@ -162,10 +182,17 @@
input.focus();
closeSuggestions();
};
}
function getCookie(name) {
var match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
return match ? match[2] : null;
}
// Register service worker for PWA support.
if ("serviceWorker" in navigator) {
var baseMeta = document.querySelector('meta[name="base-path"]');
var base = baseMeta ? baseMeta.getAttribute("content") : "";
navigator.serviceWorker.register(base + "/sw.js", { scope: base + "/" });
}
})();

66
web/static/sw.js Normal file
View file

@ -0,0 +1,66 @@
// Favoritter — Service Worker for PWA offline support.
// SPDX-License-Identifier: AGPL-3.0-or-later
var BASE = "{{BASE_PATH}}";
var CACHE_NAME = "favoritter-v1";
var STATIC_URLS = [
BASE + "/static/vendor/pico.min.css",
BASE + "/static/vendor/htmx.min.js",
BASE + "/static/css/style.css",
BASE + "/static/js/app.js",
BASE + "/static/icons/icon-192.png"
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(STATIC_URLS);
})
);
self.skipWaiting();
});
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (names) {
return Promise.all(
names.filter(function (n) { return n !== CACHE_NAME; })
.map(function (n) { return caches.delete(n); })
);
})
);
self.clients.claim();
});
self.addEventListener("fetch", function (event) {
var url = new URL(event.request.url);
// Cache-first for static assets.
if (url.pathname.startsWith(BASE + "/static/")) {
event.respondWith(
caches.match(event.request).then(function (cached) {
return cached || fetch(event.request).then(function (response) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(event.request, clone);
});
return response;
});
})
);
return;
}
// Network-first for navigation (HTML pages).
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
})
);
return;
}
// Default: network only.
event.respondWith(fetch(event.request));
});

View file

@ -6,6 +6,11 @@
<title>{{if .Title}}{{.Title}} — {{end}}{{.SiteName}}</title>
<link rel="stylesheet" href="{{basePath}}/static/vendor/pico.min.css">
<link rel="stylesheet" href="{{basePath}}/static/css/style.css">
<meta name="theme-color" content="#1095c1">
<link rel="manifest" href="{{basePath}}/manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="{{basePath}}/static/icons/icon-192.png">
<link rel="apple-touch-icon" href="{{basePath}}/static/icons/icon-192.png">
<meta name="base-path" content="{{basePath}}">
{{block "head" .}}{{end}}
</head>
<body>

View file

@ -54,6 +54,14 @@
</td>
<td>{{.CreatedAt.Format "02.01.2006"}}</td>
<td>
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/role" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<select name="role" class="inline-input">
<option value="user" {{if eq .Role "user"}}selected{{end}}>Bruker</option>
<option value="admin" {{if eq .Role "admin"}}selected{{end}}>Admin</option>
</select>
<button type="submit" class="outline nav-button">Lagre</button>
</form>
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/reset-password" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="outline secondary nav-button">Tilbakestill passord</button>
@ -64,6 +72,14 @@
{{if .Disabled}}Aktiver{{else}}Deaktiver{{end}}
</button>
</form>
<button
hx-post="{{basePath}}/admin/users/{{.ID}}/delete"
hx-confirm="Er du HELT sikker? Dette sletter brukeren og alle favorittene permanent."
hx-target="closest tr"
hx-swap="outerHTML"
class="outline secondary nav-button"
style="color: var(--pico-del-color);"
>Slett</button>
</td>
</tr>
{{end}}

View file

@ -2,7 +2,13 @@
{{with .Data}}{{with .Fave}}
{{if eq .Privacy "public"}}
<meta property="og:title" content="{{truncate 70 .Description}}">
{{if .Notes}}
<meta property="og:description" content="{{truncate 200 .Notes}}">
{{else if .URL}}
<meta property="og:description" content="{{.URL}}">
{{else}}
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
{{end}}
<meta property="og:type" content="article">
{{if $.ExternalURL}}
<meta property="og:url" content="{{$.ExternalURL}}/faves/{{.ID}}">
@ -45,6 +51,10 @@
<p><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></p>
{{end}}
{{if .Notes}}
<div class="fave-notes">{{.Notes}}</div>
{{end}}
{{if .Tags}}
<p>
{{range .Tags}}

View file

@ -30,6 +30,13 @@
placeholder="https://...">
</label>
<label for="notes">
Notater (valgfri)
<textarea id="notes" name="notes"
rows="4"
placeholder="Utfyllende tekst, anmeldelse, tanker...">{{.Notes}}</textarea>
</label>
<label for="image">
Bilde (valgfri)
<input type="file" id="image" name="image"
@ -64,7 +71,7 @@
hx-get="{{basePath}}/tags/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#tag-suggestions"
hx-params="*"
hx-params="none"
hx-vals='{"q": ""}'>
<small id="tags-help">Skriv for å søke i eksisterende merkelapper. Maks {{maxTags}} stk. Bruk piltaster for å velge.</small>
</label>

View file

@ -21,9 +21,6 @@
<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>
@ -32,6 +29,28 @@
{{end}}
</footer>
{{end}}
<footer class="fave-card-actions">
<span class="privacy-toggle" id="privacy-{{.ID}}">
<button
hx-post="{{basePath}}/faves/{{.ID}}/privacy"
hx-target="#privacy-{{.ID}}"
hx-swap="outerHTML"
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}"
aria-label="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
>{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}</button>
</span>
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link"
aria-label="Rediger {{.Description}}">Rediger</a>
<button
hx-delete="{{basePath}}/faves/{{.ID}}"
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
hx-target="closest article"
hx-swap="outerHTML"
class="fave-action-btn secondary"
aria-label="Slett {{.Description}}"
>Slett</button>
</footer>
</article>
{{end}}
</div>

View file

@ -1,3 +1,13 @@
{{define "head"}}
<meta property="og:title" content="{{.SiteName}}">
<meta property="og:description" content="Lagre og del dine favoritter">
<meta property="og:type" content="website">
{{if .ExternalURL}}
<meta property="og:url" content="{{.ExternalURL}}">
{{end}}
<meta property="og:site_name" content="{{.SiteName}}">
{{end}}
{{define "content"}}
{{if .User}}
<hgroup>

View file

@ -2,6 +2,11 @@
{{with .Data}}{{$d := .}}{{with .ProfileUser}}
{{if eq .ProfileVisibility "public"}}
<meta property="og:title" content="{{.DisplayNameOrUsername}} sine favoritter">
{{if .Bio}}
<meta property="og:description" content="{{truncate 200 .Bio}}">
{{else}}
<meta property="og:description" content="{{.DisplayNameOrUsername}} sine favoritter på {{$.SiteName}}">
{{end}}
<meta property="og:type" content="profile">
{{if $.ExternalURL}}
<meta property="og:url" content="{{$.ExternalURL}}/u/{{.Username}}">
@ -75,6 +80,20 @@
{{end}}
</footer>
{{end}}
{{if $d.IsOwner}}
<footer class="fave-card-actions">
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link"
aria-label="Rediger {{.Description}}">Rediger</a>
<button
hx-delete="{{basePath}}/faves/{{.ID}}"
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
hx-target="closest article"
hx-swap="outerHTML"
class="fave-action-btn secondary"
aria-label="Slett {{.Description}}"
>Slett</button>
</footer>
{{end}}
</article>
{{end}}
</div>

View file

@ -1,3 +1,15 @@
{{define "head"}}
{{with .Data}}
<meta property="og:title" content="Favoritter med merkelapp: {{.TagName}}">
<meta property="og:description" content="{{.Total}} offentlige favoritter med merkelappen «{{.TagName}}»">
<meta property="og:type" content="website">
{{if $.ExternalURL}}
<meta property="og:url" content="{{$.ExternalURL}}/tags/{{.TagName}}">
{{end}}
<meta property="og:site_name" content="{{$.SiteName}}">
{{end}}
{{end}}
{{define "content"}}
{{with .Data}}
<hgroup>

View file

@ -0,0 +1,10 @@
<span class="privacy-toggle" id="privacy-{{.ID}}">
<button
hx-post="{{basePath}}/faves/{{.ID}}/privacy"
hx-target="#privacy-{{.ID}}"
hx-swap="outerHTML"
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}"
aria-label="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
>{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}</button>
</span>

View file

@ -2,6 +2,6 @@
id="tag-option-{{$i}}"
class="tag-suggestion"
aria-selected="false"
onclick="addTag(this, '{{$tag.Name}}')"
data-tag-name="{{$tag.Name}}"
tabindex="-1">{{$tag.Name}}</li>
{{end}}