Files
ONE/backend/internal/admin/api.go
T
Sakurasan 4bb2ff4145 多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内,
  首次登录把 env / settings 引导凭据自迁移成行哈希
- 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库,
  改角色 / 停用账号即时生效(存量会话立即 401)
- 登录:先查 users 表,再走 settings 哈希 / env 引导链;
  admin/admin 开发模式在任何账号设过密码后失效
- 权限:系统设置、用户管理仅 owner;内容管理 admin+owner;
  admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除),
  设置页「登录与存储」tab 对管理员隐藏
- 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码);
  评论区管理员身份跟随各自账号,不再统一挂站主名下
- 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
2026-10-01 22:35:37 +08:00

1471 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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})
}