mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-21 01:06:10 +00:00
GetFile and the zip selection now limit results to the session's shared scope via the shared search helpers, consistent with photo search and in preparation for upcoming sharing features. No change for full-access sessions.
222 lines
6.7 KiB
Go
222 lines
6.7 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/photoprism/photoprism/internal/auth/acl"
|
|
"github.com/photoprism/photoprism/internal/entity/query"
|
|
"github.com/photoprism/photoprism/internal/form"
|
|
"github.com/photoprism/photoprism/internal/photoprism"
|
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
|
"github.com/photoprism/photoprism/pkg/i18n"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
)
|
|
|
|
// ZipCreate creates a zip file archive for download.
|
|
//
|
|
// @Summary creates a zip file archive for download
|
|
// @Id ZipCreate
|
|
// @Tags Download
|
|
// @Produce json
|
|
// @Failure 400,403,404 {object} i18n.Response
|
|
// @Success 200 {file} application/zip
|
|
// @Router /api/v1/zip [post]
|
|
func ZipCreate(router *gin.RouterGroup) {
|
|
router.POST("/zip", func(c *gin.Context) {
|
|
s := Auth(c, acl.ResourcePhotos, acl.ActionDownload)
|
|
|
|
if s.Abort(c) {
|
|
return
|
|
}
|
|
|
|
conf := get.Config()
|
|
|
|
if !conf.Settings().Features.Download {
|
|
AbortFeatureDisabled(c)
|
|
return
|
|
}
|
|
|
|
var frm form.Selection
|
|
start := time.Now()
|
|
|
|
// Assign and validate request form values.
|
|
LimitRequestBodyBytes(c, MaxSelectionRequestBytes)
|
|
|
|
if err := c.BindJSON(&frm); err != nil {
|
|
if IsRequestBodyTooLarge(err) {
|
|
AbortRequestTooLarge(c, i18n.ErrBadRequest)
|
|
return
|
|
}
|
|
|
|
AbortBadRequest(c, err)
|
|
return
|
|
}
|
|
|
|
if frm.Empty() {
|
|
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
|
|
return
|
|
}
|
|
|
|
// Configure file selection based on user settings.
|
|
var selection query.FileSelection
|
|
if settings := conf.Settings().Download; settings.Disabled {
|
|
AbortFeatureDisabled(c)
|
|
return
|
|
} else {
|
|
selection = query.DownloadSelection(settings.MediaRaw, settings.MediaSidecar, settings.Originals)
|
|
}
|
|
|
|
// Find files to download within the session's shared scope, consistent with how photo
|
|
// search filters results.
|
|
files, err := query.SelectedFilesForSession(frm, selection, s)
|
|
|
|
if err != nil {
|
|
Error(c, http.StatusBadRequest, err, i18n.ErrZipFailed)
|
|
return
|
|
} else if len(files) == 0 {
|
|
Abort(c, http.StatusNotFound, i18n.ErrNoFilesForDownload)
|
|
return
|
|
}
|
|
|
|
// Refuse to assemble a new download zip if storage is over quota or critically low.
|
|
if conf.InsufficientStorage() {
|
|
Abort(c, http.StatusInsufficientStorage, i18n.ErrInsufficientStorage)
|
|
return
|
|
}
|
|
|
|
// Configure file names.
|
|
dlName := DownloadName(c)
|
|
// Build filesystem paths using filepath for OS compatibility.
|
|
zipPath := filepath.Join(conf.TempPath(), fs.ZipDir)
|
|
zipToken := rnd.Base36(8)
|
|
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
|
|
zipFileName := filepath.Join(zipPath, zipBaseName)
|
|
|
|
// Create temp directory.
|
|
if err = os.MkdirAll(zipPath, 0700); err != nil {
|
|
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
|
|
return
|
|
}
|
|
|
|
// Create new zip file.
|
|
var newZipFile *os.File
|
|
// #nosec G304 zip name derived from request
|
|
if newZipFile, err = os.Create(zipFileName); err != nil {
|
|
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
|
|
return
|
|
}
|
|
|
|
// Create zip writer.
|
|
zipWriter := zip.NewWriter(newZipFile)
|
|
|
|
var aliases = make(map[string]int)
|
|
|
|
// Add files to zip.
|
|
for _, file := range files {
|
|
if file.FileName == "" {
|
|
log.Warnf("download: %s cannot be downloaded (empty file name)", clean.Log(file.FileUID))
|
|
continue
|
|
} else if file.FileHash == "" {
|
|
log.Warnf("download: %s cannot be downloaded (empty file hash)", clean.Log(file.FileName))
|
|
continue
|
|
}
|
|
|
|
fileName := photoprism.FileName(file.FileRoot, file.FileName)
|
|
alias := file.DownloadName(dlName, 0)
|
|
key := strings.ToLower(alias)
|
|
|
|
if seq := aliases[key]; seq > 0 {
|
|
alias = file.DownloadName(dlName, seq)
|
|
}
|
|
|
|
aliases[key]++
|
|
|
|
if fs.FileExists(fileName) {
|
|
if zipErr := fs.ZipFile(zipWriter, fileName, alias, false); zipErr != nil {
|
|
log.Errorf("download: failed to add %s (%s)", clean.Log(file.FileName), zipErr)
|
|
Abort(c, http.StatusInternalServerError, i18n.ErrZipFailed)
|
|
return
|
|
}
|
|
|
|
log.Infof("download: added %s as %s", clean.Log(file.FileName), clean.Log(alias))
|
|
} else {
|
|
log.Warnf("download: %s not found", clean.Log(file.FileName))
|
|
logErr("download", file.Update("FileMissing", true))
|
|
}
|
|
}
|
|
|
|
// Ensure all data is flushed to disk before responding to the client
|
|
// to avoid rare races where the follow-up GET happens before the
|
|
// zip writer/file have been fully closed.
|
|
if cerr := zipWriter.Close(); cerr != nil {
|
|
Error(c, http.StatusInternalServerError, cerr, i18n.ErrZipFailed)
|
|
return
|
|
}
|
|
if ferr := newZipFile.Close(); ferr != nil {
|
|
Error(c, http.StatusInternalServerError, ferr, i18n.ErrZipFailed)
|
|
return
|
|
}
|
|
|
|
elapsed := int(time.Since(start).Seconds())
|
|
|
|
log.Infof("download: created %s [%s]", clean.Log(zipBaseName), time.Since(start))
|
|
|
|
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgZipCreatedIn, elapsed), "filename": zipBaseName})
|
|
})
|
|
}
|
|
|
|
// ZipDownload returns a zip file archive after it has been created.
|
|
//
|
|
// @Summary returns a zip file archive after it has been created
|
|
// @Id ZipDownload
|
|
// @Tags Download
|
|
// @Produce application/zip
|
|
// @Failure 403,404,500 {object} i18n.Response
|
|
// @Success 200 {file} application/zip
|
|
// @Param filename path string true "zip archive filename returned by the POST /api/v1/zip endpoint"
|
|
// @Router /api/v1/zip/{filename} [get]
|
|
func ZipDownload(router *gin.RouterGroup) {
|
|
router.GET("/zip/:filename", func(c *gin.Context) {
|
|
if InvalidDownloadToken(c) {
|
|
log.Errorf("download: %s", c.AbortWithError(http.StatusForbidden, fmt.Errorf("invalid download token")))
|
|
return
|
|
}
|
|
|
|
conf := get.Config()
|
|
zipBaseName := clean.FileName(filepath.Base(c.Param("filename")))
|
|
zipPath := filepath.Join(conf.TempPath(), fs.ZipDir)
|
|
zipFileName := filepath.Join(zipPath, zipBaseName)
|
|
|
|
if !fs.FileExists(zipFileName) {
|
|
log.Errorf("download: %s", c.AbortWithError(http.StatusNotFound, fmt.Errorf("%s not found", clean.Log(zipFileName))))
|
|
return
|
|
}
|
|
|
|
defer func(fileName, baseName string) {
|
|
log.Infof("download: %s has been downloaded", clean.Log(baseName))
|
|
|
|
// Wait a moment before deleting the zip file, just to be sure:
|
|
// https://github.com/photoprism/photoprism/issues/2532
|
|
time.Sleep(time.Second)
|
|
|
|
// Remove the zip file to free up disk space.
|
|
if err := os.Remove(fileName); err != nil {
|
|
log.Warnf("download: failed to delete %s (%s)", clean.Log(fileName), err)
|
|
} else {
|
|
log.Debugf("download: deleted %s", clean.Log(baseName))
|
|
}
|
|
}(zipFileName, zipBaseName)
|
|
|
|
c.FileAttachment(zipFileName, zipBaseName)
|
|
})
|
|
}
|