diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 1c8c2e5..b179bc5 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -218,12 +218,10 @@ func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) } // Delete user's images from disk before database deletion. - faves, _, _ := h.deps.Faves.ListByUser(id, 100000, 0) - for _, f := range faves { - if f.ImagePath != "" { - if delErr := image.Delete(h.deps.Config.UploadDir, f.ImagePath); delErr != nil { - slog.Error("image delete error", "fave_id", f.ID, "error", delErr) - } + imagePaths, _ := h.deps.Faves.ImagePathsByUser(id) + for _, p := range imagePaths { + if delErr := image.Delete(h.deps.Config.UploadDir, p); delErr != nil { + slog.Error("image delete error", "error", delErr) } } if user.AvatarPath != "" { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d0c550f..f04e3c6 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -32,14 +32,20 @@ type Deps struct { type Handler struct { deps Deps rateLimiter *middleware.RateLimiter + + // Cached PWA responses (computed once at init, never change). + manifestJSON []byte + swJS []byte } // New creates a new Handler with the given dependencies. func New(deps Deps) *Handler { - return &Handler{ + h := &Handler{ deps: deps, rateLimiter: middleware.NewRateLimiter(deps.Config.RateLimit), } + h.initPWACache() + return h } // RateLimiterCleanupLoop periodically evicts stale rate limiter entries. diff --git a/internal/handler/pwa.go b/internal/handler/pwa.go index f64fb20..61430bb 100644 --- a/internal/handler/pwa.go +++ b/internal/handler/pwa.go @@ -14,8 +14,9 @@ import ( "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) { +// 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{ @@ -42,44 +43,33 @@ func (h *Handler) handleManifest(w http.ResponseWriter, r *http.Request) { }, }, } + h.manifestJSON, _ = json.Marshal(manifest) - w.Header().Set("Content-Type", "application/manifest+json") - json.NewEncoder(w).Encode(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)) + } + } } -// 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. +// 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) { - 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)) + 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) { - 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") @@ -88,7 +78,6 @@ func (h *Handler) handleShare(w http.ResponseWriter, r *http.Request) { 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)) } } @@ -96,7 +85,7 @@ func (h *Handler) handleShare(w http.ResponseWriter, r *http.Request) { description := sharedTitle if description == "" && sharedText != "" { description = sharedText - sharedText = "" // Don't duplicate into notes. + sharedText = "" } target := h.deps.Config.BasePath + "/faves/new?" @@ -114,7 +103,7 @@ func (h *Handler) handleShare(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target+params.Encode(), http.StatusSeeOther) } -// handleFaveNewWithPreFill shows the new fave form, optionally pre-filled +// 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()) diff --git a/internal/store/fave.go b/internal/store/fave.go index 62f6fc3..69f5cf6 100644 --- a/internal/store/fave.go +++ b/internal/store/fave.go @@ -97,6 +97,29 @@ func (s *FaveStore) Delete(id int64) error { return nil } +// ImagePathsByUser returns all non-empty image paths for a user's faves. +// Used for cleanup before user deletion. +func (s *FaveStore) ImagePathsByUser(userID int64) ([]string, error) { + rows, err := s.db.Query( + "SELECT image_path FROM faves WHERE user_id = ? AND image_path != ''", + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var paths []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, err + } + paths = append(paths, p) + } + return paths, rows.Err() +} + // ListByUser returns all faves for a user (both public and private), // ordered by newest first, with pagination. func (s *FaveStore) ListByUser(userID int64, limit, offset int) ([]*model.Fave, int, error) { diff --git a/web/templates/pages/fave_list.html b/web/templates/pages/fave_list.html index 77f8874..814bd17 100644 --- a/web/templates/pages/fave_list.html +++ b/web/templates/pages/fave_list.html @@ -36,6 +36,7 @@ hx-target="#privacy-{{.ID}}" hx-swap="outerHTML" class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}" + aria-label="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}" title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}" >{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}