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: `![](${f.url})\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 `![${alt}](${ok.get(u).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(() => [
+
@@ -775,16 +993,13 @@ const tbGroups = computed(() => [
- + +
+ + +
+
{{ t.text }}
+
{{ cjkChars }} 汉字 diff --git a/frontend/src/api.js b/frontend/src/api.js index f06fd6e..9020eff 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -96,6 +96,35 @@ export const adminApi = { deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }), // 上传走 FormData:request() 是 JSON helper,这里单独 fetch。 // 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。 + // 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里 + uploadFile: (file, onProgress) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', base + '/api/admin/files') + xhr.withCredentials = true + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total) + } + xhr.onload = () => { + if (xhr.status === 401) { + window.dispatchEvent(new CustomEvent('one:unauthorized')) + reject(new Error('未登录或登录已过期')) + return + } + let data = {} + try { + data = JSON.parse(xhr.responseText) + } catch {} + if (xhr.status >= 200 && xhr.status < 300) resolve(data) + else reject(new Error(data.error || `上传失败(${xhr.status})`)) + } + xhr.onerror = () => reject(new Error('网络错误,上传中止')) + const fd = new FormData() + fd.append('file', file) + xhr.send(fd) + }), + // 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors } + importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }), uploadFiles: async (formData) => { const res = await fetch(base + '/api/admin/files', { method: 'POST', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 60bac9b..e0b79d6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -835,17 +835,37 @@ html.topbar-hidden {--topbar-h: 0px;} 0%, 100% {opacity: 1;} 50% {opacity: 0.3;} } +/* md 页签容器现在是 CodeMirror 挂载点(原为 textarea) */ .md-pane {width: 100%; min-height: 460px; border: 1px solid var(--admin-line); border-radius: 3px; background: var(--admin-card); - padding: 18px 22px; - font-family: var(--mono); - font-size: 14px; - line-height: 1.7; + padding: 6px 14px; color: var(--admin-ink); - resize: vertical; + overflow: hidden;} +.md-pane:focus-within {border-color: var(--admin-accent);} +/* 轻提示栈:上传进度 / 转存结果 / 校验失败 */ +.toast-stack {position: fixed; + right: 18px; + bottom: 18px; + z-index: 90; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 360px;} +.toast {padding: 9px 14px; + border-radius: 6px; + font-size: 13px; + line-height: 1.6; + color: var(--admin-ink); + background: var(--admin-card); + border: 1px solid var(--admin-line); + box-shadow: 0 6px 24px rgba(0,0,0,.12); + overflow-wrap: anywhere;} +.toast.ok {border-color: color-mix(in srgb, var(--admin-accent) 55%, transparent);} +.toast.err {border-color: #c26a55; + color: #b4553f; outline: none;} .md-pane:focus {border-color: var(--admin-accent);} .toolbar {display: flex;