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