Adding initial scaffolding to rename the repository

Signed-off-by: Kris Nóva <kris@nivenly.com>
This commit is contained in:
Kris Nóva 2021-01-30 22:41:23 -08:00
parent c7472582a7
commit 0f880ac1fa
46 changed files with 5707 additions and 674 deletions

5
internal/README.md Normal file
View file

@ -0,0 +1,5 @@
# Internal
This `/internal/api` package is temporary and is serving as reference for the API and client code that will exist in the rest of this repository.
This is a clockwork. Do NOT use this code.

5
internal/README.md~ Normal file
View file

@ -0,0 +1,5 @@
# Internal
This `/internal/api` package is temporary and is serving as reference for the API and client code that will exist in the rest of this repository.
This is a clockwork. Do not fucking use this code.

364
internal/api/account.go Normal file
View file

@ -0,0 +1,364 @@
package api
import (
"fmt"
"net/http"
"path"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"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/i18n"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/workers"
"github.com/photoprism/photoprism/pkg/fs"
)
// Namespaces for caching and logs.
const (
accountFolder = "account-folder"
)
// GET /api/v1/accounts
func GetAccounts(router *gin.RouterGroup) {
router.GET("/accounts", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
c.JSON(http.StatusOK, entity.Accounts{})
return
}
var f form.AccountSearch
err := c.MustBindWith(&f, binding.Form)
if err != nil {
AbortBadRequest(c)
return
}
result, err := query.AccountSearch(f)
if err != nil {
AbortBadRequest(c)
return
}
// TODO c.Header("X-Count", strconv.Itoa(count))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
c.JSON(http.StatusOK, result)
})
}
// GET /api/v1/accounts/:id
//
// Parameters:
// id: string Account ID as returned by the API
func GetAccount(router *gin.RouterGroup) {
router.GET("/accounts/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
if m, err := query.AccountByID(id); err == nil {
c.JSON(http.StatusOK, m)
} else {
Abort(c, http.StatusNotFound, i18n.ErrAccountNotFound)
}
})
}
// GET /api/v1/accounts/:id/folders
//
// Parameters:
// id: string Account ID as returned by the API
func GetAccountFolders(router *gin.RouterGroup) {
router.GET("/accounts/:id/folders", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
start := time.Now()
id := ParseUint(c.Param("id"))
cache := service.FolderCache()
cacheKey := fmt.Sprintf("%s:%d", accountFolder, id)
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(fs.FileInfos)
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return
}
m, err := query.AccountByID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAccountNotFound)
return
}
list, err := m.Directories()
if err != nil {
log.Errorf("%s: %s", accountFolder, err.Error())
Abort(c, http.StatusBadRequest, i18n.ErrConnectionFailed)
return
}
cache.SetDefault(cacheKey, list)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, list)
})
}
// GET /api/v1/accounts/:id/share
//
// Parameters:
// id: string Account ID as returned by the API
func ShareWithAccount(router *gin.RouterGroup) {
router.POST("/accounts/:id/share", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionUpload)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
m, err := query.AccountByID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAccountNotFound)
return
}
var f form.AccountShare
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
dst := f.Destination
files, err := query.FilesByUID(f.Photos, 1000, 0)
if err != nil {
AbortEntityNotFound(c)
return
}
var aliases = make(map[string]int)
for _, file := range files {
alias := path.Join(dst, file.ShareBase(0))
key := strings.ToLower(alias)
if seq := aliases[key]; seq > 0 {
alias = file.ShareBase(seq)
}
aliases[key] += 1
entity.FirstOrCreateFileShare(entity.NewFileShare(file.ID, m.ID, alias))
}
workers.StartShare(service.Config())
c.JSON(http.StatusOK, files)
})
}
// POST /api/v1/accounts
func CreateAccount(router *gin.RouterGroup) {
router.POST("/accounts", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
var f form.Account
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if err := f.ServiceDiscovery(); err != nil {
log.Error(err)
Abort(c, http.StatusBadRequest, i18n.ErrConnectionFailed)
return
}
m, err := entity.CreateAccount(f)
if err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
event.SuccessMsg(i18n.MsgAccountCreated)
c.JSON(http.StatusOK, m)
})
}
// PUT /api/v1/accounts/:id
//
// Parameters:
// id: string Account ID as returned by the API
func UpdateAccount(router *gin.RouterGroup) {
router.PUT("/accounts/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
m, err := query.AccountByID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAccountNotFound)
return
}
// 1) Init form with model values
f, err := form.NewAccount(m)
if err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
// 2) Update form with values from request
if err := c.BindJSON(&f); err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
// 3) Save model with values from form
if err := m.SaveForm(f); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
event.SuccessMsg(i18n.MsgAccountSaved)
m, err = query.AccountByID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if m.AccSync {
workers.StartSync(service.Config())
}
c.JSON(http.StatusOK, m)
})
}
// DELETE /api/v1/accounts/:id
//
// Parameters:
// id: string Account ID as returned by the API
func DeleteAccount(router *gin.RouterGroup) {
router.DELETE("/accounts/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
m, err := query.AccountByID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAccountNotFound)
return
}
if err := m.Delete(); err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed)
return
}
event.SuccessMsg(i18n.MsgAccountDeleted)
c.JSON(http.StatusOK, m)
})
}

531
internal/api/album.go Normal file
View file

@ -0,0 +1,531 @@
package api
import (
"archive/zip"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"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/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// SaveAlbumAsYaml saves album data as YAML file.
func SaveAlbumAsYaml(a entity.Album) {
c := service.Config()
// Write YAML sidecar file (optional).
if !c.BackupYaml() {
return
}
fileName := a.YamlFileName(c.AlbumsPath())
if err := a.SaveAsYaml(fileName); err != nil {
log.Errorf("album: %s (update yaml)", err)
} else {
log.Debugf("album: updated yaml file %s", txt.Quote(filepath.Base(fileName)))
}
}
// GET /api/v1/albums
func GetAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.AlbumSearch
err := c.MustBindWith(&f, binding.Form)
if err != nil {
AbortBadRequest(c)
return
}
// Guest permissions are limited to shared albums.
if s.Guest() {
f.ID = s.Shares.Join(query.Or)
}
result, err := query.AlbumSearch(f)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
return
}
AddCountHeader(c, len(result))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
c.JSON(http.StatusOK, result)
})
}
// GET /api/v1/albums/:uid
func GetAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
a, err := query.AlbumByUID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
c.JSON(http.StatusOK, a)
})
}
// POST /api/v1/albums
func CreateAlbum(router *gin.RouterGroup) {
router.POST("/albums", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Album
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
a := entity.NewAlbum(f.AlbumTitle, entity.AlbumDefault)
a.AlbumFavorite = f.AlbumFavorite
log.Debugf("album: creating %+v %+v", f, a)
if res := entity.Db().Create(a); res.Error != nil {
AbortAlreadyExists(c, txt.Quote(a.AlbumTitle))
return
}
event.SuccessMsg(i18n.MsgAlbumCreated)
UpdateClientConfig()
PublishAlbumEvent(EntityCreated, a.AlbumUID, c)
SaveAlbumAsYaml(*a)
c.JSON(http.StatusOK, a)
})
}
// PUT /api/v1/albums/:uid
func UpdateAlbum(router *gin.RouterGroup) {
router.PUT("/albums/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
a, err := query.AlbumByUID(uid)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
f, err := form.NewAlbum(a)
if err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
if err := c.BindJSON(&f); err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
if err := a.SaveForm(f); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
UpdateClientConfig()
event.SuccessMsg(i18n.MsgAlbumSaved)
PublishAlbumEvent(EntityUpdated, uid, c)
SaveAlbumAsYaml(a)
c.JSON(http.StatusOK, a)
})
}
// DELETE /api/v1/albums/:uid
func DeleteAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
id := c.Param("uid")
a, err := query.AlbumByUID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
PublishAlbumEvent(EntityDeleted, id, c)
conf.Db().Delete(&a)
UpdateClientConfig()
SaveAlbumAsYaml(a)
event.SuccessMsg(i18n.MsgAlbumDeleted, txt.Quote(a.AlbumTitle))
c.JSON(http.StatusOK, a)
})
}
// POST /api/v1/albums/:uid/like
//
// Parameters:
// uid: string Album UID
func LikeAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionLike)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
a, err := query.AlbumByUID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
if err := a.Update("AlbumFavorite", true); err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, id, c)
SaveAlbumAsYaml(a)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgChangesSaved))
})
}
// DELETE /api/v1/albums/:uid/like
//
// Parameters:
// uid: string Album UID
func DislikeAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionLike)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
a, err := query.AlbumByUID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
if err := a.Update("AlbumFavorite", false); err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, id, c)
SaveAlbumAsYaml(a)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgChangesSaved))
})
}
// POST /api/v1/albums/:uid/clone
func CloneAlbums(router *gin.RouterGroup) {
router.POST("/albums/:uid/clone", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
a, err := query.AlbumByUID(c.Param("uid"))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
var added []entity.PhotoAlbum
for _, uid := range f.Albums {
cloneAlbum, err := query.AlbumByUID(uid)
if err != nil {
log.Errorf("album: %s", err)
continue
}
photos, err := query.AlbumPhotos(cloneAlbum, 10000)
if err != nil {
log.Errorf("album: %s", err)
continue
}
added = append(added, a.AddPhotos(photos.UIDs())...)
}
if len(added) > 0 {
event.SuccessMsg(i18n.MsgSelectionAddedTo, txt.Quote(a.Title()))
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
SaveAlbumAsYaml(a)
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgAlbumCloned), "album": a, "added": added})
})
}
// POST /api/v1/albums/:uid/photos
func AddPhotosToAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/photos", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
uid := c.Param("uid")
a, err := query.AlbumByUID(uid)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
photos, err := query.PhotoSelection(f)
if err != nil {
log.Errorf("album: %s", err)
AbortBadRequest(c)
return
}
added := a.AddPhotos(photos.UIDs())
if len(added) > 0 {
if len(added) == 1 {
event.SuccessMsg(i18n.MsgEntryAddedTo, txt.Quote(a.Title()))
} else {
event.SuccessMsg(i18n.MsgEntriesAddedTo, len(added), txt.Quote(a.Title()))
}
RemoveFromAlbumCoverCache(a.AlbumUID)
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
SaveAlbumAsYaml(a)
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgChangesSaved), "album": a, "photos": photos.UIDs(), "added": added})
})
}
// DELETE /api/v1/albums/:uid/photos
func RemovePhotosFromAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/photos", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(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
}
a, err := query.AlbumByUID(c.Param("uid"))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
removed := a.RemovePhotos(f.Photos)
if len(removed) > 0 {
if len(removed) == 1 {
event.SuccessMsg(i18n.MsgEntryRemovedFrom, txt.Quote(a.Title()))
} else {
event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), txt.Quote(txt.Quote(a.Title())))
}
RemoveFromAlbumCoverCache(a.AlbumUID)
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
SaveAlbumAsYaml(a)
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgChangesSaved), "album": a, "photos": f.Photos, "removed": removed})
})
}
// GET /api/v1/albums/:uid/dl
func DownloadAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid/dl", func(c *gin.Context) {
if InvalidDownloadToken(c) {
AbortUnauthorized(c)
return
}
start := time.Now()
a, err := query.AlbumByUID(c.Param("uid"))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
files, err := query.AlbumPhotos(a, 10000)
if err != nil {
AbortEntityNotFound(c)
return
}
albumName := strings.Title(a.AlbumSlug)
if len(albumName) < 2 {
albumName = fmt.Sprintf("photoprism-album-%s", a.AlbumUID)
}
zipFileName := fmt.Sprintf("%s.zip", albumName)
AddDownloadHeader(c, zipFileName)
zipWriter := zip.NewWriter(c.Writer)
defer func() { _ = zipWriter.Close() }()
var aliases = make(map[string]int)
for _, file := range files {
if file.FileHash == "" {
log.Warnf("download: empty file hash, skipped %s", txt.Quote(file.FileName))
continue
}
if file.FileSidecar {
log.Debugf("download: skipped sidecar %s", txt.Quote(file.FileName))
continue
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
alias := file.ShareBase(0)
key := strings.ToLower(alias)
if seq := aliases[key]; seq > 0 {
alias = file.ShareBase(seq)
}
aliases[key] += 1
if fs.FileExists(fileName) {
if err := addFileToZip(zipWriter, fileName, alias); err != nil {
log.Error(err)
Abort(c, http.StatusInternalServerError, i18n.ErrZipFailed)
return
}
log.Infof("download: added %s as %s", txt.Quote(file.FileName), txt.Quote(alias))
} else {
log.Errorf("download: file %s is missing", txt.Quote(file.FileName))
}
}
log.Infof("download: album zip %s created in %s", txt.Quote(zipFileName), time.Since(start))
})
}

107
internal/api/api.go Normal file
View file

@ -0,0 +1,107 @@
/*
Package api contains PhotoPrism REST API handlers.
Copyright (c) 2018 - 2021 Michael Mayer <hello@photoprism.org>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required
to describe our software, run your own server, for educational purposes, but not for
offering commercial goods, products, or services without prior written permission.
In other words, please ask.
Feel free to send an e-mail to hello@photoprism.org if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
https://docs.photoprism.org/developer-guide/
*/
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
var log = event.Log
func logError(prefix string, err error) {
if err != nil {
log.Errorf("%s: %s", prefix, err.Error())
}
}
func UpdateClientConfig() {
conf := service.Config()
event.Publish("config.updated", event.Data{"config": conf.UserConfig()})
}
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
log.Debugf("api: abort %s with code %d (%s)", c.FullPath(), code, resp.String())
c.AbortWithStatusJSON(code, resp)
}
func Error(c *gin.Context, code int, err error, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
if err != nil {
resp.Details = err.Error()
log.Errorf("api: error %s with code %d in %s (%s)", txt.Quote(err.Error()), code, c.FullPath(), resp.String())
}
c.AbortWithStatusJSON(code, resp)
}
func AbortUnauthorized(c *gin.Context) {
Abort(c, http.StatusUnauthorized, i18n.ErrUnauthorized)
}
func AbortEntityNotFound(c *gin.Context) {
Abort(c, http.StatusNotFound, i18n.ErrEntityNotFound)
}
func AbortSaveFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
}
func AbortDeleteFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrDeleteFailed)
}
func AbortUnexpected(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
}
func AbortBadRequest(c *gin.Context) {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
}
func AbortAlreadyExists(c *gin.Context, s string) {
Abort(c, http.StatusConflict, i18n.ErrAlreadyExists, s)
}
func AbortFeatureDisabled(c *gin.Context) {
Abort(c, http.StatusForbidden, i18n.ErrFeatureDisabled)
}

349
internal/api/batch.go Normal file
View file

@ -0,0 +1,349 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"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/i18n"
"github.com/photoprism/photoprism/internal/query"
)
// POST /api/v1/batch/photos/archive
func BatchPhotosArchive(router *gin.RouterGroup) {
router.POST("/batch/photos/archive", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(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("archive: adding %s", f.String())
// Soft delete by setting deleted_at to current date.
err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error
if err != nil {
AbortSaveFailed(c)
return
}
// Remove archived photos from albums.
logError("archive", entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error)
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
}
UpdateClientConfig()
event.EntitiesArchived("photos", f.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionArchived))
})
}
// POST /api/v1/batch/photos/approve
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(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", f.String())
photos, err := query.PhotoSelection(f)
if err != nil {
AbortEntityNotFound(c)
return
}
var approved entity.Photos
for _, p := range photos {
if err := p.Approve(); err != nil {
log.Errorf("photo: %s (approve)", err.Error())
} else {
approved = append(approved, p)
SavePhotoAsYaml(p)
}
}
UpdateClientConfig()
event.EntitiesUpdated("photos", approved)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionApproved))
})
}
// POST /api/v1/batch/photos/restore
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(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("archive: restoring %s", f.String())
err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos).
UpdateColumn("deleted_at", gorm.Expr("NULL")).Error
if err != nil {
AbortSaveFailed(c)
return
}
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
}
UpdateClientConfig()
event.EntitiesRestored("photos", f.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored))
})
}
// POST /api/v1/batch/albums/delete
func BatchAlbumsDelete(router *gin.RouterGroup) {
router.POST("/batch/albums/delete", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(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", f.String())
entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.Album{})
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))
})
}
// POST /api/v1/batch/photos/private
func BatchPhotosPrivate(router *gin.RouterGroup) {
router.POST("/batch/photos/private", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionPrivate)
if s.Invalid() {
AbortUnauthorized(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: mark %s as private", f.String())
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
if err != nil {
AbortSaveFailed(c)
return
}
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
}
if entities, err := query.PhotoSelection(f); err == nil {
event.EntitiesUpdated("photos", entities)
}
UpdateClientConfig()
FlushCoverCache()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
})
}
// POST /api/v1/batch/labels/delete
func BatchLabelsDelete(router *gin.RouterGroup) {
router.POST("/batch/labels/delete", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(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", 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 {
logError("labels", label.Delete())
}
UpdateClientConfig()
event.EntitiesDeleted("labels", f.Labels)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgLabelsDeleted))
})
}
// POST /api/v1/batch/photos/delete
func BatchPhotosDelete(router *gin.RouterGroup) {
router.POST("/batch/photos/delete", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.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
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("archive: permanently deleting %s", f.String())
photos, err := query.PhotoSelection(f)
if err != nil {
AbortEntityNotFound(c)
return
}
var deleted entity.Photos
// Delete photos.
for _, p := range photos {
if err := photoprism.Delete(p); err != nil {
log.Errorf("photo: %s (delete)", err.Error())
} else {
deleted = append(deleted, p)
}
}
// Update counts and views if needed.
if len(deleted) > 0 {
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
}
UpdateClientConfig()
event.EntitiesDeleted("photos", deleted.UIDs())
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted))
})
}

68
internal/api/cache.go Normal file
View file

@ -0,0 +1,68 @@
package api
import (
"fmt"
"strconv"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
)
// MaxAge represents a cache TTL in seconds.
type MaxAge int
// String returns the cache TTL in seconds as string.
func (a MaxAge) String() string {
return strconv.Itoa(int(a))
}
// Default cache TTL times in seconds.
var (
CoverCacheTTL MaxAge = 3600 // 1 hour
ThumbCacheTTL MaxAge = 3600 * 24 * 90 // ~ 3 months
)
type ThumbCache struct {
FileName string
ShareName string
}
type ByteCache struct {
Data []byte
}
// CacheKey returns a cache key string based on namespace, uid and name.
func CacheKey(ns, uid, name string) string {
return fmt.Sprintf("%s:%s:%s", ns, uid, name)
}
// RemoveFromFolderCache removes an item from the folder cache e.g. after indexing.
func RemoveFromFolderCache(rootName string) {
cache := service.FolderCache()
cacheKey := fmt.Sprintf("folder:%s:%t:%t", rootName, true, false)
cache.Delete(cacheKey)
log.Debugf("removed %s from cache", cacheKey)
}
// RemoveFromAlbumCoverCache removes covers by album UID e.g. after adding or removing photos.
func RemoveFromAlbumCoverCache(uid string) {
cache := service.CoverCache()
for typeName := range thumb.Types {
cacheKey := CacheKey(albumCover, uid, typeName)
cache.Delete(cacheKey)
log.Debugf("removed %s from cache", cacheKey)
}
}
// FlushCoverCache clears the complete cover cache.
func FlushCoverCache() {
service.CoverCache().Flush()
log.Debugf("albums: flushed cover cache")
}

135
internal/api/config.go Normal file
View file

@ -0,0 +1,135 @@
package api
import (
"io/ioutil"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"gopkg.in/yaml.v2"
)
// GET /api/v1/config
func GetConfig(router *gin.RouterGroup) {
router.GET("/config", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceConfig, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if s.User.Guest() {
c.JSON(http.StatusOK, conf.GuestConfig())
} else if s.User.Registered() {
c.JSON(http.StatusOK, conf.UserConfig())
} else {
c.JSON(http.StatusOK, conf.PublicConfig())
}
})
}
// GET /api/v1/config/options
func GetConfigOptions(router *gin.RouterGroup) {
router.GET("/config/options", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionRead)
conf := service.Config()
if s.Invalid() || conf.Public() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
c.JSON(http.StatusOK, conf.Options())
})
}
// POST /api/v1/config/options
func SaveConfigOptions(router *gin.RouterGroup) {
router.POST("/config/options", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionUpdate)
conf := service.Config()
if s.Invalid() || conf.Public() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
fileName := conf.ConfigFile()
if fileName == "" {
log.Errorf("options: empty config file name")
AbortSaveFailed(c)
return
}
type valueMap map[string]interface{}
v := make(valueMap)
if fs.FileExists(fileName) {
yamlData, err := ioutil.ReadFile(fileName)
if err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
if err := yaml.Unmarshal(yamlData, v); err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
}
if err := c.BindJSON(&v); err != nil {
log.Errorf("options: %s", err)
AbortBadRequest(c)
return
}
yamlData, err := yaml.Marshal(v)
if err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
// Make sure directory exists.
if err := os.MkdirAll(filepath.Dir(fileName), os.ModePerm); err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
// Write YAML data to file.
if err := ioutil.WriteFile(fileName, yamlData, os.ModePerm); err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
if err := conf.Options().Load(fileName); err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
conf.Propagate()
UpdateClientConfig()
log.Infof(i18n.Msg(i18n.MsgSettingsSaved))
c.JSON(http.StatusOK, conf.Options())
})
}

243
internal/api/covers.go Normal file
View file

@ -0,0 +1,243 @@
package api
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// Namespaces for caching and logs.
const (
albumCover = "album-cover"
labelCover = "label-cover"
)
// GET /api/v1/albums/:uid/t/:token/:type
//
// Parameters:
// uid: string album uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func AlbumCover(router *gin.RouterGroup) {
router.GET("/albums/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", albumIconSvg)
return
}
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("%s: invalid type %s", albumCover, typeName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
cache := service.CoverCache()
cacheKey := CacheKey(albumCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
if !fs.FileExists(cached.FileName) {
log.Errorf("%s: %s not found", albumCover, uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.AlbumCoverByUID(uid)
if err != nil {
log.Debugf("%s: no photos yet, using generic image for %s", albumCover, uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: could not find original for %s", albumCover, fileName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
log.Warnf("%s: %s is missing", albumCover, txt.Quote(f.FileName))
logError(albumCover, f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() && c.Query("download") == "" {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", albumCover, thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
} else if thumbnail == "" {
log.Errorf("%s: %s has empty thumb name - bug?", albumCover, filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}
})
}
// GET /api/v1/labels/:uid/t/:token/:type
//
// Parameters:
// uid: string label uid
// token: string security token (see config)
// type: string thumb type, see photoprism.ThumbnailTypes
func LabelCover(router *gin.RouterGroup) {
router.GET("/labels/:uid/t/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", labelIconSvg)
return
}
start := time.Now()
conf := service.Config()
typeName := c.Param("type")
uid := c.Param("uid")
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("%s: invalid type %s", labelCover, txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
cache := service.CoverCache()
cacheKey := CacheKey(labelCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
if !fs.FileExists(cached.FileName) {
log.Errorf("%s: %s not found", labelCover, uid)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.LabelThumbByUID(uid)
if err != nil {
log.Errorf(err.Error())
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: file %s is missing", labelCover, txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError(labelCover, f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", labelCover, thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("%s: %s", labelCover, err)
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
} else if thumbnail == "" {
log.Errorf("%s: %s has empty thumb name - bug?", labelCover, filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}
})
}

8
internal/api/doc.go Normal file
View file

@ -0,0 +1,8 @@
/*
Package api contains REST request handlers used by the server package.
Additional information concerning the API can be found in our Developer Guide:
https://github.com/photoprism/photoprism/wiki/REST-API
*/
package api

70
internal/api/download.go Normal file
View file

@ -0,0 +1,70 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
)
// TODO: GET /api/v1/dl/file/:hash
// TODO: GET /api/v1/dl/photo/:uid
// TODO: GET /api/v1/dl/album/:uid
// DownloadName returns the download file name type.
func DownloadName(c *gin.Context) entity.DownloadName {
switch c.Query("name") {
case "file":
return entity.DownloadNameFile
case "share":
return entity.DownloadNameShare
case "original":
return entity.DownloadNameOriginal
default:
return service.Config().Settings().Download.Name
}
}
// GET /api/v1/dl/:hash
//
// Parameters:
// hash: string The file hash as returned by the search API
func GetDownload(router *gin.RouterGroup) {
router.GET("/dl/:hash", func(c *gin.Context) {
if InvalidDownloadToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
fileHash := c.Param("hash")
f, err := query.FileByHash(fileHash)
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": err.Error()})
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("download: file %s is missing", txt.Quote(f.FileName))
c.Data(404, "image/svg+xml", brokenIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("download", f.Update("FileMissing", true))
return
}
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
})
}

35
internal/api/errors.go Normal file
View file

@ -0,0 +1,35 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
)
func GetErrors(router *gin.RouterGroup) {
router.GET("/errors", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLogs, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
limit := txt.Int(c.Query("count"))
offset := txt.Int(c.Query("offset"))
if resp, err := query.Errors(limit, offset, c.Query("q")); err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
return
} else {
AddCountHeader(c, len(resp))
AddLimitHeader(c, limit)
AddOffsetHeader(c, offset)
c.JSON(http.StatusOK, resp)
}
})
}

55
internal/api/event.go Normal file
View file

@ -0,0 +1,55 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
)
type EntityEvent string
const (
EntityUpdated EntityEvent = "updated"
EntityCreated EntityEvent = "created"
EntityDeleted EntityEvent = "deleted"
)
func PublishPhotoEvent(e EntityEvent, uid string, c *gin.Context) {
f := form.PhotoSearch{ID: uid, Merged: true}
result, _, err := query.PhotoSearch(f)
if err != nil {
log.Error(err)
AbortUnexpected(c)
return
}
event.PublishEntities("photos", string(e), result)
}
func PublishAlbumEvent(e EntityEvent, uid string, c *gin.Context) {
f := form.AlbumSearch{ID: uid}
result, err := query.AlbumSearch(f)
if err != nil {
log.Error(err)
AbortUnexpected(c)
return
}
event.PublishEntities("albums", string(e), result)
}
func PublishLabelEvent(e EntityEvent, uid string, c *gin.Context) {
f := form.LabelSearch{ID: uid}
result, err := query.Labels(f)
if err != nil {
log.Error(err)
AbortUnexpected(c)
return
}
event.PublishEntities("labels", string(e), result)
}

54
internal/api/feedback.go Normal file
View file

@ -0,0 +1,54 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/gin-gonic/gin"
)
// POST /api/v1/feedback
func SendFeedback(router *gin.RouterGroup) {
router.POST("/feedback", func(c *gin.Context) {
conf := service.Config()
if conf.Public() {
Abort(c, http.StatusForbidden, i18n.ErrPublic)
return
}
s := Auth(SessionID(c), acl.ResourceFeedback, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf.UpdateHub()
var f form.Feedback
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if f.Empty() {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
if err := conf.Hub().SendFeedback(f); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK})
})
}

33
internal/api/file.go Normal file
View file

@ -0,0 +1,33 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/query"
)
// GET /api/v1/files/:hash
//
// Parameters:
// hash: string SHA-1 hash of the file
func GetFile(router *gin.RouterGroup) {
router.GET("/files/:hash", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFiles, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
p, err := query.FileByHash(c.Param("hash"))
if err != nil {
AbortEntityNotFound(c)
return
}
c.JSON(http.StatusOK, p)
})
}

View file

@ -0,0 +1,88 @@
package api
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
// DELETE /api/v1/photos/:uid/files/:file_uid
//
// Parameters:
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func DeleteFile(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/files/:file_uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFiles, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.ReadOnly() || !conf.Settings().Features.Edit {
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
return
}
photoUID := c.Param("uid")
fileUID := c.Param("file_uid")
file, err := query.FileByUID(fileUID)
if err != nil {
log.Errorf("photo: %s (delete file)", err)
AbortEntityNotFound(c)
return
}
if file.FilePrimary {
log.Errorf("photo: can't delete primary file")
AbortDeleteFailed(c)
return
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
baseName := filepath.Base(fileName)
mediaFile, err := photoprism.NewMediaFile(fileName)
if err != nil {
log.Errorf("photo: %s (delete %s)", err, txt.Quote(baseName))
AbortEntityNotFound(c)
return
}
if err := mediaFile.Remove(); err != nil {
log.Errorf("photo: %s (delete %s from folder)", err, txt.Quote(baseName))
}
if err := file.Delete(true); err != nil {
log.Errorf("photo: %s (delete %s from index)", err, txt.Quote(baseName))
AbortDeleteFailed(c)
return
}
// Notify clients by publishing events.
PublishPhotoEvent(EntityUpdated, photoUID, c)
event.SuccessMsg(i18n.MsgFileDeleted)
if p, err := query.PhotoPreloadByUID(photoUID); err != nil {
AbortEntityNotFound(c)
return
} else {
c.JSON(http.StatusOK, p)
}
})
}

110
internal/api/folder.go Normal file
View file

@ -0,0 +1,110 @@
package api
import (
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
)
type FoldersResponse struct {
Root string `json:"root,omitempty"`
Folders []entity.Folder `json:"folders"`
Files []entity.File `json:"files,omitempty"`
Recursive bool `json:"recursive,omitempty"`
Cached bool `json:"cached,omitempty"`
}
// GetFolders is a reusable request handler for directory listings (GET /api/v1/folders/*).
func GetFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string) {
handler := func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFolders, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.FolderSearch
start := time.Now()
err := c.MustBindWith(&f, binding.Form)
if err != nil {
AbortBadRequest(c)
return
}
cache := service.FolderCache()
recursive := f.Recursive
listFiles := f.Files
uncached := listFiles || f.Uncached
resp := FoldersResponse{Root: rootName, Recursive: recursive, Cached: !uncached}
path := c.Param("path")
cacheKey := fmt.Sprintf("folder:%s:%t:%t", filepath.Join(rootName, path), recursive, listFiles)
if !uncached {
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(FoldersResponse)
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return
}
}
if folders, err := query.FoldersByPath(rootName, rootPath, path, recursive); err != nil {
log.Errorf("folder: %s", err)
c.JSON(http.StatusOK, resp)
return
} else {
resp.Folders = folders
}
if listFiles {
if files, err := query.FilesByPath(f.Count, f.Offset, rootName, path); err != nil {
log.Errorf("folder: %s", err)
} else {
resp.Files = files
}
}
if !uncached {
cache.SetDefault(cacheKey, resp)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
}
AddFileCountHeaders(c, len(resp.Files), len(resp.Folders))
AddCountHeader(c, len(resp.Files)+len(resp.Folders))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
c.JSON(http.StatusOK, resp)
}
router.GET("/folders/"+urlPath, handler)
router.GET("/folders/"+urlPath+"/*path", handler)
}
// GET /api/v1/folders/originals
func GetFoldersOriginals(router *gin.RouterGroup) {
conf := service.Config()
GetFolders(router, "originals", entity.RootOriginals, conf.OriginalsPath())
}
// GET /api/v1/folders/import
func GetFoldersImport(router *gin.RouterGroup) {
conf := service.Config()
GetFolders(router, "import", entity.RootImport, conf.ImportPath())
}

106
internal/api/geo.go Normal file
View file

@ -0,0 +1,106 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/form"
geojson "github.com/paulmach/go.geojson"
)
// GET /api/v1/geo
func GetGeo(router *gin.RouterGroup) {
router.GET("/geo", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.GeoSearch
err := c.MustBindWith(&f, binding.Form)
if err != nil {
AbortBadRequest(c)
return
}
photos, err := query.Geo(f)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
return
}
fc := geojson.NewFeatureCollection()
bbox := make([]float64, 4)
bboxMin := func(pos int, val float64) {
if bbox[pos] == 0.0 || bbox[pos] > val {
bbox[pos] = val
}
}
bboxMax := func(pos int, val float64) {
if bbox[pos] == 0.0 || bbox[pos] < val {
bbox[pos] = val
}
}
for _, p := range photos {
bboxMin(0, p.Lng())
bboxMin(1, p.Lat())
bboxMax(2, p.Lng())
bboxMax(3, p.Lat())
props := gin.H{
"UID": p.PhotoUID,
"Hash": p.FileHash,
"Width": p.FileWidth,
"Height": p.FileHeight,
"TakenAt": p.TakenAt,
"Title": p.PhotoTitle,
}
if p.PhotoDescription != "" {
props["Description"] = p.PhotoDescription
}
if p.PhotoType != entity.TypeImage && p.PhotoType != entity.TypeDefault {
props["Type"] = p.PhotoType
}
if p.PhotoFavorite {
props["Favorite"] = true
}
feat := geojson.NewPointFeature([]float64{p.Lng(), p.Lat()})
feat.ID = p.ID
feat.Properties = props
fc.AddFeature(feat)
}
fc.BoundingBox = bbox
resp, err := fc.MarshalJSON()
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
return
}
AddTokenHeaders(c)
c.Data(http.StatusOK, "application/json", resp)
})
}

71
internal/api/headers.go Normal file
View file

@ -0,0 +1,71 @@
package api
import (
"fmt"
"strconv"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
)
const (
ContentTypeAvc = `video/mp4; codecs="avc1`
)
// AddCacheHeader adds a cache control header to the response.
func AddCacheHeader(c *gin.Context, maxAge MaxAge) {
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform", maxAge.String()))
}
// AddCoverCacheHeader adds cover image cache control headers to the response.
func AddCoverCacheHeader(c *gin.Context) {
AddCacheHeader(c, CoverCacheTTL)
}
// AddCacheHeader adds thumbnail cache control headers to the response.
func AddThumbCacheHeader(c *gin.Context) {
c.Header("Cache-Control", fmt.Sprintf("private, max-age=%s, no-transform, immutable", ThumbCacheTTL.String()))
}
// AddCountHeader adds the actual result count to the response.
func AddCountHeader(c *gin.Context, count int) {
c.Header("X-Count", strconv.Itoa(count))
}
// AddLimitHeader adds the max result count to the response.
func AddLimitHeader(c *gin.Context, limit int) {
c.Header("X-Limit", strconv.Itoa(limit))
}
// AddOffsetHeader adds the result offset to the response.
func AddOffsetHeader(c *gin.Context, offset int) {
c.Header("X-Offset", strconv.Itoa(offset))
}
// AddDownloadHeader adds a header indicating the response is expected to be downloaded.
func AddDownloadHeader(c *gin.Context, fileName string) {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
}
// AddSessionHeader adds a session id header to the response.
func AddSessionHeader(c *gin.Context, id string) {
c.Header("X-Session-ID", id)
}
// AddContentTypeHeader adds a content type header to the response.
func AddContentTypeHeader(c *gin.Context, contentType string) {
c.Header("Content-Type", contentType)
}
// AddFileCountHeaders adds file and folder counts to the response.
func AddFileCountHeaders(c *gin.Context, filesCount, foldersCount int) {
c.Header("X-Files", strconv.Itoa(filesCount))
c.Header("X-Folders", strconv.Itoa(foldersCount))
}
// AddTokenHeaders adds preview token headers to the response.
func AddTokenHeaders(c *gin.Context) {
c.Header("X-Preview-Token", service.Config().PreviewToken())
c.Header("X-Download-Token", service.Config().DownloadToken())
}

137
internal/api/import.go Normal file
View file

@ -0,0 +1,137 @@
package api
import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"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/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// POST /api/v1/import*
func StartImport(router *gin.RouterGroup) {
router.POST("/import/*path", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionImport)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.ReadOnly() || !conf.Settings().Features.Import {
AbortFeatureDisabled(c)
return
}
start := time.Now()
var f form.ImportOptions
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
subPath := ""
path := conf.ImportPath()
if subPath = c.Param("path"); subPath != "" && subPath != "/" {
subPath = strings.Replace(subPath, ".", "", -1)
path = filepath.Join(path, subPath)
} else if f.Path != "" {
subPath = strings.Replace(f.Path, ".", "", -1)
path = filepath.Join(path, subPath)
}
path = filepath.Clean(path)
imp := service.Import()
RemoveFromFolderCache(entity.RootImport)
var opt photoprism.ImportOptions
if f.Move {
event.InfoMsg(i18n.MsgMovingFilesFrom, txt.Quote(filepath.Base(path)))
opt = photoprism.ImportOptionsMove(path)
} else {
event.InfoMsg(i18n.MsgCopyingFilesFrom, txt.Quote(filepath.Base(path)))
opt = photoprism.ImportOptionsCopy(path)
}
if len(f.Albums) > 0 {
log.Debugf("import: files will be added to album %s", strings.Join(f.Albums, " and "))
opt.Albums = f.Albums
}
imp.Start(opt)
if subPath != "" && path != conf.ImportPath() && fs.IsEmpty(path) {
if err := os.Remove(path); err != nil {
log.Errorf("import: failed deleting empty folder %s: %s", txt.Quote(path), err)
} else {
log.Infof("import: deleted empty folder %s", txt.Quote(path))
}
}
moments := service.Moments()
if err := moments.Start(); err != nil {
log.Warnf("moments: %s", err)
}
elapsed := int(time.Since(start).Seconds())
msg := i18n.Msg(i18n.MsgImportCompletedIn, elapsed)
event.Success(msg)
event.Publish("import.completed", event.Data{"path": path, "seconds": elapsed})
event.Publish("index.completed", event.Data{"path": path, "seconds": elapsed})
for _, uid := range f.Albums {
PublishAlbumEvent(EntityUpdated, uid, c)
}
UpdateClientConfig()
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}
// DELETE /api/v1/import
func CancelImport(router *gin.RouterGroup) {
router.DELETE("/import", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionImport)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.ReadOnly() || !conf.Settings().Features.Import {
AbortFeatureDisabled(c)
return
}
imp := service.Import()
imp.Cancel()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgImportCanceled))
})
}

126
internal/api/index.go Normal file
View file

@ -0,0 +1,126 @@
package api
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"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/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
// POST /api/v1/index
func StartIndexing(router *gin.RouterGroup) {
router.POST("/index", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if !conf.Settings().Features.Library {
AbortFeatureDisabled(c)
return
}
start := time.Now()
var f form.IndexOptions
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
path := conf.OriginalsPath()
ind := service.Index()
indOpt := photoprism.IndexOptions{
Rescan: f.Rescan,
Convert: conf.Settings().Index.Convert && conf.SidecarWritable(),
Path: filepath.Clean(f.Path),
Stack: true,
}
if len(indOpt.Path) > 1 {
event.InfoMsg(i18n.MsgIndexingFiles, txt.Quote(indOpt.Path))
} else {
event.InfoMsg(i18n.MsgIndexingOriginals)
}
indexed := ind.Start(indOpt)
RemoveFromFolderCache(entity.RootOriginals)
prg := service.Purge()
prgOpt := photoprism.PurgeOptions{
Path: filepath.Clean(f.Path),
Ignore: indexed,
}
if files, photos, err := prg.Start(prgOpt); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
} else if len(files) > 0 || len(photos) > 0 {
event.InfoMsg(i18n.MsgRemovedFilesAndPhotos, len(files), len(photos))
}
event.Publish("index.updating", event.Data{
"step": "moments",
})
moments := service.Moments()
if err := moments.Start(); err != nil {
log.Warnf("moments: %s", err)
}
elapsed := int(time.Since(start).Seconds())
msg := i18n.Msg(i18n.MsgIndexingCompletedIn, elapsed)
event.Success(msg)
event.Publish("index.completed", event.Data{"path": path, "seconds": elapsed})
UpdateClientConfig()
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})
})
}
// DELETE /api/v1/index
func CancelIndexing(router *gin.RouterGroup) {
router.DELETE("/index", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if !conf.Settings().Features.Library {
AbortFeatureDisabled(c)
return
}
ind := service.Index()
ind.Cancel()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgIndexingCanceled))
})
}

162
internal/api/label.go Normal file
View file

@ -0,0 +1,162 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"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/i18n"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /api/v1/labels
func GetLabels(router *gin.RouterGroup) {
router.GET("/labels", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.LabelSearch
err := c.MustBindWith(&f, binding.Form)
if err != nil {
AbortBadRequest(c)
return
}
result, err := query.Labels(f)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
return
}
// TODO c.Header("X-Count", strconv.Itoa(count))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
c.JSON(http.StatusOK, result)
})
}
// PUT /api/v1/labels/:uid
func UpdateLabel(router *gin.RouterGroup) {
router.PUT("/labels/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Label
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
id := c.Param("uid")
m, err := query.LabelByUID(id)
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
return
}
m.SetName(f.LabelName)
entity.Db().Save(&m)
event.SuccessMsg(i18n.MsgLabelSaved)
PublishLabelEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, m)
})
}
// POST /api/v1/labels/:uid/like
//
// Parameters:
// uid: string Label UID
func LikeLabel(router *gin.RouterGroup) {
router.POST("/labels/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
label, err := query.LabelByUID(id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if err := label.Update("LabelFavorite", true); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if label.LabelPriority < 0 {
event.Publish("count.labels", event.Data{
"count": 1,
})
}
PublishLabelEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, http.Response{})
})
}
// DELETE /api/v1/labels/:uid/like
//
// Parameters:
// uid: string Label UID
func DislikeLabel(router *gin.RouterGroup) {
router.DELETE("/labels/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
label, err := query.LabelByUID(id)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if err := label.Update("LabelFavorite", false); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if label.LabelPriority < 0 {
event.Publish("count.labels", event.Data{
"count": -1,
})
}
PublishLabelEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, http.Response{})
})
}

250
internal/api/link.go Normal file
View file

@ -0,0 +1,250 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"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/i18n"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
)
// PUT /api/v1/:entity/:uid/links/:link
func UpdateLink(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLinks, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Link
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
link := entity.FindLink(c.Param("link"))
link.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews
link.LinkExpires = f.LinkExpires
if f.LinkToken != "" {
link.LinkToken = strings.TrimSpace(strings.ToLower(f.LinkToken))
}
if f.Password != "" {
if err := link.SetPassword(f.Password); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
}
if err := link.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
UpdateClientConfig()
event.SuccessMsg(i18n.MsgAlbumSaved)
PublishAlbumEvent(EntityUpdated, link.ShareUID, c)
c.JSON(http.StatusOK, link)
}
// DELETE /api/v1/:entity/:uid/links/:link
func DeleteLink(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLinks, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
link := entity.FindLink(c.Param("link"))
if err := link.Delete(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
UpdateClientConfig()
event.SuccessMsg(i18n.MsgAlbumSaved)
PublishAlbumEvent(EntityUpdated, link.ShareUID, c)
c.JSON(http.StatusOK, link)
}
// CreateLink returns a new link entity initialized with request data
func CreateLink(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLinks, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Link
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
link := entity.NewLink(c.Param("uid"), f.CanComment, f.CanEdit)
link.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews
link.LinkExpires = f.LinkExpires
if f.Password != "" {
if err := link.SetPassword(f.Password); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
}
if err := link.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
UpdateClientConfig()
event.SuccessMsg(i18n.MsgAlbumSaved)
PublishAlbumEvent(EntityUpdated, link.ShareUID, c)
c.JSON(http.StatusOK, link)
}
// POST /api/v1/albums/:uid/links
func CreateAlbumLink(router *gin.RouterGroup) {
router.POST("/albums/:uid/links", func(c *gin.Context) {
if _, err := query.AlbumByUID(c.Param("uid")); err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
CreateLink(c)
})
}
// PUT /api/v1/albums/:uid/links/:link
func UpdateAlbumLink(router *gin.RouterGroup) {
router.PUT("/albums/:uid/links/:link", func(c *gin.Context) {
UpdateLink(c)
})
}
// DELETE /api/v1/albums/:uid/links/:link
func DeleteAlbumLink(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/links/:link", func(c *gin.Context) {
DeleteLink(c)
})
}
// GET /api/v1/albums/:uid/links
func GetAlbumLinks(router *gin.RouterGroup) {
router.GET("/albums/:uid/links", func(c *gin.Context) {
m, err := query.AlbumByUID(c.Param("uid"))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
c.JSON(http.StatusOK, m.Links())
})
}
// POST /api/v1/photos/:uid/links
func CreatePhotoLink(router *gin.RouterGroup) {
router.POST("/photos/:uid/links", func(c *gin.Context) {
if _, err := query.PhotoByUID(c.Param("uid")); err != nil {
AbortEntityNotFound(c)
return
}
CreateLink(c)
})
}
// PUT /api/v1/photos/:uid/links/:link
func UpdatePhotoLink(router *gin.RouterGroup) {
router.PUT("/photos/:uid/links/:link", func(c *gin.Context) {
UpdateLink(c)
})
}
// DELETE /api/v1/photos/:uid/links/:link
func DeletePhotoLink(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/links/:link", func(c *gin.Context) {
DeleteLink(c)
})
}
// GET /api/v1/photos/:uid/links
func GetPhotoLinks(router *gin.RouterGroup) {
router.GET("/photos/:uid/links", func(c *gin.Context) {
m, err := query.PhotoByUID(c.Param("uid"))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
c.JSON(http.StatusOK, m.Links())
})
}
// POST /api/v1/labels/:uid/links
func CreateLabelLink(router *gin.RouterGroup) {
router.POST("/labels/:uid/links", func(c *gin.Context) {
if _, err := query.LabelByUID(c.Param("uid")); err != nil {
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
return
}
CreateLink(c)
})
}
// PUT /api/v1/labels/:uid/links/:link
func UpdateLabelLink(router *gin.RouterGroup) {
router.PUT("/labels/:uid/links/:link", func(c *gin.Context) {
UpdateLink(c)
})
}
// DELETE /api/v1/labels/:uid/links/:link
func DeleteLabelLink(router *gin.RouterGroup) {
router.DELETE("/labels/:uid/links/:link", func(c *gin.Context) {
DeleteLink(c)
})
}
// GET /api/v1/labels/:uid/links
func GetLabelLinks(router *gin.RouterGroup) {
router.GET("/labels/:uid/links", func(c *gin.Context) {
m, err := query.LabelByUID(c.Param("uid"))
if err != nil {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
return
}
c.JSON(http.StatusOK, m.Links())
})
}

View file

@ -0,0 +1,32 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
)
// GET /api/v1/moments/time
func GetMomentsTime(router *gin.RouterGroup) {
router.GET("/moments/time", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionExport)
if s.Invalid() {
AbortUnauthorized(c)
return
}
result, err := query.MomentsTime(1)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
c.JSON(http.StatusOK, result)
})
}

13
internal/api/parse.go Normal file
View file

@ -0,0 +1,13 @@
package api
import "strconv"
func ParseUint(s string) uint {
result, err := strconv.ParseUint(s, 10, 32)
if err != nil {
log.Warnf("api: %s", err)
}
return uint(result)
}

330
internal/api/photo.go Normal file
View file

@ -0,0 +1,330 @@
package api
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"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/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// SavePhotoAsYaml saves photo data as YAML file.
func SavePhotoAsYaml(p entity.Photo) {
c := service.Config()
// Write YAML sidecar file (optional).
if !c.BackupYaml() {
return
}
fileName := p.YamlFileName(c.OriginalsPath(), c.SidecarPath())
if err := p.SaveAsYaml(fileName); err != nil {
log.Errorf("photo: %s (update yaml)", err)
} else {
log.Debugf("photo: updated yaml file %s", txt.Quote(filepath.Base(fileName)))
}
}
// GET /api/v1/photos/:uid
//
// Parameters:
// uid: string PhotoUID as returned by the API
func GetPhoto(router *gin.RouterGroup) {
router.GET("/photos/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
c.IndentedJSON(http.StatusOK, p)
})
}
// PUT /api/v1/photos/:uid
func UpdatePhoto(router *gin.RouterGroup) {
router.PUT("/photos/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
m, err := query.PhotoByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
// TODO: Proof-of-concept for form handling - might need refactoring
// 1) Init form with model values
f, err := form.NewPhoto(m)
if err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
// 2) Update form with values from request
if err := c.BindJSON(&f); err != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
} else if f.PhotoPrivate {
FlushCoverCache()
}
// 3) Save model with values from form
if err := entity.SavePhotoForm(m, f); err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
PublishPhotoEvent(EntityUpdated, uid, c)
event.SuccessMsg(i18n.MsgChangesSaved)
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
SavePhotoAsYaml(p)
c.JSON(http.StatusOK, p)
})
}
// GET /api/v1/photos/:uid/dl
//
// Parameters:
// uid: string PhotoUID as returned by the API
func GetPhotoDownload(router *gin.RouterGroup) {
router.GET("/photos/:uid/dl", func(c *gin.Context) {
if InvalidDownloadToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
f, err := query.FileByPhotoUID(c.Param("uid"))
if err != nil {
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("photo: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("photo", f.Update("FileMissing", true))
return
}
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
})
}
// GET /api/v1/photos/:uid/yaml
//
// Parameters:
// uid: string PhotoUID as returned by the API
func GetPhotoYaml(router *gin.RouterGroup) {
router.GET("/photos/:uid/yaml", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionExport)
if s.Invalid() {
AbortUnauthorized(c)
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
data, err := p.Yaml()
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
if c.Query("download") != "" {
AddDownloadHeader(c, c.Param("uid")+fs.YamlExt)
}
c.Data(http.StatusOK, "text/x-yaml; charset=utf-8", data)
})
}
// POST /api/v1/photos/:uid/approve
//
// Parameters:
// uid: string PhotoUID as returned by the API
func ApprovePhoto(router *gin.RouterGroup) {
router.POST("/photos/:uid/approve", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
m, err := query.PhotoByUID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if err := m.Approve(); err != nil {
log.Errorf("photo: %s", err.Error())
AbortSaveFailed(c)
return
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
// POST /api/v1/photos/:uid/like
//
// Parameters:
// uid: string PhotoUID as returned by the API
func LikePhoto(router *gin.RouterGroup) {
router.POST("/photos/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionLike)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
m, err := query.PhotoByUID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if err := m.SetFavorite(true); err != nil {
log.Errorf("photo: %s", err.Error())
AbortSaveFailed(c)
return
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
// DELETE /api/v1/photos/:uid/like
//
// Parameters:
// uid: string PhotoUID as returned by the API
func DislikePhoto(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionLike)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
m, err := query.PhotoByUID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if err := m.SetFavorite(false); err != nil {
log.Errorf("photo: %s", err.Error())
AbortSaveFailed(c)
return
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
// POST /api/v1/photos/:uid/files/:file_uid/primary
//
// Parameters:
// uid: string PhotoUID as returned by the API
// file_uid: string File UID as returned by the API
func PhotoPrimary(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/primary", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
fileUID := c.Param("file_uid")
err := query.SetPhotoPrimary(uid, fileUID)
if err != nil {
AbortEntityNotFound(c)
return
}
PublishPhotoEvent(EntityUpdated, uid, c)
event.SuccessMsg(i18n.MsgChangesSaved)
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
c.JSON(http.StatusOK, p)
})
}

220
internal/api/photo_label.go Normal file
View file

@ -0,0 +1,220 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
)
// POST /api/v1/photos/:uid/label
//
// Parameters:
// uid: string PhotoUID as returned by the API
func AddPhotoLabel(router *gin.RouterGroup) {
router.POST("/photos/:uid/label", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
m, err := query.PhotoByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
var f form.Label
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
labelEntity := entity.FirstOrCreateLabel(entity.NewLabel(f.LabelName, f.LabelPriority))
if labelEntity == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed creating label"})
return
}
if err := labelEntity.Restore(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not restore label"})
}
photoLabel := entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(m.ID, labelEntity.ID, f.Uncertainty, "manual"))
if photoLabel == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed updating photo label"})
return
}
if photoLabel.Uncertainty > f.Uncertainty {
if err := photoLabel.Updates(map[string]interface{}{
"Uncertainty": f.Uncertainty,
"LabelSrc": entity.SrcManual,
}); err != nil {
log.Errorf("label: %s", err)
}
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
if err := p.SaveLabels(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
PublishPhotoEvent(EntityUpdated, c.Param("uid"), c)
event.Success("label updated")
c.JSON(http.StatusOK, p)
})
}
// DELETE /api/v1/photos/:uid/label/:id
//
// Parameters:
// uid: string PhotoUID as returned by the API
// id: int LabelId as returned by the API
func RemovePhotoLabel(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/label/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
m, err := query.PhotoByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
labelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
return
}
label, err := query.PhotoLabel(m.ID, uint(labelId))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if label.LabelSrc == classify.SrcManual || label.LabelSrc == classify.SrcKeyword {
logError("label", entity.Db().Delete(&label).Error)
} else {
label.Uncertainty = 100
logError("label", entity.Db().Save(&label).Error)
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
logError("label", p.RemoveKeyword(label.Label.LabelName))
if err := p.SaveLabels(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
PublishPhotoEvent(EntityUpdated, c.Param("uid"), c)
event.Success("label removed")
c.JSON(http.StatusOK, p)
})
}
// PUT /api/v1/photos/:uid/label/:id
//
// Parameters:
// uid: string PhotoUID as returned by the API
// id: int LabelId as returned by the API
func UpdatePhotoLabel(router *gin.RouterGroup) {
router.PUT("/photos/:uid/label/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
// TODO: Code clean-up, simplify
m, err := query.PhotoByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
labelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
return
}
label, err := query.PhotoLabel(m.ID, uint(labelId))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if err := c.BindJSON(&label); err != nil {
AbortBadRequest(c)
return
}
if err := label.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
if err := p.SaveLabels(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
PublishPhotoEvent(EntityUpdated, c.Param("uid"), c)
event.Success("label saved")
c.JSON(http.StatusOK, p)
})
}

View file

@ -0,0 +1,74 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
)
// GET /api/v1/photos
//
// Query:
// q: string Query string
// label: string Label
// cat: string Category
// country: string Country code
// camera: int UpdateCamera ID
// order: string Sort order
// count: int Max result count (required)
// offset: int Result offset
// before: date Find photos taken before (format: "2006-01-02")
// after: date Find photos taken after (format: "2006-01-02")
// favorite: bool Find favorites only
func GetPhotos(router *gin.RouterGroup) {
router.GET("/photos", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.PhotoSearch
err := c.MustBindWith(&f, binding.Form)
if err != nil {
AbortBadRequest(c)
return
}
// Guests may only see public content in shared albums.
if s.Guest() {
if f.Album == "" || !s.HasShare(f.Album) {
AbortUnauthorized(c)
return
}
f.Public = true
f.Private = false
f.Hidden = false
f.Archived = false
f.Review = false
}
result, count, err := query.PhotoSearch(f)
if err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
AddCountHeader(c, count)
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
c.JSON(http.StatusOK, result)
})
}

168
internal/api/photo_thumb.go Normal file
View file

@ -0,0 +1,168 @@
package api
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /api/v1/t/:hash/:token/:type
//
// Parameters:
// hash: string sha1 file hash
// token: string url security token, see config
// type: string thumb type, see thumb.Types
func GetThumb(router *gin.RouterGroup) {
router.GET("/t/:hash/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
start := time.Now()
conf := service.Config()
fileHash := c.Param("hash")
typeName := c.Param("type")
download := c.Query("download") != ""
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("thumbs: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
if thumbType.ExceedsSize() && !conf.ThumbUncached() {
typeName, thumbType = thumb.Find(conf.ThumbSize())
if typeName == "" {
log.Errorf("thumbs: invalid size %d", conf.ThumbSize())
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
}
cache := service.ThumbCache()
cacheKey := CacheKey("thumbs", fileHash, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
if !fs.FileExists(cached.FileName) {
log.Errorf("thumbs: %s not found", fileHash)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
AddThumbCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
// Return existing thumbs straight away.
if !download {
if fileName, err := thumb.Filename(fileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil && fs.FileExists(fileName) {
c.File(fileName)
return
}
}
// Query index for file infos.
f, err := query.FileByHash(fileHash)
if err != nil {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
return
}
// Find fallback if file is not a JPEG image.
if f.NoJPEG() {
f, err = query.FileByPhotoUID(f.PhotoUID)
if err != nil {
c.Data(http.StatusOK, "image/svg+xml", fileIconSvg)
return
}
}
// Return SVG icon as placeholder if file has errors.
if f.FileError != "" {
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("thumbs: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("thumbs", f.Update("FileMissing", true))
if f.AllFilesMissing() {
log.Infof("thumbs: deleting photo, all files missing for %s", txt.Quote(f.FileName))
logError("thumbs", f.RelatedPhoto().Delete(false))
}
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() && c.Query("download") == "" {
log.Debugf("thumbs: using original, size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height)
AddThumbCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("thumbs: %s", err)
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
} else if thumbnail == "" {
log.Errorf("thumbs: %s has empty thumb name - bug?", filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddThumbCacheHeader(c)
if download {
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}
})
}

View file

@ -0,0 +1,199 @@
package api
import (
"fmt"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
// POST /api/v1/photos/:uid/files/:file_uid/unstack
//
// Parameters:
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func PhotoUnstack(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/unstack", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
fileUID := c.Param("file_uid")
file, err := query.FileByUID(fileUID)
if err != nil {
log.Errorf("photo: %s (unstack)", err)
AbortEntityNotFound(c)
return
}
if file.FilePrimary {
log.Errorf("photo: can't unstack primary file")
AbortBadRequest(c)
return
} else if file.FileSidecar {
log.Errorf("photo: can't unstack sidecar files")
AbortBadRequest(c)
return
} else if file.FileRoot != entity.RootOriginals {
log.Errorf("photo: only originals can be unstacked")
AbortBadRequest(c)
return
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
baseName := filepath.Base(fileName)
unstackFile, err := photoprism.NewMediaFile(fileName)
if err != nil {
log.Errorf("photo: %s (unstack %s)", err, txt.Quote(baseName))
AbortEntityNotFound(c)
return
}
stackPhoto := *file.Photo
stackPrimary, err := stackPhoto.PrimaryFile()
if err != nil {
log.Errorf("photo: can't find primary file for existing photo (unstack %s)", txt.Quote(baseName))
AbortUnexpected(c)
return
}
related, err := unstackFile.RelatedFiles(false)
if err != nil {
log.Errorf("photo: %s (unstack %s)", err, txt.Quote(baseName))
AbortEntityNotFound(c)
return
} else if related.Len() == 0 {
log.Errorf("photo: no files found (unstack %s)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
} else if related.Main == nil {
log.Errorf("photo: no main file found (unstack %s)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
}
var files photoprism.MediaFiles
unstackSingle := false
if unstackFile.BasePrefix(false) == stackPhoto.PhotoName {
if conf.ReadOnly() {
log.Errorf("photo: can't rename files in read only mode (unstack %s)", txt.Quote(baseName))
AbortFeatureDisabled(c)
return
}
destName := fmt.Sprintf("%s.%s%s", unstackFile.AbsPrefix(false), unstackFile.Checksum(), unstackFile.Extension())
if err := unstackFile.Move(destName); err != nil {
log.Errorf("photo: can't rename %s to %s (unstack)", txt.Quote(unstackFile.BaseName()), txt.Quote(filepath.Base(destName)))
AbortUnexpected(c)
return
}
files = append(files, unstackFile)
unstackSingle = true
} else {
files = related.Files
}
newPhoto := entity.NewPhoto(false)
newPhoto.PhotoPath = unstackFile.RootRelPath()
newPhoto.PhotoName = unstackFile.BasePrefix(false)
if err := newPhoto.Create(); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(baseName))
AbortSaveFailed(c)
return
}
for _, r := range files {
relName := r.RootRelName()
relRoot := r.Root()
if unstackSingle {
relName = file.FileName
relRoot = file.FileRoot
}
if err := entity.UnscopedDb().Exec(`UPDATE files
SET photo_id = ?, photo_uid = ?, file_name = ?, file_missing = 0
WHERE file_name = ? AND file_root = ?`,
newPhoto.ID, newPhoto.PhotoUID, r.RootRelName(),
relName, relRoot).Error; err != nil {
// Handle error...
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(r.BaseName()))
// Remove new photo from database.
if err := newPhoto.Delete(true); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(r.BaseName()))
}
// Revert file rename.
if unstackSingle {
if err := r.Move(photoprism.FileName(relRoot, relName)); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(r.BaseName()))
}
}
AbortSaveFailed(c)
return
}
}
ind := service.Index()
// Index unstacked files.
if res := ind.FileName(unstackFile.FileName(), photoprism.IndexOptionsSingle()); res.Failed() {
log.Errorf("photo: %s (unstack %s)", res.Err, txt.Quote(baseName))
AbortSaveFailed(c)
return
}
// Reset type for existing photo stack to image.
if err := stackPhoto.Update("PhotoType", entity.TypeImage); err != nil {
log.Errorf("photo: %s (unstack %s)", err, txt.Quote(baseName))
AbortUnexpected(c)
return
}
// Re-index existing photo stack.
if res := ind.FileName(photoprism.FileName(stackPrimary.FileRoot, stackPrimary.FileName), photoprism.IndexOptionsSingle()); res.Failed() {
log.Errorf("photo: %s (unstack %s)", res.Err, txt.Quote(baseName))
AbortSaveFailed(c)
return
}
// Notify clients by publishing events.
PublishPhotoEvent(EntityCreated, newPhoto.PhotoUID, c)
PublishPhotoEvent(EntityUpdated, stackPhoto.PhotoUID, c)
event.SuccessMsg(i18n.MsgFileUnstacked)
p, err := query.PhotoPreloadByUID(stackPhoto.PhotoUID)
if err != nil {
AbortEntityNotFound(c)
return
}
c.JSON(http.StatusOK, p)
})
}

141
internal/api/session.go Normal file
View file

@ -0,0 +1,141 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/session"
)
// POST /api/v1/session
func CreateSession(router *gin.RouterGroup) {
router.POST("/session", func(c *gin.Context) {
var f form.Login
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
var data session.Data
id := SessionID(c)
if s := Session(id); s.Valid() {
data = s
} else {
data = session.Data{}
id = ""
}
conf := service.Config()
if f.HasToken() {
links := entity.FindValidLinks(f.Token, "")
if len(links) == 0 {
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidLink)})
}
data.Tokens = []string{f.Token}
for _, link := range links {
data.Shares = append(data.Shares, link.ShareUID)
link.Redeem()
}
// Upgrade from anonymous to guest. Don't downgrade.
if data.User.Anonymous() {
data.User = entity.Guest
}
} else if f.HasCredentials() {
user := entity.FindUserByName(f.UserName)
if user == nil {
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
}
if user.InvalidPassword(f.Password) {
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
}
data.User = *user
} else {
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidPassword)})
return
}
if err := service.Session().Update(id, data); err != nil {
id = service.Session().Create(data)
}
AddSessionHeader(c, id)
if data.User.Anonymous() {
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id, "data": data, "config": conf.GuestConfig()})
} else {
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id, "data": data, "config": conf.UserConfig()})
}
})
}
// DELETE /api/v1/session/:id
func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
id := c.Param("id")
service.Session().Delete(id)
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id})
})
}
// Gets session id from HTTP header.
func SessionID(c *gin.Context) string {
return c.GetHeader("X-Session-ID")
}
// Session returns the current session data.
func Session(id string) session.Data {
// Return fake admin session if site is public.
if service.Config().Public() {
return session.Data{User: entity.Admin}
}
// Check if session id is valid.
return service.Session().Get(id)
}
// Auth returns the session if user is authorized for the current action.
func Auth(id string, resource acl.Resource, action acl.Action) session.Data {
sess := Session(id)
if acl.Permissions.Deny(resource, sess.User.Role(), action) {
return session.Data{}
}
return sess
}
// InvalidPreviewToken returns true if the token is invalid.
func InvalidPreviewToken(c *gin.Context) bool {
token := c.Param("token")
if token == "" {
token = c.Query("t")
}
return service.Config().InvalidPreviewToken(token)
}
// InvalidDownloadToken returns true if the token is invalid.
func InvalidDownloadToken(c *gin.Context) bool {
return service.Config().InvalidDownloadToken(c.Query("t"))
}

65
internal/api/settings.go Normal file
View file

@ -0,0 +1,65 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
)
// GET /api/v1/settings
func GetSettings(router *gin.RouterGroup) {
router.GET("/settings", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSettings, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
if settings := service.Config().Settings(); settings != nil {
c.JSON(http.StatusOK, settings)
} else {
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
}
})
}
// POST /api/v1/settings
func SaveSettings(router *gin.RouterGroup) {
router.POST("/settings", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSettings, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if conf.DisableSettings() {
AbortUnauthorized(c)
return
}
settings := conf.Settings()
if err := c.BindJSON(settings); err != nil {
AbortBadRequest(c)
return
}
if err := settings.Save(conf.SettingsFile()); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
UpdateClientConfig()
log.Infof(i18n.Msg(i18n.MsgSettingsSaved))
c.JSON(http.StatusOK, settings)
})
}

69
internal/api/share.go Normal file
View file

@ -0,0 +1,69 @@
package api
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
)
// GET /s/:token/...
func Shares(router *gin.RouterGroup) {
router.GET("/:token", func(c *gin.Context) {
conf := service.Config()
token := c.Param("token")
links := entity.FindValidLinks(token, "")
if len(links) == 0 {
log.Warn("share: invalid token")
c.Redirect(http.StatusTemporaryRedirect, "/")
return
}
clientConfig := conf.GuestConfig()
clientConfig.SiteUrl = fmt.Sprintf("%ss/%s", clientConfig.SiteUrl, token)
c.HTML(http.StatusOK, "share.tmpl", gin.H{"config": clientConfig})
})
router.GET("/:token/:share", func(c *gin.Context) {
conf := service.Config()
token := c.Param("token")
share := c.Param("share")
links := entity.FindValidLinks(token, share)
if len(links) < 1 {
log.Warn("share: invalid token or share")
c.Redirect(http.StatusTemporaryRedirect, "/")
return
}
uid := links[0].ShareUID
if uid != share {
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("/s/%s/%s", token, uid))
return
}
clientConfig := conf.GuestConfig()
clientConfig.SiteUrl = fmt.Sprintf("%ss/%s/%s", clientConfig.SiteUrl, token, uid)
clientConfig.SitePreview = fmt.Sprintf("%s/preview", clientConfig.SiteUrl)
if a, err := query.AlbumByUID(uid); err == nil {
clientConfig.SiteCaption = a.AlbumTitle
if a.AlbumDescription != "" {
clientConfig.SiteDescription = a.AlbumDescription
}
}
c.HTML(http.StatusOK, "share.tmpl", gin.H{"config": clientConfig})
})
}

View file

@ -0,0 +1,168 @@
package api
import (
"fmt"
"image"
"image/color"
"net/http"
"os"
"path"
"time"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /s/:token/:uid/preview
// TODO: Proof of concept, needs refactoring.
func SharePreview(router *gin.RouterGroup) {
router.GET("/:token/:share/preview", func(c *gin.Context) {
conf := service.Config()
token := c.Param("token")
share := c.Param("share")
links := entity.FindLinks(token, share)
if len(links) != 1 {
log.Warn("share: invalid token (preview)")
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
thumbPath := path.Join(conf.ThumbPath(), "share")
if err := os.MkdirAll(thumbPath, os.ModePerm); err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
previewFilename := fmt.Sprintf("%s/%s.jpg", thumbPath, share)
yesterday := time.Now().Add(-24 * time.Hour)
if info, err := os.Stat(previewFilename); err != nil {
log.Debugf("share: creating new preview for %s", share)
} else if info.ModTime().After(yesterday) {
log.Debugf("share: using cached preview for %s", share)
c.File(previewFilename)
return
} else if err := os.Remove(previewFilename); err != nil {
log.Errorf("share: could not remove old preview of %s", share)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
var f form.PhotoSearch
// Previews may only contain public content in shared albums.
f.Album = share
f.Public = true
f.Private = false
f.Hidden = false
f.Archived = false
f.Review = false
f.Primary = true
// Get first 12 album entries.
f.Count = 12
f.Order = "relevance"
p, count, err := query.PhotoSearch(f)
if err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
if count == 0 {
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
} else if count < 12 {
f := p[0]
thumbType, _ := thumb.Types["fit_720"]
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("share: file %s is missing (preview)", txt.Quote(f.FileName))
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
if err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
c.File(thumbnail)
return
}
width := 908
height := 680
x := 0
y := 0
preview := imaging.New(width, height, color.NRGBA{255, 255, 255, 255})
thumbType, _ := thumb.Types["tile_224"]
for _, f := range p {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("share: file %s is missing (preview)", txt.Quote(f.FileName))
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
if err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
src, err := imaging.Open(thumbnail)
if err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
preview = imaging.Paste(preview, src, image.Pt(x, y))
x += 228
if x > width {
x = 0
y += 228
}
}
// Save the resulting image as JPEG.
err = imaging.Save(preview, previewFilename)
if err != nil {
log.Error(err)
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
return
}
c.File(previewFilename)
})
}

14
internal/api/status.go Normal file
View file

@ -0,0 +1,14 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /api/v1/status
func GetStatus(router *gin.RouterGroup) {
router.GET("/status", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "operational"})
})
}

78
internal/api/svg.go Normal file
View file

@ -0,0 +1,78 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
var photoIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>`)
var rawIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><circle cx="12" cy="12" r="3.2"/>
<path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/>
<path d="M0 0h24v24H0z" fill="none"/></svg>`)
var fileIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/><path d="M0 0h24v24H0z" fill="none"/></svg>`)
var videoIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M0 0h24v24H0z" fill="none"/><path d="M10 8v8l5-4-5-4zm9-5H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>`)
var folderIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`)
var albumIconSvg = folderIconSvg
var labelIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/><path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"/></svg>`)
var brokenIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0zm0 0h24v24H0zm21 19c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2"/>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M21 5v6.59l-3-3.01-4 4.01-4-4-4 4-3-3.01V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2zm-3 6.42l3 3.01V19c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2v-6.58l3 2.99 4-4 4 4 4-3.99z"/></svg>`)
var uncachedIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/>
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>`)
// GET /api/v1/svg/*
func GetSvg(router *gin.RouterGroup) {
router.GET("/svg/photo", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
})
router.GET("/svg/raw", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", rawIconSvg)
})
router.GET("/svg/file", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", fileIconSvg)
})
router.GET("/svg/video", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
})
router.GET("/svg/label", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", labelIconSvg)
})
router.GET("/svg/folder", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
})
router.GET("/svg/album", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
})
router.GET("/svg/broken", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
})
router.GET("/svg/uncached", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", uncachedIconSvg)
})
}

113
internal/api/upload.go Normal file
View file

@ -0,0 +1,113 @@
package api
import (
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
)
// POST /api/v1/upload/:path
func Upload(router *gin.RouterGroup) {
router.POST("/upload/:path", func(c *gin.Context) {
conf := service.Config()
if conf.ReadOnly() || !conf.Settings().Features.Upload {
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
return
}
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpload)
if s.Invalid() {
AbortUnauthorized(c)
return
}
start := time.Now()
subPath := c.Param("path")
f, err := c.MultipartForm()
if err != nil {
AbortBadRequest(c)
return
}
event.Publish("upload.start", event.Data{"time": start})
files := f.File["files"]
uploaded := len(files)
var uploads []string
p := path.Join(conf.ImportPath(), "upload", subPath)
if err := os.MkdirAll(p, os.ModePerm); err != nil {
AbortBadRequest(c)
return
}
for _, file := range files {
filename := path.Join(p, filepath.Base(file.Filename))
log.Debugf("upload: saving file %s", txt.Quote(file.Filename))
if err := c.SaveUploadedFile(file, filename); err != nil {
AbortBadRequest(c)
return
}
uploads = append(uploads, filename)
}
if !conf.UploadNSFW() {
nd := service.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", txt.Quote(filename))
containsNSFW = true
}
if containsNSFW {
for _, filename := range uploads {
if err := os.Remove(filename); err != nil {
log.Errorf("nsfw: could not delete %s", txt.Quote(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})
})
}

58
internal/api/user.go Normal file
View file

@ -0,0 +1,58 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
)
// PUT /api/v1/users/:uid/password
func ChangePassword(router *gin.RouterGroup) {
router.PUT("/users/:uid/password", func(c *gin.Context) {
conf := service.Config()
if conf.Public() || conf.DisableSettings() {
Abort(c, http.StatusForbidden, i18n.ErrPublic)
return
}
s := Auth(SessionID(c), acl.ResourcePeople, acl.ActionUpdateSelf)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
m := entity.FindUserByUID(uid)
if m == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return
}
f := form.ChangePassword{}
if err := c.BindJSON(&f); err != nil {
Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword)
return
}
if m.InvalidPassword(f.OldPassword) {
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return
}
if err := m.SetPassword(f.NewPassword); err != nil {
Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword)
return
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPasswordChanged))
})
}

100
internal/api/video.go Normal file
View file

@ -0,0 +1,100 @@
package api
import (
"net/http"
"time"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/video"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /api/v1/videos/:hash/:token/:type
//
// Parameters:
// hash: string The photo or video file hash as returned by the search API
// type: string Video type
func GetVideo(router *gin.RouterGroup) {
router.GET("/videos/:hash/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
fileHash := c.Param("hash")
typeName := c.Param("type")
videoType, ok := video.Types[typeName]
if !ok {
log.Errorf("video: invalid type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
f, err := query.FileByHash(fileHash)
if err != nil {
log.Errorf("video: %s", err.Error())
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
if !f.FileVideo {
f, err = query.VideoByPhotoUID(f.PhotoUID)
if err != nil {
log.Errorf("video: %s", err.Error())
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
}
if f.FileError != "" {
log.Errorf("video: file error %s", f.FileError)
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if mf, err := photoprism.NewMediaFile(fileName); err != nil {
log.Errorf("video: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("video", f.Update("FileMissing", true))
return
} else if f.FileCodec != string(videoType.Codec) {
log.Debugf("video: transcoding %s from %s to avc", txt.Quote(f.FileName), txt.Quote(f.FileCodec))
start := time.Now()
conv := service.Convert()
if avcFile, err := conv.ToAvc(mf); err != nil {
log.Errorf("video: failed transcoding %s", txt.Quote(f.FileName))
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
return
} else {
fileName = avcFile.FileName()
}
log.Debugf("video: transcoding completed in %s", time.Since(start))
}
AddContentTypeHeader(c, ContentTypeAvc)
if c.Query("download") != "" {
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
} else {
c.File(fileName)
}
return
})
}

193
internal/api/websocket.go Normal file
View file

@ -0,0 +1,193 @@
package api
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/rnd"
)
var wsConnection = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
var wsTimeout = 90 * time.Second
type clientInfo struct {
SessionToken string `json:"session"`
JsHash string `json:"js"`
CssHash string `json:"css"`
ManifestHash string `json:"manifest"`
Version string `json:"version"`
}
var wsAuth = struct {
user map[string]entity.User
mutex sync.RWMutex
}{user: make(map[string]entity.User)}
func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *config.Config) {
defer ws.Close()
ws.SetReadLimit(512)
ws.SetReadDeadline(time.Now().Add(wsTimeout))
ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil })
for {
_, m, err := ws.ReadMessage()
if err != nil {
break
}
var info clientInfo
if err := json.Unmarshal(m, &info); err != nil {
// Do nothing.
} else {
if sess := Session(info.SessionToken); sess.Valid() {
wsAuth.mutex.Lock()
wsAuth.user[connId] = sess.User
wsAuth.mutex.Unlock()
var clientConfig config.ClientConfig
if sess.User.Guest() {
clientConfig = conf.GuestConfig()
} else if sess.User.Registered() {
clientConfig = conf.UserConfig()
} else {
clientConfig = conf.PublicConfig()
}
writeMutex.Lock()
ws.SetWriteDeadline(time.Now().Add(30 * time.Second))
if err := ws.WriteJSON(gin.H{"event": "config.updated", "data": event.Data{"config": clientConfig}}); err != nil {
// Do nothing.
}
writeMutex.Unlock()
}
}
}
}
func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
pingTicker := time.NewTicker(15 * time.Second)
s := event.Subscribe(
"log.*",
"notify.*",
"index.*",
"upload.*",
"import.*",
"config.*",
"count.*",
"photos.*",
"cameras.*",
"lenses.*",
"countries.*",
"albums.*",
"labels.*",
"sync.*",
)
defer func() {
pingTicker.Stop()
event.Unsubscribe(s)
ws.Close()
wsAuth.mutex.Lock()
wsAuth.user[connId] = entity.UnknownUser
wsAuth.mutex.Unlock()
}()
for {
select {
case <-pingTicker.C:
writeMutex.Lock()
ws.SetWriteDeadline(time.Now().Add(30 * time.Second))
if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
writeMutex.Unlock()
return
}
writeMutex.Unlock()
case msg := <-s.Receiver:
wsAuth.mutex.RLock()
user := entity.UnknownUser
if hit, ok := wsAuth.user[connId]; ok {
user = hit
}
wsAuth.mutex.RUnlock()
if user.Registered() {
writeMutex.Lock()
ws.SetWriteDeadline(time.Now().Add(30 * time.Second))
if err := ws.WriteJSON(gin.H{"event": msg.Name, "data": msg.Fields}); err != nil {
writeMutex.Unlock()
return
}
writeMutex.Unlock()
}
}
}
}
// GET /api/v1/ws
func Websocket(router *gin.RouterGroup) {
if router == nil {
return
}
conf := service.Config()
if conf == nil {
return
}
router.GET("/ws", func(c *gin.Context) {
w := c.Writer
r := c.Request
ws, err := wsConnection.Upgrade(w, r, nil)
if err != nil {
return
}
var writeMutex sync.Mutex
defer ws.Close()
connId := rnd.UUID()
// Init connection.
wsAuth.mutex.Lock()
if conf.Public() {
wsAuth.user[connId] = entity.Admin
} else {
wsAuth.user[connId] = entity.UnknownUser
}
wsAuth.mutex.Unlock()
go wsWriter(ws, &writeMutex, connId)
wsReader(ws, &writeMutex, connId, conf)
})
}

192
internal/api/zip.go Normal file
View file

@ -0,0 +1,192 @@
package api
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
)
// POST /api/v1/zip
func CreateZip(router *gin.RouterGroup) {
router.POST("/zip", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDownload)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if !conf.Settings().Features.Download {
AbortFeatureDisabled(c)
return
}
var f form.Selection
start := time.Now()
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if f.Empty() {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
files, err := query.FileSelection(f)
if err != nil {
Error(c, http.StatusBadRequest, err, i18n.ErrZipFailed)
return
} else if len(files) == 0 {
Abort(c, http.StatusNotFound, i18n.ErrNoFilesForDownload)
return
}
zipPath := path.Join(conf.TempPath(), "zip")
zipToken := rnd.Token(8)
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
zipFileName := path.Join(zipPath, zipBaseName)
if err := os.MkdirAll(zipPath, 0700); err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
return
}
newZipFile, err := os.Create(zipFileName)
if err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
return
}
defer newZipFile.Close()
zipWriter := zip.NewWriter(newZipFile)
defer zipWriter.Close()
dlName := DownloadName(c)
var aliases = make(map[string]int)
for _, file := range files {
if file.FileHash == "" {
log.Warnf("download: empty file hash, skipped %s", txt.Quote(file.FileName))
continue
}
if file.FileSidecar {
log.Debugf("download: skipped sidecar %s", txt.Quote(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] += 1
if fs.FileExists(fileName) {
if err := addFileToZip(zipWriter, fileName, alias); err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
return
}
log.Infof("download: added %s as %s", txt.Quote(file.FileName), txt.Quote(alias))
} else {
log.Warnf("download: file %s is missing", txt.Quote(file.FileName))
logError("download", file.Update("FileMissing", true))
}
}
elapsed := int(time.Since(start).Seconds())
log.Infof("download: zip %s created in %s", txt.Quote(zipBaseName), time.Since(start))
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgZipCreatedIn, elapsed), "filename": zipBaseName})
})
}
// GET /api/v1/zip/:filename
func DownloadZip(router *gin.RouterGroup) {
router.GET("/zip/:filename", func(c *gin.Context) {
if InvalidDownloadToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
conf := service.Config()
zipBaseName := filepath.Base(c.Param("filename"))
zipPath := path.Join(conf.TempPath(), "zip")
zipFileName := path.Join(zipPath, zipBaseName)
if !fs.FileExists(zipFileName) {
log.Errorf("could not find zip file: %s", zipFileName)
c.Data(404, "image/svg+xml", photoIconSvg)
return
}
c.FileAttachment(zipFileName, zipBaseName)
if err := os.Remove(zipFileName); err != nil {
log.Errorf("download: failed removing %s (%s)", txt.Quote(zipFileName), err.Error())
}
})
}
func addFileToZip(zipWriter *zip.Writer, fileName, fileAlias string) error {
fileToZip, err := os.Open(fileName)
if err != nil {
return err
}
defer fileToZip.Close()
// Get the file information
info, err := fileToZip.Stat()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = fileAlias
// Change to deflate to gain better compression
// see http://golang.org/pkg/archive/zip/#pkg-constants
header.Method = zip.Deflate
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, fileToZip)
return err
}