2026-04-28 18:44:16 +03:00
|
|
|
package boxstore
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"image"
|
|
|
|
|
"image/color"
|
|
|
|
|
"image/draw"
|
|
|
|
|
_ "image/gif"
|
2026-04-28 21:11:37 +03:00
|
|
|
"image/jpeg"
|
2026-04-28 18:44:16 +03:00
|
|
|
_ "image/png"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"warpbox/lib/helpers"
|
|
|
|
|
"warpbox/lib/models"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
thumbnailDir = ".thumbnails"
|
|
|
|
|
thumbnailMaxSize = 160
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type thumbnailTask struct {
|
|
|
|
|
BoxID string
|
|
|
|
|
FileID string
|
|
|
|
|
Name string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func StartThumbnailWorker(batchSize int, interval time.Duration) {
|
|
|
|
|
if batchSize < 1 {
|
|
|
|
|
batchSize = 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if interval <= 0 {
|
|
|
|
|
interval = 30 * time.Second
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
for {
|
|
|
|
|
ProcessThumbnailBatch(batchSize)
|
|
|
|
|
time.Sleep(interval)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ProcessThumbnailBatch(batchSize int) int {
|
|
|
|
|
tasks := collectThumbnailTasks(batchSize)
|
|
|
|
|
for _, task := range tasks {
|
|
|
|
|
if err := generateThumbnail(task); err != nil {
|
|
|
|
|
markThumbnailFailed(task.BoxID, task.FileID)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return len(tasks)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
|
|
|
|
|
if !helpers.ValidLowerHexID(fileID, 16) {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return helpers.SafeChildPath(filepath.Join(BoxPath(boxID), thumbnailDir), fileID+".jpg")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func collectThumbnailTasks(batchSize int) []thumbnailTask {
|
2026-04-28 21:11:37 +03:00
|
|
|
entries, err := os.ReadDir(uploadRoot)
|
2026-04-28 18:44:16 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks := make([]thumbnailTask, 0, batchSize)
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks = append(tasks, collectBoxThumbnailTasks(entry.Name(), batchSize-len(tasks))...)
|
|
|
|
|
if len(tasks) >= batchSize {
|
|
|
|
|
return tasks
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tasks
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
|
|
|
|
|
if remaining <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
manifestMu.Lock()
|
|
|
|
|
defer manifestMu.Unlock()
|
|
|
|
|
|
|
|
|
|
manifest, err := readManifestUnlocked(boxID)
|
2026-04-30 04:24:49 +03:00
|
|
|
if err != nil || IsExpired(manifest) || manifest.OneTimeDownload {
|
2026-04-28 18:44:16 +03:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks := make([]thumbnailTask, 0, remaining)
|
|
|
|
|
changed := false
|
|
|
|
|
for index, file := range manifest.Files {
|
|
|
|
|
if len(tasks) >= remaining {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if file.Status != models.FileStatusReady || file.ThumbnailPath != nil || file.ThumbnailStatus != "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !canGenerateThumbnail(file) {
|
|
|
|
|
manifest.Files[index].ThumbnailStatus = models.ThumbnailStatusUnsupported
|
|
|
|
|
changed = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks = append(tasks, thumbnailTask{
|
|
|
|
|
BoxID: boxID,
|
|
|
|
|
FileID: file.ID,
|
|
|
|
|
Name: file.Name,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if changed {
|
|
|
|
|
writeManifestUnlocked(boxID, manifest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tasks
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canGenerateThumbnail(file models.BoxFile) bool {
|
|
|
|
|
if strings.HasPrefix(file.MimeType, "image/") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension := strings.ToLower(filepath.Ext(file.Name))
|
|
|
|
|
return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".gif"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func generateThumbnail(task thumbnailTask) error {
|
2026-04-28 21:42:36 +03:00
|
|
|
sourcePath, ok := SafeBoxFilePath(task.BoxID, task.Name)
|
|
|
|
|
if !ok {
|
|
|
|
|
return os.ErrInvalid
|
|
|
|
|
}
|
|
|
|
|
if err := ensureRegularFile(sourcePath); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
source, err := os.Open(sourcePath)
|
2026-04-28 18:44:16 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer source.Close()
|
|
|
|
|
|
|
|
|
|
src, _, err := image.Decode(source)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumb := resizeImage(src, thumbnailMaxSize)
|
|
|
|
|
if err := os.MkdirAll(filepath.Join(BoxPath(task.BoxID), thumbnailDir), 0755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
path, ok := ThumbnailFilePath(task.BoxID, task.FileID)
|
|
|
|
|
if !ok {
|
|
|
|
|
return os.ErrInvalid
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 21:42:36 +03:00
|
|
|
target, tempPath, err := createTempSibling(path)
|
2026-04-28 18:44:16 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-28 21:42:36 +03:00
|
|
|
committed := false
|
|
|
|
|
defer func() {
|
|
|
|
|
target.Close()
|
|
|
|
|
if !committed {
|
|
|
|
|
os.Remove(tempPath)
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-04-28 18:44:16 +03:00
|
|
|
|
|
|
|
|
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-28 21:42:36 +03:00
|
|
|
if err := target.Close(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := os.Rename(tempPath, path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
committed = true
|
2026-04-28 18:44:16 +03:00
|
|
|
|
|
|
|
|
return markThumbnailReady(task.BoxID, task.FileID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resizeImage(src image.Image, maxSize int) image.Image {
|
|
|
|
|
bounds := src.Bounds()
|
|
|
|
|
width := bounds.Dx()
|
|
|
|
|
height := bounds.Dy()
|
|
|
|
|
if width <= 0 || height <= 0 {
|
|
|
|
|
return src
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
targetWidth := width
|
|
|
|
|
targetHeight := height
|
|
|
|
|
if width > maxSize || height > maxSize {
|
|
|
|
|
if width >= height {
|
|
|
|
|
targetWidth = maxSize
|
|
|
|
|
targetHeight = height * maxSize / width
|
|
|
|
|
} else {
|
|
|
|
|
targetHeight = maxSize
|
|
|
|
|
targetWidth = width * maxSize / height
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if targetWidth < 1 {
|
|
|
|
|
targetWidth = 1
|
|
|
|
|
}
|
|
|
|
|
if targetHeight < 1 {
|
|
|
|
|
targetHeight = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
|
|
|
|
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
|
|
|
|
for y := 0; y < targetHeight; y++ {
|
|
|
|
|
for x := 0; x < targetWidth; x++ {
|
|
|
|
|
srcX := bounds.Min.X + x*width/targetWidth
|
|
|
|
|
srcY := bounds.Min.Y + y*height/targetHeight
|
|
|
|
|
dst.Set(x, y, src.At(srcX, srcY))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dst
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markThumbnailReady(boxID string, fileID string) error {
|
|
|
|
|
path := "/box/" + boxID + "/thumbnails/" + url.PathEscape(fileID)
|
|
|
|
|
return updateThumbnailState(boxID, fileID, &path, models.ThumbnailStatusReady)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func markThumbnailFailed(boxID string, fileID string) {
|
|
|
|
|
updateThumbnailState(boxID, fileID, nil, models.ThumbnailStatusFailed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateThumbnailState(boxID string, fileID string, thumbnailPath *string, status string) error {
|
|
|
|
|
manifestMu.Lock()
|
|
|
|
|
defer manifestMu.Unlock()
|
|
|
|
|
|
|
|
|
|
manifest, err := readManifestUnlocked(boxID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for index, file := range manifest.Files {
|
|
|
|
|
if file.ID != fileID {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
manifest.Files[index].ThumbnailPath = thumbnailPath
|
|
|
|
|
manifest.Files[index].ThumbnailStatus = status
|
|
|
|
|
return writeManifestUnlocked(boxID, manifest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return os.ErrNotExist
|
|
|
|
|
}
|