photoprism/internal/api/users_upload.go
Michael Mayer 60162b3fc5 Auth: Refactor user management API and CLI commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
2023-03-08 23:30:39 +01:00

248 lines
6.3 KiB
Go

package api
import (
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// UploadUserFiles adds files to the user upload folder, from where they can be moved and indexed.
//
// POST /users/:uid/upload/:token
func UploadUserFiles(router *gin.RouterGroup) {
router.POST("/users/:uid/upload/:token", func(c *gin.Context) {
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Upload {
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
return
}
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 their own files.
if s.User().UserUID != uid {
event.AuditErr([]string{ClientIP(c), "session %s", "upload files", "user uid does not match"}, s.RefID)
AbortForbidden(c)
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
}
event.Publish("upload.start", event.Data{"time": start})
files := f.File["files"]
uploaded := len(files)
var uploads []string
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
}
for _, file := range files {
filename := path.Join(uploadDir, filepath.Base(file.Filename))
log.Debugf("upload: saving file %s", clean.Log(file.Filename))
if err := c.SaveUploadedFile(file, filename); err != nil {
log.Errorf("upload: failed saving file %s", clean.Log(filepath.Base(file.Filename)))
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
uploads = append(uploads, filename)
}
if !conf.UploadNSFW() {
nd := get.NsfwDetector()
containsNSFW := false
for _, filename := range uploads {
labels, err := nd.File(filename)
if err != nil {
log.Debug(err)
continue
}
if labels.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())
msg := i18n.Msg(i18n.MsgFilesUploadedIn, uploaded, elapsed)
log.Info(msg)
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}
// 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.User().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 f form.UploadOptions
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
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()
var destFolder string
if destFolder = s.User().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(f.Albums) > 0 &&
acl.Resources.AllowAny(acl.ResourceAlbums, s.User().AclRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
log.Debugf("upload: adding files to album %s", clean.Log(strings.Join(f.Albums, " and ")))
opt.Albums = f.Albums
}
// Set user UID if known.
if s.UserUID != "" {
opt.UserUID = 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 deleting 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 := len(imported); n == 0 {
log.Infof("upload: no new files imported", 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 - possible bug")
} else if err := moments.Start(); err != nil {
log.Warnf("moments: %s", err)
}
}
elapsed := int(time.Since(start).Seconds())
// Show success message.
msg := i18n.Msg(i18n.MsgUploadProcessed)
event.Success(msg)
event.Publish("import.completed", event.Data{"path": uploadPath, "seconds": elapsed})
event.Publish("index.completed", event.Data{"path": uploadPath, "seconds": elapsed})
for _, uid := range f.Albums {
PublishAlbumEvent(EntityUpdated, uid, c)
}
// Update the user interface.
UpdateClientConfig()
// Update album, label, and subject cover thumbs.
if err := query.UpdateCovers(); err != nil {
log.Warnf("upload: %s (update covers)", err)
}
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}