Files
djk/internal/app/ui_viewer.go
T

363 lines
12 KiB
Go

package app
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const viewerLifetime = 365 * 24 * time.Hour
const viewerIdleCacheLimit = 64
type viewerContextKey struct{}
type viewerRecords struct {
id string
directory string
manager *viewerManager
refs int
used time.Time
historyOnce sync.Once
history *playbackHistoryStore
followingOnce sync.Once
following *followingStore
}
type viewerManager struct {
directory string
key []byte
cookie string
err error
mu sync.Mutex
viewers map[string]*viewerRecords
legacyMu sync.Mutex
accountsOnce sync.Once
accounts *accountStore
}
func (app *UIApp) browserViewers() *viewerManager {
app.viewersOnce.Do(func() {
manager := &viewerManager{directory: app.cfg.dataDirectory(), viewers: make(map[string]*viewerRecords)}
manager.key, manager.err = loadViewerKey(manager.directory)
if manager.err == nil {
sum := sha256.Sum256(manager.key)
manager.cookie = "juku_viewer_" + hex.EncodeToString(sum[:8])
}
app.viewers = manager
})
return app.viewers
}
func loadViewerKey(directory string) ([]byte, error) {
path := filepath.Join(directory, "viewer-key")
key, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
key = make([]byte, 32)
if _, err = rand.Read(key); err != nil {
return nil, err
}
if err = os.MkdirAll(directory, 0o700); err != nil {
return nil, err
}
if err = writeNewViewerFile(path, key); errors.Is(err, os.ErrExist) {
key, err = os.ReadFile(path)
}
}
if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, errors.New("浏览器身份密钥损坏,请恢复数据目录中的 viewer-key 备份")
}
return key, nil
}
func writeNewViewerFile(path string, data []byte) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
os.Remove(path)
}
return err
}
func (manager *viewerManager) signature(value string) []byte {
mac := hmac.New(sha256.New, manager.key)
mac.Write([]byte(manager.cookie + "|" + value))
return mac.Sum(nil)
}
func (manager *viewerManager) token(identity string, expires time.Time) string {
value := "1." + identity + "." + strconv.FormatInt(expires.Unix(), 10)
return value + "." + hex.EncodeToString(manager.signature(value))
}
func (manager *viewerManager) identity(request *http.Request) string {
cookie, err := request.Cookie(manager.cookie)
if err != nil || len(cookie.Value) > 180 {
return ""
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 4 || parts[0] != "1" || len(parts[1]) != 64 || len(parts[3]) != 64 {
return ""
}
identity, identityErr := hex.DecodeString(parts[1])
signature, signatureErr := hex.DecodeString(parts[3])
expires, expiryErr := strconv.ParseInt(parts[2], 10, 64)
if identityErr != nil || len(identity) != 32 || signatureErr != nil || expiryErr != nil || expires <= time.Now().Unix() || !hmac.Equal(signature, manager.signature(strings.Join(parts[:3], "."))) {
return ""
}
return parts[1]
}
func viewerID(identity string) string {
sum := sha256.Sum256([]byte(identity))
return hex.EncodeToString(sum[:])
}
func (manager *viewerManager) acquire(id string) *viewerRecords {
manager.mu.Lock()
defer manager.mu.Unlock()
viewer := manager.viewers[id]
if viewer == nil {
viewer = &viewerRecords{id: id, directory: filepath.Join(manager.directory, "viewers", id), manager: manager}
manager.viewers[id] = viewer
}
viewer.refs++
viewer.used = time.Now()
return viewer
}
func (viewer *viewerRecords) retain() {
if viewer == nil {
return
}
viewer.manager.mu.Lock()
viewer.refs++
viewer.manager.mu.Unlock()
}
func (viewer *viewerRecords) release() {
if viewer == nil {
return
}
manager := viewer.manager
manager.mu.Lock()
defer manager.mu.Unlock()
viewer.refs--
viewer.used = time.Now()
for len(manager.viewers) > viewerIdleCacheLimit {
var oldest *viewerRecords
for _, candidate := range manager.viewers {
if candidate.refs == 0 && (oldest == nil || candidate.used.Before(oldest.used)) {
oldest = candidate
}
}
if oldest == nil {
break
}
delete(manager.viewers, oldest.id)
}
}
func (viewer *viewerRecords) playbackHistory() *playbackHistoryStore {
viewer.historyOnce.Do(func() { viewer.history = newPlaybackHistoryStore(viewer.directory) })
return viewer.history
}
func (viewer *viewerRecords) followingStore() *followingStore {
viewer.followingOnce.Do(func() { viewer.following = newFollowingStore(viewer.directory) })
return viewer.following
}
func contextViewer(ctx context.Context) *viewerRecords {
viewer, _ := ctx.Value(viewerContextKey{}).(*viewerRecords)
return viewer
}
func requestViewer(writer http.ResponseWriter, request *http.Request) *viewerRecords {
viewer := contextViewer(request.Context())
if viewer == nil {
writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "请刷新页面,恢复此浏览器的独立观看记录")
}
return viewer
}
func viewerOwnsPlayback(ctx context.Context, session *playbackSession) bool {
viewer := contextViewer(ctx)
return viewer != nil && session != nil && session.viewer == viewer && playbackSourceAllowed(ctx, session)
}
// playbackCapabilityPath 判断是否为无需 viewer cookie 的媒体资产路径:
// 这些 URL 中已包含不可猜测的播放会话 ID,可作为独立能力凭证。
func playbackCapabilityPath(path string) bool {
return strings.HasPrefix(path, "/api/ui/playback/media/") || path == "/api/ui/playback/hls/index.m3u8" || path == "/api/ui/playback/hls/segment.ts"
}
func writeViewerError(writer http.ResponseWriter, status int, code, message string) {
writer.Header().Set("Cache-Control", "private, no-store")
writer.Header().Add("Vary", "Cookie")
writeJSON(writer, status, map[string]string{"code": code, "error": message})
}
func (app *UIApp) withBrowserViewer(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
if path != "/api/ui/following" && !strings.HasPrefix(path, "/api/ui/playback/") && !strings.HasPrefix(path, "/api/ui/account/") && path != "/api/ui/viewer/legacy" && !accountAdminPath(path) {
next.ServeHTTP(writer, request)
return
}
writer.Header().Set("Cache-Control", "private, no-store")
writer.Header().Add("Vary", "Cookie")
manager := app.browserViewers()
if manager.err != nil {
writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", "无法读取浏览器身份:"+publicError(manager.err).Error())
return
}
account, _, accountCookie, accountErr := manager.requestAccount(request)
if accountErr != nil {
writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", accountErr.Error())
return
}
identity := manager.identity(request)
if account.ID == "" && (identity == "" || accountCookie) {
if playbackCapabilityPath(path) {
// AVPlayer 等原生媒体管线可能不携带 cookie;播放会话 ID 本身即能力凭证,
// 由媒体资产接口自行校验会话与运行编号。
next.ServeHTTP(writer, request)
return
}
writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "请刷新页面,恢复此浏览器的独立观看记录")
return
}
id := viewerID(identity)
if account.ID != "" {
id = account.viewerID()
}
if expected := request.Header.Get("X-Juku-Viewer"); expected != "" && expected != id {
writeViewerError(writer, http.StatusConflict, "viewer_changed", "浏览器身份已变化,请刷新页面后继续")
return
}
viewer := manager.acquire(id)
defer viewer.release()
next.ServeHTTP(writer, request.WithContext(context.WithValue(request.Context(), viewerContextKey{}, viewer)))
})
}
func (app *UIApp) handleViewer(writer http.ResponseWriter, request *http.Request) {
if !playbackRequestAllowed(writer, request, http.MethodGet) {
return
}
writer.Header().Set("Cache-Control", "private, no-store")
writer.Header().Add("Vary", "Cookie")
manager := app.browserViewers()
if manager.err != nil {
writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", "无法保存浏览器身份:"+publicError(manager.err).Error())
return
}
if err := manager.accountStore().err; err != nil {
writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", err.Error())
return
}
account, accountName, accountCookie, err := manager.requestAccount(request)
if err != nil {
accountOperationError(writer, err)
return
}
accountExpired := accountCookie && account.ID == ""
if accountExpired {
manager.setAccountCookie(writer, request, "")
}
identity := manager.identity(request)
confirmed := identity != "" || account.ID != ""
if identity == "" {
if !confirmed && request.URL.Query().Get("confirm") == "1" {
writeViewerError(writer, http.StatusUnauthorized, "viewer_cookies_disabled", "请允许此网站保存 Cookie,再重试;观看记录需要用它识别当前浏览器")
return
}
identity = randomHex(32)
if identity == "" {
writeViewerError(writer, http.StatusInternalServerError, "viewer_unavailable", "无法建立浏览器身份,请重试")
return
}
}
manager.setGuestCookie(writer, request, identity)
if !confirmed {
writeJSON(writer, http.StatusOK, map[string]bool{"ready": false})
return
}
id := viewerID(identity)
if account.ID != "" {
id = account.viewerID()
}
available, err := manager.legacyAvailable(id)
requireLogin, allowRegistration := manager.accountStore().policy()
result := map[string]any{"onlineOnly": account.onlineOnly(), "sources": account.effectiveSources(), "sourceChoices": accountSourceChoices, "ready": true, "id": id, "legacyAvailable": available, "accountExpired": accountExpired, "requireLogin": requireLogin, "allowRegistration": allowRegistration}
if account.ID != "" {
result["account"] = account.public()
result["guestImportAvailable"] = manager.guestImportAvailable(identity, accountName)
}
if err != nil {
result["legacyError"] = "旧版记录恢复暂不可用:" + publicError(err).Error()
}
writeJSON(writer, http.StatusOK, result)
}
func (app *UIApp) prepareBrowserViewers() error {
manager := app.browserViewers()
if manager.err != nil {
return fmt.Errorf("初始化独立观看记录失败:%w", manager.err)
}
if err := manager.accountStore().err; err != nil {
return err
}
admin, err := manager.ensureAdministrator(app.cfg)
if err != nil {
return err
}
fmt.Printf("管理员账号: %s\n", admin.Username)
if admin.InitialPassword != "" {
fmt.Printf("管理员随机初始密码: %s\n首次登录必须修改密码。此密码仅在首次创建时输出,请妥善保存。\n", admin.InitialPassword)
} else if admin.RequirePasswordChange {
fmt.Println("管理员尚未修改初始密码;若已遗失,可用 -admin-password 指定新密码后启动。")
}
if available, err := manager.prepareLegacyCode(); err != nil {
fmt.Printf("旧版观看记录已保留,恢复码准备失败:%v\n", publicError(err))
} else if available {
fmt.Printf("旧版观看记录已保留。可在“代理 / 设置 → 个人记录”输入 %s 中的恢复码,接回当前浏览器。\n", filepath.Join(manager.directory, "viewer-legacy-code"))
}
return nil
}
func (app *UIApp) requirePlaybackOwner(writer http.ResponseWriter, request *http.Request, id string) bool {
if requestViewer(writer, request) == nil {
return false
}
app.playbackMu.Lock()
allowed := viewerOwnsPlayback(request.Context(), app.playbacks[id])
app.playbackMu.Unlock()
if !allowed {
writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开本剧"})
}
return allowed
}