From 34f0faedcc5a3dbc7aa2c344a2849d5057a91f48 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:26:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=EF=BC=9AR2?= =?UTF-8?q?=20=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=20+=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=20+=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8/=E5=B0=81=E9=9D=A2=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存储抽象(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 全绿;前端构建通过 --- .env.example | 5 + backend/go.mod | 18 ++ backend/go.sum | 36 ++++ backend/internal/admin/api.go | 227 +++++++++++++++++++++ backend/internal/api/api.go | 58 +++++- backend/internal/config/config.go | 45 +++++ backend/internal/model/model.go | 23 +++ backend/internal/storage/local.go | 79 ++++++++ backend/internal/storage/r2.go | 74 +++++++ backend/internal/storage/storage.go | 54 +++++ backend/internal/store/store.go | 117 +++++++++++ backend/main.go | 18 +- frontend/src/admin/AdminLayout.vue | 5 + frontend/src/admin/EditorView.vue | 121 ++++++++++-- frontend/src/admin/FilesView.vue | 292 ++++++++++++++++++++++++++++ frontend/src/api.js | 27 ++- frontend/src/router.js | 1 + frontend/src/styles.css | 6 + 18 files changed, 1191 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 backend/internal/storage/local.go create mode 100644 backend/internal/storage/r2.go create mode 100644 backend/internal/storage/storage.go create mode 100644 frontend/src/admin/FilesView.vue diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4117b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +AccessKey= +SecretAccessKey= +S3Api="https://.r2.cloudflarestorage.com" +PublicURL= +Bucket= diff --git a/backend/go.mod b/backend/go.mod index 52a1540..56f12da 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 4a8cf15..a4653a9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 2aeba82..3ebd36f 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -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") + } +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 20ea361..eafcd4c 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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 + 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) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 625c101..3c38df3 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index caf3a31..1a76224 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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"` diff --git a/backend/internal/storage/local.go b/backend/internal/storage/local.go new file mode 100644 index 0000000..5a9b5ee --- /dev/null +++ b/backend/internal/storage/local.go @@ -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 +} diff --git a/backend/internal/storage/r2.go b/backend/internal/storage/r2.go new file mode 100644 index 0000000..e89f507 --- /dev/null +++ b/backend/internal/storage/r2.go @@ -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://.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 +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go new file mode 100644 index 0000000..2b94df6 --- /dev/null +++ b/backend/internal/storage/storage.go @@ -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 +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index ef420be..38d9c94 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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 +} diff --git a/backend/main.go b/backend/main.go index c42cc92..a168a56 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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{ diff --git a/frontend/src/admin/AdminLayout.vue b/frontend/src/admin/AdminLayout.vue index 1f5daa0..286d6e1 100644 --- a/frontend/src/admin/AdminLayout.vue +++ b/frontend/src/admin/AdminLayout.vue @@ -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()) ◈ 作品 + + ▣ + 文件 +
操作
✎ diff --git a/frontend/src/admin/EditorView.vue b/frontend/src/admin/EditorView.vue index 27725a1..7b60385 100644 --- a/frontend/src/admin/EditorView.vue +++ b/frontend/src/admin/EditorView.vue @@ -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,8 +755,14 @@ const tbGroups = computed(() => [ aria-label="图片 URL" @keydown.enter="commitImage" /> - - +
+ + + +
+ @@ -683,6 +774,8 @@ const tbGroups = computed(() => [ v-model="form.content_md" placeholder="直接写 Markdown…" aria-label="Markdown 正文" + @paste="onMdPaste" + @drop="onMdDrop" >
@@ -715,15 +808,21 @@ const tbGroups = computed(() => [
- +
+ + +
+

回车或失焦即生效;前台卡片与详情页头图会用到。

diff --git a/frontend/src/admin/FilesView.vue b/frontend/src/admin/FilesView.vue new file mode 100644 index 0000000..d5e4be4 --- /dev/null +++ b/frontend/src/admin/FilesView.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/frontend/src/api.js b/frontend/src/api.js index 581c0dc..b15127f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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), diff --git a/frontend/src/router.js b/frontend/src/router.js index a3e619f..532622e 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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') }, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b33c41a..2e305ff 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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);}