diff --git a/internal/auth/handler_middleware_test.go b/internal/auth/handler_middleware_test.go index 5fceea9f..c9ca7bbc 100644 --- a/internal/auth/handler_middleware_test.go +++ b/internal/auth/handler_middleware_test.go @@ -272,6 +272,26 @@ func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) { } } +func TestAuthMiddlewareAllowsPublicWatchlistAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := &fakeAuthService{validateErr: errors.New("no auth")} + router := gin.New() + router.Use(AuthMiddleware(svc)) + router.GET("/api/public/users/:userID/watchlist", func(c *gin.Context) { c.Status(http.StatusOK) }) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if svc.validateSessionCalled || svc.validateAPITokenCalled { + t.Fatalf("public watchlist endpoint should not authenticate") + } +} + type fakeAuthService struct { user *domain.User diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index b3cb1d69..7b21b07d 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -30,6 +30,9 @@ var publicRoutes = []publicRoute{ // Auth API. {method: http.MethodPost, path: "/api/auth/login"}, + + // Explicitly public, read-only API resources. + {method: http.MethodGet, path: "/api/public/", prefix: true}, } func isPublicRequest(method string, path string) bool { diff --git a/internal/watchlist/export.go b/internal/watchlist/export.go new file mode 100644 index 00000000..aaaf98c9 --- /dev/null +++ b/internal/watchlist/export.go @@ -0,0 +1,152 @@ +package watchlist + +import ( + "strings" + "time" + + "mal/internal/domain" +) + +type publicWatchlist struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + UserID string `json:"user_id"` + Summary watchlistSummary `json:"summary"` + Entries []publicWatchlistEntry `json:"entries"` +} + +type watchlistSummary struct { + Total int `json:"total"` + Watching int `json:"watching"` + Completed int `json:"completed"` + PlanToWatch int `json:"plan_to_watch"` + OnHold int `json:"on_hold"` + Dropped int `json:"dropped"` +} + +type publicWatchlistEntry struct { + MALID int64 `json:"mal_id"` + Status string `json:"status"` + AddedAt time.Time `json:"added_at"` + CompletedAt *time.Time `json:"completed_at"` + CompletionDateAccuracy *string `json:"completion_date_accuracy"` + LastWatchedAt *time.Time `json:"last_watched_at"` + Progress watchlistProgress `json:"progress"` + Titles watchlistTitles `json:"titles"` +} + +type watchlistProgress struct { + CurrentEpisode *int64 `json:"current_episode"` + CurrentTimeSeconds *float64 `json:"current_time_seconds"` +} + +type watchlistTitles struct { + Preferred string `json:"preferred"` + Original string `json:"original"` + English *string `json:"english"` + Japanese *string `json:"japanese"` +} + +func newPublicWatchlist(userID string, rows []domain.UserWatchListRow, generatedAt time.Time) publicWatchlist { + export := publicWatchlist{ + SchemaVersion: "1.0", + GeneratedAt: generatedAt, + UserID: userID, + Entries: make([]publicWatchlistEntry, 0, len(rows)), + } + + for _, row := range rows { + export.Summary.add(row.Status) + export.Entries = append(export.Entries, newPublicWatchlistEntry(row)) + } + + return export +} + +func (s *watchlistSummary) add(status string) { + s.Total++ + switch status { + case "watching": + s.Watching++ + case "completed": + s.Completed++ + case "plan_to_watch": + s.PlanToWatch++ + case "on_hold": + s.OnHold++ + case "dropped": + s.Dropped++ + } +} + +func newPublicWatchlistEntry(row domain.UserWatchListRow) publicWatchlistEntry { + currentEpisode := nullableInt64(row.CurrentEpisode.Int64, row.CurrentEpisode.Valid) + currentTimeSeconds := nullableFloat64(row.CurrentTimeSeconds, row.CurrentEpisode.Valid) + lastWatchedAt := nullableTime(row.LastEpisodeAt.Time, row.LastEpisodeAt.Valid) + if row.PlaybackCurrentEpisode.Valid { + currentEpisode = &row.PlaybackCurrentEpisode.Int64 + currentTimeSeconds = nullableFloat64(row.PlaybackCurrentTimeSeconds.Float64, row.PlaybackCurrentTimeSeconds.Valid) + lastWatchedAt = nullableTime(row.PlaybackUpdatedAt.Time, row.PlaybackUpdatedAt.Valid) + } + + entry := publicWatchlistEntry{ + MALID: row.AnimeID, + Status: row.Status, + AddedAt: row.CreatedAt, + LastWatchedAt: lastWatchedAt, + Progress: watchlistProgress{ + CurrentEpisode: currentEpisode, + CurrentTimeSeconds: currentTimeSeconds, + }, + Titles: watchlistTitles{ + Preferred: row.DisplayTitle(), + Original: row.TitleOriginal, + English: nullableString(row.TitleEnglish.String, row.TitleEnglish.Valid), + Japanese: nullableString(row.TitleJapanese.String, row.TitleJapanese.Valid), + }, + } + + if row.Status == "completed" { + accuracy := "exact" + if row.CompletedAt.Valid { + entry.CompletedAt = &row.CompletedAt.Time + if row.CompletedAtEstimated { + accuracy = "estimated_from_historical_update" + } + } else { + entry.CompletedAt = &row.UpdatedAt + accuracy = "estimated_from_historical_update" + } + entry.CompletionDateAccuracy = &accuracy + } + + return entry +} + +func nullableString(value string, valid bool) *string { + if !valid || strings.TrimSpace(value) == "" { + return nil + } + return &value +} + +func nullableInt64(value int64, valid bool) *int64 { + if !valid { + return nil + } + return &value +} + +func nullableFloat64(value float64, valid bool) *float64 { + if !valid { + return nil + } + return &value +} + +func nullableTime(value time.Time, valid bool) *time.Time { + if !valid { + return nil + } + return &value +} diff --git a/internal/watchlist/handler.go b/internal/watchlist/handler.go index abf2d2c9..f39940b6 100644 --- a/internal/watchlist/handler.go +++ b/internal/watchlist/handler.go @@ -6,6 +6,8 @@ import ( "mal/internal/server" "net/http" "strconv" + "strings" + "time" "github.com/gin-gonic/gin" ) @@ -22,6 +24,7 @@ func (h *WatchlistHandler) Register(r *gin.Engine) { r.POST("/api/watchlist", h.HandleUpdateWatchlist) r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist) r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching) + r.GET("/api/public/users/:userID/watchlist", h.HandleGetPublicWatchlist) r.GET("/watchlist", h.HandleGetWatchlist) } @@ -138,3 +141,45 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { "User": user, }) } + +func (h *WatchlistHandler) HandleGetPublicWatchlist(c *gin.Context) { + userID := strings.TrimSpace(c.Param("userID")) + if userID == "" { + server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid user id") + return + } + + entries, err := h.svc.GetWatchlist(c.Request.Context(), userID) + if err != nil { + server.RespondError( + c, + http.StatusInternalServerError, + "public_watchlist_load_failed", + "watchlist", + "failed to load public watchlist", + map[string]any{"user_id": userID}, + err, + ) + return + } + + c.Header("Cache-Control", "no-store") + if c.Query("download") == "1" { + c.Header("Content-Disposition", `attachment; filename="watchlist-`+safeFilenamePart(userID)+`.json"`) + } + c.JSON(http.StatusOK, newPublicWatchlist(userID, entries, time.Now().UTC())) +} + +func safeFilenamePart(value string) string { + const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + var filename strings.Builder + for _, char := range value { + if strings.ContainsRune(allowed, char) { + filename.WriteRune(char) + } + } + if filename.Len() == 0 { + return "user" + } + return filename.String() +} diff --git a/internal/watchlist/handler_test.go b/internal/watchlist/handler_test.go index 71335753..42dd94bc 100644 --- a/internal/watchlist/handler_test.go +++ b/internal/watchlist/handler_test.go @@ -2,11 +2,14 @@ package watchlist import ( "context" + "database/sql" + "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" + "time" "mal/internal/db" "mal/internal/domain" @@ -110,6 +113,196 @@ func TestHandleDeleteContinueWatching(t *testing.T) { } } +func TestHandleGetPublicWatchlistReturnsMinimalJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + + addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC) + completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC) + lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC) + svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)} + router := newWatchlistTestRouter(svc) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", got) + } + if svc.watchlistUserID != "user-42" { + t.Fatalf("GetWatchlist user id = %q, want user-42", svc.watchlistUserID) + } + + var body publicWatchlistResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v; body=%s", err, rec.Body.String()) + } + + assertPublicWatchlistEnvelope(t, body) + if len(body.Entries) != 2 { + t.Fatalf("entries = %d, want 2", len(body.Entries)) + } + assertCompletedPublicEntry(t, body.Entries[0], addedAt, completedAt) + assertWatchingPublicEntry(t, body.Entries[1], lastWatchedAt) + + assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes()) +} + +func publicWatchlistRows(addedAt time.Time, completedAt time.Time, lastWatchedAt time.Time) []domain.UserWatchListRow { + return []domain.UserWatchListRow{ + { + AnimeID: 1, + Status: "completed", + CreatedAt: addedAt, + UpdatedAt: completedAt.Add(48 * time.Hour), + CompletedAt: sql.NullTime{Time: completedAt, Valid: true}, + TitleOriginal: "Sousou no Frieren", + TitleEnglish: sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true}, + TitleJapanese: sql.NullString{String: "葬送のフリーレン", Valid: true}, + ImageUrl: "https://cdn.example/frieren.webp", + Airing: sql.NullBool{Bool: false, Valid: true}, + }, + { + AnimeID: 2, + Status: "watching", + CreatedAt: addedAt.Add(24 * time.Hour), + CurrentEpisode: sql.NullInt64{Int64: 3, Valid: true}, + CurrentTimeSeconds: 10, + PlaybackCurrentEpisode: sql.NullInt64{Int64: 7, Valid: true}, + PlaybackCurrentTimeSeconds: sql.NullFloat64{Float64: 321.5, Valid: true}, + PlaybackUpdatedAt: sql.NullTime{Time: lastWatchedAt, Valid: true}, + TitleOriginal: "Dungeon Meshi", + ImageUrl: "https://cdn.example/dungeon.webp", + }, + } +} + +type publicWatchlistResponse struct { + SchemaVersion string `json:"schema_version"` + UserID string `json:"user_id"` + Summary struct { + Total int `json:"total"` + Completed int `json:"completed"` + Watching int `json:"watching"` + } `json:"summary"` + Entries []publicWatchlistResponseEntry `json:"entries"` +} + +type publicWatchlistResponseEntry struct { + MALID int64 `json:"mal_id"` + Status string `json:"status"` + AddedAt string `json:"added_at"` + CompletedAt *string `json:"completed_at"` + CompletionDateAccuracy *string `json:"completion_date_accuracy"` + LastWatchedAt *string `json:"last_watched_at"` + Progress struct { + CurrentEpisode *int64 `json:"current_episode"` + CurrentTimeSeconds *float64 `json:"current_time_seconds"` + } `json:"progress"` + Titles struct { + Preferred string `json:"preferred"` + Original string `json:"original"` + English *string `json:"english"` + Japanese *string `json:"japanese"` + } `json:"titles"` +} + +func assertPublicWatchlistEnvelope(t *testing.T, body publicWatchlistResponse) { + t.Helper() + assertEqual(t, "schema version", body.SchemaVersion, "1.0") + assertEqual(t, "user id", body.UserID, "user-42") + assertEqual(t, "summary total", body.Summary.Total, 2) + assertEqual(t, "summary completed", body.Summary.Completed, 1) + assertEqual(t, "summary watching", body.Summary.Watching, 1) +} + +func assertCompletedPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, addedAt time.Time, completedAt time.Time) { + t.Helper() + assertEqual(t, "completed MAL id", entry.MALID, int64(1)) + assertEqual(t, "completed status", entry.Status, "completed") + assertEqual(t, "added_at", entry.AddedAt, addedAt.Format(time.RFC3339)) + assertPointerEqual(t, "completed_at", entry.CompletedAt, completedAt.Format(time.RFC3339)) + assertPointerEqual(t, "completion date accuracy", entry.CompletionDateAccuracy, "exact") + assertEqual(t, "preferred title", entry.Titles.Preferred, "Frieren: Beyond Journey's End") + assertEqual(t, "original title", entry.Titles.Original, "Sousou no Frieren") +} + +func assertWatchingPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, lastWatchedAt time.Time) { + t.Helper() + assertPointerEqual(t, "watching progress episode", entry.Progress.CurrentEpisode, int64(7)) + assertPointerEqual(t, "watching progress seconds", entry.Progress.CurrentTimeSeconds, 321.5) + assertPointerEqual(t, "last_watched_at", entry.LastWatchedAt, lastWatchedAt.Format(time.RFC3339)) + assertNilPointer(t, "watching completed_at", entry.CompletedAt) + assertNilPointer(t, "watching completion date accuracy", entry.CompletionDateAccuracy) +} + +func assertEqual[T comparable](t *testing.T, label string, got T, want T) { + t.Helper() + if got != want { + t.Fatalf("%s = %v, want %v", label, got, want) + } +} + +func assertPointerEqual[T comparable](t *testing.T, label string, got *T, want T) { + t.Helper() + if got == nil || *got != want { + t.Fatalf("%s = %v, want %v", label, got, want) + } +} + +func assertNilPointer[T any](t *testing.T, label string, got *T) { + t.Helper() + if got != nil { + t.Fatalf("%s = %v, want null", label, got) + } +} + +func assertMinimalPublicWatchlistJSON(t *testing.T, data []byte) { + t.Helper() + var raw struct { + RecommendationGuidance json.RawMessage `json:"recommendation_guidance"` + Entries []map[string]json.RawMessage `json:"entries"` + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("decode raw response: %v", err) + } + if raw.RecommendationGuidance != nil { + t.Fatalf("recommendation_guidance should not be part of the public contract") + } + for _, key := range []string{ + "updated_at", + "status_meaning", + "myanimelist_status", + "anilist_status", + "exclude_from_recommendations", + "recommendation_exclusion_reason", + "anime", + "external_ids", + "links", + } { + if _, exists := raw.Entries[0][key]; exists { + t.Fatalf("%s should not be part of the public contract", key) + } + } +} + +func TestHandleGetPublicWatchlistCanDownloadJSON(t *testing.T) { + router := newWatchlistTestRouter(&fakeWatchlistService{}) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist?download=1", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist-user-42.json"` { + t.Fatalf("Content-Disposition = %q", got) + } +} + func newWatchlistTestRouter(svc domain.WatchlistService) *gin.Engine { router := gin.New() router.Use(func(c *gin.Context) { @@ -134,6 +327,10 @@ type fakeWatchlistService struct { deletedContinueUserID string deletedContinueAnimeID int64 + + watchlist []domain.UserWatchListRow + watchlistUserID string + watchlistErr error } func (s *fakeWatchlistService) UpdateEntry(_ context.Context, userID string, animeID int64, status string) error { @@ -149,8 +346,9 @@ func (s *fakeWatchlistService) RemoveEntry(_ context.Context, userID string, ani return s.removeErr } -func (s *fakeWatchlistService) GetWatchlist(context.Context, string) ([]domain.UserWatchListRow, error) { - return nil, nil +func (s *fakeWatchlistService) GetWatchlist(_ context.Context, userID string) ([]domain.UserWatchListRow, error) { + s.watchlistUserID = userID + return s.watchlist, s.watchlistErr } func (s *fakeWatchlistService) GetWatchlistMap(context.Context, string, []int64) (map[int64]bool, error) { diff --git a/static/watchlist.ts b/static/watchlist.ts index 418ab4f9..d1e19aea 100644 --- a/static/watchlist.ts +++ b/static/watchlist.ts @@ -228,14 +228,6 @@ const toggleWatchlist = async ( type WatchlistSort = "date" | "title"; -const csvEscape = (value: unknown): string => { - const str = String(value ?? ""); - if (/[",\r\n]/.test(str)) { - return `"${str.replaceAll(/"/g, '""')}"`; - } - return str; -}; - const watchlistItems = (): HTMLElement[] => [ ...document.querySelectorAll(".watchlist-item"), ]; @@ -314,35 +306,6 @@ const applyWatchlistFilter = (status: string): void => { }); }; -const exportWatchlistCsv = (): void => { - const rows = [...watchlistItems()] - .toSorted((a, b) => { - const dateA = Number.parseInt(a.dataset.updatedAt ?? "0", 10) || 0; - const dateB = Number.parseInt(b.dataset.updatedAt ?? "0", 10) || 0; - return dateB - dateA; - }) - .map((item) => { - const updatedAt = Number.parseInt(item.dataset.updatedAt ?? "0", 10) || 0; - const updatedAtISO = updatedAt > 0 ? new Date(updatedAt * 1000).toISOString() : ""; - const title = item.dataset.title || item.querySelector("h3")?.textContent?.trim() || ""; - return [item.dataset.malId || "", title, item.dataset.status || "", updatedAtISO]; - }); - - const csv = [["mal_id", "title", "status", "updated_at"], ...rows] - .map((row) => row.map(csvEscape).join(",")) - .join("\r\n"); - - const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "watchlist.csv"; - document.body.append(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); -}; - const initWatchlistPage = (): void => { let currentSortBy: WatchlistSort = "date"; let sortOrderDesc = true; @@ -396,12 +359,6 @@ const initWatchlistPage = (): void => { sortVisibleWatchlistItems(currentSortBy, sortOrderDesc); return; } - - const exportBtn = target.closest("button[data-watchlist-export]"); - if (exportBtn) { - exportWatchlistCsv(); - return; - } }); }; diff --git a/templates/watchlist.gohtml b/templates/watchlist.gohtml index 619aed73..4dafdddd 100644 --- a/templates/watchlist.gohtml +++ b/templates/watchlist.gohtml @@ -15,13 +15,13 @@