mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix: enforce adult profile pin visibility
This commit is contained in:
@@ -29,7 +29,7 @@ func smartSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
// Run the actual library search using the cleaned query so the
|
||||
// caller can render local + external results in one round-trip.
|
||||
items, _ := svc.Media.SearchMedia(c.Request.Context(), intent.Query, 60)
|
||||
items, _ := svc.Media.SearchMediaVisible(c.Request.Context(), intent.Query, 60, mediaVisibilityForRequest(c, svc))
|
||||
external := service.SearchExternalMedia(
|
||||
c.Request.Context(),
|
||||
intent.Query,
|
||||
@@ -57,8 +57,9 @@ func aiRecommendHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
titles := make([]string, 0, len(hist))
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
for _, h := range hist {
|
||||
if h.Media != nil && strings.TrimSpace(h.Media.Title) != "" {
|
||||
if h.Media != nil && visibility.Allows(h.Media) && strings.TrimSpace(h.Media.Title) != "" {
|
||||
titles = append(titles, h.Media.Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.POST("/play-profiles/:id/verify-pin", verifyPlayProfilePINHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
|
||||
// ── Search aliases ──
|
||||
|
||||
@@ -83,7 +83,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
id := c.Param("id")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
items, total, err := svc.Media.ListMedia(c.Request.Context(), id, page, size)
|
||||
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -108,6 +108,10 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +120,7 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
|
||||
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -127,7 +131,12 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -13,6 +15,10 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
type verifyPlayProfilePINReq struct {
|
||||
PIN string `json:"pin"`
|
||||
}
|
||||
|
||||
// listPlayProfilesHandler returns the caller's profiles, or every
|
||||
// profile when the caller is an admin AND ?all=true is set.
|
||||
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -84,3 +90,35 @@ func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyPlayProfilePINHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req verifyPlayProfilePINReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
profile, err := svc.PlayProfiles.VerifyPIN(c.Request.Context(), c.Param("id"), toString(uid), req.PIN)
|
||||
if errors.Is(err, service.ErrPlayProfileNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrPlayProfileForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "profile forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrPlayProfilePINInvalid) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "PIN 错误"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(12 * time.Hour)
|
||||
token := signPlayProfilePINToken(svc, toString(uid), profile.ID, expiresAt)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"profile": profile,
|
||||
"token": token,
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,14 @@ func recentHistoryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := make([]service.HistoryItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Media == nil || visibility.Allows(item.Media) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": filtered})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +79,14 @@ func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := make([]any, 0, len(items))
|
||||
for i := range items {
|
||||
if visibility.Allows(&items[i]) {
|
||||
filtered = append(filtered, items[i])
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": filtered})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +141,14 @@ func getPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := detail.Items[:0]
|
||||
for i := range detail.Items {
|
||||
if visibility.Allows(&detail.Items[i]) {
|
||||
filtered = append(filtered, detail.Items[i])
|
||||
}
|
||||
}
|
||||
detail.Items = filtered
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,16 @@ import (
|
||||
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
token := externalPlaybackToken(c, svc)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"media": m,
|
||||
"stream_url": "/api/stream/" + m.ID,
|
||||
"hls_url": "/api/hls/" + m.ID + "/index.m3u8",
|
||||
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||
"hls_url": "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -67,12 +69,12 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
token := externalPlaybackToken(c, svc)
|
||||
streamURL := absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token))
|
||||
streamURL := absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c))
|
||||
escapedStream := url.QueryEscape(streamURL)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": streamURL,
|
||||
@@ -92,18 +94,37 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
token := externalPlaybackToken(c, svc)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)),
|
||||
"url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)),
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func externalProfileQuery(c *gin.Context) string {
|
||||
profileID := strings.TrimSpace(c.GetHeader("X-Play-Profile-ID"))
|
||||
if profileID == "" {
|
||||
profileID = strings.TrimSpace(c.Query("profile_id"))
|
||||
}
|
||||
if profileID == "" {
|
||||
return ""
|
||||
}
|
||||
query := "&profile_id=" + url.QueryEscape(profileID)
|
||||
pinToken := strings.TrimSpace(c.GetHeader("X-Play-Profile-PIN-Token"))
|
||||
if pinToken == "" {
|
||||
pinToken = strings.TrimSpace(c.Query("profile_pin_token"))
|
||||
}
|
||||
if pinToken != "" {
|
||||
query += "&profile_pin_token=" + url.QueryEscape(pinToken)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func externalPlaybackToken(c *gin.Context, svc *service.Container) string {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
|
||||
|
||||
@@ -21,7 +21,7 @@ func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
|
||||
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -41,13 +41,13 @@ func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
|
||||
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"items": items,
|
||||
"filters": gin.H{
|
||||
"year": c.Query("year"),
|
||||
"type": c.Query("type"),
|
||||
|
||||
@@ -13,7 +13,12 @@ import (
|
||||
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
err := svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -35,7 +40,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.MediaVisibility {
|
||||
adultEnabled := settingBool(c, svc, "adult.enabled", false)
|
||||
visibility := service.MediaVisibility{IncludeNSFW: adultEnabled}
|
||||
profile, locked := selectedPlayProfile(c, svc)
|
||||
if locked {
|
||||
return service.MediaVisibility{
|
||||
IncludeNSFW: false,
|
||||
AllowedLibraryIDs: []string{"__locked__"},
|
||||
}
|
||||
}
|
||||
if profile == nil {
|
||||
return visibility
|
||||
}
|
||||
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult
|
||||
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
||||
return visibility
|
||||
}
|
||||
|
||||
func selectedPlayProfile(c *gin.Context, svc *service.Container) (*model.PlayProfile, bool) {
|
||||
if svc == nil || svc.Repo == nil || svc.Repo.PlayProfile == nil {
|
||||
return nil, false
|
||||
}
|
||||
userID := currentUserID(c)
|
||||
if userID == "" {
|
||||
return nil, false
|
||||
}
|
||||
profileID := strings.TrimSpace(c.GetHeader("X-Play-Profile-ID"))
|
||||
if profileID == "" {
|
||||
profileID = strings.TrimSpace(c.Query("profile_id"))
|
||||
}
|
||||
if profileID != "" {
|
||||
profile, err := svc.Repo.PlayProfile.FindByID(c.Request.Context(), profileID)
|
||||
if err == nil && profile != nil && profile.UserID == userID {
|
||||
if profile.RequirePIN && !validPlayProfilePINToken(c, svc, userID, profile.ID) {
|
||||
return nil, true
|
||||
}
|
||||
return profile, false
|
||||
}
|
||||
}
|
||||
rows, err := svc.Repo.PlayProfile.ListByUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for i := range rows {
|
||||
if rows[i].IsDefault {
|
||||
if rows[i].RequirePIN && !validPlayProfilePINToken(c, svc, userID, rows[i].ID) {
|
||||
return nil, true
|
||||
}
|
||||
return &rows[i], false
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func mediaVisibleForRequest(c *gin.Context, svc *service.Container, media *model.Media) bool {
|
||||
return mediaVisibilityForRequest(c, svc).Allows(media)
|
||||
}
|
||||
|
||||
func settingBool(c *gin.Context, svc *service.Container, key string, fallback bool) bool {
|
||||
if svc == nil || svc.Repo == nil || svc.Repo.Setting == nil {
|
||||
return fallback
|
||||
}
|
||||
value, err := svc.Repo.Setting.Get(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled", "启用", "开启":
|
||||
return true
|
||||
case "0", "false", "no", "off", "disabled", "禁用", "关闭", "":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) string {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
return toString(uid)
|
||||
}
|
||||
|
||||
func profileAllowedLibraryIDs(profile model.PlayProfile) []string {
|
||||
if strings.TrimSpace(profile.AllowedLibraryIDs) == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(profile.AllowedLibraryIDs), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func signPlayProfilePINToken(svc *service.Container, userID, profileID string, expiresAt time.Time) string {
|
||||
if svc == nil || svc.Cfg == nil {
|
||||
return ""
|
||||
}
|
||||
payload := fmt.Sprintf("%s|%s|%d", userID, profileID, expiresAt.Unix())
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
signature := playProfilePINSignature(svc.Cfg.Secrets.JWTSecret, encodedPayload)
|
||||
if signature == "" {
|
||||
return ""
|
||||
}
|
||||
return encodedPayload + "." + signature
|
||||
}
|
||||
|
||||
func validPlayProfilePINToken(c *gin.Context, svc *service.Container, userID, profileID string) bool {
|
||||
token := strings.TrimSpace(c.GetHeader("X-Play-Profile-PIN-Token"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(c.Query("profile_pin_token"))
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return false
|
||||
}
|
||||
expectedSignature := playProfilePINSignature(svc.Cfg.Secrets.JWTSecret, parts[0])
|
||||
if expectedSignature == "" || !hmac.Equal([]byte(expectedSignature), []byte(parts[1])) {
|
||||
return false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
fields := strings.Split(string(payloadBytes), "|")
|
||||
if len(fields) != 3 || fields[0] != userID || fields[1] != profileID {
|
||||
return false
|
||||
}
|
||||
expiresUnix, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() <= expiresUnix
|
||||
}
|
||||
|
||||
func playProfilePINSignature(secret, encodedPayload string) string {
|
||||
if strings.TrimSpace(secret) == "" || encodedPayload == "" {
|
||||
return ""
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(encodedPayload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
// The base /history GET / POST routes already exist; these add the three
|
||||
// auxiliary surfaces the React WatchHistoryPage needs:
|
||||
//
|
||||
// GET /api/watch-history paginated list (admin sees every user)
|
||||
// GET /api/watch-history/stats aggregate watch time + completion
|
||||
// GET /api/watch-history/continue resume rail (incomplete only)
|
||||
// DELETE /api/watch-history clear (?media_item_id= optional)
|
||||
// DELETE /api/watch-history/:id remove one row
|
||||
// GET /api/watch-history paginated list (admin sees every user)
|
||||
// GET /api/watch-history/stats aggregate watch time + completion
|
||||
// GET /api/watch-history/continue resume rail (incomplete only)
|
||||
// DELETE /api/watch-history clear (?media_item_id= optional)
|
||||
// DELETE /api/watch-history/:id remove one row
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -36,7 +36,14 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := make([]service.HistoryItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Media == nil || visibility.Allows(item.Media) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +116,9 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
mIdx := make(map[string]model.Media, len(media))
|
||||
for _, m := range media {
|
||||
if !mediaVisibleForRequest(c, svc, &m) {
|
||||
continue
|
||||
}
|
||||
mIdx[m.ID] = m
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
|
||||
Reference in New Issue
Block a user