初始提交:短剧库源码(含转码优化 ultrafast)
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user