diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 42a472fd..6e4dc700 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -1399,6 +1399,46 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) { assert.Equal(t, "pending", after2.DownloadState) assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file") + // --- bulk multi-select delete-files (§0 item 8 follow-up) --- + // Re-download both, then delete them in ONE request with a forged + // foreign id mixed in: only the matching-feed ids are applied. + for _, ep := range []database.MediaItems{ep1, ep2} { + require.NoError(t, setup.DB.SetPodcastEpisodePending(adminCtx, ep.ID)) + _, err = setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID) + require.NoError(t, err) + } + worker.DrainNow(context.Background()) + require.Eventually(t, func() bool { + eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) + if err != nil || len(eps) != 2 { + return false + } + for _, ep := range eps { + if ep.DownloadState != "downloaded" { + return false + } + } + return true + }, 30*time.Second, 250*time.Millisecond, "both re-downloaded for the bulk test") + + foreignID := uuid.New().String() + bulkForm := url.Values{} + bulkForm["episode-file-select"] = []string{uuid.UUID(ep1.ID.Bytes).String(), foreignID, uuid.UUID(ep2.ID.Bytes).String()} + code, rbody = podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", bulkForm) + require.Equal(t, http.StatusOK, code, string(rbody)) + assert.Contains(t, string(rbody), `"deleted":2`, "the forged foreign id is refused, the real two applied") + assert.NoFileExists(t, pathFor(ep1)) + assert.NoFileExists(t, pathFor(ep2)) + afterBulk, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) + require.NoError(t, err) + for _, ep := range afterBulk { + assert.Equal(t, "pending", ep.DownloadState, "bulk deletion resets every selected row (%s)", ep.Title) + } + + // Non-admins cannot bulk-delete either. + code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", "") + assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot bulk-delete files") + // --- refresh must NOT re-materialize deleted files (invariant 2): // the poll upsert rewrites metadata but never touches download_state. code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "") diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go index e8d31340..9d250d81 100644 --- a/internal/handlers/podcast.go +++ b/internal/handlers/podcast.go @@ -120,6 +120,58 @@ func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"}) } +// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files — +// admin-only (§0 item 8): multi-select file deletion. Body carries the +// CHECKED boxes' values (form-encoded, repeated episode-file-select +// keys — hx-include gathers them; parsed directly, never bound: the +// repeated-key wire is another echo-bind gotcha). Each id is validated +// to belong to THIS feed; rows reset to pending, files removed. +func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error { + user := MustGetAuthenticatedUser(c) + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"}) + } + feedID, err := parseUUID(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) + } + if err := c.Request().ParseForm(); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + rawIDs := c.Request().PostForm["episode-file-select"] + if len(rawIDs) == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"}) + } + actx, err := h.worker.AdminCtx(c.Request().Context()) + if err != nil { + return podcastErrorResponse(c, err) + } + + deleted := 0 + for _, raw := range rawIDs { + episodeID, perr := parseUUID(raw) + if perr != nil { + continue + } + // The checkbox list only renders this feed's rows, but the wire + // is user-controlled: a forged id for a foreign feed's episode + // is refused (feed-match guard), not silently applied. + ep, gerr := h.db.GetPodcastEpisode(actx, episodeID) + if gerr != nil || ep.SourceFeedID != feedID { + continue + } + if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil { + deleted++ + } + } + log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)", + deleted, len(rawIDs), feedID, user.Username) + if isHXRequest(c) { + return h.hxEpisodeList(c, feedID.String(), false, false) + } + return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted}) +} + // flipSubscriptionToAll implements issue #3: Download All (button or // add-feed checkbox) sets the CALLER's subscription policy to 'all' — // per-user, never global (files are shared D6, policies are not). The diff --git a/internal/router/podcast.go b/internal/router/podcast.go index b48a41c8..286319ed 100644 --- a/internal/router/podcast.go +++ b/internal/router/podcast.go @@ -78,5 +78,6 @@ func registerPodcastRoutes(cfg *Config) { // Admin maintenance (§0 item 8): file deletion frees the disk and // resets rows to pending. Handler checks admin role. feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles)) + feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk)) feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile)) } diff --git a/templates/podcast_detail.templ b/templates/podcast_detail.templ index 139bdb73..f97a0c6c 100644 --- a/templates/podcast_detail.templ +++ b/templates/podcast_detail.templ @@ -240,6 +240,31 @@ templ PodcastDetailPage(user User, data PodcastDetailData) { Hide played/downloaded + + + if data.IsAdmin && data.Feed.DownloadedN > 0 { +