favoritter/internal/handler/pwa.go

131 lines
4 KiB
Go
Raw Permalink Normal View History

// 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 ""
}