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) } 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) { 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 }