- 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)
1471 lines
43 KiB
Go
1471 lines
43 KiB
Go
// 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})
|
||
}
|