feat(app): add account and config components

This commit is contained in:
吴璨
2026-09-30 10:18:32 +08:00
parent 52451ae4b5
commit b094d0a6b1
10 changed files with 1623 additions and 0 deletions
+315
View File
@@ -0,0 +1,315 @@
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()})
}
+177
View File
@@ -0,0 +1,177 @@
package app
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
func administratorFixture(t *testing.T) (*UIApp, *accountTestBrowser) {
t.Helper()
app := viewerTestApp(t)
app.cfg.adminUsername, app.cfg.adminPassword, app.cfg.adminPasswordExplicit = "admin", accountFixturePassword, true
if _, err := app.browserViewers().ensureAdministrator(app.cfg); err != nil {
t.Fatal(err)
}
admin := newAccountTestBrowser(t, app)
admin.login(t, "admin", accountFixturePassword)
return app, admin
}
func TestAdministratorRandomBootstrapRequiresPasswordChange(t *testing.T) {
app := viewerTestApp(t)
manager := app.browserViewers()
initial, err := manager.ensureAdministrator(app.cfg)
if err != nil || initial.Username != "admin" || len(initial.InitialPassword) < 24 || !initial.RequirePasswordChange {
t.Fatal("random administrator bootstrap failed", err)
}
admin := newAccountTestBrowser(t, app)
admin.login(t, "admin", initial.InitialPassword)
for _, path := range []string{"/api/ui/dramas", "/api/ui/playback/history", "/api/ui/admin/settings", "/api/ui/config"} {
if result := admin.request(t, http.MethodGet, path, nil); result.Code != http.StatusForbidden || !strings.Contains(result.Body.String(), "password_change_required") {
t.Fatal("initial password bypassed change requirement", path, result.Code)
}
}
if result := admin.request(t, http.MethodGet, "/", nil); result.Code != http.StatusFound || result.Header().Get("Location") != "/login" {
t.Fatal("initial password did not redirect to change page", result.Code)
}
if result := admin.request(t, http.MethodPost, "/api/ui/account/password", map[string]string{"password": initial.InitialPassword, "newPassword": initial.InitialPassword}); result.Code != http.StatusBadRequest {
t.Fatal("initial password could be reused")
}
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/account/password", map[string]string{"password": initial.InitialPassword, "newPassword": accountFixturePassword}))
admin.refresh(t)
viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil))
viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/dramas", nil))
restarted := &UIApp{cfg: app.cfg}
after, err := restarted.browserViewers().ensureAdministrator(app.cfg)
if err != nil || after.InitialPassword != "" || after.RequirePasswordChange || after.Username != "admin" {
t.Fatal("restart reset administrator password", err)
}
admin.handler = restarted.routes()
admin.refresh(t)
viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil))
}
func TestAdministratorSpecifiedCredentialsAndStartupReset(t *testing.T) {
app := viewerTestApp(t)
app.cfg.adminUsername, app.cfg.adminPassword = "owner", accountFixturePassword
app.cfg.adminUserExplicit, app.cfg.adminPasswordExplicit = true, true
first, err := app.browserViewers().ensureAdministrator(app.cfg)
if err != nil || first.RequirePasswordChange || first.InitialPassword != "" || first.Username != "owner" {
t.Fatal("specified bootstrap requires change", err)
}
admin := newAccountTestBrowser(t, app)
admin.login(t, "owner", accountFixturePassword)
viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil))
restarted := &UIApp{cfg: app.cfg}
defaults := app.cfg
defaults.adminUsername, defaults.adminPassword, defaults.adminUserExplicit, defaults.adminPasswordExplicit = "admin", "", false, false
after, err := restarted.browserViewers().ensureAdministrator(defaults)
if err != nil || after.Username != "owner" {
t.Fatal("default flags renamed existing administrator", err)
}
reset := app.cfg
reset.adminUsername, reset.adminPassword = "new-owner", "a-new-administrator-password"
oldID := app.browserViewers().accountStore().state.Accounts["owner"].viewerID()
if _, err = restarted.browserViewers().ensureAdministrator(reset); err != nil {
t.Fatal(err)
}
account := restarted.browserViewers().accountStore().state.Accounts["new-owner"]
if account.viewerID() != oldID || account.RequirePasswordChange {
t.Fatal("startup override lost records or kept forced change")
}
admin.handler = restarted.routes()
if result := admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil); result.Code != http.StatusForbidden {
t.Fatal("startup credential reset kept old administrator session", result.Code)
}
admin.refresh(t)
admin.login(t, "new-owner", reset.adminPassword)
viewerResultOK(t, admin.request(t, http.MethodGet, "/api/ui/admin/settings", nil))
encoded, _ := json.Marshal(reset)
if strings.Contains(string(encoded), reset.adminPassword) {
t.Fatal("administrator password serialized into normal config")
}
}
func TestAdministratorPolicyEnforcedRegistrationManualUsersAndGuestIsolation(t *testing.T) {
app, admin := administratorFixture(t)
guest, other := newAccountTestBrowser(t, app), newAccountTestBrowser(t, app)
guest.watch(t, app, 1, 17)
other.watch(t, app, 2, 35)
policy := func(login, registration bool) {
t.Helper()
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": login, "allowRegistration": registration}))
}
policy(false, false)
if result := guest.request(t, http.MethodPost, "/api/ui/account/register", map[string]string{"username": "blocked", "password": accountFixturePassword}); result.Code != http.StatusForbidden {
t.Fatal("disabled public registration accepted", result.Code)
}
created := admin.request(t, http.MethodPost, "/api/ui/admin/accounts", map[string]string{"username": "friend", "password": accountFixturePassword})
if created.Code != http.StatusCreated {
t.Fatal("administrator cannot create a user", created.Code, created.Body.String())
}
member := newAccountTestBrowser(t, app)
member.login(t, "friend", accountFixturePassword)
if member.id == admin.id || len(member.history(t)) != 0 {
t.Fatal("created user inherited administrator identity")
}
for _, browser := range []*accountTestBrowser{guest, member} {
for _, path := range []string{"/api/ui/admin/accounts", "/api/ui/admin/settings", "/api/ui/config"} {
if result := browser.request(t, http.MethodGet, path, nil); result.Code != http.StatusForbidden {
t.Fatal("non-administrator accessed management", path, result.Code)
}
}
if result := browser.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": false, "allowRegistration": true}); result.Code != http.StatusForbidden {
t.Fatal("non-administrator changed access policy")
}
}
policy(true, false)
for _, path := range []string{"/api/ui/dramas", "/api/ui/tasks", "/api/ui/following", "/api/ui/playback/history"} {
if result := guest.request(t, http.MethodGet, path, nil); result.Code != http.StatusUnauthorized || !strings.Contains(result.Body.String(), "login_required") {
t.Fatal("anonymous API access bypassed login requirement", path, result.Code)
}
}
if result := guest.request(t, http.MethodGet, "/", nil); result.Code != http.StatusFound || result.Header().Get("Location") != "/login" {
t.Fatal("anonymous browser was not redirected")
}
viewerResultOK(t, guest.request(t, http.MethodGet, "/login", nil))
viewerResultOK(t, guest.request(t, http.MethodGet, "/assets/main.js", nil))
if state := guest.refresh(t); state["requireLogin"] != true || state["allowRegistration"] != false {
t.Fatal("login page cannot read access flags")
}
viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/dramas", nil))
restarted := &UIApp{cfg: app.cfg}
required, registration := restarted.browserViewers().accountStore().policy()
if !required || registration {
t.Fatal("restart lost administrator policy")
}
policy(false, false)
if guest.history(t)[0].Position != 17 || other.history(t)[0].Position != 35 {
t.Fatal("toggling login requirement mixed or deleted guest histories")
}
}
func TestAdministratorPolicyPersistenceFailureAndNameCollision(t *testing.T) {
app, admin := administratorFixture(t)
store := app.browserViewers().accountStore()
path := store.path
store.path = app.cfg.dataDirectory()
result := admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": false})
if result.Code != http.StatusInternalServerError {
t.Fatal("failed policy save reported success")
}
if required, registration := store.policy(); required || !registration {
t.Fatal("failed policy save changed active policy")
}
store.path = path
other := viewerTestApp(t)
member := newAccountTestBrowser(t, other)
member.register(t, "admin")
if _, err := other.browserViewers().ensureAdministrator(other.cfg); err == nil {
t.Fatal("bootstrap promoted an existing ordinary account")
}
if other.browserViewers().accountStore().state.Accounts["admin"].Admin {
t.Fatal("name collision escalated privileges")
}
}
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"context"
"net/http"
"strings"
)
func (account accountRecord) onlineOnly() bool {
return account.OnlineOnly && !account.Admin
}
func downloadAllowed(ctx context.Context) bool {
scope := sourceScope(ctx)
return scope == nil || !scope.OnlineOnly
}
func accountDownloadPath(path string) bool {
if strings.HasPrefix(path, "/api/ui/tasks/") {
return true
}
switch path {
case "/api/ui/download", "/api/ui/tasks", "/api/ui/update", "/api/ui/merge", "/api/emby/export":
return true
}
return false
}
func requireDownload(writer http.ResponseWriter, request *http.Request) bool {
if downloadAllowed(request.Context()) {
return true
}
writeViewerError(writer, http.StatusForbidden, "download_forbidden", "当前账号仅可在线观看,请联系管理员开通下载权限")
return false
}
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestOnlineOnlyAccountPersistsAndBlocksDownloadAPIs(t *testing.T) {
app, admin := administratorFixture(t)
created := admin.request(t, http.MethodPost, "/api/ui/admin/accounts", map[string]any{"username": "online-user", "password": accountFixturePassword, "sources": []string{sourceHongguo}, "onlineOnly": true})
if created.Code != http.StatusCreated {
t.Fatal(created.Code, created.Body.String())
}
member := newAccountTestBrowser(t, app)
member.login(t, "online-user", accountFixturePassword)
state := member.refresh(t)
if state["onlineOnly"] != true || state["account"].(map[string]any)["onlineOnly"] != true {
t.Fatal("online-only permission missing from viewer state")
}
for _, test := range []struct {
method, path string
body any
}{
{http.MethodGet, "/api/ui/tasks", nil},
{http.MethodPost, "/api/ui/download", map[string]any{"ids": []string{historyFixtureDramaID}}},
{http.MethodPost, "/api/ui/update", map[string]any{"ids": []string{historyFixtureDramaID}}},
{http.MethodPost, "/api/ui/merge", map[string]any{"dramaIds": []string{historyFixtureDramaID}}},
{http.MethodPost, "/api/ui/tasks/retry", map[string]any{"ids": []string{"existing-task"}}},
{http.MethodPost, "/api/ui/tasks/resume", map[string]any{"ids": []string{"existing-task"}}},
{http.MethodPost, "/api/ui/tasks/pause", map[string]any{"ids": []string{"existing-task"}}},
{http.MethodPost, "/api/ui/tasks/cancel", map[string]any{"ids": []string{"existing-task"}}},
{http.MethodPost, "/api/ui/tasks/clear", map[string]any{"all": true}},
{http.MethodPost, "/api/emby/export", map[string]any{"dramaId": historyFixtureDramaID, "baseUrl": "http://local.test"}},
{http.MethodPost, "/api/ui/playback/open", map[string]any{"taskId": "existing-task"}},
} {
response := member.request(t, test.method, test.path, test.body)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "download_forbidden") {
t.Fatal("online-only request reached a download handler", test.path, response.Code, response.Body.String())
}
}
session := member.watch(t, app, 1, 12)
app.closePlayback(session)
viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/following", nil))
viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/dramas", nil))
if response := member.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "online-user", "onlineOnly": false}); response.Code != http.StatusForbidden {
t.Fatal("ordinary user granted themselves downloads", response.Code)
}
if response := admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "admin", "onlineOnly": true}); response.Code != http.StatusBadRequest {
t.Fatal("administrator permissions were reduced", response.Code)
}
restored := &UIApp{cfg: app.cfg}
saved := restored.browserViewers().accountStore().state.Accounts["online-user"]
if !saved.onlineOnly() || len(saved.Sources) != 1 || saved.Sources[0] != sourceHongguo {
t.Fatal("permissions lost on restart")
}
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "online-user", "onlineOnly": false}))
if state := member.refresh(t); state["onlineOnly"] != false || len(state["sources"].([]any)) != 1 {
t.Fatal("download grant changed source permissions")
}
viewerResultOK(t, member.request(t, http.MethodGet, "/api/ui/tasks", nil))
if (accountRecord{}).onlineOnly() || (accountRecord{Admin: true, OnlineOnly: true}).onlineOnly() {
t.Fatal("legacy or administrator download defaults changed")
}
}
func TestOnlineOnlyRevocationRejectsStalePagesAndAccountExports(t *testing.T) {
app, admin := administratorFixture(t)
member := newAccountTestBrowser(t, app)
member.register(t, "download-revoked")
online := member.watch(t, app, 1, 3)
collection := member.watch(t, app, 1, 4)
app.playbackMu.Lock()
app.playbacks[collection].downloadIDs = []string{"download-one"}
app.playbackMu.Unlock()
account := app.browserViewers().accountStore().state.Accounts["download-revoked"]
key, err := app.embySigningKey(true)
if err != nil {
t.Fatal(err)
}
chapter := "hongguo:7000000000000000001:1"
query := url.Values{"id": {historyFixtureDramaID}, "chapter": {chapter}, "account": {account.ID}, "key": {embyToken(key, historyFixtureDramaID, chapter, account.ID)}}
if result := member.request(t, http.MethodHead, "/api/emby/stream.m3u8?"+query.Encode(), nil); result.Code != http.StatusOK {
t.Fatal("permitted account export rejected", result.Code)
}
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "download-revoked", "onlineOnly": true}))
app.playbackMu.Lock()
keptOnline, keptCollection := app.playbacks[online] != nil, app.playbacks[collection] != nil
app.playbackMu.Unlock()
if !keptOnline || keptCollection {
t.Fatal("revocation did not distinguish online and download playback")
}
request := httptest.NewRequest(http.MethodGet, "http://shared.example.test/api/ui/dramas", nil)
request.Header.Set("X-Juku-Viewer", member.id)
request.Header.Set("X-Juku-Online-Only", "false")
for _, cookie := range member.cookies {
request.AddCookie(cookie)
}
recorder := httptest.NewRecorder()
member.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "permissions_changed") {
t.Fatal("stale or forged download permission accepted", recorder.Code)
}
for _, endpoint := range []string{"stream.m3u8", "segment.ts"} {
if result := member.request(t, http.MethodGet, "/api/emby/"+endpoint+"?"+query.Encode(), nil); result.Code != http.StatusForbidden {
t.Fatal("revoked account export remains usable", endpoint, result.Code)
}
}
restored := &UIApp{cfg: app.cfg}
if !restored.browserViewers().accountStore().state.Accounts["download-revoked"].onlineOnly() {
t.Fatal("revocation lost after restart")
}
app.closePlayback(online)
}
func TestOnlineOnlyCollectionHistoryResumesOnline(t *testing.T) {
app, admin := administratorFixture(t)
member := newAccountTestBrowser(t, app)
member.register(t, "collection-history")
sessionID := member.watch(t, app, 1, 8)
app.playbackMu.Lock()
session := app.playbacks[sessionID]
session.downloadIDs = []string{"download-one", "download-two", "download-three"}
app.playbackMu.Unlock()
progress := playbackHistoryProgress{Run: 1, Sequence: 2, Episode: 1, Position: 9, Duration: 90}
viewerResultOK(t, member.request(t, http.MethodPost, "/api/ui/playback/progress", map[string]any{"session": sessionID, "progress": progress}))
if history := member.history(t); len(history) != 1 || history[0].Mode != "collection" {
t.Fatal("missing collection history")
}
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "collection-history", "onlineOnly": true}))
response := member.request(t, http.MethodPost, "/api/ui/playback/open", map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true})
viewerResultOK(t, response)
var opened struct {
Session string `json:"session"`
Mode string `json:"mode"`
InitialPosition float64 `json:"initialPosition"`
}
if err := json.Unmarshal(response.Body.Bytes(), &opened); err != nil {
t.Fatal(err)
}
if opened.Mode != "online" || opened.InitialPosition != 9 {
t.Fatal("collection history did not resume online", response.Body.String())
}
app.closePlayback(opened.Session)
}
+112
View File
@@ -0,0 +1,112 @@
package app
import (
"errors"
"net/http"
"os"
"path/filepath"
)
func (manager *viewerManager) guestImportAvailable(identity, account string) bool {
if identity == "" || account == "" {
return false
}
guest := viewerID(identity)
store := manager.accountStore()
store.mu.Lock()
imported, exists := store.state.GuestImports[guest]
store.mu.Unlock()
if exists && (imported.Completed || imported.Account != account) {
return false
}
for _, name := range []string{"playback-history.json", "following.json"} {
if info, err := os.Stat(filepath.Join(manager.directory, "viewers", guest, name)); err == nil && info.Mode().IsRegular() && info.Size() > 0 {
return true
}
}
manager.legacyMu.Lock()
defer manager.legacyMu.Unlock()
legacy, err := manager.legacyImport()
return err == nil && !legacy.Completed && legacy.Viewer == guest
}
func (app *UIApp) handleAccountImport(writer http.ResponseWriter, request *http.Request) {
var input struct{}
if !readAccountRequest(writer, request, &input) {
return
}
manager := app.browserViewers()
account, name, _, _ := manager.requestAccount(request)
if account.ID == "" {
writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "请先登录,再接回此浏览器原有记录"})
return
}
identity := manager.identity(request)
if identity == "" {
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
return
}
guest := viewerID(identity)
manager.legacyMu.Lock()
defer manager.legacyMu.Unlock()
legacy, legacyErr := manager.legacyImport()
pendingLegacy := legacyErr == nil && !legacy.Completed && legacy.Viewer == guest
store := manager.accountStore()
store.mu.Lock()
defer store.mu.Unlock()
imported, exists := store.state.GuestImports[guest]
if exists && imported.Account != name {
writeJSON(writer, http.StatusConflict, map[string]string{"error": "此浏览器原有记录已接入其他账号"})
return
}
if exists && imported.Completed {
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
return
}
if len(store.state.GuestImports) >= 10000 && !exists {
writeJSON(writer, http.StatusConflict, map[string]string{"error": "此服务的浏览器记录接回数量已达上限"})
return
}
source := manager.acquire(guest)
defer source.release()
target := requestViewer(writer, request)
if target == nil || target.id != account.viewerID() {
return
}
history, err := source.playbackHistory().list()
var following []followingEntry
if err == nil {
following, err = source.followingStore().list()
}
if err == nil {
_, err = target.playbackHistory().list()
}
if err == nil {
_, err = target.followingStore().list()
}
if err == nil && len(history) == 0 && len(following) == 0 && !pendingLegacy {
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
return
}
if err == nil && !exists {
state := store.snapshotLocked()
state.GuestImports[guest] = accountGuestImport{Account: name}
err = store.saveLocked(state)
}
if err == nil {
err = target.followingStore().importLegacy(following)
}
if err == nil {
err = target.playbackHistory().importLegacy(history)
}
if err == nil {
state := store.snapshotLocked()
state.GuestImports[guest] = accountGuestImport{Account: name, Completed: true}
err = store.saveLocked(state)
}
if err != nil {
accountOperationError(writer, errors.New("记录尚未全部接回,可在当前账号重试;原始文件已保留:"+publicError(err).Error()))
return
}
writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "history": len(history), "following": len(following)})
}
+69
View File
@@ -0,0 +1,69 @@
package app
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"unicode"
"unicode/utf8"
)
const accountPasswordIterations = 600000
func normalizeAccountUsername(name string) (string, string, error) {
name = strings.TrimSpace(name)
if !utf8.ValidString(name) || utf8.RuneCountInString(name) < 2 || utf8.RuneCountInString(name) > 32 {
return "", "", errors.New("用户名需为 2–32 个字,支持中文、字母、数字、下划线和短横线")
}
for _, char := range name {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && char != '_' && char != '-' {
return "", "", errors.New("用户名只能包含中文、字母、数字、下划线和短横线")
}
}
return strings.ToLower(name), name, nil
}
func validateAccountPassword(password string) error {
count := utf8.RuneCountInString(password)
if !utf8.ValidString(password) || count < 10 || count > 128 {
return errors.New("密码需为 10–128 个字符,可使用长短语")
}
return nil
}
func accountPasswordKey(password string, salt []byte, iterations int) []byte {
mac := hmac.New(sha256.New, []byte(password))
mac.Write(salt)
mac.Write([]byte{0, 0, 0, 1})
block := mac.Sum(nil)
key := append([]byte(nil), block...)
for remaining := iterations - 1; remaining > 0; remaining-- {
mac.Reset()
mac.Write(block)
block = mac.Sum(block[:0])
for index, value := range block {
key[index] ^= value
}
}
return key
}
func newAccountPassword(password string) (string, string, error) {
if err := validateAccountPassword(password); err != nil {
return "", "", err
}
salt := randomHex(16)
if salt == "" {
return "", "", errors.New("无法生成密码盐,请重试")
}
decoded, _ := hex.DecodeString(salt)
return salt, hex.EncodeToString(accountPasswordKey(password, decoded, accountPasswordIterations)), nil
}
func verifyAccountPassword(account accountRecord, password string) bool {
salt, _ := hex.DecodeString(account.Salt)
expected, _ := hex.DecodeString(account.PasswordHash)
return hmac.Equal(expected, accountPasswordKey(password, salt, account.Iterations))
}
+6
View File
@@ -0,0 +1,6 @@
package app
var accountSourceChoices = []accountSourceChoice{{ID: "hongguo", Name: "红果"}}
var accountSourceAliases = map[string]string{"hongguo": "hongguo"}
const accountLegacySource = ""
+390
View File
@@ -0,0 +1,390 @@
package app
import (
"context"
"errors"
"net/http"
"strings"
"time"
)
type accountSourceChoice struct {
ID string `json:"id"`
Name string `json:"name"`
}
type accountSourceScope struct {
OnlineOnly bool
AccountID string
Sources []string
}
type accountSourceContextKey struct{}
func allAccountSources() []string {
result := make([]string, 0, len(accountSourceChoices))
for _, choice := range accountSourceChoices {
result = append(result, choice.ID)
}
return result
}
func normalizeAccountSources(sources []string) ([]string, error) {
if sources == nil {
return nil, nil
}
if len(sources) == 0 || len(sources) > len(accountSourceChoices) {
return nil, errors.New("请至少选择一个可用站源")
}
selected := make(map[string]bool, len(sources))
for _, source := range sources {
valid := false
for _, choice := range accountSourceChoices {
if source == choice.ID {
valid = true
break
}
}
if !valid || selected[source] {
return nil, errors.New("站源权限无效,请重新选择")
}
selected[source] = true
}
result := make([]string, 0, len(sources))
for _, choice := range accountSourceChoices {
if selected[choice.ID] {
result = append(result, choice.ID)
}
}
return result, nil
}
func (account accountRecord) effectiveSources() []string {
if account.Admin || account.Sources == nil {
return allAccountSources()
}
return append([]string{}, account.Sources...)
}
func sourceScope(ctx context.Context) *accountSourceScope {
scope, _ := ctx.Value(accountSourceContextKey{}).(*accountSourceScope)
return scope
}
func withSourceScope(ctx context.Context, account accountRecord) context.Context {
return context.WithValue(ctx, accountSourceContextKey{}, &accountSourceScope{OnlineOnly: account.onlineOnly(), AccountID: account.ID, Sources: account.effectiveSources()})
}
func sourceScopeRestricted(ctx context.Context) bool {
scope := sourceScope(ctx)
return scope != nil && len(scope.Sources) < len(accountSourceChoices)
}
func accountSourceGroup(source string) string {
return accountSourceAliases[strings.ToLower(strings.TrimSpace(source))]
}
func accountDramaSource(id string) string {
id = strings.TrimSpace(id)
if source, sourceID, ok := splitProviderDramaID(id); ok && sourceID != "" {
return accountSourceGroup(source)
}
if id == "" || strings.Contains(id, ":") {
return ""
}
return accountLegacySource
}
func sourceAllowed(ctx context.Context, source string) bool {
scope := sourceScope(ctx)
if scope == nil {
return true
}
group := accountSourceGroup(source)
for _, allowed := range scope.Sources {
if allowed == group {
return true
}
}
return false
}
func dramaAllowed(ctx context.Context, id, source string) bool {
if sourceScope(ctx) == nil {
return true
}
group := accountDramaSource(id)
return group != "" && (source == "" || accountSourceGroup(source) == group) && sourceAllowed(ctx, group)
}
func taskSourceAllowed(ctx context.Context, task Task) bool {
return dramaAllowed(ctx, task.DramaID, task.Chapter.Source)
}
func requireSource(writer http.ResponseWriter, ctx context.Context, source string) bool {
if sourceAllowed(ctx, source) {
return true
}
writeSourceDenied(writer)
return false
}
func writeSourceDenied(writer http.ResponseWriter) {
writeViewerError(writer, http.StatusForbidden, "source_forbidden", "当前账号无权访问该站源,请联系管理员")
}
func (app *UIApp) requireDramaSources(writer http.ResponseWriter, request *http.Request, ids []string) bool {
app.mu.Lock()
allowed := app.dramaSourcesAllowedLocked(request.Context(), ids)
app.mu.Unlock()
if !allowed {
writeSourceDenied(writer)
}
return allowed
}
func (app *UIApp) dramaSourcesAllowedLocked(ctx context.Context, ids []string) bool {
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
if !dramaAllowed(ctx, id, "") {
return false
}
wanted[id] = true
}
for _, drama := range app.dramas {
if wanted[drama.ID] && !dramaAllowed(ctx, drama.ID, drama.Source) {
return false
}
}
return true
}
func (app *UIApp) requireTaskSourcesLocked(writer http.ResponseWriter, request *http.Request, selection taskSelection) bool {
ctx := request.Context()
allowed := app.dramaSourcesAllowedLocked(ctx, selection.DramaIDs)
for _, id := range selection.IDs {
if task := app.tasks[id]; task != nil && (!dramaAllowed(ctx, task.DramaID, "") || !taskSourceAllowed(ctx, task.Source)) {
allowed = false
}
}
for _, id := range selection.idsLocked(app) {
if task := app.tasks[id]; task != nil && (!dramaAllowed(ctx, task.DramaID, "") || !taskSourceAllowed(ctx, task.Source)) {
allowed = false
}
}
if !allowed {
writeSourceDenied(writer)
}
return allowed
}
func (app *UIApp) taskViewsForSourceLocked(ctx context.Context) []uiTaskView {
views := app.taskViewsLocked()
if sourceScope(ctx) == nil {
return views
}
result := make([]uiTaskView, 0, len(views))
for _, view := range views {
task := app.tasks[view.ID]
if task != nil && dramaAllowed(ctx, view.DramaID, "") && taskSourceAllowed(ctx, task.Source) {
result = append(result, view)
}
}
return result
}
func (app *UIApp) librarySnapshotForSourceLocked(ctx context.Context, revision uint64) map[string]any {
if !sourceScopeRestricted(ctx) {
return app.librarySnapshotLocked(revision)
}
response := app.librarySnapshotLocked(0)
dramas := make([]Drama, 0)
for _, drama := range app.dramas {
if dramaAllowed(ctx, drama.ID, drama.Source) {
dramas = append(dramas, drama)
}
}
sources := map[string]librarySourceState{}
loading := false
for source, state := range app.librarySources {
if sourceAllowed(ctx, source) {
sources[source] = state
loading = loading || state.Status == "loading"
}
}
remaining := sortMetadataRemaining(dramas)
for source := range remaining {
if source != "" && !sourceAllowed(ctx, source) {
delete(remaining, source)
}
}
more := map[string]bool{}
for source, count := range remaining {
more[source] = count > 0
}
if sourceAllowed(ctx, sourceHongguo) {
more[sourceHongguo] = more[sourceHongguo] || hongguoCatalogHasMore(app.libraryApp)
}
for source, value := range more {
if source != "" {
more[""] = more[""] || value
}
}
response["data"], response["total"], response["sources"], response["error"] = dramas, len(dramas), sources, librarySourceErrors(sources)
response["loading"], response["loadingMore"] = loading, loading && app.libraryMore
response["loadingSource"] = ""
response["loadingPhase"] = ""
if sourceAllowed(ctx, app.libraryLoadingSource) {
response["loadingSource"] = app.libraryLoadingSource
response["loadingPhase"] = app.libraryLoadingPhase
}
response["metadataRemaining"], response["hasMoreBySource"], response["hasMore"] = remaining, more, more[""]
pending := 0
for _, drama := range dramas {
if app.metadataPending[drama.ID] {
pending++
}
}
response["metadata"] = map[string]any{"running": pending > 0, "checked": 0, "total": pending, "updated": 0, "failed": 0}
return response
}
func (app *UIApp) imageSource(ctx context.Context, remote string) (string, bool) {
source, _, allowed := app.imageTarget(ctx, "", remote)
return source, allowed
}
func (app *UIApp) imageTarget(ctx context.Context, id, remote string) (string, string, bool) {
app.mu.Lock()
defer app.mu.Unlock()
for _, drama := range app.dramas {
if id != "" && drama.ID != id || !dramaAllowed(ctx, drama.ID, drama.Source) {
continue
}
if address, valid := buildImageURL(bestDramaCover(drama)); valid && (id != "" || address == remote) {
return dramaProvider(drama), address, true
}
}
return "", "", false
}
func playbackSourceAllowed(ctx context.Context, session *playbackSession) bool {
if session == nil {
return false
}
if !downloadAllowed(ctx) && (len(session.downloadIDs) > 0 || session.accountID != "") {
return false
}
if session.dramaID != "" && !dramaAllowed(ctx, session.dramaID, "") {
return false
}
for _, task := range session.tasks {
if !taskSourceAllowed(ctx, task) {
return false
}
}
return true
}
func (app *UIApp) closeForbiddenPlaybacks(account accountRecord) {
ctx := withSourceScope(context.Background(), account)
var ids []string
app.playbackMu.Lock()
for id, session := range app.playbacks {
if (session.accountID == account.ID || session.viewer != nil && session.viewer.id == account.viewerID()) && !playbackSourceAllowed(ctx, session) {
ids = append(ids, id)
}
}
app.playbackMu.Unlock()
for _, id := range ids {
app.closePlayback(id)
}
}
func (app *UIApp) handleAdminAccountPermissions(writer http.ResponseWriter, request *http.Request) {
var input struct {
Username string `json:"username"`
Sources []string `json:"sources"`
OnlineOnly *bool `json:"onlineOnly"`
}
if !readAccountRequest(writer, request, &input) {
return
}
name, _, err := normalizeAccountUsername(input.Username)
sources, sourceErr := normalizeAccountSources(input.Sources)
if err != nil || sourceErr != nil || sources == nil && input.OnlineOnly == nil {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择账号及需要修改的权限"})
return
}
store := app.browserViewers().accountStore()
store.mu.Lock()
account, exists := store.state.Accounts[name]
if !exists || account.Admin {
store.mu.Unlock()
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "只能修改现有普通账号的权限"})
return
}
if input.Sources != nil {
account.Sources = sources
}
if input.OnlineOnly != nil {
account.OnlineOnly = *input.OnlineOnly
}
state := store.snapshotLocked()
state.Accounts[name] = account
err = store.saveLocked(state)
store.mu.Unlock()
if err != nil {
accountOperationError(writer, err)
return
}
app.closeForbiddenPlaybacks(account)
writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "account": account.public()})
}
func sourceRankingBoards(ctx context.Context) []rankingBoard {
result := make([]rankingBoard, 0, len(rankingBoards))
for _, board := range rankingBoards {
if sourceAllowed(ctx, board.Source) {
result = append(result, board)
}
}
return result
}
func (store *playbackHistoryStore) removeForSources(ctx context.Context, id string, all bool) error {
if !all || !sourceScopeRestricted(ctx) {
return store.remove(id, all)
}
store.mu.Lock()
if store.loadErr != nil {
err := store.loadErr
store.mu.Unlock()
return err
}
now := time.Now()
for id, entry := range store.entries {
if dramaAllowed(ctx, id, entry.Source) {
delete(store.entries, id)
store.deleted[id] = now
}
}
store.revision++
store.mu.Unlock()
return store.flush()
}
func (app *UIApp) embyAccountSourceAllowed(owner, dramaID string) bool {
store := app.browserViewers().accountStore()
store.mu.Lock()
defer store.mu.Unlock()
if store.err != nil {
return false
}
for _, account := range store.state.Accounts {
if account.ID == owner {
return !account.onlineOnly() && dramaAllowed(withSourceScope(context.Background(), account), dramaID, "")
}
}
return false
}
+68
View File
@@ -0,0 +1,68 @@
package app
import (
"encoding/json"
"net/http"
"os"
"reflect"
"testing"
)
func TestAccountSourceChoicesPersistAndRejectInvalidChanges(t *testing.T) {
app, admin := administratorFixture(t)
result := admin.request(t, http.MethodPost, "/api/ui/admin/accounts", map[string]any{"username": "source-user", "password": accountFixturePassword, "sources": []string{sourceHongguo}})
if result.Code != http.StatusCreated {
t.Fatal(result.Code, result.Body.String())
}
member := newAccountTestBrowser(t, app)
member.login(t, "source-user", accountFixturePassword)
if sources := member.refresh(t)["sources"]; !reflect.DeepEqual(sources, []any{sourceHongguo}) {
t.Fatal("incorrect account sources", sources)
}
for _, input := range []map[string]any{
{"username": "source-user", "sources": []string{}},
{"username": "source-user", "sources": []string{"unknown-source"}},
{"username": "source-user", "sources": []string{sourceHongguo, sourceHongguo}},
{"username": "admin", "sources": []string{sourceHongguo}},
} {
if response := admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", input); response.Code != http.StatusBadRequest {
t.Fatal("invalid permission change accepted", response.Code)
}
}
if response := member.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "source-user", "sources": allAccountSources()}); response.Code != http.StatusForbidden {
t.Fatal("member can grant permissions", response.Code)
}
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/sources", map[string]any{"username": "source-user", "sources": []string{sourceHongguo}}))
restored := &UIApp{cfg: app.cfg}
account := restored.browserViewers().accountStore().state.Accounts["source-user"]
if !reflect.DeepEqual(account.Sources, []string{sourceHongguo}) || restored.browserViewers().accountStore().err != nil {
t.Fatal("source permissions lost on restart")
}
store := app.browserViewers().accountStore()
store.mu.Lock()
snapshot := store.snapshotLocked()
item := snapshot.Accounts["source-user"]
item.Sources[0] = "unknown-source"
snapshot.Accounts["source-user"] = item
if store.state.Accounts["source-user"].Sources[0] != sourceHongguo {
t.Fatal("snapshot aliases live permissions")
}
encoded, err := json.Marshal(snapshot)
store.mu.Unlock()
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(store.path, encoded, 0600); err != nil {
t.Fatal(err)
}
corrupt := &UIApp{cfg: app.cfg}
if corrupt.browserViewers().accountStore().err == nil {
t.Fatal("invalid persisted permissions granted access")
}
if !reflect.DeepEqual((accountRecord{}).effectiveSources(), allAccountSources()) {
t.Fatal("legacy and anonymous access changed")
}
if !reflect.DeepEqual((accountRecord{Admin: true, Sources: []string{sourceHongguo}}).effectiveSources(), allAccountSources()) {
t.Fatal("administrator lost full access")
}
}
+303
View File
@@ -0,0 +1,303 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
const accountLifetime = 30 * 24 * time.Hour
const accountLimit = 1000
const accountSessionLimit = 16
var errAccountStore = errors.New("账号数据无法读取,原文件已保留,请检查数据目录或恢复备份")
type accountRecord struct {
OnlineOnly bool `json:"onlineOnly,omitempty"`
Sources []string `json:"sources,omitempty"`
Admin bool `json:"admin,omitempty"`
RequirePasswordChange bool `json:"requirePasswordChange,omitempty"`
ID string `json:"id"`
Username string `json:"username"`
Salt string `json:"salt"`
PasswordHash string `json:"passwordHash"`
Iterations int `json:"iterations"`
Algorithm string `json:"algorithm"`
CreatedAt time.Time `json:"createdAt"`
}
type accountPublic struct {
OnlineOnly bool `json:"onlineOnly"`
Sources []string `json:"sources"`
Admin bool `json:"admin"`
RequirePasswordChange bool `json:"requirePasswordChange"`
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
}
func (account accountRecord) public() accountPublic {
return accountPublic{OnlineOnly: account.onlineOnly(), Sources: account.effectiveSources(), Username: account.Username, CreatedAt: account.CreatedAt, Admin: account.Admin, RequirePasswordChange: account.RequirePasswordChange}
}
func (account accountRecord) viewerID() string {
return viewerID("account:" + account.ID)
}
type accountSession struct {
Account string `json:"account"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type accountGuestImport struct {
Account string `json:"account"`
Completed bool `json:"completed"`
}
type accountState struct {
RequireLogin bool `json:"requireLogin"`
AllowRegistration bool `json:"allowRegistration"`
Version int `json:"version"`
Accounts map[string]accountRecord `json:"accounts"`
Sessions map[string]accountSession `json:"sessions"`
GuestImports map[string]accountGuestImport `json:"guestImports,omitempty"`
}
type accountAttempt struct {
count int
until time.Time
}
type accountStore struct {
mu sync.Mutex
path string
state accountState
err error
attempts map[string]accountAttempt
hashing chan struct{}
}
func validAccountHex(value string, size int) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == size && hex.EncodeToString(decoded) == value
}
func (manager *viewerManager) accountStore() *accountStore {
manager.accountsOnce.Do(func() {
store := &accountStore{path: filepath.Join(manager.directory, "accounts.json"),
state: accountState{Version: 1, AllowRegistration: true, Accounts: map[string]accountRecord{}, Sessions: map[string]accountSession{}, GuestImports: map[string]accountGuestImport{}},
attempts: map[string]accountAttempt{}, hashing: make(chan struct{}, 2)}
store.err = store.load()
manager.accounts = store
})
return manager.accounts
}
func (store *accountStore) load() error {
file, err := os.Open(store.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return errAccountStore
}
defer file.Close()
state := accountState{AllowRegistration: true}
decoder := json.NewDecoder(io.LimitReader(file, 16<<20))
if decoder.Decode(&state) != nil || decoder.Decode(new(any)) != io.EOF || state.Version != 1 || state.Accounts == nil || len(state.Accounts) > accountLimit || len(state.Sessions) > accountLimit*accountSessionLimit {
return errAccountStore
}
identifiers := make(map[string]bool, len(state.Accounts))
adminCount := 0
for name, account := range state.Accounts {
key, _, nameErr := normalizeAccountUsername(account.Username)
if nameErr != nil || key != name || !validAccountHex(account.ID, 32) || identifiers[account.ID] || !validAccountHex(account.Salt, 16) || !validAccountHex(account.PasswordHash, 32) || account.Algorithm != "pbkdf2-sha256" || account.Iterations < accountPasswordIterations || account.Iterations > 2000000 || account.CreatedAt.IsZero() {
return errAccountStore
}
sources, sourceErr := normalizeAccountSources(account.Sources)
if sourceErr != nil {
return errAccountStore
}
account.Sources = sources
state.Accounts[name] = account
if account.Admin {
adminCount++
}
identifiers[account.ID] = true
}
if adminCount > 1 {
return errAccountStore
}
for token, session := range state.Sessions {
if !validAccountHex(token, 32) || state.Accounts[session.Account].ID == "" || session.CreatedAt.IsZero() || !session.ExpiresAt.After(session.CreatedAt) {
return errAccountStore
}
}
for guest, imported := range state.GuestImports {
if !validAccountHex(guest, 32) || state.Accounts[imported.Account].ID == "" {
return errAccountStore
}
}
if state.Sessions == nil {
state.Sessions = map[string]accountSession{}
}
if state.GuestImports == nil {
state.GuestImports = map[string]accountGuestImport{}
}
store.state = state
return nil
}
func (store *accountStore) snapshotLocked() accountState {
state := accountState{Version: 1, RequireLogin: store.state.RequireLogin, AllowRegistration: store.state.AllowRegistration, Accounts: make(map[string]accountRecord, len(store.state.Accounts)), Sessions: make(map[string]accountSession, len(store.state.Sessions)), GuestImports: make(map[string]accountGuestImport, len(store.state.GuestImports))}
for key, value := range store.state.Accounts {
if value.Sources != nil {
value.Sources = append([]string{}, value.Sources...)
}
state.Accounts[key] = value
}
for key, value := range store.state.Sessions {
state.Sessions[key] = value
}
for key, value := range store.state.GuestImports {
state.GuestImports[key] = value
}
return state
}
func (store *accountStore) saveLocked(state accountState) error {
if store.err != nil {
return store.err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(store.path), ".accounts-*.json")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err = os.Rename(file.Name(), store.path); err != nil {
return err
}
store.state = state
return nil
}
func accountTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (manager *viewerManager) accountCookieName() string { return manager.cookie + "_account" }
func (store *accountStore) resolve(token string) (accountRecord, string, bool) {
if !validAccountHex(token, 32) {
return accountRecord{}, "", false
}
store.mu.Lock()
defer store.mu.Unlock()
session, found := store.state.Sessions[accountTokenHash(token)]
if !found || !session.ExpiresAt.After(time.Now()) {
return accountRecord{}, "", false
}
account, found := store.state.Accounts[session.Account]
return account, session.Account, found && store.err == nil
}
func (manager *viewerManager) requestAccount(request *http.Request) (accountRecord, string, bool, error) {
cookie, err := request.Cookie(manager.accountCookieName())
if err != nil || cookie.Value == "" {
return accountRecord{}, "", false, nil
}
store := manager.accountStore()
if store.err != nil {
return accountRecord{}, "", true, store.err
}
account, name, valid := store.resolve(cookie.Value)
if !valid {
return accountRecord{}, "", true, nil
}
return account, name, true, nil
}
func (store *accountStore) allowAttempt(name, remote string, now time.Time) bool {
ip, _, err := net.SplitHostPort(remote)
if err != nil {
ip = remote
}
store.mu.Lock()
defer store.mu.Unlock()
for key, attempt := range store.attempts {
if !attempt.until.After(now) {
delete(store.attempts, key)
}
}
keys := []string{"name:" + name, "ip:" + ip}
for index, key := range keys {
limit := 10
if index == 1 {
limit = 60
}
if store.attempts[key].count >= limit || len(store.attempts) >= 4096 && store.attempts[key].count == 0 {
return false
}
}
for _, key := range keys {
attempt := store.attempts[key]
if attempt.count == 0 {
attempt.until = now.Add(time.Minute)
}
attempt.count++
store.attempts[key] = attempt
}
return true
}
func (state *accountState) addSession(name string, now time.Time) (string, error) {
token := randomHex(32)
if token == "" {
return "", errors.New("无法创建登录会话,请重试")
}
var current []string
for key, session := range state.Sessions {
if !session.ExpiresAt.After(now) {
delete(state.Sessions, key)
continue
}
if session.Account == name {
current = append(current, key)
}
}
sort.Slice(current, func(i, j int) bool {
return state.Sessions[current[i]].CreatedAt.Before(state.Sessions[current[j]].CreatedAt)
})
for len(current) >= accountSessionLimit {
delete(state.Sessions, current[0])
current = current[1:]
}
state.Sessions[accountTokenHash(token)] = accountSession{Account: name, CreatedAt: now, ExpiresAt: now.Add(accountLifetime)}
return token, nil
}