diff --git a/internal/handler/fave.go b/internal/handler/fave.go index 9a5dd82..fb75784 100644 --- a/internal/handler/fave.go +++ b/internal/handler/fave.go @@ -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()) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 2b5f879..c2f3a80 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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))) diff --git a/internal/handler/pwa.go b/internal/handler/pwa.go new file mode 100644 index 0000000..f64fb20 --- /dev/null +++ b/internal/handler/pwa.go @@ -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 "" +} diff --git a/internal/handler/web_test.go b/internal/handler/web_test.go index 6cda977..aa53c2e 100644 --- a/internal/handler/web_test.go +++ b/internal/handler/web_test.go @@ -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¬es=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) { diff --git a/web/static/icons/icon-192.png b/web/static/icons/icon-192.png new file mode 100644 index 0000000..95fe080 Binary files /dev/null and b/web/static/icons/icon-192.png differ diff --git a/web/static/icons/icon-512-maskable.png b/web/static/icons/icon-512-maskable.png new file mode 100644 index 0000000..9879528 Binary files /dev/null and b/web/static/icons/icon-512-maskable.png differ diff --git a/web/static/icons/icon-512.png b/web/static/icons/icon-512.png new file mode 100644 index 0000000..9879528 Binary files /dev/null and b/web/static/icons/icon-512.png differ diff --git a/web/static/js/app.js b/web/static/js/app.js index 3e5e40b..952ece5 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -188,4 +188,11 @@ 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 + "/" }); + } })(); diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..1fe129c --- /dev/null +++ b/web/static/sw.js @@ -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)); +}); diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index c08d892..4c0d73f 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -6,6 +6,11 @@