文件上传:R2 对象存储 + 后台文件管理 + 编辑器/封面联动

存储抽象(internal/storage,新包)
- BlobStore 接口(Put / Open / Delete)+ 两个实现:R2(aws-sdk-go-v2 走
  R2 的 S3 兼容 API,path-style、region auto)与本地磁盘(DataDir/uploads,
  零配置兜底)。选择:Endpoint / S3Api / Bucket / AccessKey / SecretAccessKey
  齐全 → R2,缺任一项回落本地并在启动日志提示缺失的字段名(只报名字不报值)
- 端点 URL 里的路径段不交给 SDK:path-style 下它会被折进对象 key,
  导致「数据库 key」和「实际对象 key」对不上(直链 404,实测复现)。
  EndpointKeyPrefix 提取路径段给上传 handler 拼进 key,StripEndpointPath
  只取 scheme://host 给 SDK——数据库 / 存储端 / 直链三方一致

数据与 API
- files 表:id / key(唯一) / name / mime / size / sha256 / store(r2|local) /
  created_at;URL 不入库,按「store 来源 + PublicBase」响应时解析,
  切存储端不破坏存量链接
- POST /api/admin/files:multipart 多文件,单文件 ≤50MB(MaxBytesReader 64MB);
  类型白名单 = 图片(jpg/png/webp/gif/avif)+ 附件(pdf/zip/txt),
  扩展名 + http.DetectContentType 双重校验(实测拦截随机字节改名 .png),
  SVG 拒绝(同源脚本);内容 sha256 做 key(2026/09/{哈希前12位}{扩展名}),
  同内容重复上传自动去重复用
- GET /api/admin/files(分页 + 文件名搜索)、DELETE /{id}(先删对象再删行,
  存储端失败保留行可重试)
- 公开路由 GET /uploads/{key}(main.go 挂载):按 key 查行、存储层流式返回,
  Cache-Control immutable + ETag 304;R2 + PublicBase 时 302 直链(后端不出流量)
- URL 解析:FileURL(store, key, PublicBase)——R2 且配了公开域名走直链,
  否则 /uploads/ 流式

后台文件管理页(FilesView,「工作台 → 文件」)
- 点击 / 拖拽多选上传(uploadFiles 走 FormData 裸 fetch,401 广播与
  request() 一致);缩略图卡片网格(图片出图、其他出类型占位);
  复制链接(clipboard,非 https 回落 prompt)/ 打开 / 删除(确认提示);
  分页、loading/empty 沿用既有模式

编辑器联动(EditorView)
- 封面:URL 输入框旁「上传」按钮,选图自动填 cover_url
- wysiwyg:Crepe ImageBlock 官方 onUpload 钩子——粘贴 / 拖拽 / 插图
  全部走上传,返回 URL 后由 Crepe 插节点
- Markdown 模式:插图弹层加「上传」按钮 + 编辑器粘贴 / 拖拽图片,
  上传后在原光标处插入 ![](url)(异步上传先记光标位,逐张追踪偏移)

.env.example
- 模板入库(无敏感值):Endpoint = 公开访问域名(直链)、S3Api = 上传端点、
  Bucket / AccessKey / SecretAccessKey

验证
- 本地兜底全流程:上传 201、同内容去重复用、随机字节改名 .png 被
  内容嗅探拒绝、.svg 拒绝、公开路由 immutable 缓存头 + 内容一致、
  删除后存储与公开路由双清 404
- R2 真实链路(站主 .env):上传 store=r2、直链 200(过程中定位并修复
  S3Api 路径段折进 key 导致的直链 404,见 EndpointKeyPrefix)
- 后端 go build/test/vet 全绿;前端构建通过
This commit is contained in:
Sakurasan
2026-09-27 16:26:11 +08:00
parent bc1b0ccce3
commit 34f0faedcc
18 changed files with 1191 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
AccessKey=
SecretAccessKey=
S3Api="https://<account_id>.r2.cloudflarestorage.com"
PublicURL=
Bucket=
+18
View File
@@ -9,6 +9,24 @@ require (
)
require (
github.com/aws/aws-sdk-go-v2 v1.47.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/config v1.33.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+36
View File
@@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 h1:n6kO3OlBvnDEksQpvBLbAldjHwGlu8kErvhHJkhlaRY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+227
View File
@@ -4,15 +4,25 @@
package admin
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
@@ -20,6 +30,9 @@ type API struct {
Store *store.Store
Cfg *config.Config
Sessions *Sessions
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
Blobs storage.BlobStore
PublicBase string
loginOnce sync.Once
logins *loginLimiter
@@ -56,6 +69,8 @@ func (a *API) Routes() http.Handler {
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/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
return mux
}
@@ -545,3 +560,215 @@ func (a *API) bulkPosts(w http.ResponseWriter, r *http.Request) {
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))
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
}
// 内容去重:同一份内容只存一份,复用已有行
if exist, err := a.Store.GetFileByKey(key); err == nil {
return exist, nil
}
f, err := os.Open(tmp.Name())
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: fh.Filename,
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
}
func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/"))
if err != nil {
httpx.BadRequest(w, "bad file id")
return
}
switch r.Method {
case http.MethodGet:
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
}
// 先删对象存储再删行:存储端失败时行保留,可以重试
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
}
httpx.OK(w, map[string]any{"ok": true})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
}
}
+54
View File
@@ -6,19 +6,23 @@ import (
"encoding/xml"
"errors"
"html"
"io"
"net/http"
"strconv"
"strings"
"time"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
type API struct {
Store *store.Store
Cfg *config.Config
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
}
func (a *API) Routes() http.Handler {
@@ -275,3 +279,53 @@ func orDefault(s, def string) string {
}
return s
}
// ---------- uploads(上传文件的公开访问) ----------
// UploadsHandler 供 main.go 挂在根 mux 的 /uploads/ 前缀上。按 key 查索引行,
// 经存储层流式返回本体;R2 + 公开域名时 302 到直链(后端不出流量)。
// key 必须在 files 表里有行——防止拿任意对象名探测存储端。
func (a *API) UploadsHandler() http.Handler {
return http.HandlerFunc(a.uploads)
}
func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
if key == "" || strings.Contains(key, "..") {
httpx.NotFound(w)
return
}
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
// R2 且配了公开域名:302 到直链,后端不出流量
if f.Store == "r2" && a.Cfg.UploadsPublicBase != "" {
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
return
}
rc, size, err := a.Blobs.Open(r.Context(), f.Key)
if err != nil {
httpx.NotFound(w)
return
}
defer rc.Close()
w.Header().Set("Content-Type", f.Mime)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("ETag", `"`+f.SHA256+`"`)
// key 含内容哈希:内容不变则 URL 不变,可永久缓存
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
io.Copy(w, rc)
}
+45
View File
@@ -3,6 +3,7 @@ package config
import (
"crypto/rand"
"encoding/hex"
"log"
"os"
"path/filepath"
"strings"
@@ -19,6 +20,20 @@ type Config struct {
DataDir string
SiteURL string
InsecureDev bool
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
// PublicURL = 公开访问域名(r2.dev / 绑定的自定义域名),文件直链走它
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
// (DataDir/uploads),并在启动日志提示一句。
StorageDriver string // r2 | local
S3Endpoint string // env: S3Api
R2Bucket string
R2AccessKey string
R2SecretKey string
UploadsPublicBase string // env: PublicURL
}
func getenv(k, def string) string {
@@ -77,6 +92,36 @@ func Load() (*Config, error) {
c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == ""
// 对象存储:变量名按站主 .env 里整理好的来(无 ONE_ 前缀)。
c.UploadsPublicBase = strings.TrimRight(getenv("PublicURL", ""), "/")
c.S3Endpoint = getenv("S3Api", "")
c.R2AccessKey = getenv("AccessKey", "")
c.R2SecretKey = getenv("SecretAccessKey", "")
c.R2Bucket = getenv("Bucket", "")
if c.S3Endpoint != "" && c.R2Bucket != "" && c.R2AccessKey != "" && c.R2SecretKey != "" {
c.StorageDriver = "r2"
} else {
c.StorageDriver = "local"
// 配了一半(有凭据没端点之类)时给一句启动日志,别让站主猜。
// 只报字段名,不报值。
var missing []string
if c.S3Endpoint == "" {
missing = append(missing, "S3Api")
}
if c.R2Bucket == "" {
missing = append(missing, "Bucket")
}
if c.R2AccessKey == "" {
missing = append(missing, "AccessKey")
}
if c.R2SecretKey == "" {
missing = append(missing, "SecretAccessKey")
}
if len(missing) > 0 {
log.Printf("storage: R2 配置缺 %s,上传回落本地磁盘", strings.Join(missing, "、"))
}
}
return c, nil
}
+23
View File
@@ -100,6 +100,29 @@ type Page struct {
Size int `json:"size"`
}
// File 是一条上传文件的索引行。内容本体在对象存储里(store 字段记来源:
// r2 | local),key 是存储端的对象名,URL 由 API 层按「来源 + PublicBase」
// 在响应时解析——切存储端不破坏存量链接。
type File struct {
ID int64 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Store string `json:"store"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
// FilePage 是文件管理的分页容器(Items 用 File,与文章的 Page 区分开)。
type FilePage struct {
Items []File `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// Dashboard is the snapshot rendered on /admin (homepage).
type Dashboard struct {
TotalPosts int `json:"total_posts"`
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// LocalStore 把文件落在本地磁盘(DataDir/uploads),零配置兜底。
// key 允许带「2026/09/」这类前缀,实现里做路径清洗防目录穿越。
type LocalStore struct {
Root string
}
func NewLocal(root string) *LocalStore {
return &LocalStore{Root: root}
}
func (s *LocalStore) path(key string) (string, error) {
clean := filepath.Clean("/" + strings.TrimPrefix(key, "/"))[1:] // 锚到根再去掉,防 ../
if clean == "" || strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("bad key: %q", key)
}
return filepath.Join(s.Root, filepath.FromSlash(clean)), nil
}
func (s *LocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
dst, err := s.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (s *LocalStore) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
dst, err := s.path(key)
if err != nil {
return nil, 0, err
}
f, err := os.Open(dst)
if err != nil {
return nil, 0, err
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, st.Size(), nil
}
func (s *LocalStore) Delete(ctx context.Context, key string) error {
dst, err := s.path(key)
if err != nil {
return err
}
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package storage
import (
"context"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2Store 走 Cloudflare R2 的 S3 兼容 API。endpoint 是账户级端点
// (https://<accountid>.r2.cloudflarestorage.com),R2 两种寻址都支持,
// 这里用 path-style 最稳。
//
// 端点 URL 里的路径段(如果有)不交给 SDK —— path-style 下它会被折进
// 对象 key,导致「数据库 key」和「实际对象 key」对不上(直链 404)。
// 这里只取 scheme://host 当端点;路径段由 EndpointKeyPrefix 暴露给
// 上传 handler 拼进 key,三处(存储 / 数据库 / 直链)从此一致。
type R2Store struct {
client *s3.Client
bucket string
}
func NewR2(endpoint, bucket, accessKey, secretKey string) (*R2Store, error) {
if endpoint == "" || bucket == "" {
return nil, fmt.Errorf("r2: endpoint/bucket required")
}
// R2 不认 region,SDK 又必须有——官方文档给的值就是 "auto"。
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion("auto"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithBaseEndpoint(StripEndpointPath(endpoint)),
)
if err != nil {
return nil, fmt.Errorf("r2: load aws config: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
return &R2Store{client: client, bucket: bucket}, nil
}
func (s *R2Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: r,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
return err
}
func (s *R2Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, 0, err
}
return out.Body, *out.ContentLength, nil
}
func (s *R2Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
+54
View File
@@ -0,0 +1,54 @@
// Package storage 是文件上传的存储抽象。R2(S3 兼容 API)是第一个实现,
// 本地磁盘是零配置兜底;以后接其他存储端 = 再写一个 BlobStore 实现,
// 从 FromEnv 的选择逻辑里加一个分支,调用方(admin API / 公开路由)不变。
//
// URL 不在存储层决定:文件行里记 store 来源,读取时按「来源 + PublicBase」
// 解析直链或后端代理路由,因此切换存储端不破坏存量链接。
package storage
import (
"context"
"io"
"net/url"
"strings"
)
// BlobStore 是对象存储的最小面。size 只是提示(S3 PutObject 需要预知长度,
// 用临时文件或内存缓冲满足),实现方不得依赖它做校验——类型与大小上限
// 在上传 handler 里统一把关。
type BlobStore interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, key string) error
}
// FileURL 解析文件的公开访问地址:R2 上的文件且配了公开域名(PublicBase,
// 尾部不带 /)走 CDN 直链;其余(本地兜底、或没配公开域名)走后端的
// /uploads/key 流式路由。调用方传入文件行里的 store 来源。
func FileURL(storeName, key, publicBase string) string {
if storeName == "r2" && publicBase != "" {
return publicBase + "/" + key
}
return "/uploads/" + key
}
// EndpointKeyPrefix 提取端点 URL 里的路径段(去首尾斜杠)作为上传 key 的
// 前缀:S3Api 写成 https://host/oss 时,对象 key 会带上 oss/ 前缀,
// 数据库 / 直链 / 存储端三方都用这个前缀对齐。没有路径段返回空串。
func EndpointKeyPrefix(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return ""
}
return strings.Trim(u.Path, "/")
}
// StripEndpointPath 去掉端点 URL 的路径,只留 scheme://host[:port]:
// SDK 的 BaseEndpoint 用它(路径段已经折进 key 前缀,见 EndpointKeyPrefix)。
func StripEndpointPath(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return endpoint
}
return u.Scheme + "://" + u.Host
}
+117
View File
@@ -86,6 +86,16 @@ func (s *Store) migrate() error {
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS files (
id %s,
key TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
store TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL
)`, ai),
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -114,6 +124,8 @@ func (s *Store) migrate() error {
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
{"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`},
{"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -1391,3 +1403,108 @@ func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error
*dst = out
return nil
}
// ---------- files ----------
func scanFile(sc interface{ Scan(...any) error }) (model.File, error) {
var f model.File
err := sc.Scan(&f.ID, &f.Key, &f.Name, &f.Mime, &f.Size, &f.SHA256, &f.Store, &f.CreatedAt)
return f, err
}
func (s *Store) CreateFile(f model.File) (model.File, error) {
f.CreatedAt = now()
q := s.db.Q(`INSERT INTO files (key,name,mime,size,sha256,store,created_at) VALUES (?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt).Scan(&id)
if err != nil {
return model.File{}, err
}
} else {
res, err := s.db.Exec(q, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt)
if err != nil {
return model.File{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.File{}, err
}
}
f.ID = id
return f, nil
}
// ListFiles 按创建时间倒序分页,q 模糊匹配原始文件名 / key。
func (s *Store) ListFiles(page, size int, q string) (model.FilePage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
if query := strings.TrimSpace(q); query != "" {
where = ` WHERE name LIKE ? OR key LIKE ?`
like := "%" + query + "%"
args = append(args, like, like)
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM files`+where), args...).Scan(&total); err != nil {
return model.FilePage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files`+
where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.FilePage{}, err
}
defer rows.Close()
out := []model.File{}
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return model.FilePage{}, err
}
out = append(out, f)
}
return model.FilePage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
func (s *Store) GetFile(id int64) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
func (s *Store) GetFileByKey(key string) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE key = ?`), key))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
// DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。
func (s *Store) DeleteFile(id int64) (model.File, error) {
f, err := s.GetFile(id)
if err != nil {
return model.File{}, err
}
res, err := s.db.Exec(s.db.Q(`DELETE FROM files WHERE id = ?`), id)
if err != nil {
return model.File{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return model.File{}, ErrNotFound
}
return f, nil
}
+17 -1
View File
@@ -20,6 +20,7 @@ import (
"oneblog/internal/api"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/storage"
"oneblog/internal/store"
)
@@ -46,12 +47,27 @@ func main() {
log.Fatalf("store: %v", err)
}
public := &api.API{Store: st, Cfg: cfg}
// 文件上传的存储后端:R2 配置齐全用 R2,否则本地磁盘兜底。
// 两个 API 共享同一实例;公开访问路由 /uploads/ 也走它。
var blobs storage.BlobStore
switch cfg.StorageDriver {
case "r2":
blobs, err = storage.NewR2(cfg.S3Endpoint, cfg.R2Bucket, cfg.R2AccessKey, cfg.R2SecretKey)
if err != nil {
log.Fatalf("storage: %v", err)
}
default:
blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads"))
}
public := &api.API{Store: st, Cfg: cfg, Blobs: blobs}
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
adminAPI.Blobs = blobs
root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes())
public.Mount(root)
root.Handle("/uploads/", public.UploadsHandler())
root.Handle("/", spaHandler(cfg.WebDist))
srv := &http.Server{
+5
View File
@@ -59,6 +59,7 @@ const crumb = computed(() => {
const p = route.path
if (p.endsWith('/new')) return '写新的'
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/files')) return '文件'
if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览'
@@ -119,6 +120,10 @@ watch(() => route.path, () => loadCounts())
<span class="ic">◈</span>
<span>作品</span>
</RouterLink>
<RouterLink to="/admin/files" class="item">
<span class="ic">▣</span>
<span>文件</span>
</RouterLink>
<div class="group">操作</div>
<RouterLink to="/admin/new" class="item">
<span class="ic">✎</span>
+100 -1
View File
@@ -141,6 +141,11 @@ async function initEditor() {
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
},
// 图片上传:wysiwyg 里的粘贴 / 拖拽 / 插图按钮全部走后台文件上传,
// onUpload 返回 URL 后由 Crepe 自己插节点
[CrepeFeature.ImageBlock]: {
onUpload: (file) => uploadOne(file)
}
}
})
@@ -520,6 +525,86 @@ function commitImage() {
showImageInput.value = false
}
// ---------- 上传(封面 / 编辑器插图共用) ----------
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false)
async function uploadOne(file) {
const fd = new FormData()
fd.append('file', file)
try {
const data = await adminApi.uploadFiles(fd)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败')
return f.url
} catch (e) {
error.value = e.message || '上传失败'
throw e
}
}
const coverInput = ref(null)
async function onCoverPick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = '' // 允许重复选同一个文件
if (!file) return
uploadingImage.value = true
try {
form.cover_url = await uploadOne(file)
} finally {
uploadingImage.value = false
}
}
const imageInput = ref(null)
async function onImagePick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
uploadingImage.value = true
try {
imageUrl.value = await uploadOne(file)
if (!imageAlt.value) imageAlt.value = file.name.replace(/\.[^.]+$/, '')
commitImage()
} finally {
uploadingImage.value = false
}
}
// md 面板的粘贴 / 拖拽图片:上传后在原光标处插入 markdown。
// 上传是异步的,先记光标位置,逐张插入时用 pos 追踪偏移。
function captureSel() {
const el = mdPane.value
const s = el ? el.selectionStart : form.content_md.length
const e = el ? el.selectionEnd : s
return { s, e: Math.max(s, e) }
}
async function onMdFiles(files, sel) {
let pos = sel.s
for (const f of files) {
try {
const url = await uploadOne(f)
const md = `![](${url})\n`
insertAtCursor(md, pos, pos)
pos += md.length
} catch (_) {
/* uploadOne 已把错误写进 error */
}
}
}
function onMdPaste(e) {
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function onMdDrop(e) {
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function insertDate() {
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
}
@@ -670,9 +755,15 @@ const tbGroups = computed(() => [
aria-label="图片 URL"
@keydown.enter="commitImage"
/>
<button class="btn btn-primary" @click="commitImage">插入</button>
<div style="display: flex; gap: 6px;">
<button class="btn btn-primary" style="flex: 1;" @click="commitImage">插入</button>
<button class="btn" style="flex: 1;" :disabled="uploadingImage" @click="imageInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
<button class="btn" @click="showImageInput = false">取消</button>
</div>
<input ref="imageInput" type="file" accept="image/*" hidden @change="onImagePick" />
</div>
<!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
@@ -683,6 +774,8 @@ const tbGroups = computed(() => [
v-model="form.content_md"
placeholder="直接写 Markdown…"
aria-label="Markdown 正文"
@paste="onMdPaste"
@drop="onMdDrop"
></textarea>
<div class="editor-stats">
@@ -715,6 +808,7 @@ const tbGroups = computed(() => [
<div v-if="form.cover_url" class="cover-mini" :style="{ backgroundImage: `url(${form.cover_url})` }">
<button class="x" @click="clearCover" aria-label="移除封面">×</button>
</div>
<div class="cover-row">
<input
id="cover"
v-model="form.cover_url"
@@ -724,6 +818,11 @@ const tbGroups = computed(() => [
spellcheck="false"
aria-label="封面图 URL"
/>
<button class="btn" type="button" :disabled="uploadingImage" @click="coverInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
</div>
<input ref="coverInput" type="file" accept="image/*" hidden @change="onCoverPick" />
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">回车或失焦即生效;前台卡片与详情页头图会用到。</p>
</div>
+292
View File
@@ -0,0 +1,292 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate } from '../utils'
// 文件管理:上传(点击 / 拖拽,多选)、网格缩略图、复制链接、删除。
// 缩略图与链接都用后端解析好的 f.url(R2 直链或 /uploads 流式路由)。
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const page = computed(() => Number(route.query.page || 1))
const size = 24
const uploading = ref(false)
const uploadError = ref('')
const dragOver = ref(false)
const copiedId = ref(0)
const fileInput = ref(null)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await adminApi.files({ page: page.value, q: String(route.query.q || '') })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
if (my === seq) loading.value = false
}
}
onMounted(load)
watch(() => route.query.page, load)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
// ---------- 上传 ----------
async function upload(files) {
if (!files || !files.length || uploading.value) return
uploading.value = true
uploadError.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
await adminApi.uploadFiles(fd)
await load()
} catch (e) {
uploadError.value = e.message || '上传失败'
} finally {
uploading.value = false
}
}
function onPick(e) {
upload(e.target.files)
e.target.value = '' // 允许重复选同一个文件
}
function onDrop(e) {
dragOver.value = false
upload(e.dataTransfer.files)
}
// ---------- 操作 ----------
async function copyUrl(f) {
try {
await navigator.clipboard.writeText(f.url)
copiedId.value = f.id
setTimeout(() => {
if (copiedId.value === f.id) copiedId.value = 0
}, 2000)
} catch (_) {
// 非 https 环境没有 clipboard API:退回手动复制
window.prompt('复制此链接:', f.url)
}
}
async function remove(f) {
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
try {
await adminApi.deleteFile(f.id)
// 当前页删空时退一页,其余直接重载
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
else load()
} catch (e) {
error.value = e.message || '删除失败'
}
}
// ---------- 展示助手 ----------
function humanSize(n) {
if (n < 1024) return n + ' B'
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
return (n / 1073741824).toFixed(2) + ' GB'
}
function ext(name) {
const i = name.lastIndexOf('.')
return i < 0 ? '文件' : name.slice(i + 1).toUpperCase()
}
// 拖拽悬停态在离开窗口时复位
function onWindowDragEnd() {
dragOver.value = false
}
onMounted(() => window.addEventListener('dragend', onWindowDragEnd))
onBeforeUnmount(() => window.removeEventListener('dragend', onWindowDragEnd))
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
文件 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
</h1>
</header>
<div
class="dropzone"
:class="{ over: dragOver, uploading }"
role="button"
tabindex="0"
aria-label="上传文件"
@click="fileInput && fileInput.click()"
@keydown.enter="fileInput && fileInput.click()"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" multiple hidden accept=".jpg,.jpeg,.png,.webp,.gif,.avif,.pdf,.zip,.txt" @change="onPick" />
<span v-if="uploading">上传中…</span>
<span v-else>点击选择或把文件拖到这里上传 · 图片 / pdf / zip / txt · 单文件 ≤ 50MB</span>
</div>
<p v-if="uploadError" class="upload-error" role="alert">{{ uploadError }}</p>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">还没有上传过文件。</div>
<template v-else>
<div class="file-grid">
<div v-for="f in items" :key="f.id" class="file-card">
<div class="thumb">
<img
v-if="f.mime.startsWith('image/')"
:src="f.url"
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
/>
<span v-else class="ph" aria-hidden="true">{{ ext(f.name) }}</span>
</div>
<div class="name" :title="f.name">{{ f.name }}</div>
<div class="meta">
<span>{{ humanSize(f.size) }}</span>
<span style="margin: 0 5px;" aria-hidden="true">·</span>
<span :title="f.created_at">{{ relativeDate(f.created_at) }}</span>
</div>
<div class="actions">
<a href="#" @click.prevent="copyUrl(f)">{{ copiedId === f.id ? '已复制 ✓' : '复制链接' }}</a>
<a :href="f.url" target="_blank" rel="noopener">打开</a>
<a href="#" class="danger" @click.prevent="remove(f)">删除</a>
</div>
</div>
</div>
<nav
v-if="pageCount > 1"
aria-label="分页"
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
>
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
← 上一页
</button>
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
下一页 →
</button>
</nav>
</template>
</section>
</template>
<style scoped>
.dropzone {
border: 1.5px dashed var(--admin-line);
border-radius: 6px;
padding: 22px;
text-align: center;
color: var(--admin-muted);
font-size: 13px;
margin-bottom: 16px;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.dropzone:hover,
.dropzone.over {
border-color: var(--admin-accent);
background: color-mix(in srgb, var(--admin-accent) 6%, transparent);
}
.dropzone.uploading {
cursor: wait;
}
.upload-error {
color: var(--admin-danger);
font-size: 13px;
margin: -8px 0 12px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.file-card {
border: 1px solid var(--admin-line);
border-radius: 6px;
background: var(--admin-card);
padding: 10px;
}
.thumb {
height: 110px;
border-radius: 4px;
background: var(--admin-paper-sunken);
display: grid;
place-items: center;
overflow: hidden;
margin-bottom: 8px;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb .ph {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--admin-faint);
}
.name {
font-size: 13px;
font-weight: 600;
color: var(--admin-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 11.5px;
color: var(--admin-muted);
margin: 3px 0 8px;
}
.actions {
display: flex;
gap: 10px;
font-size: 12.5px;
}
.actions a {
color: var(--admin-muted);
}
.actions a:hover {
color: var(--admin-accent);
}
.actions a.danger:hover {
color: var(--admin-danger);
}
</style>
+26 -1
View File
@@ -82,7 +82,32 @@ export const adminApi = {
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
// ---------- 文件上传 ----------
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
uploadFiles: async (formData) => {
const res = await fetch(base + '/api/admin/files', {
method: 'POST',
credentials: 'include',
body: formData
})
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
const err = new Error('未登录或登录已过期')
err.status = 401
throw err
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.error || `上传失败(${res.status})`)
err.status = res.status
throw err
}
return data
}
}
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
+1
View File
@@ -17,6 +17,7 @@ const routes = [
{ path: '', name: 'admin-dashboard', component: () => import('./admin/DashboardView.vue') },
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
+6
View File
@@ -222,6 +222,12 @@ html.topbar-hidden {--topbar-h: 0px;}
}
.btn:disabled {opacity: 0.5;
cursor: not-allowed;}
/* 编辑器封面:URL 输入框 + 上传按钮同行 */
.cover-row {display: flex;
gap: 6px;
align-items: center;}
.cover-row > input {flex: 1;
min-width: 0;}
.prose {font-size: 16.5px;
line-height: var(--lh);
color: var(--ink);}