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