diff --git a/.env.example b/.env.example
index 7ae6970..68379d0 100644
--- a/.env.example
+++ b/.env.example
@@ -18,3 +18,5 @@ ONE_TELEGRAM_BOT_TOKEN=
# 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名)
ONE_GOOGLE_CLIENT_ID=
ONE_GOOGLE_CLIENT_SECRET=
+
+ONE_SECRET=# 任意字符串,用于加密
diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go
index 595258a..f2c34ba 100644
--- a/backend/internal/admin/api.go
+++ b/backend/internal/admin/api.go
@@ -13,7 +13,9 @@ import (
"io"
"mime/multipart"
"net/http"
+ "net/url"
"os"
+ "path"
"path/filepath"
"strconv"
"strings"
@@ -23,6 +25,7 @@ import (
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/hub"
+ "oneblog/internal/linkmeta"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
@@ -79,6 +82,7 @@ func (a *API) Routes() http.Handler {
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/comments", a.guard(a.adminComments))
@@ -727,6 +731,12 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
}
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
@@ -734,12 +744,15 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
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(tmp.Name())
+ f, err := os.Open(tmpPath)
if err != nil {
return model.File{}, err
}
@@ -750,7 +763,7 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
created, err := a.Store.CreateFile(model.File{
Key: key,
- Name: fh.Filename,
+ Name: name,
Mime: mime,
Size: size,
SHA256: sum,
@@ -763,6 +776,99 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
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) {
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/"))
if err != nil {
diff --git a/backend/internal/hub/hub.go b/backend/internal/hub/hub.go
index 0b1b3a2..3f64383 100644
--- a/backend/internal/hub/hub.go
+++ b/backend/internal/hub/hub.go
@@ -18,6 +18,11 @@ func New() *Hub {
// Subscribe 订阅某主题;返回信号 channel 和退订函数。
// channel 容量 1:订阅者处理不过来时新信号直接丢弃(合并刷新)。
func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) {
+ // nil hub(测试/未装配场景)静默降级:永远收不到信号的通道
+ if h == nil {
+ ch := make(chan struct{})
+ return ch, func() {}
+ }
ch := make(chan struct{}, 1)
h.mu.Lock()
if h.subs[topic] == nil {
@@ -38,6 +43,9 @@ func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) {
// Broadcast 唤醒某主题的全部订阅者;积压的订阅者不阻塞。
func (h *Hub) Broadcast(topic int64) {
+ if h == nil {
+ return
+ }
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs[topic] {
diff --git a/backend/internal/linkmeta/linkmeta.go b/backend/internal/linkmeta/linkmeta.go
index 1185fbd..93473da 100644
--- a/backend/internal/linkmeta/linkmeta.go
+++ b/backend/internal/linkmeta/linkmeta.go
@@ -75,25 +75,7 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
return nil, errors.New("linkmeta: empty host")
}
- dial := f.Dial
- if dial == nil {
- dial = safeDial
- }
- timeout := f.Timeout
- if timeout <= 0 {
- timeout = 5 * time.Second
- }
- client := &http.Client{
- Transport: &http.Transport{
- DialContext: dial,
- TLSHandshakeTimeout: 3 * time.Second,
- // 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
- ForceAttemptHTTP2: false,
- },
- Timeout: timeout,
- CheckRedirect: limitRedirects,
- }
-
+ client := f.client()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
@@ -125,6 +107,78 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
return card, nil
}
+// client 组装带 SSRF 防护的 HTTP 客户端(Fetch 与 FetchBytes 共用)
+func (f *Fetcher) client() *http.Client {
+ dial := f.Dial
+ if dial == nil {
+ dial = safeDial
+ }
+ timeout := f.Timeout
+ if timeout <= 0 {
+ timeout = 5 * time.Second
+ }
+ return &http.Client{
+ Transport: &http.Transport{
+ DialContext: dial,
+ TLSHandshakeTimeout: 3 * time.Second,
+ // 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
+ ForceAttemptHTTP2: false,
+ },
+ Timeout: timeout,
+ CheckRedirect: limitRedirects,
+ }
+}
+
+// FetchBytes 抓二进制内容(外链图片转存用):与 Fetch 共用同一套
+// SSRF 防护与跳转限制,字节数有 maxBytes 硬上限。
+// 返回内容与 Content-Type(响应头缺失时用内容嗅探兜底)。
+func FetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
+ return (&Fetcher{}).fetchBytes(ctx, rawURL, maxBytes)
+}
+
+func (f *Fetcher) fetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
+ u, err := url.Parse(strings.TrimSpace(rawURL))
+ if err != nil {
+ return nil, "", fmt.Errorf("linkmeta: bad url: %w", err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return nil, "", fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
+ }
+ if u.Host == "" {
+ return nil, "", errors.New("linkmeta: empty host")
+ }
+ client := f.client()
+ // 二进制传输放宽时限:大图慢链路 5 秒的元信息默认值不够用
+ if f.Timeout <= 0 {
+ client.Timeout = 30 * time.Second
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ req.Header.Set("User-Agent", userAgent)
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, "", fmt.Errorf("linkmeta: fetch: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ return nil, "", fmt.Errorf("linkmeta: status %d", resp.StatusCode)
+ }
+ data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
+ if err != nil {
+ return nil, "", fmt.Errorf("linkmeta: read: %w", err)
+ }
+ if int64(len(data)) > maxBytes {
+ return nil, "", fmt.Errorf("linkmeta: exceeds %d bytes", maxBytes)
+ }
+ ct := resp.Header.Get("Content-Type")
+ if ct == "" {
+ ct = http.DetectContentType(data)
+ }
+ return data, ct, nil
+}
+
func limitRedirects(req *http.Request, via []*http.Request) error {
if len(via) > maxRedirects {
return fmt.Errorf("linkmeta: too many redirects")
diff --git a/frontend/package.json b/frontend/package.json
index b3ec1dd..ca76f2d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
+ "@codemirror/commands": "^6.11.1",
+ "@codemirror/lang-markdown": "^6.5.2",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/state": "^6.7.6",
+ "@codemirror/view": "^6.43.13",
"@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6",
"vue": "^3.4.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index f2d5db6..027e00f 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,6 +8,21 @@ importers:
.:
dependencies:
+ '@codemirror/commands':
+ specifier: ^6.11.1
+ version: 6.11.1
+ '@codemirror/lang-markdown':
+ specifier: ^6.5.2
+ version: 6.5.2
+ '@codemirror/language':
+ specifier: ^6.12.4
+ version: 6.12.4
+ '@codemirror/state':
+ specifier: ^6.7.6
+ version: 6.7.6
+ '@codemirror/view':
+ specifier: ^6.43.13
+ version: 6.43.13
'@milkdown/crepe':
specifier: ^7.22.1
version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
@@ -140,14 +155,14 @@ packages:
'@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
- '@codemirror/state@6.7.5':
- resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==}
+ '@codemirror/state@6.7.6':
+ resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
- '@codemirror/view@6.43.12':
- resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
+ '@codemirror/view@6.43.13':
+ resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -1118,15 +1133,15 @@ snapshots:
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/commands@6.11.1':
dependencies:
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
@@ -1147,7 +1162,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
@@ -1155,7 +1170,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
@@ -1165,8 +1180,8 @@ snapshots:
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
'@lezer/html': 1.3.13
@@ -1181,8 +1196,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5
@@ -1191,8 +1206,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1215,8 +1230,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1226,8 +1241,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2
@@ -1235,7 +1250,7 @@ snapshots:
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/php': 1.0.6
@@ -1243,7 +1258,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
@@ -1256,7 +1271,7 @@ snapshots:
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
@@ -1264,7 +1279,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1289,8 +1304,8 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
@@ -1298,7 +1313,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1332,8 +1347,8 @@ snapshots:
'@codemirror/language@6.12.4':
dependencies:
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1345,30 +1360,30 @@ snapshots:
'@codemirror/lint@6.9.7':
dependencies:
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
crelt: 1.0.7
- '@codemirror/state@6.7.5':
+ '@codemirror/state@6.7.6':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@lezer/highlight': 1.2.3
- '@codemirror/view@6.43.12':
+ '@codemirror/view@6.43.13':
dependencies:
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
@@ -1550,11 +1565,11 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {}
- '@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
+ '@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies:
'@codemirror/language': 6.12.4
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
'@floating-ui/dom': 1.8.0
'@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1
@@ -1594,10 +1609,10 @@ snapshots:
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4
'@codemirror/language-data': 6.5.2
- '@codemirror/state': 6.7.5
+ '@codemirror/state': 6.7.6
'@codemirror/theme-one-dark': 6.1.3
- '@codemirror/view': 6.43.12
- '@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
+ '@codemirror/view': 6.43.13
+ '@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@types/lodash-es': 4.17.12
clsx: 2.1.1
codemirror: 6.0.2
@@ -1621,9 +1636,9 @@ snapshots:
'@milkdown/exception@7.22.1': {}
- '@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
+ '@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies:
- '@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
+ '@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1
'@milkdown/exception': 7.22.1
@@ -2036,8 +2051,8 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
- '@codemirror/state': 6.7.5
- '@codemirror/view': 6.43.12
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.13
commander@15.0.0: {}
diff --git a/frontend/src/admin/EditorView.vue b/frontend/src/admin/EditorView.vue
index 6579bd5..a0d7840 100644
--- a/frontend/src/admin/EditorView.vue
+++ b/frontend/src/admin/EditorView.vue
@@ -2,9 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
+import { site } from '../site'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
+import { EditorView as CMView, keymap } from '@codemirror/view'
+import { EditorState } from '@codemirror/state'
+import { markdown as markdownLang } from '@codemirror/lang-markdown'
+import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
const route = useRoute()
const router = useRouter()
@@ -184,6 +189,10 @@ onBeforeUnmount(() => {
crepe.destroy()
crepe = null
}
+ if (cmView) {
+ cmView.destroy()
+ cmView = null
+ }
})
// ---------- mode switching ----------
@@ -195,6 +204,10 @@ async function switchMode(next) {
crepe.destroy()
crepe = null
}
+ // CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
+ await nextTick()
+ mountCM()
+ syncCM()
} else {
// MD → WYSIWYG: spin up crepe with the current markdown
await nextTick()
@@ -399,6 +412,76 @@ function clearCover() {
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null)
+
+// ---------- CodeMirror 源码模式 ----------
+// md 页签不是裸 textarea:语法高亮 + 行内历史(⌘Z),粘贴/拖图片直接上传。
+// markdown 仍是唯一真相源——CM 的改动写回 form.content_md,
+// 外部改动(转存替换、wysiwyg 切回)用 syncCM 全量灌回。
+let cmView = null
+const cmTheme = CMView.theme({
+ '&': { color: 'var(--admin-ink)', backgroundColor: 'transparent', fontSize: '13.5px' },
+ '.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '1.75', padding: '12px 0 40vh' },
+ '.cm-scroller': { overflow: 'auto', maxHeight: '70vh' },
+ '.cm-line': { padding: '0 2px' },
+ '&.cm-focused': { outline: 'none' },
+ '.cm-cursor': { borderLeftColor: 'var(--admin-accent)' },
+ '.cm-selectionBackground, ::selection': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 18%, transparent) !important' },
+ '.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 6%, transparent)' },
+ '.cm-heading, .cm-header': { color: 'var(--admin-accent)', fontWeight: '600' },
+ '.cm-link, .cm-url': { color: 'var(--admin-accent)', textDecoration: 'underline' },
+ '.cm-emphasis': { fontStyle: 'italic' },
+ '.cm-strong': { fontWeight: '700' },
+ '.cm-code, .cm-monospace': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: 'var(--admin-muted)' }
+})
+
+function handleEditorFiles(files, view) {
+ const list = [...files].filter((f) => f.type.startsWith('image/'))
+ if (!list.length) return false
+ view.dispatch({
+ changes: { from: view.state.selection.main.from, insert: ' ' }
+ })
+ ;[...list].forEach((f) => uploadOne(f, { insertImage: true }))
+ return true
+}
+
+function mountCM() {
+ if (cmView || !mdPane.value) return
+ cmView = new CMView({
+ parent: mdPane.value,
+ state: EditorState.create({
+ doc: form.content_md,
+ extensions: [
+ history(),
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
+ markdownLang(),
+ cmTheme,
+ CMView.updateListener.of((u) => {
+ if (u.docChanged) form.content_md = u.state.doc.toString()
+ }),
+ CMView.domEventHandlers({
+ paste: (e, view) => handleEditorFiles(e.clipboardData?.files, view),
+ drop: (e, view) => {
+ if (e.dataTransfer?.files?.length) {
+ e.preventDefault()
+ return handleEditorFiles(e.dataTransfer.files, view)
+ }
+ return false
+ }
+ })
+ ]
+ })
+ })
+}
+
+// 用 form.content_md 的当前值整段灌回(进入 md 页签、转存替换后调用)
+function syncCM() {
+ if (!cmView) return
+ const cur = cmView.state.doc.toString()
+ if (cur === form.content_md) return
+ cmView.dispatch({
+ changes: { from: 0, to: cur.length, insert: form.content_md }
+ })
+}
const showLinkInput = ref(false)
const showImageInput = ref(false)
const linkText = ref('')
@@ -407,22 +490,31 @@ const imageUrl = ref('')
const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) {
+ if (cmView) {
+ if (selStart == null) {
+ const s = cmView.state.selection.main
+ selStart = s.from
+ selEnd = s.to
+ }
+ cmView.dispatch({
+ changes: { from: selStart, to: selEnd, insert: text },
+ selection: { anchor: selStart + text.length }
+ })
+ cmView.focus()
+ return
+ }
const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd)
form.content_md = before + text + after
- nextTick(() => {
- if (!mdPane.value) return
- const newPos = selStart + text.length
- mdPane.value.focus()
- mdPane.value.setSelectionRange(newPos, newPos)
- })
}
function withSelection(fn) {
- const el = mdPane.value
- const start = el ? el.selectionStart : form.content_md.length
- const end = el ? el.selectionEnd : form.content_md.length
- fn(start, end)
+ if (cmView) {
+ const sel = cmView.state.selection.main
+ fn(sel.from, sel.to)
+ return
+ }
+ fn(form.content_md.length, form.content_md.length)
}
function tbBold() {
@@ -533,24 +625,142 @@ function commitImage() {
showImageInput.value = false
}
+// ---------- toast ----------
+// 编辑器内的轻提示:上传进度、转存结果、校验失败。自动消失,错误多留一会儿。
+const toasts = reactive([])
+let toastSeq = 0
+function toast(text, type = 'info', ttl = 2600) {
+ const id = ++toastSeq
+ toasts.push({ id, text, type })
+ setTimeout(() => {
+ const i = toasts.findIndex((t) => t.id === id)
+ if (i >= 0) toasts.splice(i, 1)
+ }, ttl)
+ return id
+}
+function toastUpdate(id, text, type = 'info') {
+ const t = toasts.find((x) => x.id === id)
+ if (t) {
+ t.text = text
+ t.type = type
+ }
+}
+function toastDone(id, text) {
+ toastUpdate(id, text, 'ok')
+ const idx = toasts.findIndex((t) => t.id === id)
+ if (idx >= 0) {
+ const t = toasts[idx]
+ setTimeout(() => {
+ const i = toasts.indexOf(t)
+ if (i >= 0) toasts.splice(i, 1)
+ }, 1800)
+ }
+}
+
// ---------- 上传(封面 / 编辑器插图共用) ----------
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false)
-async function uploadOne(file) {
- const fd = new FormData()
- fd.append('file', file)
+// 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
+const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
+const MAX_UPLOAD_MB = 50
+
+function validateFile(file) {
+ const ext = (file.name.split('.').pop() || '').toLowerCase()
+ if (!ALLOW_UPLOAD_EXT.includes(ext)) {
+ return `不支持的类型 .${ext}(允许:${ALLOW_UPLOAD_EXT.join('/')})`
+ }
+ if (file.size > MAX_UPLOAD_MB * 1024 * 1024) {
+ return `${file.name} 超过 ${MAX_UPLOAD_MB}MB 上限`
+ }
+ return ''
+}
+
+// opts.cmView 传了就把结果以 markdown 图片插到该编辑器光标处(md 页签粘贴/拖拽)
+async function uploadOne(file, opts = {}) {
+ const bad = validateFile(file)
+ if (bad) {
+ toast(bad, 'err')
+ throw new Error(bad)
+ }
+ const tid = toast(`上传 ${file.name} 0%`, 'info', 60000)
try {
- const data = await adminApi.uploadFiles(fd)
+ const data = await adminApi.uploadFile(file, (p) =>
+ toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
+ )
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败')
+ toastDone(tid, `${file.name} 上传完成`)
+ if (opts.cmView) {
+ const v = opts.cmView
+ const pos = v.state.selection.main.from
+ v.dispatch({ changes: { from: pos, insert: `\n` } })
+ }
return f.url
} catch (e) {
+ toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
error.value = e.message || '上传失败'
throw e
}
}
+// ---------- 外链图片转存 ----------
+// xLog 的 Cloud:正文里的外链 一键抓回自己的存储(后端走 SSRF
+// 防护拨号 + 同一套白名单/去重),成功后原地替换 markdown 里的 URL。
+const importing = ref(false)
+
+function externalImages(md) {
+ const own = [site.uploads_public_base, location.origin].filter(Boolean)
+ const out = []
+ const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g
+ let m
+ while ((m = re.exec(md))) {
+ const u = m[1]
+ if (!own.some((b) => u.startsWith(b)) && !u.startsWith('/uploads/')) out.push(u)
+ }
+ return [...new Set(out)]
+}
+
+async function importExternal() {
+ if (importing.value) return
+ const urls = externalImages(form.content_md)
+ if (!urls.length) {
+ toast('正文里没有外链图片', 'info')
+ return
+ }
+ importing.value = true
+ const tid = toast(`转存外链图片 0/${urls.length}…`, 'info', 120000)
+ try {
+ const data = await adminApi.importFiles(urls)
+ // 后端按原址回:source -> 转存后的文件(同内容去重时可能是已有行)
+ const ok = new Map((data.files || []).map((e) => [e.source, e.file]))
+ const errs = data.errors || {}
+ let done = 0
+ let changed = false
+ form.content_md = form.content_md.replace(
+ /!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
+ (whole, alt, u) => {
+ if (!ok.has(u)) return whole
+ done++
+ toastUpdate(tid, `转存外链图片 ${done}/${urls.length}…`)
+ changed = true
+ return `.url})`
+ }
+ )
+ if (changed) syncCM()
+ const fail = Object.keys(errs).length
+ if (fail) {
+ toastUpdate(tid, `转存完成:${done} 成功,${fail} 失败(${Object.values(errs)[0]})`, fail ? 'err' : 'ok')
+ } else {
+ toastDone(tid, `转存完成:${done} 张已入自己的存储`)
+ }
+ } catch (e) {
+ toastUpdate(tid, '转存失败:' + (e.message || ''), 'err')
+ } finally {
+ importing.value = false
+ }
+}
+
const coverInput = ref(null)
async function onCoverPick(e) {
const file = e.target.files && e.target.files[0]
@@ -693,6 +903,14 @@ const tbGroups = computed(() => [