// Package admin serves the authenticated surface at /api/admin/*. // It is mounted separately from the public API so the two never share a // handler chain. package admin import ( "context" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path" "path/filepath" "strconv" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" "oneblog/internal/auth" "oneblog/internal/config" "oneblog/internal/httpx" "oneblog/internal/hub" "oneblog/internal/linkmeta" "oneblog/internal/model" "oneblog/internal/storage" "oneblog/internal/store" "oneblog/internal/thumbs" ) type API struct { Store *store.Store // Cfg 是环境变量配置(兜底值)。设置了 Res 时一律以 Res.Config() // 的有效配置为准(DB 里的系统设置优先);Res 为 nil(部分测试)才直接用它。 Cfg *config.Config Res *config.Resolver Sessions *Sessions // Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配); // 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。 Hub *hub.Hub // 文件上传的存储后端兜底(Res 未设置时使用;设置了走 Res.Blobs() 热重建) Blobs storage.BlobStore // Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的 // 缩略图产物,否则已删图片会一直占着缓存。 Thumbs *thumbs.Store // Passkeys 是 WebAuthn 服务(main.go 装配;未配置时为 nil,相关端点直接 503) Passkeys *auth.Passkeys loginOnce sync.Once logins *loginLimiter } func NewAPI(st *store.Store, cfg *config.Config, sessions *Sessions) *API { a := &API{Store: st, Cfg: cfg, Sessions: sessions} a.limiter() return a } // limiter 惰性初始化,兼容测试里的 &API{...} 零值构造。 func (a *API) limiter() *loginLimiter { a.loginOnce.Do(func() { if a.logins == nil { a.logins = newLoginLimiter() } }) return a.logins } const cookieName = "one_session" func (a *API) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/admin/login", a.login) mux.HandleFunc("/api/admin/logout", a.logout) mux.HandleFunc("/api/admin/me", a.guard(a.me)) mux.HandleFunc("/api/admin/dashboard", a.guard(a.dashboard)) mux.HandleFunc("/api/admin/posts", a.guard(a.listPosts)) mux.HandleFunc("/api/admin/posts/", a.guard(a.postByID)) mux.HandleFunc("/api/admin/tags", a.guard(a.listTags)) mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID)) mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects)) mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID)) mux.HandleFunc("/api/admin/files", a.guard(a.files)) mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles)) mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID)) mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) // 用户管理:站主专属(多用户与角色) mux.HandleFunc("/api/admin/users", a.guardOwner(a.users)) mux.HandleFunc("/api/admin/users/", a.guardOwner(a.userByID)) mux.HandleFunc("/api/admin/account/password", a.guard(a.changePassword)) // 账户页:资料 + 身份绑定 + passkey mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.account(w, r) case http.MethodPatch, http.MethodPut: a.patchAccount(w, r) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/PATCH required") } })) mux.HandleFunc("/api/admin/account/identities/", a.guard(a.unbindIdentity)) mux.HandleFunc("/api/admin/account/passkeys", a.guard(a.listPasskeys)) mux.HandleFunc("/api/admin/account/passkeys/begin", a.guard(a.beginPasskey)) mux.HandleFunc("/api/admin/account/passkeys/finish", a.guard(a.finishPasskey)) mux.HandleFunc("/api/admin/account/passkeys/", a.guard(a.deletePasskey)) mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments)) mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID)) mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders)) mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) { // 路径形如 /api/admin/readers/{id}/ban rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/") parts := strings.Split(rest, "/") if len(parts) != 2 || parts[1] != "ban" { httpx.NotFound(w) return } id, err := strconv.ParseInt(parts[0], 10, 64) if err != nil || id <= 0 { httpx.BadRequest(w, "bad reader id") return } a.adminReaderBan(w, r, id) })) return mux } // guard / actor 解析在 actor.go:token 只带用户 ID,角色每请求从库现读。 func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(strings.ToLower(h), "bearer ") { return strings.TrimSpace(h[7:]) } return "" } // ---------- auth ---------- type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } // verifyAdmin 校验登录凭据,返回应发会话的后台账号行。 // // 第一优先:users 表里的后台账号(owner / admin,行内 bcrypt 哈希)—— // 多用户体系的主路径,站主在后台改过密码后哈希就在自己那行上。 // 兜底:引导链。settings 里的哈希(旧版「管理员账号」写入的)或 // ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在同一台机器上, // 能读 env 的人本来就能直接改库);首次登录成功时把引导凭据自迁移成 // owner 行的哈希,此后引导键不再参与。没显式设 env 密码时是 InsecureDev // (admin/admin),只在 owner 行还没有哈希时有效——后台一旦设过密码即失效。 func (a *API) verifyAdmin(username, password string) (model.Reader, bool) { // 1) 库里后台账号 if u, err := a.Store.GetStaffByHandle(username); err == nil && !u.Banned && u.PasswordHash != "" { if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { return u, true } } // 2) 引导链:只认 owner 用户名(settings 覆盖值或 env 默认值) c := a.cfg() ownerName := c.AdminUser // Overlay 已把 settings 的 admin_username 叠进来 owner, oerr := a.Store.EnsureOwner(ownerName) if oerr != nil { return model.Reader{}, false } if username != owner.Handle { return model.Reader{}, false } // env 显式密码:永久后路(无论行内是否有哈希) if !c.InsecureDev && subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1 { a.ensureOwnerHash(owner, password) return owner, true } // 行内还没有哈希 → 首次登录引导:收 settings 哈希或 dev 密码,写行。 // settings 哈希存在时 dev 密码(admin/admin)不再生效——那是真的库内密码。 if owner.PasswordHash == "" { st, serr := a.Store.GetSettings() hasLegacyHash := serr == nil && st.AdminPasswordHash != "" if hasLegacyHash && bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(password)) == nil { a.ensureOwnerHash(owner, password) return owner, true } if !hasLegacyHash && c.InsecureDev && password == c.AdminPass { a.ensureOwnerHash(owner, password) return owner, true } } return model.Reader{}, false } // ensureOwnerHash 把引导凭据落成 owner 行的 bcrypt 哈希(自迁移)。 // 走 EnsureStaffHash:只在行内为空时写,不覆盖后台改过的密码。 func (a *API) ensureOwnerHash(owner model.Reader, password string) { h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return } _ = a.Store.EnsureStaffHash(owner.ID, string(h)) } func (a *API) login(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { httpx.Error(w, http.StatusMethodNotAllowed, "POST required") return } key := sourceKey(r) if a.limiter().Blocked(key) { httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试") return } var in loginRequest if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } user, ok := a.verifyAdmin(in.Username, in.Password) if !ok { a.limiter().Add(key) httpx.Unauthorized(w) return } a.limiter().Reset(key) token, exp := a.Sessions.Issue(user.ID) http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: token, Path: "/", HttpOnly: true, Secure: isTLS(r), SameSite: http.SameSiteLaxMode, Expires: exp, MaxAge: a.Sessions.TTL(), }) httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339), "role": user.Role, "user": user.Handle}) } const rfc3339 = "2006-01-02T15:04:05Z07:00" func (a *API) logout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: isTLS(r), SameSite: http.SameSiteLaxMode, MaxAge: -1, }) httpx.OK(w, map[string]any{"ok": true}) } func (a *API) me(w http.ResponseWriter, r *http.Request) { // guard 已解析过 actor;这里直接回带角色,前端据此显隐「用户管理」等入口 act := actorFrom(r) httpx.OK(w, map[string]any{"user": act.Handle, "role": act.Role}) } // ---------- users(多用户管理,站主专属) ---------- // users:GET 列表 / POST 创建内容管理员。 func (a *API) users(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: list, err := a.Store.ListStaff() if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"users": list}) case http.MethodPost: var in struct { Username string `json:"username"` Password string `json:"password"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } name := strings.TrimSpace(in.Username) if !validStaffName(name) { httpx.BadRequest(w, "用户名限 2-32 位,字母 / 数字 / _ - . @") return } if len(in.Password) < 6 || len(in.Password) > 72 { httpx.BadRequest(w, "密码长度需在 6-72 位之间") return } hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { httpx.ServerError(w, err) return } u, err := a.Store.CreateStaff(name, string(hash)) if errors.Is(err, store.ErrConflict) { httpx.Error(w, http.StatusConflict, "用户名已被占用") return } if err != nil { httpx.ServerError(w, err) return } httpx.Created(w, u) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") } } // userByID:PATCH(重置密码 / 停用恢复)/ DELETE。owner 行不可动,自己 // 不能停用自己(会把自己锁在会话外面)。 func (a *API) userByID(w http.ResponseWriter, r *http.Request) { id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/")) if err != nil || id <= 0 { httpx.BadRequest(w, "bad user id") return } target, terr := a.Store.GetUserByID(id) if terr != nil || target.Provider != "admin" { httpx.NotFound(w) return } if target.Role == model.RoleOwner { httpx.BadRequest(w, "站主账号不可在此修改") return } act := actorFrom(r) switch r.Method { case http.MethodPatch: var in struct { Password *string `json:"password"` Banned *bool `json:"banned"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if in.Password != nil && *in.Password != "" { if len(*in.Password) < 6 || len(*in.Password) > 72 { httpx.BadRequest(w, "密码长度需在 6-72 位之间") return } hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost) if err != nil { httpx.ServerError(w, err) return } if err := a.Store.SetStaffPassword(id, string(hash)); err != nil { httpx.ServerError(w, err) return } } if in.Banned != nil { if *in.Banned && id == act.ID { httpx.BadRequest(w, "不能停用自己") return } if err := a.Store.SetStaffBanned(id, *in.Banned); err != nil { httpx.ServerError(w, err) return } } u, err := a.Store.GetUserByID(id) if err != nil { httpx.NotFound(w) return } httpx.OK(w, u) case http.MethodDelete: if id == act.ID { httpx.BadRequest(w, "不能删除自己") return } if err := a.Store.DeleteStaff(id); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "PATCH/DELETE required") } } // validStaffName 用户名白名单:2-32 位,字母数字与 _ - . @。 func validStaffName(s string) bool { if len(s) < 2 || len(s) > 32 { return false } for _, c := range s { ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.' || c == '@' if !ok { return false } } return true } // changePassword 自己改自己的密码(owner / admin 通用)。 // 旧密码只在行内已有哈希时校验——OAuth / Passkey 直接登录、从没设过密码的 // 账号第一次设密码不需要旧密码。 func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch && r.Method != http.MethodPut { httpx.Error(w, http.StatusMethodNotAllowed, "PATCH required") return } var in struct { Old string `json:"old_password"` New string `json:"new_password"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if len(in.New) < 6 || len(in.New) > 72 { httpx.BadRequest(w, "新密码长度需在 6-72 位之间") return } act := actorFrom(r) u, err := a.Store.GetUserByID(act.ID) if err != nil { httpx.Unauthorized(w) return } if u.PasswordHash != "" { if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Old)) != nil { httpx.Error(w, http.StatusForbidden, "旧密码不对") return } } hash, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost) if err != nil { httpx.ServerError(w, err) return } if err := a.Store.SetStaffPassword(u.ID, string(hash)); err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true}) } // ---------- posts ---------- func (a *API) listPosts(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: o := store.ListOptions{ Kind: httpx.QueryString(r, "kind"), Tag: httpx.QueryString(r, "tag"), Query: httpx.QueryString(r, "q"), Status: httpx.QueryString(r, "status"), OrderBy: httpx.QueryString(r, "order"), Page: httpx.QueryInt(r, "page", 1), Size: httpx.QueryInt(r, "size", 20), } if o.Status == "" { o.Status = "any" } page, err := a.Store.List(o) if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, page) case http.MethodPost: var in model.PostInput if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } in.Status = store.NormalizeStatus(in.Status) attachLinkCard(r.Context(), &in, nil) autoMeta(&in, nil) p, err := a.Store.Create(in) if err != nil { httpx.ServerError(w, err) return } httpx.Created(w, p) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") } } func (a *API) postByID(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/posts/"), "/") if rest == "" { a.listPosts(w, r) return } if rest == "bulk" { a.bulkPosts(w, r) return } id, err := parseInt(rest) if err != nil { httpx.BadRequest(w, "bad post id") return } switch r.Method { case http.MethodGet: p, err := a.Store.Get(id) writeOne(w, p, err) case http.MethodPut, http.MethodPatch: var in model.PostInput if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if in.Status != "" { in.Status = store.NormalizeStatus(in.Status) } // 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动 // 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住 var curCard *model.LinkCard var curPost *model.Post if cur, err := a.Store.Get(id); err == nil { curCard = cur.LinkCard curPost = &cur } attachLinkCard(r.Context(), &in, curCard) autoMeta(&in, curPost) p, err := a.Store.Update(id, in) writeOne(w, p, err) case http.MethodDelete: if err := a.Store.Delete(id); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT/DELETE required") } } func writeOne(w http.ResponseWriter, p model.Post, err error) { if err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } httpx.OK(w, p) } // ---------- tags ---------- func (a *API) listTags(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: tags, err := a.Store.ListTags() if err != nil { httpx.ServerError(w, err) return } if tags == nil { tags = []model.Tag{} } httpx.OK(w, map[string]any{"tags": tags}) case http.MethodPost: var in struct { Name string `json:"name"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } name := strings.TrimSpace(in.Name) if name == "" { httpx.BadRequest(w, "name required") return } t, err := a.Store.CreateTag(name) if err != nil { httpx.ServerError(w, err) return } httpx.Created(w, t) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") } } func (a *API) tagByID(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/tags/"), "/") parts := strings.SplitN(rest, "/", 2) id, err := parseInt(parts[0]) if err != nil { httpx.BadRequest(w, "bad tag id") return } if len(parts) == 2 && parts[1] == "merge" { a.mergeTag(w, r, id) return } switch r.Method { case http.MethodPut, http.MethodPatch: var in struct { Name string `json:"name"` Color string `json:"color"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } t, err := a.Store.UpdateTag(id, in.Name, in.Color) writeTag(w, t, err) case http.MethodDelete: if err := a.Store.DeleteTag(id); err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required") } } func (a *API) mergeTag(w http.ResponseWriter, r *http.Request, fromID int64) { if r.Method != http.MethodPost { httpx.Error(w, http.StatusMethodNotAllowed, "POST required") return } var in struct { ToID int64 `json:"to_id"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if in.ToID == 0 { httpx.BadRequest(w, "to_id required") return } t, err := a.Store.MergeTags(fromID, in.ToID) writeTag(w, t, err) } func writeTag(w http.ResponseWriter, t model.Tag, err error) { if err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.Error(w, http.StatusBadRequest, err.Error()) return } httpx.OK(w, t) } // ---------- projects ---------- func (a *API) listProjects(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: status := httpx.QueryString(r, "status") projects, err := a.Store.ListProjects(status) if err != nil { httpx.ServerError(w, err) return } if projects == nil { projects = []model.Project{} } httpx.OK(w, map[string]any{"projects": projects}) case http.MethodPost: var in model.ProjectInput if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if strings.TrimSpace(in.Title) == "" { httpx.BadRequest(w, "title required") return } p, err := a.Store.CreateProject(in) if err != nil { httpx.ServerError(w, err) return } httpx.Created(w, p) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") } } func (a *API) projectByID(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/projects/"), "/") if rest == "" { a.listProjects(w, r) return } id, err := parseInt(rest) if err != nil { httpx.BadRequest(w, "bad project id") return } switch r.Method { case http.MethodGet: p, err := a.Store.GetProject(id) writeProject(w, p, err) case http.MethodPut, http.MethodPatch: var in model.ProjectInput if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } p, err := a.Store.UpdateProject(id, in) writeProject(w, p, err) case http.MethodDelete: if err := a.Store.DeleteProject(id); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT/DELETE required") } } func writeProject(w http.ResponseWriter, p model.Project, err error) { if err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } httpx.OK(w, p) } // ---------- settings ---------- // credKeys 是从环境变量迁进后台的系统设置项。 // secret 为 true 的项 GET 时永远不回值,只报「是否已配置、来自哪里」。 type credKey struct { key string // settings 表的键 kind string // 前端字段名(= key) secret bool envOf func(c *config.Config) string } var credKeys = []credKey{ {key: "site_url", envOf: func(c *config.Config) string { return c.SiteURL }}, {key: "github_client_id", envOf: func(c *config.Config) string { return c.GitHubClientID }}, {key: "github_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GitHubClientSecret }}, {key: "google_client_id", envOf: func(c *config.Config) string { return c.GoogleClientID }}, {key: "google_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GoogleClientSecret }}, {key: "telegram_bot", envOf: func(c *config.Config) string { return c.TelegramBot }}, {key: "telegram_bot_token", secret: true, envOf: func(c *config.Config) string { return c.TelegramToken }}, {key: "s3_endpoint", envOf: func(c *config.Config) string { return c.S3Endpoint }}, {key: "r2_bucket", envOf: func(c *config.Config) string { return c.R2Bucket }}, {key: "r2_access_key", secret: true, envOf: func(c *config.Config) string { return c.R2AccessKey }}, {key: "r2_secret_key", secret: true, envOf: func(c *config.Config) string { return c.R2SecretKey }}, {key: "uploads_public_base", envOf: func(c *config.Config) string { return c.UploadsPublicBase }}, {key: "admin_username", envOf: func(c *config.Config) string { return c.AdminUser }}, {key: "admin_password_hash", secret: true, envOf: func(*config.Config) string { return "" }}, } // credentialMeta 告诉前端每个迁移项的配置状态: // set=是否已有生效值,source=db(后台配的)/ env(环境变量兜底)/ unset。 func (a *API) credentialMeta() (map[string]any, error) { m, err := a.Store.GetSettingsMap() if err != nil { return nil, err } c := a.cfg() out := map[string]any{} for _, ck := range credKeys { src := "unset" if ck.key == "admin_password_hash" { // 多用户后密码在 owner 行上;settings 键只是旧版遗留引导, // 行上有哈希就以此为准 if owner, err := a.Store.EnsureOwner(c.AdminUser); err == nil && owner.PasswordHash != "" { src = "db" } else if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" { src = "db" } else { src = "env" // env 显式密码或 dev 默认,都算「有生效值」 } out[ck.key] = map[string]any{"set": src != "unset", "source": src} continue } if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" { src = "db" } else if ck.envOf(c) != "" { src = "env" } out[ck.key] = map[string]any{"set": src != "unset", "source": src} } return out, nil } // settingsPut 是设置页的保存载荷:常规设置全量替换(与旧行为一致), // secrets 只写传了非空值的键(秘密项留空 = 保持现值,永远不可能被顺手清掉)。 type settingsPut struct { model.Settings Secrets map[string]string `json:"secrets"` } // writableSecrets 是允许通过 PUT 写入的秘密键;admin_password 单独处理 // (入库前要 bcrypt 哈希)。 var writableSecrets = map[string]bool{ "github_client_secret": true, "google_client_secret": true, "telegram_bot_token": true, "r2_access_key": true, "r2_secret_key": true, } func (a *API) settings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: st, err := a.Store.GetSettings() if err != nil { httpx.ServerError(w, err) return } meta, err := a.credentialMeta() if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta}) case http.MethodPut, http.MethodPost: // 系统设置(含凭据与管理员用户名)是站主的权限;内容管理员只读 if act := actorFrom(r); act.Role != model.RoleOwner { httpx.Error(w, http.StatusForbidden, "需要站主权限") return } var in settingsPut if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } // 密码变更不走这里:多用户后密码在 users 行上, // 自己改走 /api/admin/account/password,重置别人走 /api/admin/users/{id} for k, v := range in.Secrets { v = strings.TrimSpace(v) if v == "" { continue // 留空 = 不改 } if !writableSecrets[k] { httpx.BadRequest(w, "unknown secret key: "+k) return } if err := a.Store.SetSetting(k, v); err != nil { httpx.ServerError(w, err) return } } if err := a.Store.UpdateSettings(in.Settings); err != nil { httpx.ServerError(w, err) return } a.invalidate() st, err := a.Store.GetSettings() if err != nil { httpx.ServerError(w, err) return } meta, err := a.credentialMeta() if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta}) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required") } } // invalidate 让有效配置缓存立即失效(Res 未装配时没有缓存,跳过)。 func (a *API) invalidate() { if a.Res != nil { a.Res.Invalidate() } } func parseInt(s string) (int64, error) { if s == "" { return 0, errors.New("empty") } var n int64 for _, c := range s { if c < '0' || c > '9' { return 0, errors.New("not a number") } n = n*10 + int64(c-'0') } return n, nil } // ---------- dashboard ---------- func (a *API) dashboard(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { httpx.Error(w, http.StatusMethodNotAllowed, "GET required") return } d, err := a.Store.Dashboard() if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, d) } // ---------- bulk posts ---------- type bulkPostsRequest struct { IDs []int64 `json:"ids"` Action string `json:"action"` // "publish" | "draft" | "delete" } func (a *API) bulkPosts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { httpx.Error(w, http.StatusMethodNotAllowed, "POST required") return } var in bulkPostsRequest if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if len(in.IDs) == 0 { httpx.BadRequest(w, "ids required") return } switch in.Action { case "publish", "draft": n, err := a.Store.BulkUpdateStatus(in.IDs, store.NormalizeStatus(in.Action)) if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true, "updated": n}) case "delete": var failed int for _, id := range in.IDs { if err := a.Store.Delete(id); err != nil { failed++ } } httpx.OK(w, map[string]any{"ok": true, "deleted": len(in.IDs) - failed, "failed": failed}) default: httpx.BadRequest(w, "action must be one of publish|draft|delete") } } // ---------- files(上传与文件管理) ---------- // 单文件上限与类型白名单。SVG 拒绝:同源内联可执行脚本。 const ( maxFileUpload = 50 << 20 ) var allowFileExt = map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", ".pdf": "application/pdf", ".zip": "application/zip", ".txt": "text/plain", } func (a *API) files(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: fp, err := a.Store.ListFiles(httpx.QueryInt(r, "page", 1), httpx.QueryInt(r, "size", 20), httpx.QueryString(r, "q")) if err != nil { httpx.ServerError(w, err) return } for i := range fp.Items { fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.cfg().UploadsPublicBase) } httpx.OK(w, fp) case http.MethodPost: a.uploadFiles(w, r) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") } } func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) { // 64MB = 50MB 文件 + multipart 编码开销 r.Body = http.MaxBytesReader(w, r.Body, maxFileUpload+(8<<20)) if err := r.ParseMultipartForm(8 << 20); err != nil { httpx.BadRequest(w, "上传失败:请求体超过上限(单文件 50MB)") return } if r.MultipartForm != nil { defer r.MultipartForm.RemoveAll() } fhs := r.MultipartForm.File["file"] if len(fhs) == 0 { httpx.BadRequest(w, "没有收到文件") return } out := make([]model.File, 0, len(fhs)) for _, fh := range fhs { f, err := a.storeOne(r.Context(), fh) if err != nil { if bu, ok := err.(badUpload); ok { httpx.BadRequest(w, string(bu)) return } httpx.ServerError(w, err) return } out = append(out, f) } // URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外 for i := range out { out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.cfg().UploadsPublicBase) } httpx.Created(w, out) } // badUpload 区分「文件本身的问题」(类型 / 大小 → 400 直接告诉站主)与 // 服务器故障(500,已入库的部分保留)。 type badUpload string func (e badUpload) Error() string { return string(e) } // storeOne 校验、哈希、落盘单个文件。内容哈希做 key(同年月分目录), // 同内容重复上传直接复用已有行,不产生孤儿对象。 func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.File, error) { if fh.Size > maxFileUpload { return model.File{}, badUpload(fmt.Sprintf("%s:超过单文件 50MB 上限", fh.Filename)) } ext := strings.ToLower(filepath.Ext(fh.Filename)) mime, ok := allowFileExt[ext] if !ok { return model.File{}, badUpload(fmt.Sprintf("%s:不支持的类型 %q", fh.Filename, ext)) } src, err := fh.Open() if err != nil { return model.File{}, err } defer src.Close() // 头 512 字节给 http.DetectContentType 嗅探真实类型,全文流过 sha256, // 同时落到临时文件(S3 PutObject 需要确定的 ContentLength)。 tmp, err := os.CreateTemp("", "one-upload-*") if err != nil { return model.File{}, err } defer os.Remove(tmp.Name()) hasher := sha256.New() head := make([]byte, 512) hn, _ := io.ReadFull(src, head) head = head[:hn] for _, w := range []io.Writer{tmp, hasher} { if _, err := w.Write(head); err != nil { tmp.Close() return model.File{}, err } } copied, err := io.Copy(io.MultiWriter(tmp, hasher), src) if err != nil { tmp.Close() return model.File{}, err } if err := tmp.Close(); err != nil { return model.File{}, err } size := int64(hn) + copied // 嗅探结果必须与扩展名声明的类型完全一致(防改后缀绕过白名单)。 // 不给 octet-stream 留口子:一张「image/png」嗅探出 octet-stream // 就绝不是 PNG——随机字节改名上传就是这么溜进来的。 detected := strings.SplitN(http.DetectContentType(head), ";", 2)[0] if detected != mime { return model.File{}, badUpload(fmt.Sprintf("%s:文件内容与扩展名不符", fh.Filename)) } sum := hex.EncodeToString(hasher.Sum(nil)) return a.persistFile(ctx, fh.Filename, ext, mime, tmp.Name(), size, sum) } // persistFile 落库共享段:内容哈希做 key(同年月分目录)、去重复用、 // Put 对象存储、建行。storeOne(本地上传)与 importOne(外链转存)共用。 func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string, size int64, sum string) (model.File, error) { key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext) // S3Api 端点带路径段时(如 .../oss),该段会折进对象 key—— // 数据库必须记录同样的完整 key,直链才不会 404 if p := storage.EndpointKeyPrefix(a.cfg().S3Endpoint); p != "" { key = p + "/" + key } // 内容去重:同一份内容只存一份,复用已有行。 // URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值, // 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。 if exist, err := a.Store.GetFileByKey(key); err == nil { exist.URL = storage.FileURL(exist.Store, exist.Key, a.cfg().UploadsPublicBase) return exist, nil } f, err := os.Open(tmpPath) if err != nil { return model.File{}, err } defer f.Close() if err := a.blobs().Put(ctx, key, f, size, mime); err != nil { return model.File{}, err } created, err := a.Store.CreateFile(model.File{ Key: key, Name: name, Mime: mime, Size: size, SHA256: sum, Store: a.cfg().StorageDriver, }) if err != nil { return model.File{}, err } created.URL = storage.FileURL(created.Store, created.Key, a.cfg().UploadsPublicBase) return created, nil } // mimeToExt 是 allowFileExt 的反向映射:外链转存时内容嗅探出 mime, // 反推扩展名(URL 本身可能不带后缀或后缀不可信)。 var mimeToExt = func() map[string]string { m := make(map[string]string, len(allowFileExt)) for ext, mime := range allowFileExt { m[mime] = ext } return m }() // importFiles 外链转存:POST /api/admin/files/import {"urls": [...]}。 // 站主把别处的图片贴进正文后一键搬进自己的存储——与手动上传同一套 // 白名单、内容嗅探与内容去重;抓取走 linkmeta 的 SSRF 防护拨号。 func (a *API) importFiles(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { httpx.Error(w, http.StatusMethodNotAllowed, "POST required") return } var in struct { URLs []string `json:"urls"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if len(in.URLs) == 0 || len(in.URLs) > 20 { httpx.BadRequest(w, "urls 需要1-20个") return } // files 带上 source(原址),前端按它做正文替换 type imported struct { Source string `json:"source"` File model.File `json:"file"` } files := make([]imported, 0, len(in.URLs)) errs := map[string]string{} for _, u := range in.URLs { f, err := a.importOne(r.Context(), u) if err != nil { if bu, ok := err.(badUpload); ok { errs[u] = string(bu) } else { errs[u] = err.Error() } continue } files = append(files, imported{Source: u, File: f}) } httpx.OK(w, map[string]any{"files": files, "errors": errs}) } func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error) { cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() data, ct, err := linkmeta.FetchBytes(cctx, rawURL, maxFileUpload) if err != nil { return model.File{}, fmt.Errorf("抓取失败:%w", err) } if len(data) == 0 { return model.File{}, badUpload("空内容") } // 类型必须落在本站白名单里:嗅探优先(不信响应头,更不信 URL 后缀) detected := strings.SplitN(http.DetectContentType(data[:512]), ";", 2)[0] ext, ok := mimeToExt[detected] if !ok { return model.File{}, badUpload("不支持的类型 " + detected) } _ = ct // 落临时文件:persistFile / S3 PutObject 都要确定的文件与长度 tmp, err := os.CreateTemp("", "one-import-*") if err != nil { return model.File{}, err } defer os.Remove(tmp.Name()) size, err := tmp.Write(data) if err != nil { tmp.Close() return model.File{}, err } if err := tmp.Close(); err != nil { return model.File{}, err } sum := sha256.Sum256(data) // 名字取 URL 路径末段(仅用于文件管理页展示,不参与存储路径) name := rawURL if u, err := url.Parse(rawURL); err == nil && u.Path != "" { if base := path.Base(u.Path); base != "" && base != "/" && base != "." { name = base } } return a.persistFile(ctx, name, ext, detected, tmp.Name(), int64(size), hex.EncodeToString(sum[:])) } func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/") idPart, sub := rest, "" if i := strings.Index(rest, "/"); i >= 0 { idPart, sub = rest[:i], rest[i+1:] } // /files/{id} 与 /files/{id}/refs 两种形态,别的一律不收 if sub != "" && (sub != "refs" || r.Method != http.MethodGet) { httpx.BadRequest(w, "bad file path") return } id, err := parseInt(idPart) if err != nil { httpx.BadRequest(w, "bad file id") return } switch r.Method { case http.MethodGet: if sub == "refs" { a.fileRefs(w, r, id) return } f, err := a.Store.GetFile(id) if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } if err != nil { httpx.ServerError(w, err) return } f.URL = storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase) httpx.OK(w, f) case http.MethodDelete: f, err := a.Store.GetFile(id) if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } if err != nil { httpx.ServerError(w, err) return } // 正文/封面/项目/站主头像里还在用就先挡住,除非明确带 force=1。 // 检查放在删对象之前:一旦 blob 删了就没法回头。 if refs, err := a.Store.FileReferences(f.Key); err != nil { httpx.ServerError(w, err) return } else if len(refs) > 0 && httpx.QueryString(r, "force") != "1" { httpx.Error(w, http.StatusConflict, fileInUseMessage(refs)) return } // 先删对象存储再删行:存储端失败时行保留,可以重试 if err := a.blobs().Delete(r.Context(), f.Key); err != nil { httpx.ServerError(w, err) return } if _, err := a.Store.DeleteFile(id); err != nil { httpx.ServerError(w, err) return } if a.Thumbs != nil { a.Thumbs.Purge(f.SHA256) } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required") } } // fileRefs 报告这个文件被谁引用,供前端在删除弹层里列出来。 func (a *API) fileRefs(w http.ResponseWriter, r *http.Request, id int64) { f, err := a.Store.GetFile(id) if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } if err != nil { httpx.ServerError(w, err) return } refs, err := a.Store.FileReferences(f.Key) if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"key": f.Key, "count": len(refs), "items": refs}) } // fileInUseMessage 是挡住删除时回给调用方的一句话,只列前三个引用。 func fileInUseMessage(refs []model.FileRef) string { names := make([]string, 0, 3) for _, x := range refs { if x.Kind == "avatar" { names = append(names, "站主头像") continue } t := x.Title if t == "" { t = x.Slug } names = append(names, "《"+t+"》") if len(names) == 3 { break } } label := strings.Join(names, "、") if len(refs) > len(names) { label += " 等" } return fmt.Sprintf("该文件正被 %d 处引用:%s,删除后这些位置会失效(确认要删带 force=1)", len(refs), label) } // ---------- comments(评论审核与管理) ---------- func (a *API) adminComments(w http.ResponseWriter, r *http.Request) { status := httpx.QueryString(r, "status") page := httpx.QueryInt(r, "page", 1) size := httpx.QueryInt(r, "size", 20) fp, err := a.Store.ListCommentsAdmin(status, page, size) if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, fp) } func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/") id, err := parseInt(rest) if err != nil { httpx.BadRequest(w, "bad comment id") return } switch r.Method { case http.MethodPut: // 审核动作:{"status": "visible" | "pending"} var in struct { Status string `json:"status"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } if in.Status != "visible" && in.Status != "pending" { httpx.BadRequest(w, "status 只支持 visible / pending") return } if err := a.Store.SetCommentStatus(id, in.Status); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } if in.Status == "visible" && a.Hub != nil { if c, err := a.Store.GetComment(id); err == nil { a.Hub.Broadcast(c.PostID) } } httpx.OK(w, map[string]any{"ok": true}) case http.MethodDelete: // 后台删除同样走软删(墓碑保楼层) if err := a.Store.DeleteComment(id); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } if a.Hub != nil { if c, err := a.Store.GetComment(id); err == nil { a.Hub.Broadcast(c.PostID) } } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required") } } // ---------- readers(评论用户与禁言) ---------- func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: readers, err := a.Store.ListReaders() if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"readers": readers}) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET required") } } func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) { var in struct { Banned bool `json:"banned"` } if err := httpx.Decode(r, &in); err != nil { httpx.BadRequest(w, "invalid body") return } // 站主身份(管理员会话映射出来的读者)不允许禁言 if rd, err := a.Store.GetReader(id); err == nil && rd.Provider == "admin" { httpx.BadRequest(w, "不能禁言站主身份") return } if err := a.Store.SetReaderBanned(id, in.Banned); err != nil { if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned}) }