photoprism/internal/api/batch.go
Michael Mayer 7e7ba69982 Auth: Add client_uid and client_name to auth_sessions table #808 #3943
This also adds the ability to change the client role if needed and
improves the usage information and output of the CLI commands.

Signed-off-by: Michael Mayer <michael@photoprism.app>
2024-01-18 16:53:05 +01:00

434 lines
10 KiB
Go

package api
import (
"net/http"
"path"
"time"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"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"
)
// BatchPhotosArchive moves multiple photos to the archive.
//
// POST /api/v1/batch/photos/archive
func BatchPhotosArchive(router *gin.RouterGroup) {
router.POST("/batch/photos/archive", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: archiving %s", clean.Log(f.String()))
if get.Config().BackupYaml() {
// Fetch selection from index.
photos, err := query.SelectedPhotos(f)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if err := p.Archive(); err != nil {
log.Errorf("archive: %s", err)
} else {
SavePhotoAsYaml(p)
}
}
} else if err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error; err != nil {
log.Errorf("archive: failed to archive %d pictures (%s)", len(f.Photos), err)
AbortSaveFailed(c)
return
} else if err = entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error; err != nil {
log.Errorf("archive: failed to flag %d pictures as hidden (%s)", len(f.Photos), err)
}
// Update precalculated photo and file counts.
logWarn("index", entity.UpdateCounts())
// Update album, subject, and label cover thumbs.
logWarn("index", query.UpdateCovers())
UpdateClientConfig()
event.EntitiesArchived("photos", f.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionArchived))
})
}
// BatchPhotosRestore restores multiple photos from the archive.
//
// POST /api/v1/batch/photos/restore
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: restoring %s", clean.Log(f.String()))
if get.Config().BackupYaml() {
// Fetch selection from index.
photos, err := query.SelectedPhotos(f)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if err := p.Restore(); err != nil {
log.Errorf("restore: %s", err)
} else {
SavePhotoAsYaml(p)
}
}
} else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos).
UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil {
log.Errorf("restore: %s", err)
AbortSaveFailed(c)
return
}
// Update precalculated photo and file counts.
logWarn("index", entity.UpdateCounts())
// Update album, subject, and label cover thumbs.
logWarn("index", query.UpdateCovers())
UpdateClientConfig()
event.EntitiesRestored("photos", f.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored))
})
}
// BatchPhotosApprove approves multiple photos that are currently under review.
//
// POST /api/v1/batch/photos/approve
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Abort(c) {
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: approving %s", clean.Log(f.String()))
// Fetch selection from index.
photos, err := query.SelectedPhotos(f)
if err != nil {
AbortEntityNotFound(c)
return
}
var approved entity.Photos
for _, p := range photos {
if err = p.Approve(); err != nil {
log.Errorf("approve: %s", err)
} else {
approved = append(approved, p)
SavePhotoAsYaml(p)
}
}
UpdateClientConfig()
event.EntitiesUpdated("photos", approved)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionApproved))
})
}
// BatchAlbumsDelete permanently removes multiple albums.
//
// POST /api/v1/batch/albums/delete
func BatchAlbumsDelete(router *gin.RouterGroup) {
router.POST("/batch/albums/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionDelete)
if s.Abort(c) {
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Albums) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoAlbumsSelected)
return
}
log.Infof("albums: deleting %s", clean.Log(f.String()))
// Soft delete albums, can be restored.
entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.Album{})
/*
KEEP ENTRIES AS ALBUMS MAY NOW BE RESTORED BY NAME
entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.PhotoAlbum{})
*/
UpdateClientConfig()
event.EntitiesDeleted("albums", f.Albums)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgAlbumsDeleted))
})
}
// BatchPhotosPrivate flags multiple photos as private.
//
// POST /api/v1/batch/photos/private
func BatchPhotosPrivate(router *gin.RouterGroup) {
router.POST("/batch/photos/private", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.AccessPrivate)
if s.Abort(c) {
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: updating private flag for %s", clean.Log(f.String()))
if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private",
gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil {
log.Errorf("private: %s", err)
AbortSaveFailed(c)
return
}
// Update precalculated photo and file counts.
logWarn("index", entity.UpdateCounts())
// Fetch selection from index.
if photos, err := query.SelectedPhotos(f); err == nil {
for _, p := range photos {
SavePhotoAsYaml(p)
}
event.EntitiesUpdated("photos", photos)
}
UpdateClientConfig()
FlushCoverCache()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
})
}
// BatchLabelsDelete deletes multiple labels.
//
// POST /api/v1/batch/labels/delete
func BatchLabelsDelete(router *gin.RouterGroup) {
router.POST("/batch/labels/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionDelete)
if s.Abort(c) {
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Labels) == 0 {
log.Error("no labels selected")
Abort(c, http.StatusBadRequest, i18n.ErrNoLabelsSelected)
return
}
log.Infof("labels: deleting %s", clean.Log(f.String()))
var labels entity.Labels
if err := entity.Db().Where("label_uid IN (?)", f.Labels).Find(&labels).Error; err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed)
return
}
for _, label := range labels {
logErr("labels", label.Delete())
}
UpdateClientConfig()
event.EntitiesDeleted("labels", f.Labels)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgLabelsDeleted))
})
}
// BatchPhotosDelete permanently removes multiple photos from the archive.
//
// POST /api/v1/batch/photos/delete
func BatchPhotosDelete(router *gin.RouterGroup) {
router.POST("/batch/photos/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Delete {
AbortFeatureDisabled(c)
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
deleteStart := time.Now()
var photos entity.Photos
var err error
// Abort if user wants to delete all but does not have sufficient privileges.
if f.All && !acl.Resources.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
AbortForbidden(c)
return
}
// Get selection or all archived photos if f.All is true.
if len(f.Photos) == 0 && !f.All {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if f.All {
photos, err = query.ArchivedPhotos(1000000, 0)
} else {
photos, err = query.SelectedPhotos(f)
}
// Abort if the query failed or no photos were found.
if err != nil {
log.Errorf("archive: %s", err)
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if len(photos) > 0 {
log.Infof("archive: deleting %s", english.Plural(len(photos), "photo", "photos"))
} else {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
var deleted entity.Photos
var numFiles = 0
// Delete photos.
for _, p := range photos {
// Report file deletion.
event.AuditWarn([]string{ClientIP(c), s.UserName, "delete", path.Join(p.PhotoPath, p.PhotoName+"*")})
// Remove all related files from storage.
n, err := photoprism.DeletePhoto(p, true, true)
numFiles += n
if err != nil {
log.Errorf("delete: %s", err)
} else {
deleted = append(deleted, p)
}
}
if numFiles > 0 || len(deleted) > 0 {
log.Infof("archive: deleted %s and %s [%s]", english.Plural(numFiles, "file", "files"), english.Plural(len(deleted), "photo", "photos"), time.Since(deleteStart))
}
// Any photos deleted?
if len(deleted) > 0 {
// Update precalculated photo and file counts.
logWarn("index", entity.UpdateCounts())
UpdateClientConfig()
event.EntitiesDeleted("photos", deleted.UIDs())
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted))
})
}