mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +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
|
// Run the actual library search using the cleaned query so the
|
||||||
// caller can render local + external results in one round-trip.
|
// 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(
|
external := service.SearchExternalMedia(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
intent.Query,
|
intent.Query,
|
||||||
@@ -57,8 +57,9 @@ func aiRecommendHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
titles := make([]string, 0, len(hist))
|
titles := make([]string, 0, len(hist))
|
||||||
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
for _, h := range hist {
|
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)
|
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.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||||
|
authed.POST("/play-profiles/:id/verify-pin", verifyPlayProfilePINHandler(svc))
|
||||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||||
|
|
||||||
// ── Search aliases ──
|
// ── Search aliases ──
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -108,6 +108,10 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !mediaVisibleForRequest(c, svc, m) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +120,7 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -127,7 +131,12 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func streamHandler(svc *service.Container) gin.HandlerFunc {
|
func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -13,6 +15,10 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type verifyPlayProfilePINReq struct {
|
||||||
|
PIN string `json:"pin"`
|
||||||
|
}
|
||||||
|
|
||||||
// listPlayProfilesHandler returns the caller's profiles, or every
|
// listPlayProfilesHandler returns the caller's profiles, or every
|
||||||
// profile when the caller is an admin AND ?all=true is set.
|
// profile when the caller is an admin AND ?all=true is set.
|
||||||
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -84,3 +90,35 @@ func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.Status(http.StatusNoContent)
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||||
return
|
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)
|
c.JSON(http.StatusOK, detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ import (
|
|||||||
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
token := externalPlaybackToken(c, svc)
|
||||||
|
profileQuery := externalProfileQuery(c)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"media": m,
|
"media": m,
|
||||||
"stream_url": "/api/stream/" + m.ID,
|
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||||
"hls_url": "/api/hls/" + m.ID + "/index.m3u8",
|
"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 {
|
func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := externalPlaybackToken(c, svc)
|
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)
|
escapedStream := url.QueryEscape(streamURL)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"url": streamURL,
|
"url": streamURL,
|
||||||
@@ -92,18 +94,37 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
|
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := externalPlaybackToken(c, svc)
|
token := externalPlaybackToken(c, svc)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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,
|
"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 {
|
func externalPlaybackToken(c *gin.Context, svc *service.Container) string {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
|
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 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 30
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -41,13 +41,13 @@ func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 30
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"filters": gin.H{
|
"filters": gin.H{
|
||||||
"year": c.Query("year"),
|
"year": c.Query("year"),
|
||||||
"type": c.Query("type"),
|
"type": c.Query("type"),
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import (
|
|||||||
|
|
||||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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) {
|
if errors.Is(err, service.ErrMediaNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
@@ -35,7 +40,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
return
|
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
|
// The base /history GET / POST routes already exist; these add the three
|
||||||
// auxiliary surfaces the React WatchHistoryPage needs:
|
// auxiliary surfaces the React WatchHistoryPage needs:
|
||||||
//
|
//
|
||||||
// GET /api/watch-history paginated list (admin sees every user)
|
// GET /api/watch-history paginated list (admin sees every user)
|
||||||
// GET /api/watch-history/stats aggregate watch time + completion
|
// GET /api/watch-history/stats aggregate watch time + completion
|
||||||
// GET /api/watch-history/continue resume rail (incomplete only)
|
// GET /api/watch-history/continue resume rail (incomplete only)
|
||||||
// DELETE /api/watch-history clear (?media_item_id= optional)
|
// DELETE /api/watch-history clear (?media_item_id= optional)
|
||||||
// DELETE /api/watch-history/:id remove one row
|
// DELETE /api/watch-history/:id remove one row
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,7 +36,14 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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))
|
mIdx := make(map[string]model.Media, len(media))
|
||||||
for _, m := range media {
|
for _, m := range media {
|
||||||
|
if !mediaVisibleForRequest(c, svc, &m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
mIdx[m.ID] = m
|
mIdx[m.ID] = m
|
||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(rows))
|
out := make([]gin.H, 0, len(rows))
|
||||||
|
|||||||
@@ -207,6 +207,23 @@ func (r *LibraryRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// MediaRepository persists model.Media records.
|
// MediaRepository persists model.Media records.
|
||||||
type MediaRepository struct{ db *gorm.DB }
|
type MediaRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
// MediaQueryFilter is applied to user-facing media queries so NSFW items and
|
||||||
|
// profile-restricted libraries are filtered in SQL instead of only in React.
|
||||||
|
type MediaQueryFilter struct {
|
||||||
|
IncludeNSFW bool
|
||||||
|
AllowedLibraryIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||||
|
if !filter.IncludeNSFW {
|
||||||
|
q = q.Where("nsfw = ?", false)
|
||||||
|
}
|
||||||
|
if len(filter.AllowedLibraryIDs) > 0 {
|
||||||
|
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert inserts or updates a media row keyed by Path (unique index).
|
// Upsert inserts or updates a media row keyed by Path (unique index).
|
||||||
//
|
//
|
||||||
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
|
// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据
|
||||||
@@ -336,9 +353,14 @@ func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media
|
|||||||
|
|
||||||
// ListByLibrary returns paginated media items for a library.
|
// ListByLibrary returns paginated media items for a library.
|
||||||
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
|
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
|
||||||
|
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MediaRepository) ListByLibraryFiltered(ctx context.Context, libraryID string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, error) {
|
||||||
var items []model.Media
|
var items []model.Media
|
||||||
var total int64
|
var total int64
|
||||||
q := r.db.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", libraryID)
|
q := r.db.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", libraryID)
|
||||||
|
q = applyMediaQueryFilter(q, filter)
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -349,8 +371,13 @@ func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, o
|
|||||||
// Search runs a LIKE search against the title field. Empty query returns the
|
// Search runs a LIKE search against the title field. Empty query returns the
|
||||||
// most recently added items.
|
// most recently added items.
|
||||||
func (r *MediaRepository) Search(ctx context.Context, query string, limit int) ([]model.Media, error) {
|
func (r *MediaRepository) Search(ctx context.Context, query string, limit int) ([]model.Media, error) {
|
||||||
|
return r.SearchFiltered(ctx, query, limit, MediaQueryFilter{IncludeNSFW: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MediaRepository) SearchFiltered(ctx context.Context, query string, limit int, filter MediaQueryFilter) ([]model.Media, error) {
|
||||||
var items []model.Media
|
var items []model.Media
|
||||||
q := r.db.WithContext(ctx).Model(&model.Media{}).Limit(limit)
|
q := r.db.WithContext(ctx).Model(&model.Media{}).Limit(limit)
|
||||||
|
q = applyMediaQueryFilter(q, filter)
|
||||||
if query != "" {
|
if query != "" {
|
||||||
like := "%" + query + "%"
|
like := "%" + query + "%"
|
||||||
q = q.Where("title LIKE ? OR original_name LIKE ?", like, like)
|
q = q.Where("title LIKE ? OR original_name LIKE ?", like, like)
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ type MediaService struct {
|
|||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaVisibility struct {
|
||||||
|
IncludeNSFW bool
|
||||||
|
AllowedLibraryIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v MediaVisibility) Allows(media *model.Media) bool {
|
||||||
|
if media == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !v.IncludeNSFW && media.NSFW {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(v.AllowedLibraryIDs) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range v.AllowedLibraryIDs {
|
||||||
|
if id == media.LibraryID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// NewMediaService is the constructor.
|
// NewMediaService is the constructor.
|
||||||
func NewMediaService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *MediaService {
|
func NewMediaService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *MediaService {
|
||||||
return &MediaService{cfg: cfg, log: log, repo: repo}
|
return &MediaService{cfg: cfg, log: log, repo: repo}
|
||||||
@@ -143,6 +166,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
// ListMedia paginates media items inside a library.
|
// ListMedia paginates media items inside a library.
|
||||||
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
|
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
|
||||||
|
return s.ListMediaVisible(ctx, libraryID, page, pageSize, MediaVisibility{IncludeNSFW: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||||
if pageSize <= 0 {
|
if pageSize <= 0 {
|
||||||
pageSize = 50
|
pageSize = 50
|
||||||
}
|
}
|
||||||
@@ -152,15 +179,25 @@ func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pa
|
|||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
return s.repo.Media.ListByLibrary(ctx, libraryID, (page-1)*pageSize, pageSize)
|
return s.repo.Media.ListByLibraryFiltered(ctx, libraryID, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{
|
||||||
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchMedia performs a simple LIKE search across titles.
|
// SearchMedia performs a simple LIKE search across titles.
|
||||||
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
|
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
|
||||||
|
return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
return s.repo.Media.Search(ctx, query, limit)
|
return s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{
|
||||||
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMedia returns a single media row.
|
// GetMedia returns a single media row.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
|
||||||
|
libA := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
libB := model.Library{Name: "成人", Path: "/media/adult", Type: "movie", Enabled: true}
|
||||||
|
if err := db.Create(&libA).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&libB).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows := []model.Media{
|
||||||
|
{LibraryID: libA.ID, Title: "普通电影", Path: "/media/movies/a.mkv"},
|
||||||
|
{LibraryID: libA.ID, Title: "成人电影", Path: "/media/movies/b.mkv", NSFW: true},
|
||||||
|
{LibraryID: libB.ID, Title: "限制媒体库电影", Path: "/media/adult/c.mkv"},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := svc.SearchMediaVisible(t.Context(), "电影", 20, MediaVisibility{IncludeNSFW: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := sortedMediaTitles(items); !slices.Equal(got, []string{"普通电影", "限制媒体库电影"}) {
|
||||||
|
t.Fatalf("NSFW-filtered search = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err = svc.SearchMediaVisible(t.Context(), "电影", 20, MediaVisibility{
|
||||||
|
IncludeNSFW: true,
|
||||||
|
AllowedLibraryIDs: []string{libA.ID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := sortedMediaTitles(items); !slices.Equal(got, []string{"成人电影", "普通电影"}) {
|
||||||
|
t.Fatalf("library-filtered search = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, total, err := svc.ListMediaVisible(t.Context(), libA.ID, 1, 20, MediaVisibility{IncludeNSFW: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(listed) != 1 || listed[0].Title != "普通电影" {
|
||||||
|
t.Fatalf("NSFW-filtered list total=%d rows=%#v", total, sortedMediaTitles(listed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedMediaTitles(rows []model.Media) []string {
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, row.Title)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ type PlayProfileService struct {
|
|||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPlayProfileNotFound = errors.New("profile not found")
|
||||||
|
ErrPlayProfileForbidden = errors.New("profile forbidden")
|
||||||
|
ErrPlayProfilePINInvalid = errors.New("pin invalid")
|
||||||
|
)
|
||||||
|
|
||||||
// NewPlayProfileService is the constructor.
|
// NewPlayProfileService is the constructor.
|
||||||
func NewPlayProfileService(log *zap.Logger, repo *repository.Container) *PlayProfileService {
|
func NewPlayProfileService(log *zap.Logger, repo *repository.Container) *PlayProfileService {
|
||||||
return &PlayProfileService{log: log, repo: repo}
|
return &PlayProfileService{log: log, repo: repo}
|
||||||
@@ -138,7 +144,7 @@ func (s *PlayProfileService) Update(ctx context.Context, id string, in PlayProfi
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if row == nil {
|
if row == nil {
|
||||||
return nil, errors.New("profile not found")
|
return nil, ErrPlayProfileNotFound
|
||||||
}
|
}
|
||||||
if err := validateProfileInput(in, false); err != nil {
|
if err := validateProfileInput(in, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -158,6 +164,8 @@ func (s *PlayProfileService) Update(ctx context.Context, id string, in PlayProfi
|
|||||||
}
|
}
|
||||||
if in.RequirePIN && in.PIN != "" {
|
if in.RequirePIN && in.PIN != "" {
|
||||||
patch["pin_hash"] = hashPIN(in.PIN)
|
patch["pin_hash"] = hashPIN(in.PIN)
|
||||||
|
} else if in.RequirePIN && row.PINHash == "" {
|
||||||
|
return nil, errors.New("pin required")
|
||||||
}
|
}
|
||||||
if !in.RequirePIN {
|
if !in.RequirePIN {
|
||||||
patch["pin_hash"] = ""
|
patch["pin_hash"] = ""
|
||||||
@@ -183,6 +191,27 @@ func (s *PlayProfileService) Delete(ctx context.Context, id string) error {
|
|||||||
return s.repo.PlayProfile.Delete(ctx, id)
|
return s.repo.PlayProfile.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyPIN validates that the caller can switch to a PIN-protected profile.
|
||||||
|
func (s *PlayProfileService) VerifyPIN(ctx context.Context, id, userID, pin string) (*ProfileView, error) {
|
||||||
|
row, err := s.repo.PlayProfile.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
return nil, ErrPlayProfileNotFound
|
||||||
|
}
|
||||||
|
if row.UserID != userID {
|
||||||
|
return nil, ErrPlayProfileForbidden
|
||||||
|
}
|
||||||
|
if row.RequirePIN {
|
||||||
|
if row.PINHash == "" || hashPIN(pin) != row.PINHash {
|
||||||
|
return nil, ErrPlayProfilePINInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view := toProfileView(*row)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TouchActive bumps the LastActiveAt timestamp; called by the player
|
// TouchActive bumps the LastActiveAt timestamp; called by the player
|
||||||
// when a profile is selected.
|
// when a profile is selected.
|
||||||
func (s *PlayProfileService) TouchActive(ctx context.Context, id string) error {
|
func (s *PlayProfileService) TouchActive(ctx context.Context, id string) error {
|
||||||
@@ -201,6 +230,9 @@ func validateProfileInput(in PlayProfileInput, requireUser bool) error {
|
|||||||
if requireUser && strings.TrimSpace(in.UserID) == "" {
|
if requireUser && strings.TrimSpace(in.UserID) == "" {
|
||||||
return errors.New("user_id required")
|
return errors.New("user_id required")
|
||||||
}
|
}
|
||||||
|
if requireUser && in.RequirePIN && strings.TrimSpace(in.PIN) == "" {
|
||||||
|
return errors.New("pin required")
|
||||||
|
}
|
||||||
if in.RequirePIN && in.PIN != "" {
|
if in.RequirePIN && in.PIN != "" {
|
||||||
if len(in.PIN) < 4 || len(in.PIN) > 8 {
|
if len(in.PIN) < 4 || len(in.PIN) > 8 {
|
||||||
return errors.New("pin must be 4-8 characters")
|
return errors.New("pin must be 4-8 characters")
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlayProfileVerifyPIN(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.PlayProfile{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewPlayProfileService(zap.NewNop(), repository.New(db))
|
||||||
|
profile, err := service.Create(t.Context(), PlayProfileInput{
|
||||||
|
UserID: "user-1",
|
||||||
|
Name: "成人模式",
|
||||||
|
AllowAdult: true,
|
||||||
|
RequirePIN: true,
|
||||||
|
PIN: "1234",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.VerifyPIN(t.Context(), profile.ID, "user-1", "0000"); !errors.Is(err, ErrPlayProfilePINInvalid) {
|
||||||
|
t.Fatalf("wrong PIN error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.VerifyPIN(t.Context(), profile.ID, "user-2", "1234"); !errors.Is(err, ErrPlayProfileForbidden) {
|
||||||
|
t.Fatalf("wrong owner error = %v", err)
|
||||||
|
}
|
||||||
|
if verified, err := service.VerifyPIN(t.Context(), profile.ID, "user-1", "1234"); err != nil || verified.ID != profile.ID {
|
||||||
|
t.Fatalf("verify PIN got profile=%v err=%v", verified, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayProfileCreateRequiresPINWhenEnabled(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.PlayProfile{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewPlayProfileService(zap.NewNop(), repository.New(db))
|
||||||
|
if _, err := service.Create(t.Context(), PlayProfileInput{
|
||||||
|
UserID: "user-1",
|
||||||
|
Name: "锁定模式",
|
||||||
|
RequirePIN: true,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected PIN-required profile create to fail without PIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-2
@@ -1,6 +1,7 @@
|
|||||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||||
|
|
||||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||||
// requests and routes 401s back to the login page.
|
// requests and routes 401s back to the login page.
|
||||||
@@ -31,6 +32,15 @@ api.interceptors.request.use((config) => {
|
|||||||
config.headers = config.headers ?? {}
|
config.headers = config.headers ?? {}
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
const activeProfileId = getActivePlayProfileId()
|
||||||
|
if (activeProfileId) {
|
||||||
|
config.headers = config.headers ?? {}
|
||||||
|
config.headers['X-Play-Profile-ID'] = activeProfileId
|
||||||
|
const pinToken = getActivePlayProfilePinToken()
|
||||||
|
if (pinToken) {
|
||||||
|
config.headers['X-Play-Profile-PIN-Token'] = pinToken
|
||||||
|
}
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,16 +101,25 @@ const tokenQuery = () => {
|
|||||||
return `token=${encodeURIComponent(t)}`
|
return `token=${encodeURIComponent(t)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileQuery = () => {
|
||||||
|
const id = getActivePlayProfileId()
|
||||||
|
if (!id) return ''
|
||||||
|
const pinToken = getActivePlayProfilePinToken()
|
||||||
|
return `&profile_id=${encodeURIComponent(id)}${
|
||||||
|
pinToken ? `&profile_pin_token=${encodeURIComponent(pinToken)}` : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
// streamURL returns a direct-play URL for <video src>. The JWT is added as
|
||||||
// a query parameter because <video> elements cannot send Authorization
|
// a query parameter because <video> elements cannot send Authorization
|
||||||
// headers.
|
// headers.
|
||||||
export function streamURL(mediaId: string): string {
|
export function streamURL(mediaId: string): string {
|
||||||
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}`
|
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
export function hlsURL(mediaId: string): string {
|
export function hlsURL(mediaId: string): string {
|
||||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}`
|
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageURL converts a remote poster URL into a same-origin proxy URL so it
|
// imageURL converts a remote poster URL into a same-origin proxy URL so it
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export interface PlayProfileInput {
|
|||||||
allowed_library_ids: string[]
|
allowed_library_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayProfilePINVerifyResponse {
|
||||||
|
profile: PlayProfile
|
||||||
|
token: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
// playProfilesAPI wraps /play-profiles. The admin variant adds ?all=true.
|
// playProfilesAPI wraps /play-profiles. The admin variant adds ?all=true.
|
||||||
export const playProfilesAPI = {
|
export const playProfilesAPI = {
|
||||||
list: (all = false) =>
|
list: (all = false) =>
|
||||||
@@ -30,6 +36,11 @@ export const playProfilesAPI = {
|
|||||||
update: (id: string, input: PlayProfileInput) =>
|
update: (id: string, input: PlayProfileInput) =>
|
||||||
api.put<PlayProfile>(`/play-profiles/${id}`, input).then((r) => r.data),
|
api.put<PlayProfile>(`/play-profiles/${id}`, input).then((r) => r.data),
|
||||||
|
|
||||||
|
verifyPin: (id: string, pin: string) =>
|
||||||
|
api
|
||||||
|
.post<PlayProfilePINVerifyResponse>(`/play-profiles/${id}/verify-pin`, { pin })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
api.delete(`/play-profiles/${id}`).then((r) => r.data),
|
api.delete(`/play-profiles/${id}`).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
Activity, Bell, Clock, CloudDownload, Compass, Film,
|
Activity, Bell, Clock, CloudDownload, Compass, Film,
|
||||||
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
|
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
|
||||||
@@ -12,6 +13,10 @@ import clsx from 'clsx'
|
|||||||
import { AppFooter } from './AppFooter'
|
import { AppFooter } from './AppFooter'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { usePermissionStore } from '../stores/permissions'
|
import { usePermissionStore } from '../stores/permissions'
|
||||||
|
import { usePlayProfileStore } from '../stores/playProfile'
|
||||||
|
import { playProfilesAPI } from '../api/play_profiles'
|
||||||
|
import { requestPIN } from './PinDialog'
|
||||||
|
import type { PlayProfile } from '../types'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -22,9 +27,12 @@ export function Layout() {
|
|||||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||||
const isPermissionLoading = usePermissionStore((s) => s.isLoading)
|
const isPermissionLoading = usePermissionStore((s) => s.isLoading)
|
||||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||||
|
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||||
|
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||||
|
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||||
const [searchFocused, setSearchFocused] = useState(false)
|
const [searchFocused, setSearchFocused] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
@@ -52,8 +60,28 @@ export function Layout() {
|
|||||||
}
|
}
|
||||||
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setProfiles([])
|
||||||
|
setActiveProfile(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playProfilesAPI
|
||||||
|
.list(false)
|
||||||
|
.then((rows) => {
|
||||||
|
setProfiles(rows)
|
||||||
|
const active = rows.find((p) => p.id === activeProfileId)
|
||||||
|
if (!active) {
|
||||||
|
const defaultProfile = rows.find((p) => p.is_default && !p.require_pin)
|
||||||
|
setActiveProfile(defaultProfile?.id ?? null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [activeProfileId, setActiveProfile, user])
|
||||||
|
|
||||||
const isAdmin = user?.role === 'admin'
|
const isAdmin = user?.role === 'admin'
|
||||||
const can = (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true
|
const can = (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true
|
||||||
|
const activeProfile = profiles.find((p) => p.id === activeProfileId) ?? null
|
||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -62,6 +90,29 @@ export function Layout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleProfileSwitch = async (profile: PlayProfile) => {
|
||||||
|
if (activeProfileId === profile.id) {
|
||||||
|
setIsProfileOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let pinToken: string | null = null
|
||||||
|
if (profile.require_pin) {
|
||||||
|
const pin = await requestPIN({ profileName: profile.name })
|
||||||
|
if (!pin) return
|
||||||
|
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
|
||||||
|
pinToken = verified.token
|
||||||
|
}
|
||||||
|
setActiveProfile(profile.id, pinToken)
|
||||||
|
setIsProfileOpen(false)
|
||||||
|
toast.success(`已切换到「${profile.name}」`)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败'
|
||||||
|
toast.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
|
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
|
||||||
{/* Brand Logo & Brand Title */}
|
{/* Brand Logo & Brand Title */}
|
||||||
@@ -292,7 +343,9 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-left hidden md:block">
|
<div className="text-left hidden md:block">
|
||||||
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
||||||
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">{user?.role}</p>
|
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">
|
||||||
|
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={14} className="text-gray-500" />
|
<ChevronDown size={14} className="text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
@@ -317,14 +370,6 @@ export function Layout() {
|
|||||||
<UserIcon size={16} />
|
<UserIcon size={16} />
|
||||||
<span>个人基本信息</span>
|
<span>个人基本信息</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to="/play-profiles"
|
|
||||||
onClick={() => setIsProfileOpen(false)}
|
|
||||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
|
||||||
>
|
|
||||||
<UserCog size={16} />
|
|
||||||
<span>观影 Profile 切换</span>
|
|
||||||
</Link>
|
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
@@ -336,6 +381,48 @@ export function Layout() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className="my-1.5 border-t border-gray-100" />
|
<div className="my-1.5 border-t border-gray-100" />
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||||
|
当前观影 Profile
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveProfile(null)
|
||||||
|
setIsProfileOpen(false)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||||
|
!activeProfileId ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>账号默认</span>
|
||||||
|
<span>{!activeProfileId ? '使用中' : ''}</span>
|
||||||
|
</button>
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => handleProfileSwitch(profile)}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||||
|
activeProfileId === profile.id ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{profile.name}</span>
|
||||||
|
<span className="ml-2 shrink-0">{profile.allow_adult ? '成人' : '安全'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/play-profiles"
|
||||||
|
onClick={() => setIsProfileOpen(false)}
|
||||||
|
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||||
|
>
|
||||||
|
<UserCog size={16} />
|
||||||
|
<span>管理观影 Profile</span>
|
||||||
|
</Link>
|
||||||
|
<div className="my-1.5 border-t border-gray-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsProfileOpen(false);
|
setIsProfileOpen(false);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { LockKeyhole } from 'lucide-react'
|
||||||
|
|
||||||
|
type PinOptions = {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
profileName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestPIN(options: PinOptions): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const root = createRoot(host)
|
||||||
|
const close = (value: string | null) => {
|
||||||
|
root.unmount()
|
||||||
|
host.remove()
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
root.render(<PinDialog options={options} onClose={close} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function PinDialog({
|
||||||
|
options,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
options: PinOptions
|
||||||
|
onClose: (value: string | null) => void
|
||||||
|
}) {
|
||||||
|
const [pin, setPin] = useState('')
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const trimmed = pin.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
onClose(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||||
|
onClick={() => onClose(null)}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="w-full max-w-sm overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 p-5">
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-amber-50 text-amber-500">
|
||||||
|
<LockKeyhole size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-display text-lg font-bold text-ink-600">
|
||||||
|
{options.title || '需要 PIN 验证'}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-ink-50">
|
||||||
|
{options.message || `切换到「${options.profileName}」前请输入 PIN。`}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
minLength={4}
|
||||||
|
maxLength={8}
|
||||||
|
value={pin}
|
||||||
|
onChange={(event) => setPin(event.target.value)}
|
||||||
|
className="mt-4 w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-center text-lg font-bold tracking-[0.35em] text-ink-600 outline-none transition focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||||
|
placeholder="••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50/80 px-5 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClose(null)}
|
||||||
|
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!pin.trim()}
|
||||||
|
className="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
验证并切换
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import toast from 'react-hot-toast'
|
|||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { usePlayProfileStore } from '../stores/playProfile'
|
||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
|
import { requestPIN } from '../components/PinDialog'
|
||||||
import type { Library, PlayProfile } from '../types'
|
import type { Library, PlayProfile } from '../types'
|
||||||
|
|
||||||
// ProfileManagementPage replicates the Vue ProfileManagementView. It
|
// ProfileManagementPage replicates the Vue ProfileManagementView. It
|
||||||
@@ -17,6 +19,8 @@ import type { Library, PlayProfile } from '../types'
|
|||||||
export function ProfileManagementPage() {
|
export function ProfileManagementPage() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||||
const userID = useAuthStore((s) => s.user?.id ?? '')
|
const userID = useAuthStore((s) => s.user?.id ?? '')
|
||||||
|
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||||
|
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
@@ -65,6 +69,28 @@ export function ProfileManagementPage() {
|
|||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectProfile = async (profile: PlayProfile) => {
|
||||||
|
if (profile.user_id !== userID) {
|
||||||
|
toast.error('只能切换当前账号自己的 Profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let pinToken: string | null = null
|
||||||
|
if (profile.require_pin) {
|
||||||
|
const pin = await requestPIN({ profileName: profile.name })
|
||||||
|
if (!pin) return
|
||||||
|
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
|
||||||
|
pinToken = verified.token
|
||||||
|
}
|
||||||
|
setActiveProfile(profile.id, pinToken)
|
||||||
|
toast.success(`已切换到「${profile.name}」`)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败'
|
||||||
|
toast.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -105,6 +131,8 @@ export function ProfileManagementPage() {
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
profile={p}
|
profile={p}
|
||||||
libraries={libraries}
|
libraries={libraries}
|
||||||
|
active={activeProfileId === p.id || (!activeProfileId && p.is_default)}
|
||||||
|
onSelect={() => selectProfile(p)}
|
||||||
onEdit={() => openEdit(p)}
|
onEdit={() => openEdit(p)}
|
||||||
onDelete={() => onDelete(p)}
|
onDelete={() => onDelete(p)}
|
||||||
/>
|
/>
|
||||||
@@ -132,11 +160,15 @@ export function ProfileManagementPage() {
|
|||||||
function ProfileCard({
|
function ProfileCard({
|
||||||
profile,
|
profile,
|
||||||
libraries,
|
libraries,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
profile: PlayProfile
|
profile: PlayProfile
|
||||||
libraries: Library[]
|
libraries: Library[]
|
||||||
|
active: boolean
|
||||||
|
onSelect: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -165,6 +197,11 @@ function ProfileCard({
|
|||||||
默认
|
默认
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{active && (
|
||||||
|
<span className="rounded-lg bg-gray-950 px-2 py-0.5 text-xs text-white">
|
||||||
|
当前使用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{profile.allow_adult && (
|
{profile.allow_adult && (
|
||||||
<span className="rounded-lg bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
<span className="rounded-lg bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||||
成人内容
|
成人内容
|
||||||
@@ -188,6 +225,12 @@ function ProfileCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
|
>
|
||||||
|
设为当前
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface PlayProfileState {
|
||||||
|
activeProfileId: string | null
|
||||||
|
activeProfilePinToken: string | null
|
||||||
|
setActiveProfile: (id: string | null, pinToken?: string | null) => void
|
||||||
|
setActiveProfileId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlayProfileStore = create<PlayProfileState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
activeProfileId: null,
|
||||||
|
activeProfilePinToken: null,
|
||||||
|
setActiveProfile: (id, pinToken = null) =>
|
||||||
|
set({ activeProfileId: id || null, activeProfilePinToken: id ? pinToken || null : null }),
|
||||||
|
setActiveProfileId: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
activeProfileId: id || null,
|
||||||
|
activeProfilePinToken: id ? state.activeProfilePinToken : null,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'mediastationgo-play-profile',
|
||||||
|
storage: createJSONStorage(() => sessionStorage),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function getActivePlayProfileId(): string | null {
|
||||||
|
return usePlayProfileStore.getState().activeProfileId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivePlayProfilePinToken(): string | null {
|
||||||
|
return usePlayProfileStore.getState().activeProfilePinToken
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user