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>
This commit is contained in:
Ole-Morten Duesund 2026-04-07 10:06:18 +02:00
commit 1260cfd18f
10 changed files with 375 additions and 14 deletions

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())

View file

@ -74,6 +74,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,8 +95,9 @@ 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)))

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

@ -0,0 +1,142 @@
// 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"
)
// handleManifest serves the Web App Manifest with dynamic BasePath injection.
func (h *Handler) handleManifest(w http.ResponseWriter, r *http.Request) {
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",
},
},
}
w.Header().Set("Content-Type", "application/manifest+json")
json.NewEncoder(w).Encode(manifest)
}
// handleServiceWorker serves the service worker JS from root scope.
// BasePath is injected via placeholder replacement so the SW can
// cache the correct static asset paths.
func (h *Handler) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
staticFS, err := fs.Sub(web.StaticFS, "static")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
data, err := fs.ReadFile(staticFS, "sw.js")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
content := strings.ReplaceAll(string(data), "{{BASE_PATH}}", h.deps.Config.BasePath)
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "no-cache")
w.Write([]byte(content))
}
// 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) {
user := middleware.UserFromContext(r.Context())
if user == nil {
http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther)
return
}
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 != "" {
// Remove the URL from text so it's not duplicated.
sharedText = strings.TrimSpace(strings.Replace(sharedText, sharedURL, "", 1))
}
}
description := sharedTitle
if description == "" && sharedText != "" {
description = sharedText
sharedText = "" // Don't duplicate into notes.
}
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)
}
// handleFaveNewWithPreFill 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 ""
}

View file

@ -1133,6 +1133,155 @@ func TestDisplayNameFallbackToUsername(t *testing.T) {
}
}
// --- PWA ---
func TestManifestJSON(t *testing.T) {
_, mux := testServer(t)
req := httptest.NewRequest("GET", "/manifest.json", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("manifest: got %d, want 200", rr.Code)
}
ct := rr.Header().Get("Content-Type")
if !strings.Contains(ct, "manifest+json") {
t.Errorf("content-type = %q, want manifest+json", ct)
}
var manifest map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &manifest); err != nil {
t.Fatalf("parse manifest: %v", err)
}
if manifest["name"] != "Test" {
t.Errorf("name = %v, want Test", manifest["name"])
}
// share_target should exist with GET method.
st, ok := manifest["share_target"].(map[string]any)
if !ok {
t.Fatal("manifest missing share_target")
}
if st["method"] != "GET" {
t.Errorf("share_target method = %v, want GET", st["method"])
}
}
func TestServiceWorkerContent(t *testing.T) {
_, mux := testServer(t)
req := httptest.NewRequest("GET", "/sw.js", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("sw.js: got %d, want 200", rr.Code)
}
ct := rr.Header().Get("Content-Type")
if !strings.Contains(ct, "javascript") {
t.Errorf("content-type = %q, want javascript", ct)
}
cc := rr.Header().Get("Cache-Control")
if cc != "no-cache" {
t.Errorf("Cache-Control = %q, want no-cache", cc)
}
body := rr.Body.String()
if strings.Contains(body, "{{BASE_PATH}}") {
t.Error("sw.js should have BASE_PATH placeholder replaced")
}
if !strings.Contains(body, "CACHE_NAME") {
t.Error("sw.js should contain service worker code")
}
}
func TestShareRedirectsToFaveNew(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "testuser", "pass123", "user")
req := httptest.NewRequest("GET", "/share?url=https://example.com&title=Test+Page", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("share: got %d, want 303", rr.Code)
}
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "/faves/new") {
t.Errorf("redirect = %q, should point to /faves/new", loc)
}
if !strings.Contains(loc, "url=https") {
t.Errorf("redirect = %q, should contain url param", loc)
}
if !strings.Contains(loc, "description=Test") {
t.Errorf("redirect = %q, should contain description from title", loc)
}
}
func TestShareTextFieldFallback(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "testuser", "pass123", "user")
// Some Android apps put the URL in "text" instead of "url".
req := httptest.NewRequest("GET", "/share?text=Check+this+out+https://example.com/article", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "url=https") {
t.Errorf("should extract URL from text field: %q", loc)
}
}
func TestShareRequiresLogin(t *testing.T) {
_, mux := testServer(t)
req := httptest.NewRequest("GET", "/share?url=https://example.com", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Errorf("unauthenticated share: got %d, want 303", rr.Code)
}
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "/login") {
t.Errorf("should redirect to login: %q", loc)
}
}
func TestFaveNewPreFill(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "testuser", "pass123", "user")
req := httptest.NewRequest("GET", "/faves/new?url=https://example.com&description=Shared+Page&notes=Great+article", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("fave new pre-fill: got %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "https://example.com") {
t.Error("URL should be pre-filled")
}
if !strings.Contains(body, "Shared Page") {
t.Error("description should be pre-filled")
}
if !strings.Contains(body, "Great article") {
t.Error("notes should be pre-filled")
}
}
// --- Export page ---
func TestExportPageRendering(t *testing.T) {