feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
631
backend/service/library_import.go
Normal file
631
backend/service/library_import.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package service
|
||||
|
||||
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
|
||||
//
|
||||
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
|
||||
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
|
||||
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
|
||||
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
|
||||
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
|
||||
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
|
||||
const LibraryImportMaxBytes = 512 << 20
|
||||
|
||||
const (
|
||||
LibraryImportModeCreate = "create" // 冲突跳过
|
||||
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
|
||||
)
|
||||
|
||||
// 失败原因(前端据此区分冲突,可提示覆盖重导)
|
||||
const (
|
||||
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
|
||||
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
|
||||
libraryImportReasonInvalid = "invalid" // 清单数据非法
|
||||
libraryImportReasonFailed = "failed" // 落盘/写库失败
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
|
||||
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
|
||||
ErrLibraryImportVer = errors.New("导出版本不受支持")
|
||||
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
|
||||
ErrLibraryImportMode = errors.New("无效的导入模式")
|
||||
)
|
||||
|
||||
// LibraryImportItem 成功导入的单本结果
|
||||
type LibraryImportItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Action string `json:"action"` // created / overwritten
|
||||
Sections int `json:"sections"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
// LibraryImportFailure 单本书失败明细
|
||||
type LibraryImportFailure struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// LibraryImportReport 导入报告
|
||||
type LibraryImportReport struct {
|
||||
Mode string `json:"mode"`
|
||||
Total int `json:"total"`
|
||||
Imported []LibraryImportItem `json:"imported"`
|
||||
Failed []LibraryImportFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
|
||||
type bookImportPlan struct {
|
||||
book libraryExportBook
|
||||
dir string
|
||||
}
|
||||
|
||||
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
|
||||
type plannedSection struct {
|
||||
key string
|
||||
parentKey string
|
||||
title string
|
||||
content string
|
||||
sort int
|
||||
}
|
||||
|
||||
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
|
||||
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
|
||||
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
|
||||
return nil, ErrLibraryImportMode
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, ErrLibraryImportBadZip
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
entries := make(map[string]*zip.File, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
|
||||
}
|
||||
|
||||
plans, err := parseImportPlans(entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return nil, ErrLibraryImportEmpty
|
||||
}
|
||||
|
||||
rep := &LibraryImportReport{
|
||||
Mode: mode,
|
||||
Total: len(plans),
|
||||
Imported: []LibraryImportItem{},
|
||||
Failed: []LibraryImportFailure{},
|
||||
}
|
||||
imp := &bookImporter{s: s, entries: entries, userID: userID}
|
||||
for _, p := range plans {
|
||||
item, fail := imp.run(p, mode)
|
||||
if fail != nil {
|
||||
rep.Failed = append(rep.Failed, *fail)
|
||||
} else {
|
||||
rep.Imported = append(rep.Imported, *item)
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
|
||||
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
|
||||
if rootRaw, ok := entries["library.json"]; ok {
|
||||
var root libraryExportManifest
|
||||
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
|
||||
return nil, ErrLibraryImportVer
|
||||
}
|
||||
plans := make([]bookImportPlan, 0, len(root.Docs))
|
||||
for _, d := range root.Docs {
|
||||
dir := strings.ReplaceAll(d.Dir, "\\", "/")
|
||||
if !safeImportDir(dir) {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
raw, ok := entries[path.Clean(dir)+"/book.json"]
|
||||
if !ok {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
var b libraryExportBook
|
||||
if err := readZipJSON(raw, &b); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
plans = append(plans, bookImportPlan{book: b, dir: dir})
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
if rootRaw, ok := entries["book.json"]; ok {
|
||||
var root libraryExportManifest
|
||||
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
|
||||
return nil, ErrLibraryImportVer
|
||||
}
|
||||
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
|
||||
}
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
|
||||
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
|
||||
func safeImportDir(dir string) bool {
|
||||
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
|
||||
return false
|
||||
}
|
||||
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
|
||||
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
|
||||
return false
|
||||
}
|
||||
return sitePageSlugRe.MatchString(slug)
|
||||
}
|
||||
|
||||
// bookImporter 携带一次批量导入的共享上下文
|
||||
type bookImporter struct {
|
||||
s *LibraryService
|
||||
entries map[string]*zip.File
|
||||
userID uint
|
||||
}
|
||||
|
||||
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
|
||||
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
|
||||
}
|
||||
|
||||
// run 导入单本书;返回 item 或 failure(二者互斥)
|
||||
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
|
||||
b := p.book
|
||||
|
||||
in, err := buildImportInput(&b)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
sections, err := buildImportSections(&b)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
if err := imp.checkPlannedFiles(p); err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
coverEntry, err := imp.planCover(p)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
|
||||
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
|
||||
}
|
||||
|
||||
var docID uint
|
||||
action := "created"
|
||||
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
|
||||
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
|
||||
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
|
||||
switch {
|
||||
case active && mode != LibraryImportModeOverwrite:
|
||||
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
|
||||
case active:
|
||||
var existing model.LibraryDoc
|
||||
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
|
||||
}
|
||||
docID = existing.ID
|
||||
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
|
||||
backfillCreator = existing.CreatorID == 0
|
||||
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
|
||||
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
|
||||
oldCoverDisk = disk
|
||||
}
|
||||
action = "overwritten"
|
||||
case deleted:
|
||||
return nil, imp.fail(p, libraryImportReasonDeleted,
|
||||
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
|
||||
}
|
||||
|
||||
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
|
||||
coverDisk := ""
|
||||
if coverEntry != nil {
|
||||
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
|
||||
if ferr != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
|
||||
}
|
||||
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
|
||||
}
|
||||
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
|
||||
|
||||
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if action == "created" {
|
||||
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
|
||||
in.applyTo(d)
|
||||
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
docID = d.ID
|
||||
} else {
|
||||
var d model.LibraryDoc
|
||||
if err := tx.First(&d, docID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
in.applyTo(&d)
|
||||
updates := map[string]interface{}{
|
||||
"slug": d.Slug,
|
||||
"title": d.Title,
|
||||
"author": d.Author,
|
||||
"description": d.Description,
|
||||
"cover_url": d.CoverURL,
|
||||
"cover_width": d.CoverWidth,
|
||||
"cover_height": d.CoverHeight,
|
||||
"published": d.Published,
|
||||
"sort_order": d.SortOrder,
|
||||
"entries_auto": d.EntriesAuto,
|
||||
}
|
||||
if backfillCreator {
|
||||
updates["creator_id"] = imp.userID
|
||||
}
|
||||
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return imp.createSections(tx, docID, sections)
|
||||
})
|
||||
if txErr != nil {
|
||||
if action == "created" && coverDisk != "" {
|
||||
_ = os.Remove(coverDisk)
|
||||
}
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
|
||||
}
|
||||
|
||||
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
|
||||
for i := range oldFiles {
|
||||
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
|
||||
}
|
||||
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
|
||||
_ = os.Remove(oldCoverDisk)
|
||||
}
|
||||
|
||||
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
|
||||
added := 0
|
||||
var newFiles []model.LibraryFile
|
||||
for _, mf := range b.Files {
|
||||
if mf.Missing || mf.Stored == "" {
|
||||
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
|
||||
}
|
||||
f, fail := imp.addPlannedFile(p, docID, mf)
|
||||
if fail != nil {
|
||||
if action == "created" {
|
||||
imp.rollbackCreated(docID, newFiles, coverDisk)
|
||||
}
|
||||
return nil, fail
|
||||
}
|
||||
newFiles = append(newFiles, *f)
|
||||
added++
|
||||
}
|
||||
|
||||
return &LibraryImportItem{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Action: action,
|
||||
Sections: len(sections),
|
||||
Files: added,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 清单解析与预检 ----------
|
||||
|
||||
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
|
||||
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
|
||||
in := &LibraryInput{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Author: b.Author,
|
||||
Description: b.Description,
|
||||
CoverURL: b.CoverURL,
|
||||
CoverWidth: b.CoverWidth,
|
||||
CoverHeight: b.CoverHeight,
|
||||
Published: &pub,
|
||||
SortOrder: &sortOrder,
|
||||
EntriesAuto: &entriesAuto,
|
||||
}
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
|
||||
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
|
||||
if len(b.Sections) > MaxSectionsPerDoc {
|
||||
return nil, errors.New("章节数量超过上限(最多 200)")
|
||||
}
|
||||
keySeen := map[string]bool{}
|
||||
out := make([]plannedSection, 0, len(b.Sections))
|
||||
|
||||
chapterSort := map[string]int{}
|
||||
order := 0
|
||||
for _, sec := range b.Sections {
|
||||
if sec.ParentKey != "" {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(sec.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("存在标题为空的章节")
|
||||
}
|
||||
if err := validateSectionText(title, sec.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keySeen[sec.Key] {
|
||||
return nil, errors.New("章节标识重复:" + sec.Key)
|
||||
}
|
||||
keySeen[sec.Key] = true
|
||||
chapterSort[sec.Key] = order
|
||||
out = append(out, plannedSection{
|
||||
key: sec.Key, title: title, content: sec.Content, sort: order,
|
||||
})
|
||||
order++
|
||||
}
|
||||
|
||||
childCount := map[string]int{}
|
||||
for _, sec := range b.Sections {
|
||||
if sec.ParentKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := chapterSort[sec.ParentKey]; !ok {
|
||||
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
|
||||
}
|
||||
title := strings.TrimSpace(sec.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("存在标题为空的小节")
|
||||
}
|
||||
if err := validateSectionText(title, sec.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, plannedSection{
|
||||
key: sec.Key,
|
||||
parentKey: sec.ParentKey,
|
||||
title: title,
|
||||
content: sec.Content,
|
||||
sort: childCount[sec.ParentKey],
|
||||
})
|
||||
childCount[sec.ParentKey]++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateSectionText(title, content string) error {
|
||||
if len([]rune(title)) > 200 {
|
||||
return errors.New("章节标题不能超过 200 字:" + title)
|
||||
}
|
||||
if len([]rune(content)) > MaxSectionContent {
|
||||
return errors.New("章节正文不能超过 100000 字:" + title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
|
||||
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
|
||||
if len(p.book.Files) > MaxLibraryFilesPerDoc {
|
||||
return errors.New("附件数量超过上限(最多 20 个)")
|
||||
}
|
||||
maxB := imp.s.maxBytes()
|
||||
for _, f := range p.book.Files {
|
||||
if f.Missing || f.Stored == "" {
|
||||
continue
|
||||
}
|
||||
rel, ok := cleanZipRel(f.Stored)
|
||||
if !ok || !strings.HasPrefix(rel, "files/") {
|
||||
return errors.New("附件路径非法:" + f.Stored)
|
||||
}
|
||||
name := sanitizeFilename(f.Name)
|
||||
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
|
||||
return errors.New("附件格式不受支持:" + f.Name)
|
||||
}
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return errors.New("备份包缺少附件文件:" + f.Name)
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(maxB) {
|
||||
return errors.New("附件超过大小上限:" + f.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
|
||||
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
|
||||
cf := strings.TrimSpace(p.book.CoverFile)
|
||||
if cf == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rel, ok := cleanZipRel(cf)
|
||||
if !ok || strings.Contains(rel, "/") {
|
||||
return nil, errors.New("封面路径非法:" + cf)
|
||||
}
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return nil, errors.New("备份包缺少封面文件")
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
|
||||
return nil, errors.New("封面不能超过 5MB")
|
||||
}
|
||||
return zf, nil
|
||||
}
|
||||
|
||||
// ---------- 落盘 ----------
|
||||
|
||||
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
|
||||
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
|
||||
keyToID := make(map[string]uint, len(sections))
|
||||
for i := range sections {
|
||||
ps := §ions[i]
|
||||
if ps.parentKey != "" {
|
||||
continue
|
||||
}
|
||||
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
keyToID[ps.key] = sec.ID
|
||||
}
|
||||
for i := range sections {
|
||||
ps := §ions[i]
|
||||
if ps.parentKey == "" {
|
||||
continue
|
||||
}
|
||||
parentID, ok := keyToID[ps.parentKey]
|
||||
if !ok {
|
||||
return errors.New("小节找不到所属章节")
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID, ParentID: &parentID,
|
||||
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
|
||||
}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
|
||||
func (imp *bookImporter) addPlannedFile(
|
||||
p bookImportPlan, docID uint, mf libraryExportFile,
|
||||
) (*model.LibraryFile, *LibraryImportFailure) {
|
||||
rel, _ := cleanZipRel(mf.Stored)
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
|
||||
}
|
||||
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"download_count": mf.DownloadCount,
|
||||
"sort_order": mf.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
|
||||
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
|
||||
if imp.s.uploadsDir == "" {
|
||||
return "", 0, 0, "", errors.New("未配置上传目录")
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
if int64(len(data)) > ImageMaxBytes {
|
||||
return "", 0, 0, "", errors.New("封面不能超过 5MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||
if err != nil || width < 1 || height < 1 {
|
||||
return "", 0, 0, "", errors.New("无法解析封面图片")
|
||||
}
|
||||
if width > ImageMaxDim || height > ImageMaxDim {
|
||||
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
|
||||
}
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
full := filepath.Join(imagesDir, filename)
|
||||
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
return "/uploads/images/" + filename, width, height, full, nil
|
||||
}
|
||||
|
||||
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
|
||||
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
|
||||
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
|
||||
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
|
||||
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
|
||||
for i := range newFiles {
|
||||
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
|
||||
}
|
||||
if coverDisk != "" {
|
||||
_ = os.Remove(coverDisk)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ZIP 工具 ----------
|
||||
|
||||
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
|
||||
func cleanZipRel(rel string) (string, bool) {
|
||||
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
|
||||
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
|
||||
return "", false
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func readZipJSON(zf *zip.File, v any) error {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
return json.NewDecoder(rc).Decode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user