初始化
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
dramapkg "juhe-factory/api/internal/drama"
|
||||
"juhe-factory/api/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/richardlehane/mscfb"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const maxDramaImportBytes = 3 * 1024 * 1024
|
||||
|
||||
var chapterTitlePattern = regexp.MustCompile(`(?im)^\s*(?:#+\s*|[*+-]\s*)?((?:第[零〇一二三四五六七八九十百千万两\d]+[章节回卷篇部集幕].*)|(?:chapter\s+\d+.*))\s*$`)
|
||||
|
||||
type DramaChapter struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"`
|
||||
CharCount int `json:"char_count"`
|
||||
}
|
||||
|
||||
type DramaImportPreview struct {
|
||||
ImportToken uuid.UUID `json:"import_token"`
|
||||
HasChapters bool `json:"has_chapters"`
|
||||
TotalCharacters int `json:"total_characters"`
|
||||
Chapters []DramaChapter `json:"chapters"`
|
||||
SingleEpisodeConfirmationNeeded bool `json:"single_episode_confirmation_required"`
|
||||
}
|
||||
|
||||
func (s *Creative) PreviewDramaImport(userID, projectID uuid.UUID, filename string, data []byte, rawText string) (*DramaImportPreview, error) {
|
||||
if err := s.requirePremiumProject(userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxDramaImportBytes || len([]byte(rawText)) > maxDramaImportBytes {
|
||||
return nil, errors.New("小说文件或文本不能超过3MB")
|
||||
}
|
||||
text, sourceType, err := parseDramaSource(filename, data, rawText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text = normalizeDramaText(text)
|
||||
if text == "" {
|
||||
return nil, errors.New("未读取到有效小说正文")
|
||||
}
|
||||
chapters := splitDramaChapters(text)
|
||||
metadata := make([]DramaChapter, 0, len(chapters))
|
||||
for _, chapter := range chapters {
|
||||
metadata = append(metadata, DramaChapter{Title: chapter.Title, CharCount: len([]rune(chapter.Content))})
|
||||
}
|
||||
previewJSON, _ := json.Marshal(map[string]any{"has_chapters": len(chapters) > 0, "chapters": metadata})
|
||||
hash := sha256.Sum256([]byte(text))
|
||||
session := model.DramaImportSession{
|
||||
ID: uuid.New(), UserID: userID, ProjectID: projectID, SourceType: sourceType,
|
||||
SourceFilename: cleanDramaFilename(filename), RawContent: text, ContentSHA256: hex.EncodeToString(hash[:]),
|
||||
PreviewData: previewJSON, ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
if err := s.DB.Create(&session).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DramaImportPreview{
|
||||
ImportToken: session.ID, HasChapters: len(chapters) > 0, TotalCharacters: len([]rune(text)),
|
||||
Chapters: metadata, SingleEpisodeConfirmationNeeded: len(chapters) == 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Creative) ConfirmDramaImport(userID, projectID, token uuid.UUID, chaptersPerEpisode, startEpisodeNo int, treatAsSingle bool) ([]model.ProjectEpisode, error) {
|
||||
if chaptersPerEpisode < 1 {
|
||||
chaptersPerEpisode = 1
|
||||
}
|
||||
if startEpisodeNo < 1 {
|
||||
return nil, errors.New("起始集数无效")
|
||||
}
|
||||
created := make([]model.ProjectEpisode, 0)
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var project model.CreativeProject
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
var session model.DramaImportSession
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND user_id=? AND project_id=?", token, userID, projectID).Take(&session).Error; err != nil {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if session.ConsumedAt != nil || time.Now().After(session.ExpiresAt) {
|
||||
return errors.New("导入预览已失效,请重新上传")
|
||||
}
|
||||
chapters := splitDramaChapters(session.RawContent)
|
||||
if len(chapters) == 0 {
|
||||
if !treatAsSingle {
|
||||
return errors.New("需要确认将全部内容作为一集导入")
|
||||
}
|
||||
chapters = []DramaChapter{{Title: fmt.Sprintf("第%d集", startEpisodeNo), Content: session.RawContent, CharCount: len([]rune(session.RawContent))}}
|
||||
chaptersPerEpisode = 1
|
||||
}
|
||||
for offset, index := 0, 0; index < len(chapters); offset, index = offset+1, index+chaptersPerEpisode {
|
||||
end := index + chaptersPerEpisode
|
||||
if end > len(chapters) {
|
||||
end = len(chapters)
|
||||
}
|
||||
group := chapters[index:end]
|
||||
episodeNo := startEpisodeNo + offset
|
||||
name := fmt.Sprintf("第%d集", episodeNo)
|
||||
if len(group) == 1 && strings.TrimSpace(group[0].Title) != "" {
|
||||
name = group[0].Title
|
||||
}
|
||||
name = truncateRunes(strings.TrimSpace(name), 160)
|
||||
exists, err := episodeNameExists(tx, projectID, nil, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errors.New("同一项目下不可有同名剧集")
|
||||
}
|
||||
parts := make([]string, 0, len(group))
|
||||
for _, chapter := range group {
|
||||
parts = append(parts, strings.TrimSpace(chapter.Content))
|
||||
}
|
||||
content := strings.TrimSpace(strings.Join(parts, "\n\n"))
|
||||
if err := dramapkg.ValidateEpisodeContent(content); err != nil {
|
||||
return fmt.Errorf("第%d集:%w", episodeNo, err)
|
||||
}
|
||||
episode := model.ProjectEpisode{ID: uuid.New(), ProjectID: projectID, EpisodeNo: episodeNo, Name: name, AudioSource: "video_audio", Status: "draft"}
|
||||
if err := tx.Create(&episode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sha256.Sum256([]byte(content))
|
||||
source := model.EpisodeSource{ID: uuid.New(), EpisodeID: episode.ID, Title: episode.Name, RawContent: content, CharCount: len([]rune(content)), SourceType: session.SourceType, SourceFilename: session.SourceFilename, ContentSHA256: hex.EncodeToString(hash[:])}
|
||||
if err := tx.Create(&source).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = append(created, episode)
|
||||
}
|
||||
now := time.Now()
|
||||
return tx.Model(&session).Updates(map[string]any{"consumed_at": now, "raw_content": ""}).Error
|
||||
})
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *Creative) EpisodeSource(userID, projectID, episodeID uuid.UUID) (*model.EpisodeSource, error) {
|
||||
var source model.EpisodeSource
|
||||
result := s.DB.Table("episode_sources source").Joins("JOIN project_episodes episode ON episode.id=source.episode_id AND episode.deleted_at IS NULL").Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.deleted_at IS NULL").Where("source.episode_id=? AND episode.project_id=? AND project.user_id=?", episodeID, projectID, userID).Limit(1).Find(&source)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &source, nil
|
||||
}
|
||||
|
||||
func (s *Creative) SaveEpisodeSource(userID, projectID, episodeID uuid.UUID, content, title string) (*model.EpisodeSource, error) {
|
||||
content = normalizeDramaText(content)
|
||||
if err := dramapkg.ValidateEpisodeContent(content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.requirePremiumEpisode(userID, projectID, episodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := sha256.Sum256([]byte(content))
|
||||
source := model.EpisodeSource{ID: uuid.New(), EpisodeID: episodeID, Title: truncateRunes(strings.TrimSpace(title), 200), RawContent: content, CharCount: len([]rune(content)), SourceType: "pasted_text", ContentSHA256: hex.EncodeToString(hash[:])}
|
||||
err := s.DB.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "episode_id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "raw_content", "char_count", "source_type", "source_filename", "content_sha256", "updated_at"})}).Create(&source).Error
|
||||
return &source, err
|
||||
}
|
||||
|
||||
func (s *Creative) requirePremiumProject(userID, projectID uuid.UUID) error {
|
||||
var count int64
|
||||
err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND project_type='premium_drama' AND deleted_at IS NULL", projectID, userID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Creative) requirePremiumEpisode(userID, projectID, episodeID uuid.UUID) error {
|
||||
var count int64
|
||||
err := s.DB.Table("project_episodes episode").Joins("JOIN creative_projects project ON project.id=episode.project_id").Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL AND project.user_id=? AND project.project_type='premium_drama' AND project.deleted_at IS NULL", episodeID, projectID, userID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDramaSource(filename string, data []byte, rawText string) (string, string, error) {
|
||||
if strings.TrimSpace(rawText) != "" {
|
||||
if len(data) > 0 {
|
||||
return "", "", errors.New("文件和粘贴文本只能选择一种")
|
||||
}
|
||||
if looksLikeBinary([]byte(rawText)) {
|
||||
return "", "", errors.New("粘贴内容包含二进制控制字符")
|
||||
}
|
||||
if looksLikeHTMLOrScript([]byte(rawText)) {
|
||||
return "", "", errors.New("不支持HTML或脚本内容")
|
||||
}
|
||||
return rawText, "pasted_text", nil
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", "", errors.New("请选择小说文件或粘贴文本")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".txt":
|
||||
text, err := decodePlainText(data)
|
||||
return text, "txt", err
|
||||
case ".docx":
|
||||
text, err := extractDOCX(data)
|
||||
return text, "docx", err
|
||||
case ".doc":
|
||||
text, err := extractLegacyDOC(data)
|
||||
return text, "doc", err
|
||||
default:
|
||||
return "", "", errors.New("仅支持TXT、DOC和DOCX文件")
|
||||
}
|
||||
}
|
||||
|
||||
func decodePlainText(data []byte) (string, error) {
|
||||
if hasDisallowedBinarySignature(data) || bytes.IndexByte(data, 0) >= 0 || looksLikeBinary(data) {
|
||||
return "", errors.New("TXT文件包含二进制内容")
|
||||
}
|
||||
if looksLikeHTMLOrScript(data) {
|
||||
return "", errors.New("不支持HTML或脚本文件")
|
||||
}
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
if utf8.Valid(data) {
|
||||
return string(data), nil
|
||||
}
|
||||
decoded, err := io.ReadAll(transform.NewReader(bytes.NewReader(data), simplifiedchinese.GB18030.NewDecoder()))
|
||||
if err != nil || !utf8.Valid(decoded) {
|
||||
return "", errors.New("TXT文件编码不支持,请使用UTF-8或GB18030")
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
func extractDOCX(data []byte) (string, error) {
|
||||
if len(data) < 4 || !bytes.Equal(data[:4], []byte{'P', 'K', 3, 4}) {
|
||||
return "", errors.New("DOCX文件结构无效")
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return "", errors.New("DOCX文件结构无效")
|
||||
}
|
||||
if len(reader.File) > 1000 {
|
||||
return "", errors.New("DOCX文件包含过多内容")
|
||||
}
|
||||
var document, contentTypes []byte
|
||||
var expanded uint64
|
||||
for _, file := range reader.File {
|
||||
expanded += file.UncompressedSize64
|
||||
if expanded > 20*1024*1024 {
|
||||
return "", errors.New("DOCX展开后内容过大")
|
||||
}
|
||||
name := strings.ToLower(strings.ReplaceAll(file.Name, "\\", "/"))
|
||||
if strings.Contains(name, "vbaproject") || strings.Contains(name, "activex") || strings.Contains(name, "embeddings/") {
|
||||
return "", errors.New("DOCX包含宏、ActiveX或嵌入对象")
|
||||
}
|
||||
if name == "[content_types].xml" {
|
||||
stream, openErr := file.Open()
|
||||
if openErr != nil {
|
||||
return "", openErr
|
||||
}
|
||||
contentTypes, err = io.ReadAll(io.LimitReader(stream, 1024*1024))
|
||||
stream.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if name == "word/document.xml" {
|
||||
stream, openErr := file.Open()
|
||||
if openErr != nil {
|
||||
return "", openErr
|
||||
}
|
||||
document, err = io.ReadAll(io.LimitReader(stream, 12*1024*1024))
|
||||
stream.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
contentTypeText := strings.ToLower(string(contentTypes))
|
||||
if len(document) == 0 || !strings.Contains(contentTypeText, "wordprocessingml.document.main+xml") || strings.Contains(contentTypeText, "macroenabled") {
|
||||
return "", errors.New("DOCX缺少Word正文结构")
|
||||
}
|
||||
decoder := xml.NewDecoder(bytes.NewReader(document))
|
||||
var output strings.Builder
|
||||
for {
|
||||
token, tokenErr := decoder.Token()
|
||||
if tokenErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if tokenErr != nil {
|
||||
return "", errors.New("DOCX正文XML无效")
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.CharData:
|
||||
output.Write([]byte(value))
|
||||
case xml.EndElement:
|
||||
if value.Name.Local == "p" {
|
||||
output.WriteString("\n")
|
||||
} else if value.Name.Local == "tab" {
|
||||
output.WriteString("\t")
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func extractLegacyDOC(data []byte) (string, error) {
|
||||
magic := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
|
||||
if len(data) < len(magic) || !bytes.Equal(data[:len(magic)], magic) {
|
||||
return "", errors.New("DOC文件结构无效")
|
||||
}
|
||||
reader, err := mscfb.New(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", errors.New("DOC文件结构无效")
|
||||
}
|
||||
streams := map[string][]byte{}
|
||||
for entry, nextErr := reader.Next(); nextErr == nil; entry, nextErr = reader.Next() {
|
||||
name := strings.ToLower(entry.Name)
|
||||
if strings.Contains(name, "vba") || strings.Contains(name, "macros") || strings.Contains(name, "objectpool") {
|
||||
return "", errors.New("DOC包含宏或嵌入对象")
|
||||
}
|
||||
if name == "worddocument" || name == "0table" || name == "1table" {
|
||||
content, readErr := io.ReadAll(io.LimitReader(entry, 12*1024*1024))
|
||||
if readErr != nil {
|
||||
return "", readErr
|
||||
}
|
||||
streams[name] = content
|
||||
}
|
||||
}
|
||||
word := streams["worddocument"]
|
||||
if len(word) < 0x1AA || binary.LittleEndian.Uint16(word[:2]) != 0xA5EC {
|
||||
return "", errors.New("DOC缺少有效WordDocument流")
|
||||
}
|
||||
flags := binary.LittleEndian.Uint16(word[0x0A:0x0C])
|
||||
tableName := "0table"
|
||||
if flags&0x0200 != 0 {
|
||||
tableName = "1table"
|
||||
}
|
||||
table := streams[tableName]
|
||||
fcClx := int(binary.LittleEndian.Uint32(word[0x1A2:0x1A6]))
|
||||
lcbClx := int(binary.LittleEndian.Uint32(word[0x1A6:0x1AA]))
|
||||
text, parseErr := extractDOCPieces(word, table, fcClx, lcbClx)
|
||||
if parseErr != nil || strings.TrimSpace(text) == "" {
|
||||
return "", errors.New("DOC正文无法安全解析")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func extractDOCPieces(word, table []byte, offset, length int) (string, error) {
|
||||
if offset < 0 || length <= 0 || offset+length > len(table) {
|
||||
return "", errors.New("invalid CLX")
|
||||
}
|
||||
clx := table[offset : offset+length]
|
||||
pos := 0
|
||||
for pos < len(clx) && clx[pos] == 0x01 {
|
||||
if pos+3 > len(clx) {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
size := int(binary.LittleEndian.Uint16(clx[pos+1 : pos+3]))
|
||||
pos += 3 + size
|
||||
}
|
||||
if pos+5 > len(clx) || clx[pos] != 0x02 {
|
||||
return "", errors.New("missing Pcdt")
|
||||
}
|
||||
plcSize := int(binary.LittleEndian.Uint32(clx[pos+1 : pos+5]))
|
||||
pos += 5
|
||||
if plcSize < 4 || pos+plcSize > len(clx) {
|
||||
return "", errors.New("invalid PlcPcd")
|
||||
}
|
||||
plc := clx[pos : pos+plcSize]
|
||||
pieces := (plcSize - 4) / 12
|
||||
if pieces <= 0 || pieces > 100000 {
|
||||
return "", errors.New("invalid piece count")
|
||||
}
|
||||
cpBytes := (pieces + 1) * 4
|
||||
if cpBytes+pieces*8 > len(plc) {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
var output strings.Builder
|
||||
for i := 0; i < pieces; i++ {
|
||||
cpStart := int(binary.LittleEndian.Uint32(plc[i*4 : i*4+4]))
|
||||
cpEnd := int(binary.LittleEndian.Uint32(plc[(i+1)*4 : (i+1)*4+4]))
|
||||
if cpEnd <= cpStart {
|
||||
continue
|
||||
}
|
||||
pcd := cpBytes + i*8
|
||||
rawFC := binary.LittleEndian.Uint32(plc[pcd+2 : pcd+6])
|
||||
compressed := rawFC&0x40000000 != 0
|
||||
fc := int(rawFC & 0x3FFFFFFF)
|
||||
chars := cpEnd - cpStart
|
||||
if compressed {
|
||||
fc /= 2
|
||||
if fc < 0 || fc+chars > len(word) {
|
||||
continue
|
||||
}
|
||||
decoded, _ := io.ReadAll(transform.NewReader(bytes.NewReader(word[fc:fc+chars]), simplifiedchinese.GB18030.NewDecoder()))
|
||||
output.Write(decoded)
|
||||
} else {
|
||||
byteLen := chars * 2
|
||||
if fc < 0 || fc+byteLen > len(word) {
|
||||
continue
|
||||
}
|
||||
units := make([]uint16, chars)
|
||||
for j := 0; j < chars; j++ {
|
||||
units[j] = binary.LittleEndian.Uint16(word[fc+j*2 : fc+j*2+2])
|
||||
}
|
||||
output.WriteString(string(utf16.Decode(units)))
|
||||
}
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func splitDramaChapters(content string) []DramaChapter {
|
||||
matches := chapterTitlePattern.FindAllStringSubmatchIndex(content, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
chapters := make([]DramaChapter, 0, len(matches))
|
||||
for i, match := range matches {
|
||||
start := match[0]
|
||||
if i == 0 {
|
||||
start = 0
|
||||
}
|
||||
end := len(content)
|
||||
if i+1 < len(matches) {
|
||||
end = matches[i+1][0]
|
||||
}
|
||||
body := strings.TrimSpace(content[start:end])
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(content[match[2]:match[3]])
|
||||
chapters = append(chapters, DramaChapter{Title: title, Content: body, CharCount: len([]rune(body))})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
func normalizeDramaText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\x00", "")
|
||||
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
|
||||
lines := strings.Split(value, "\n")
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
blank := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimRightFunc(line, unicode.IsSpace)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if !blank {
|
||||
cleaned = append(cleaned, "")
|
||||
blank = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, line)
|
||||
blank = false
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(cleaned, "\n"))
|
||||
}
|
||||
|
||||
func looksLikeBinary(data []byte) bool {
|
||||
if len(data) == 0 {
|
||||
return false
|
||||
}
|
||||
controls := 0
|
||||
for _, b := range data {
|
||||
if b < 0x09 || (b > 0x0D && b < 0x20) {
|
||||
controls++
|
||||
}
|
||||
}
|
||||
return controls*100/len(data) > 2
|
||||
}
|
||||
func looksLikeHTMLOrScript(data []byte) bool {
|
||||
sample := strings.ToLower(string(data))
|
||||
markers := []string{"<!doctype html", "<html", "<head", "<body", "<script", "</script", "<iframe", "<object", "<embed", "javascript:", "vbscript:", "onerror=", "onload="}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(sample, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func hasDisallowedBinarySignature(data []byte) bool {
|
||||
signatures := [][]byte{
|
||||
{0x25, 0x50, 0x44, 0x46, 0x2D},
|
||||
{0x50, 0x4B, 0x03, 0x04},
|
||||
{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1},
|
||||
{0x89, 0x50, 0x4E, 0x47},
|
||||
{0xFF, 0xD8, 0xFF},
|
||||
{0x47, 0x49, 0x46, 0x38},
|
||||
{0x4D, 0x5A},
|
||||
{0x7F, 0x45, 0x4C, 0x46},
|
||||
{0x52, 0x61, 0x72, 0x21},
|
||||
{0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C},
|
||||
{0x1F, 0x8B},
|
||||
}
|
||||
data = bytes.TrimSpace(data)
|
||||
for _, signature := range signatures {
|
||||
if bytes.HasPrefix(data, signature) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func cleanDramaFilename(value string) string {
|
||||
value = filepath.Base(strings.TrimSpace(value))
|
||||
return truncateRunes(value, 255)
|
||||
}
|
||||
func truncateRunes(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) > limit {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Stable chapter ordering is useful in tests and when import previews are reconstructed.
|
||||
func sortEpisodesByNumber(items []model.ProjectEpisode) {
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].EpisodeNo < items[j].EpisodeNo })
|
||||
}
|
||||
Reference in New Issue
Block a user