Files
WarpBox/lib/boxstore/thumbnails.go
Daniel Legt cb026d4fd1 feat(security): use bcrypt hashes and safe paths for boxes
- Replace legacy salted password hashing with bcrypt and store hash alg
- Accept existing bcrypt hashes while keeping legacy verification fallback
- Validate box IDs and use SafeChildPath for box/file operations to prevent traversal
- Refactor download flow to share zip writer logic and correctly handle one-time deletes and optional renew-on-download only after a successful zip writefeat(security): use bcrypt hashes and safe paths for boxes

- Replace legacy salted password hashing with bcrypt and store hash alg
- Accept existing bcrypt hashes while keeping legacy verification fallback
- Validate box IDs and use SafeChildPath for box/file operations to prevent traversal
- Refactor download flow to share zip writer logic and correctly handle one-time deletes and optional renew-on-download only after a successful zip write
2026-04-28 21:42:36 +03:00

268 lines
5.4 KiB
Go

package boxstore
import (
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "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 {
entries, err := os.ReadDir(uploadRoot)
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)
if err != nil || IsExpired(manifest) {
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 {
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)
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
}
target, tempPath, err := createTempSibling(path)
if err != nil {
return err
}
committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
return err
}
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, path); err != nil {
return err
}
committed = true
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
}