From ab07a7f93fe0edd0f11d1438799089d1a3439521 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 29 Mar 2026 19:18:14 +0200 Subject: [PATCH 01/14] 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) --- Makefile | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 2c64c94..fb02f84 100644 --- a/Makefile +++ b/Makefile @@ -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 From 3341e9a8188c69b89f479430301fd9188173c446 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 29 Mar 2026 19:26:29 +0200 Subject: [PATCH 02/14] 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) --- dist/postinstall.sh | 26 ++++++++++++++++++++++---- dist/preremove.sh | 31 ++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/dist/postinstall.sh b/dist/postinstall.sh index dd6c02d..e65399b 100755 --- a/dist/postinstall.sh +++ b/dist/postinstall.sh @@ -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,25 @@ 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" +# Only show the setup message on first install (not upgrades). +action="${1:-}" +case "$action" in + # Debian first install or RPM first install ($1=1). + configure|1) + if ! systemctl is-enabled --quiet favoritter 2>/dev/null; then + echo "Favoritter installed. Configure /etc/favoritter/favoritter.env then run:" + echo " sudo systemctl enable --now favoritter" + fi + ;; +esac diff --git a/dist/preremove.sh b/dist/preremove.sh index 8e930f5..31e7a59 100755 --- a/dist/preremove.sh +++ b/dist/preremove.sh @@ -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 - systemctl stop favoritter 2>/dev/null || true - systemctl disable favoritter 2>/dev/null || true -fi +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 + ;; + # 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 From f4480dd510f7b2bd214e03a773439a064f47eee0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 29 Mar 2026 19:28:22 +0200 Subject: [PATCH 03/14] 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) --- dist/postinstall.sh | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/dist/postinstall.sh b/dist/postinstall.sh index e65399b..a79c611 100755 --- a/dist/postinstall.sh +++ b/dist/postinstall.sh @@ -26,14 +26,3 @@ if command -v systemctl >/dev/null 2>&1; then fi fi -# Only show the setup message on first install (not upgrades). -action="${1:-}" -case "$action" in - # Debian first install or RPM first install ($1=1). - configure|1) - if ! systemctl is-enabled --quiet favoritter 2>/dev/null; then - echo "Favoritter installed. Configure /etc/favoritter/favoritter.env then run:" - echo " sudo systemctl enable --now favoritter" - fi - ;; -esac From 0a77935d4dd1f8c0f0e392de19597a40c55146a7 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sun, 29 Mar 2026 19:33:24 +0200 Subject: [PATCH 04/14] docs: add PLANS.md with roadmap for v1.1, v1.2, and future Co-Authored-By: Claude Opus 4.6 (1M context) --- PLANS.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 PLANS.md diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000..3823aca --- /dev/null +++ b/PLANS.md @@ -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. From 9c3ca14578839848ee2aafe812932a9c92bd9f3f Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sat, 4 Apr 2026 00:17:38 +0200 Subject: [PATCH 05/14] 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 " 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) --- internal/store/fave.go | 10 ++++----- web/static/js/app.js | 24 +++++++++++++++++++-- web/templates/pages/fave_form.html | 2 +- web/templates/partials/tag_suggestions.html | 2 +- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/internal/store/fave.go b/internal/store/fave.go index b429581..70d9613 100644 --- a/internal/store/fave.go +++ b/internal/store/fave.go @@ -43,7 +43,7 @@ func (s *FaveStore) GetByID(id int64) (*model.Fave, error) { 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 + 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, @@ -97,7 +97,7 @@ 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 + 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 = ? @@ -126,7 +126,7 @@ 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 + 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' @@ -153,7 +153,7 @@ 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 + 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' @@ -185,7 +185,7 @@ 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 + 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 diff --git a/web/static/js/app.js b/web/static/js/app.js index a1d061c..3e5e40b 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -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,7 +182,7 @@ input.focus(); closeSuggestions(); - }; + } function getCookie(name) { var match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)")); diff --git a/web/templates/pages/fave_form.html b/web/templates/pages/fave_form.html index d1e08d8..5dcc665 100644 --- a/web/templates/pages/fave_form.html +++ b/web/templates/pages/fave_form.html @@ -64,7 +64,7 @@ hx-get="{{basePath}}/tags/search" hx-trigger="keyup changed delay:300ms" hx-target="#tag-suggestions" - hx-params="*" + hx-params="none" hx-vals='{"q": ""}'> Skriv for å søke i eksisterende merkelapper. Maks {{maxTags}} stk. Bruk piltaster for å velge. diff --git a/web/templates/partials/tag_suggestions.html b/web/templates/partials/tag_suggestions.html index 22911e1..1b833b6 100644 --- a/web/templates/partials/tag_suggestions.html +++ b/web/templates/partials/tag_suggestions.html @@ -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}} {{end}} From a8f3aa6f7e5c4f218f2ffa35f96895e6ac6d214a Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Sat, 4 Apr 2026 00:18:01 +0200 Subject: [PATCH 06/14] =?UTF-8?q?test:=20add=20comprehensive=20test=20suit?= =?UTF-8?q?e=20(44=20=E2=86=92=20169=20tests)=20and=20v1.1=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- PLANS-v1.1.md | 316 ++++++++ TESTPLAN.md | 388 ++++++++++ internal/config/config_test.go | 204 +++++ internal/database/database_test.go | 140 ++++ internal/handler/api/api_test.go | 663 ++++++++++++++++ internal/handler/handler_test.go | 4 +- internal/handler/web_test.go | 1153 ++++++++++++++++++++++++++++ internal/image/image_test.go | 234 ++++++ internal/middleware/auth_test.go | 299 ++++++++ internal/middleware/csrf_test.go | 185 +++++ internal/render/render_test.go | 161 ++++ internal/store/settings_test.go | 75 ++ 12 files changed, 3820 insertions(+), 2 deletions(-) create mode 100644 PLANS-v1.1.md create mode 100644 TESTPLAN.md create mode 100644 internal/config/config_test.go create mode 100644 internal/database/database_test.go create mode 100644 internal/handler/api/api_test.go create mode 100644 internal/handler/web_test.go create mode 100644 internal/image/image_test.go create mode 100644 internal/middleware/auth_test.go create mode 100644 internal/middleware/csrf_test.go create mode 100644 internal/render/render_test.go create mode 100644 internal/store/settings_test.go diff --git a/PLANS-v1.1.md b/PLANS-v1.1.md new file mode 100644 index 0000000..369bd37 --- /dev/null +++ b/PLANS-v1.1.md @@ -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 ` + +