diff --git a/internal/app/account_admin.go b/internal/app/account_admin.go new file mode 100644 index 0000000..c9bbdd7 --- /dev/null +++ b/internal/app/account_admin.go @@ -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()}) +} diff --git a/internal/app/account_admin_test.go b/internal/app/account_admin_test.go new file mode 100644 index 0000000..9236be5 --- /dev/null +++ b/internal/app/account_admin_test.go @@ -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") + } +} diff --git a/internal/app/account_downloads.go b/internal/app/account_downloads.go new file mode 100644 index 0000000..1f5f212 --- /dev/null +++ b/internal/app/account_downloads.go @@ -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 +} diff --git a/internal/app/account_downloads_test.go b/internal/app/account_downloads_test.go new file mode 100644 index 0000000..1a285a1 --- /dev/null +++ b/internal/app/account_downloads_test.go @@ -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) +} diff --git a/internal/app/account_import.go b/internal/app/account_import.go new file mode 100644 index 0000000..0284978 --- /dev/null +++ b/internal/app/account_import.go @@ -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)}) +} diff --git a/internal/app/account_password.go b/internal/app/account_password.go new file mode 100644 index 0000000..caa1918 --- /dev/null +++ b/internal/app/account_password.go @@ -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)) +} diff --git a/internal/app/account_source_catalog.go b/internal/app/account_source_catalog.go new file mode 100644 index 0000000..171f708 --- /dev/null +++ b/internal/app/account_source_catalog.go @@ -0,0 +1,6 @@ +package app + +var accountSourceChoices = []accountSourceChoice{{ID: "hongguo", Name: "红果"}} +var accountSourceAliases = map[string]string{"hongguo": "hongguo"} + +const accountLegacySource = "" diff --git a/internal/app/account_sources.go b/internal/app/account_sources.go new file mode 100644 index 0000000..43ff671 --- /dev/null +++ b/internal/app/account_sources.go @@ -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 +} diff --git a/internal/app/account_sources_test.go b/internal/app/account_sources_test.go new file mode 100644 index 0000000..425177b --- /dev/null +++ b/internal/app/account_sources_test.go @@ -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") + } +} diff --git a/internal/app/account_store.go b/internal/app/account_store.go new file mode 100644 index 0000000..f249945 --- /dev/null +++ b/internal/app/account_store.go @@ -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 +}