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:
parent
485d01ce45
commit
1260cfd18f
10 changed files with 375 additions and 14 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
142
internal/handler/pwa.go
Normal 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 ""
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
BIN
web/static/icons/icon-192.png
Normal file
BIN
web/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
web/static/icons/icon-512-maskable.png
Normal file
BIN
web/static/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
web/static/icons/icon-512.png
Normal file
BIN
web/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -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 + "/" });
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
66
web/static/sw.js
Normal file
66
web/static/sw.js
Normal 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));
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue