mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize/english"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/photoprism/photoprism/internal/ai/vision"
|
|
"github.com/photoprism/photoprism/internal/auth/acl"
|
|
"github.com/photoprism/photoprism/internal/entity/query"
|
|
"github.com/photoprism/photoprism/internal/event"
|
|
"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/media"
|
|
)
|
|
|
|
// UploadUserFiles adds files to the user upload folder, from where they can be moved and indexed.
|
|
//
|
|
// @Tags Users, Files
|
|
// @Router /users/{uid}/upload/{token} [post]
|
|
func UploadUserFiles(router *gin.RouterGroup) {
|
|
router.POST("/users/:uid/upload/:token", func(c *gin.Context) {
|
|
conf := get.Config()
|
|
|
|
// Abort in public mode or when the upload feature is disabled.
|
|
if conf.ReadOnly() || !conf.Settings().Features.Upload {
|
|
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
|
|
return
|
|
}
|
|
|
|
// Check if the account owner is allowed to upload files.
|
|
s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload})
|
|
|
|
if s.Abort(c) {
|
|
return
|
|
}
|
|
|
|
uid := clean.UID(c.Param("uid"))
|
|
|
|
// Users may only upload files for their own account.
|
|
if s.GetUser().UserUID != uid {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", "upload files", "user does not match"}, s.RefID)
|
|
AbortForbidden(c)
|
|
return
|
|
}
|
|
|
|
// Abort if there is not enough free storage to upload new files.
|
|
if conf.FilesQuotaReached() {
|
|
event.AuditErr([]string{ClientIP(c), "session %s", "upload files", "insufficient storage"}, s.RefID)
|
|
Abort(c, http.StatusInsufficientStorage, i18n.ErrInsufficientStorage)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
token := clean.Token(c.Param("token"))
|
|
|
|
f, err := c.MultipartForm()
|
|
|
|
if err != nil {
|
|
log.Errorf("upload: %s", err)
|
|
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
|
|
return
|
|
}
|
|
|
|
// Publish upload start event.
|
|
event.Publish("upload.start", event.Data{"uid": s.UserUID, "time": start})
|
|
|
|
files := f.File["files"]
|
|
|
|
var uploads []string
|
|
|
|
// Compose upload path.
|
|
uploadDir, err := conf.UserUploadPath(s.UserUID, s.RefID+token)
|
|
|
|
if err != nil {
|
|
log.Errorf("upload: failed to create storage folder (%s)", err)
|
|
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
|
|
return
|
|
}
|
|
|
|
// If the file extension list is empty, all file types may
|
|
// be uploaded except raw files if raw support is disabled.
|
|
allowedExt := conf.UploadAllow()
|
|
rejectArchives := !conf.UploadArchives()
|
|
rejectRaw := conf.DisableRaw()
|
|
fileSizeLimit := conf.OriginalsLimitBytes()
|
|
totalSizeLimit := conf.UploadLimitBytes()
|
|
|
|
// Save uploaded files and append their names
|
|
// to "uploads" if they pass all checks.
|
|
for _, file := range files {
|
|
baseName := filepath.Base(file.Filename)
|
|
destName := path.Join(uploadDir, baseName)
|
|
fileType := fs.FileType(baseName)
|
|
|
|
// Reject unsupported files and files with extensions that aren't allowed.
|
|
if fileType == fs.TypeUnknown {
|
|
log.Errorf("upload: rejected %s because it has an unsupported file extension", clean.Log(baseName))
|
|
continue
|
|
} else if allowedExt.Excludes(fileType.DefaultExt()) {
|
|
log.Errorf("upload: rejected %s because its extension is not allowed", clean.Log(baseName))
|
|
continue
|
|
} else if fileSizeLimit > 0 && file.Size > fileSizeLimit {
|
|
log.Errorf("upload: rejected %s because its size exceeds the file size limit", clean.Log(baseName))
|
|
continue
|
|
}
|
|
|
|
// Save uploaded file in the user upload path.
|
|
if err = c.SaveUploadedFile(file, destName); err != nil {
|
|
log.Debugf("upload: %s in %s", clean.Error(err), clean.Log(baseName))
|
|
log.Errorf("upload: failed to save %s", clean.Log(baseName))
|
|
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
|
|
return
|
|
} else {
|
|
log.Debugf("upload: saved %s in user upload path", clean.Log(baseName))
|
|
event.Publish("upload.saved", event.Data{"uid": s.UserUID, "file": baseName})
|
|
}
|
|
|
|
// Extract contents if the uploaded file is an archive.
|
|
if ext := fs.ArchiveExt(baseName); ext != "" {
|
|
if rejectArchives {
|
|
logWarn("upload", os.Remove(destName))
|
|
log.Errorf("upload: rejected %s because archive uploads are disabled", clean.Log(baseName))
|
|
continue
|
|
}
|
|
|
|
zipFiles, skippedFiles, zipErr := fs.Unzip(destName, uploadDir, fileSizeLimit, totalSizeLimit)
|
|
|
|
logWarn("upload", os.Remove(destName))
|
|
|
|
if zipErr != nil {
|
|
log.Errorf("upload: failed to extract files from %s (%s)", clean.Log(baseName), zipErr)
|
|
}
|
|
|
|
if len(skippedFiles) > 0 {
|
|
log.Errorf("upload: could not extract %s from %s due to upload restrictions", strings.Join(skippedFiles, ", "), clean.Log(baseName))
|
|
}
|
|
|
|
if len(zipFiles) == 0 {
|
|
continue
|
|
}
|
|
|
|
for _, destName = range zipFiles {
|
|
baseName = filepath.Base(destName)
|
|
fileType = fs.FileType(baseName)
|
|
|
|
// Reject unsupported files and files with extensions that aren't allowed.
|
|
if baseName == "" {
|
|
log.Errorf("upload: rejected unzipped file because it has no file name")
|
|
} else if baseName[0] == '.' || baseName[0] == '@' {
|
|
logWarn("upload", os.Remove(destName))
|
|
log.Errorf("upload: rejected unzipped file %s because it has an unsupported file name", clean.Log(baseName))
|
|
} else if fileType == fs.TypeUnknown {
|
|
logWarn("upload", os.Remove(destName))
|
|
log.Errorf("upload: rejected unzipped file %s because it has an unsupported file extension", clean.Log(baseName))
|
|
} else if allowedExt.Excludes(fileType.DefaultExt()) {
|
|
logWarn("upload", os.Remove(destName))
|
|
log.Errorf("upload: rejected unzipped file %s because its extension is not allowed", clean.Log(baseName))
|
|
} else if totalSizeLimit, err = UploadCheckFile(destName, rejectRaw, totalSizeLimit); err != nil {
|
|
log.Errorf("upload: %s", err)
|
|
} else {
|
|
// Add to the list of uploaded files after having verified that
|
|
// the unzipped file has the correct extension and format.
|
|
uploads = append(uploads, destName)
|
|
}
|
|
}
|
|
} else if totalSizeLimit, err = UploadCheckFile(destName, rejectRaw, totalSizeLimit); err != nil {
|
|
log.Errorf("upload: %s", err)
|
|
} else {
|
|
// Add to the list of uploaded files after having verified that
|
|
// the uploaded file has the correct extension and format.
|
|
uploads = append(uploads, destName)
|
|
}
|
|
}
|
|
|
|
// Check if the uploaded file may contain inappropriate content.
|
|
if len(uploads) > 0 && !conf.UploadNSFW() {
|
|
containsNSFW := false
|
|
|
|
for _, filename := range uploads {
|
|
labels, nsfwErr := vision.Nsfw([]string{filename}, media.SrcLocal)
|
|
|
|
if nsfwErr != nil {
|
|
log.Debug(nsfwErr)
|
|
continue
|
|
} else if len(labels) < 1 {
|
|
log.Errorf("nsfw: model returned no result")
|
|
continue
|
|
} else if labels[0].IsSafe() {
|
|
continue
|
|
}
|
|
|
|
log.Infof("nsfw: %s might be offensive", clean.Log(filename))
|
|
|
|
containsNSFW = true
|
|
}
|
|
|
|
if containsNSFW {
|
|
for _, filename := range uploads {
|
|
if err := os.Remove(filename); err != nil {
|
|
log.Errorf("nsfw: could not delete %s", clean.Log(filename))
|
|
}
|
|
}
|
|
|
|
Abort(c, http.StatusForbidden, i18n.ErrOffensiveUpload)
|
|
return
|
|
}
|
|
}
|
|
|
|
elapsed := int(time.Since(start).Seconds())
|
|
|
|
// Log number of successfully uploaded files.
|
|
msg := i18n.Msg(i18n.MsgFilesUploadedIn, len(uploads), elapsed)
|
|
|
|
log.Info(msg)
|
|
|
|
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
|
|
})
|
|
}
|
|
|
|
// UploadCheckFile checks if the file is supported and has the correct extension.
|
|
func UploadCheckFile(destName string, rejectRaw bool, totalSizeLimit int64) (remainingSizeLimit int64, err error) {
|
|
baseName := filepath.Base(destName)
|
|
|
|
if mediaFile, mediaErr := photoprism.NewMediaFile(destName); mediaErr != nil {
|
|
logWarn("upload", os.Remove(destName))
|
|
return totalSizeLimit, fmt.Errorf("rejected %s, %s", clean.Error(err), clean.Log(baseName))
|
|
} else if typeErr := mediaFile.CheckType(); typeErr != nil {
|
|
logWarn("upload", os.Remove(destName))
|
|
return totalSizeLimit, fmt.Errorf("rejected %s %s", clean.Log(baseName), typeErr)
|
|
} else if rejectRaw && mediaFile.IsRaw() {
|
|
logWarn("upload", os.Remove(destName))
|
|
return totalSizeLimit, fmt.Errorf("rejected %s because raw support is disabled", clean.Log(baseName))
|
|
} else if totalSizeLimit < 0 {
|
|
return -1, nil
|
|
} else if remainingSizeLimit = totalSizeLimit - mediaFile.FileSize(); totalSizeLimit == 0 || remainingSizeLimit < 1 {
|
|
logWarn("upload", os.Remove(destName))
|
|
return 0, fmt.Errorf("rejected %s because the total upload size limit has been reached", clean.Log(baseName))
|
|
} else {
|
|
return remainingSizeLimit, nil
|
|
}
|
|
}
|
|
|
|
// ProcessUserUpload triggers processing once all files have been uploaded.
|
|
//
|
|
// PUT /users/:uid/upload/:token
|
|
func ProcessUserUpload(router *gin.RouterGroup) {
|
|
router.PUT("/users/:uid/upload/:token", func(c *gin.Context) {
|
|
s := AuthAny(c, acl.ResourceFiles, acl.Permissions{acl.ActionManage, acl.ActionUpload})
|
|
|
|
if s.Abort(c) {
|
|
return
|
|
}
|
|
|
|
// Users may only upload their own files.
|
|
if s.GetUser().UserUID != clean.UID(c.Param("uid")) {
|
|
AbortForbidden(c)
|
|
return
|
|
}
|
|
|
|
conf := get.Config()
|
|
|
|
if conf.ReadOnly() || !conf.Settings().Features.Import {
|
|
AbortFeatureDisabled(c)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
|
|
var frm form.UploadOptions
|
|
|
|
// Assign and validate request form values.
|
|
if err := c.BindJSON(&frm); err != nil {
|
|
AbortBadRequest(c, err)
|
|
return
|
|
}
|
|
|
|
token := clean.Token(c.Param("token"))
|
|
uploadPath, err := conf.UserUploadPath(s.UserUID, s.RefID+token)
|
|
|
|
if err != nil {
|
|
log.Errorf("upload: failed to create storage folder (%s)", err)
|
|
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
|
|
return
|
|
}
|
|
|
|
imp := get.Import()
|
|
|
|
// Get destination folder.
|
|
var destFolder string
|
|
if destFolder = s.GetUser().GetUploadPath(); destFolder == "" {
|
|
destFolder = conf.ImportDest()
|
|
}
|
|
|
|
// Move uploaded files to the destination folder.
|
|
event.InfoMsg(i18n.MsgProcessingUpload)
|
|
opt := photoprism.ImportOptionsUpload(uploadPath, destFolder)
|
|
|
|
// Add imported files to albums if allowed.
|
|
if len(frm.Albums) > 0 &&
|
|
acl.Rules.AllowAny(acl.ResourceAlbums, s.GetUserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
|
|
log.Debugf("upload: adding files to album %s", clean.Log(strings.Join(frm.Albums, " and ")))
|
|
opt.Albums = frm.Albums
|
|
}
|
|
|
|
// Set user UID if known.
|
|
if s.UserUID != "" {
|
|
opt.UID = s.UserUID
|
|
}
|
|
|
|
// Start import.
|
|
imported := imp.Start(opt)
|
|
|
|
// Delete empty import directory.
|
|
if fs.DirIsEmpty(uploadPath) {
|
|
if err := os.Remove(uploadPath); err != nil {
|
|
log.Errorf("upload: failed to delete empty folder %s: %s", clean.Log(uploadPath), err)
|
|
} else {
|
|
log.Infof("upload: deleted empty folder %s", clean.Log(uploadPath))
|
|
}
|
|
}
|
|
|
|
// Update moments if files have been imported.
|
|
if n := imported.Processed(); n == 0 {
|
|
log.Infof("upload: found no new files to import from %s", clean.Log(uploadPath))
|
|
} else {
|
|
log.Infof("upload: imported %s", english.Plural(n, "file", "files"))
|
|
if moments := get.Moments(); moments == nil {
|
|
log.Warnf("upload: moments service not set - you may have found a bug")
|
|
} else if workerErr := moments.Start(); workerErr != nil {
|
|
log.Warnf("moments: %s", workerErr)
|
|
}
|
|
}
|
|
|
|
elapsed := int(time.Since(start).Seconds())
|
|
|
|
// Show success message.
|
|
msg := i18n.Msg(i18n.MsgUploadProcessed)
|
|
|
|
event.Success(msg)
|
|
event.Publish("import.completed", event.Data{"uid": opt.UID, "path": uploadPath, "seconds": elapsed})
|
|
event.Publish("index.completed", event.Data{"uid": opt.UID, "path": uploadPath, "seconds": elapsed})
|
|
event.Publish("upload.completed", event.Data{"uid": opt.UID, "path": uploadPath, "seconds": elapsed})
|
|
|
|
for _, uid := range frm.Albums {
|
|
PublishAlbumEvent(StatusUpdated, uid, c)
|
|
}
|
|
|
|
// Update the user interface.
|
|
UpdateClientConfig()
|
|
|
|
// Update album, label, and subject cover thumbs.
|
|
if coversErr := query.UpdateCovers(); coversErr != nil {
|
|
log.Warnf("upload: %s (update covers)", coversErr)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
|
|
})
|
|
}
|