feat(app): add account and config components
This commit is contained in:
@@ -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()})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package app
|
||||
|
||||
var accountSourceChoices = []accountSourceChoice{{ID: "hongguo", Name: "红果"}}
|
||||
var accountSourceAliases = map[string]string{"hongguo": "hongguo"}
|
||||
|
||||
const accountLegacySource = ""
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user