316 lines
11 KiB
Go
316 lines
11 KiB
Go
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()})
|
|
}
|