新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
632 lines
20 KiB
Go
632 lines
20 KiB
Go
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)
|
||
}
|