package app import ( "errors" "fmt" "net/http" "sort" "strconv" "strings" "time" ) type administratorBootstrap struct { Username string InitialPassword string RequirePasswordChange bool } func (manager *viewerManager) ensureAdministrator(cfg Config) (administratorBootstrap, error) { store := manager.accountStore() store.mu.Lock() defer store.mu.Unlock() if store.err != nil { return administratorBootstrap{}, store.err } var previous string var account accountRecord for name, record := range store.state.Accounts { if record.Admin { previous, account = name, record break } } username := firstNonEmpty(cfg.adminUsername, "admin") if previous != "" && !cfg.adminUserExplicit { username = account.Username } name, display, err := normalizeAccountUsername(username) if err != nil { return administratorBootstrap{}, err } if existing, exists := store.state.Accounts[name]; exists && existing.ID != account.ID { return administratorBootstrap{}, errors.New("管理员用户名已被普通账号占用,请通过 -admin-user 指定其他名称") } initial := "" changed := previous == "" || previous != name || account.Username != display if previous == "" { account = accountRecord{ID: randomHex(32), Username: display, Admin: true, Algorithm: "pbkdf2-sha256", Iterations: accountPasswordIterations, CreatedAt: time.Now()} if account.ID == "" { return administratorBootstrap{}, errors.New("无法生成管理员身份") } password := cfg.adminPassword if !cfg.adminPasswordExplicit { password = randomHex(16) if password == "" { return administratorBootstrap{}, errors.New("无法生成管理员初始密码") } initial = password account.RequirePasswordChange = true } account.Salt, account.PasswordHash, err = newAccountPassword(password) } else if cfg.adminPasswordExplicit { if err = validateAccountPassword(cfg.adminPassword); err == nil { if !verifyAccountPassword(account, cfg.adminPassword) { account.Salt, account.PasswordHash, err = newAccountPassword(cfg.adminPassword) account.Iterations = accountPasswordIterations changed = true } if account.RequirePasswordChange { changed = true } account.RequirePasswordChange = false } } if err != nil { return administratorBootstrap{}, fmt.Errorf("管理员密码无效:%w", err) } if changed { state := store.snapshotLocked() delete(state.Accounts, previous) account.Username = display state.Accounts[name] = account for key, session := range state.Sessions { if session.Account == previous { delete(state.Sessions, key) } } if previous != "" && previous != name { for guest, imported := range state.GuestImports { if imported.Account == previous { imported.Account = name state.GuestImports[guest] = imported } } } if err = store.saveLocked(state); err != nil { return administratorBootstrap{}, err } } return administratorBootstrap{Username: account.Username, InitialPassword: initial, RequirePasswordChange: account.RequirePasswordChange}, nil } func (store *accountStore) policy() (bool, bool) { store.mu.Lock() defer store.mu.Unlock() return store.state.RequireLogin, store.state.AllowRegistration } func accountAdminPath(path string) bool { if strings.HasPrefix(path, "/api/ui/admin/") { return true } switch path { case "/api/ui/config", "/api/ui/network/check", "/api/ui/directory/pick", "/api/ui/ffmpeg": return true } return false } func (app *UIApp) withAccountAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { path := request.URL.Path if strings.HasPrefix(path, "/assets/") || strings.HasPrefix(path, "/api/emby/media/") || path == "/api/emby/stream.m3u8" || path == "/api/emby/segment.ts" || path == "/api/emby/cover" { next.ServeHTTP(writer, request) return } switch path { case "/api/ui/viewer", "/api/ui/account/login", "/api/ui/account/register", "/api/ui/account/logout", "/api/ui/account/password": next.ServeHTTP(writer, request) return } manager := app.browserViewers() store := manager.accountStore() if manager.err != nil || store.err != nil { writeViewerError(writer, http.StatusServiceUnavailable, "viewer_unavailable", "账号数据无法读取,请检查数据目录或恢复备份") return } requireLogin, _ := store.policy() account, _, cookiePresent, err := manager.requestAccount(request) if err != nil { accountOperationError(writer, err) return } if path == "/" || path == "/login" { writer.Header().Set("Cache-Control", "private, no-store") writer.Header().Add("Vary", "Cookie") if path == "/" && (account.RequirePasswordChange || requireLogin && account.ID == "") { http.Redirect(writer, request, "/login", http.StatusFound) return } if path == "/login" && account.ID != "" && !account.RequirePasswordChange { http.Redirect(writer, request, "/", http.StatusFound) return } next.ServeHTTP(writer, request) return } if cookiePresent && account.ID == "" && !accountAdminPath(path) { writeViewerError(writer, http.StatusUnauthorized, "viewer_required", "账号登录已失效,请重新登录或继续匿名使用") return } request = request.WithContext(withSourceScope(request.Context(), account)) sources := strings.Join(account.effectiveSources(), ",") writer.Header().Set("X-Juku-Sources", sources) writer.Header().Set("Cache-Control", "private, no-store") writer.Header().Add("Vary", "Cookie") if previous := request.Header.Get("X-Juku-Sources"); previous != "" && previous != sources { writeViewerError(writer, http.StatusConflict, "sources_changed", "站源权限已更新,正在重新加载") return } onlineOnly := strconv.FormatBool(account.onlineOnly()) writer.Header().Set("X-Juku-Online-Only", onlineOnly) if previous := request.Header.Get("X-Juku-Online-Only"); previous != "" && previous != onlineOnly { writeViewerError(writer, http.StatusConflict, "permissions_changed", "账号权限已更新,正在重新加载") return } if account.RequirePasswordChange { writeViewerError(writer, http.StatusForbidden, "password_change_required", "请先修改管理员初始密码") return } if requireLogin && account.ID == "" { writeViewerError(writer, http.StatusUnauthorized, "login_required", "请登录后使用剧库") return } if accountDownloadPath(path) && !requireDownload(writer, request) { return } if accountAdminPath(path) { if !account.Admin { writeViewerError(writer, http.StatusForbidden, "admin_required", "此操作需要管理员账号") return } if request.Method != http.MethodGet && request.Method != http.MethodHead { if !playbackRequestAllowed(writer, request, http.MethodPost) { return } if request.Header.Get("X-Juku-Viewer") == "" { writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请从管理员面板操作"}) return } } } next.ServeHTTP(writer, request) }) } func (app *UIApp) handleAdminSettings(writer http.ResponseWriter, request *http.Request) { store := app.browserViewers().accountStore() if request.Method == http.MethodGet { if !playbackRequestAllowed(writer, request, http.MethodGet) { return } requireLogin, allowRegistration := store.policy() writeJSON(writer, http.StatusOK, map[string]bool{"requireLogin": requireLogin, "allowRegistration": allowRegistration}) return } var input struct { RequireLogin *bool `json:"requireLogin"` AllowRegistration *bool `json:"allowRegistration"` } if !readAccountRequest(writer, request, &input) { return } if input.RequireLogin == nil || input.AllowRegistration == nil { writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择是否必须登录及是否允许注册"}) return } store.mu.Lock() state := store.snapshotLocked() state.RequireLogin, state.AllowRegistration = *input.RequireLogin, *input.AllowRegistration err := store.saveLocked(state) store.mu.Unlock() if err != nil { accountOperationError(writer, err) return } writeJSON(writer, http.StatusOK, map[string]bool{"requireLogin": *input.RequireLogin, "allowRegistration": *input.AllowRegistration}) } func (app *UIApp) handleAdminAccounts(writer http.ResponseWriter, request *http.Request) { store := app.browserViewers().accountStore() if request.Method == http.MethodGet { if !playbackRequestAllowed(writer, request, http.MethodGet) { return } store.mu.Lock() accounts := make([]accountPublic, 0, len(store.state.Accounts)) for _, account := range store.state.Accounts { accounts = append(accounts, account.public()) } store.mu.Unlock() sort.Slice(accounts, func(i, j int) bool { if accounts[i].Admin != accounts[j].Admin { return accounts[i].Admin } return accounts[i].CreatedAt.Before(accounts[j].CreatedAt) }) writeJSON(writer, http.StatusOK, map[string]any{"data": accounts, "sourceChoices": accountSourceChoices}) return } var input struct { Username string `json:"username"` Password string `json:"password"` Sources []string `json:"sources"` OnlineOnly bool `json:"onlineOnly"` } if !readAccountRequest(writer, request, &input) { return } name, display, err := normalizeAccountUsername(input.Username) if err == nil { err = validateAccountPassword(input.Password) } if err != nil { writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } sources, sourceErr := normalizeAccountSources(input.Sources) if sourceErr != nil { writeJSON(writer, http.StatusBadRequest, map[string]string{"error": sourceErr.Error()}) return } if !accountHashPermit(writer, request, store, name) { return } defer func() { <-store.hashing }() salt, hash, err := newAccountPassword(input.Password) if err != nil { accountOperationError(writer, err) return } identifier := randomHex(32) if identifier == "" { accountOperationError(writer, errors.New("无法创建账号")) return } store.mu.Lock() defer store.mu.Unlock() if _, exists := store.state.Accounts[name]; exists { writeJSON(writer, http.StatusConflict, map[string]string{"error": "该用户名已被使用"}) return } if len(store.state.Accounts) >= accountLimit { writeJSON(writer, http.StatusConflict, map[string]string{"error": "此服务的账号数量已达上限"}) return } account := accountRecord{OnlineOnly: input.OnlineOnly, Sources: sources, ID: identifier, Username: display, Salt: salt, PasswordHash: hash, Algorithm: "pbkdf2-sha256", Iterations: accountPasswordIterations, CreatedAt: time.Now()} state := store.snapshotLocked() state.Accounts[name] = account if err = store.saveLocked(state); err != nil { accountOperationError(writer, err) return } writeJSON(writer, http.StatusCreated, map[string]any{"ok": true, "account": account.public()}) }