diff --git a/README.md b/README.md index ca86597..160e81a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,118 @@ # Photoprism Client Go Go client for the Photoprism Application. +*Author*: Kris Nóva -Bugs: Kris Nóva +--- + +# Install + +```bash + +go get github.com/kris-nova/photoprism-client-go + +``` + +# Supported Methods + +```go +func (v1 *V1Client) GetAlbums(options *AlbumOptions) ([]Album, error) +func (v1 *V1Client) GetAlbum(uuid string) (Album, error) +func (v1 *V1Client) CreateAlbum(album Album) (Album, error) +func (v1 *V1Client) UpdateAlbum(album Album) (Album, error) +func (v1 *V1Client) DeleteAlbums(albumUUIDs []string) error +func (v1 *V1Client) LikeAlbum(uuid string) error +func (v1 *V1Client) DislikeAlbum(uuid string) error +func (v1 *V1Client) CloneAlbum(album Album) (Album, error) +func (v1 *V1Client) AddPhotosToAlbum(albumUUID string, photoIDs []string) error +func (v1 *V1Client) DeletePhotosFromAlbum(albumUUID string, photoIDs []string) error +func (v1 *V1Client) GetAlbumDownload(uuid string) ([]byte, error) +func New(connURL *url.URL, token, downloadToken string) *V1Client +func (v1 *V1Client) GET(endpointFormat string, a ...interface{}) *V1Response +func (v1 *V1Client) POST(payload interface{}, endpointFormat string, a ...interface{}) *V1Response +func (v1 *V1Client) PUT(payload interface{}, endpointFormat string, a ...interface{}) *V1Response +func (v1 *V1Client) DELETE(payload interface{}, endpointFormat string, a ...interface{}) *V1Response +func (v1 *V1Client) Endpoint(str string) string +func (v1 *V1Client) SetToken(token string) +func (v1 *V1Client) Index() error +func (v1 *V1Client) CancelIndex() error +func (v1 *V1Client) GetPhoto(uuid string) (Photo, error) +func (v1 *V1Client) GetPhotos(options *PhotoOptions) ([]Photo, error) +func (v1 *V1Client) UpdatePhoto(photo Photo) (Photo, error) +func (v1 *V1Client) GetPhotoDownload(uuid string) ([]byte, error) +func (v1 *V1Client) GetPhotoYaml(uuid string) ([]byte, error) +func (v1 *V1Client) ApprovePhoto(uuid string) error +func (v1 *V1Client) LikePhoto(uuid string) error +func (v1 *V1Client) DislikePhoto(uuid string) error +func (v1 *V1Client) PhotoPrimary(uuid, fileuuid string) error +``` + +# Example Usage + +```go +package main + +import ( + "fmt" + "io/ioutil" + "path" + + photoprism "github.com/kris-nova/client-go" + "github.com/kris-nova/logger" +) + +func main() { + logger.Level = 4 + + uuid := "pqnzigq351j2fqgn" // This is a known ID + client := photoprism.New("http://localhost:8080") + err := client.Auth(photoprism.NewClientAuthLogin("admin", "missy")) + if err != nil { + logger.Critical("Error logging into API: %v", err) + os.Exit(1) + } + + // ----------------- + // GetPhoto() + // + photo, err := client.V1().GetPhoto(uuid) + if err != nil { + logger.Critical("Error fetching photo: %v", err) + os.Exit(1) + } + + // ----------------- + // UpdatePhoto() + // + photo.PhotoTitle = "A really great photo!" + photo, err = client.V1().UpdatePhoto(photo) + if err != nil { + logger.Critical("Error updating photo: %v", err) + os.Exit(1) + } + + // ----------------- + // GetPhotoDownload() + // + file, err := client.V1().GetPhotoDownload(photo.UUID) + if err != nil { + logger.Critical("Error getting photo download: %v", err) + os.Exit(1) + } + + for _, f := range photo.Files { + fileName := fmt.Sprintf("/tmp/%s", path.Base(f.FileName)) + logger.Always(fileName) + ioutil.WriteFile(fileName, file, 0666) + } + os.Exit(0) +} + +``` + + +# Pipeline + + - [ ] Upload photo methods/tests + - [ ] Code generation + - [ ] Finish the remaining methods \ No newline at end of file diff --git a/api/v1/README.md b/api/v1/README.md new file mode 100644 index 0000000..ec7f9b1 --- /dev/null +++ b/api/v1/README.md @@ -0,0 +1,40 @@ +# V1 SDK + +This is the main SDK code. Here be dragons. + +The V1 Client ships with the following HTTP methods + + - GET + - POST + - PUT + - DELETE + +The client can also be chained using the following two chain methods. + +#### JSON Marshal + +To send a GET request to `/api/v1/photos/:uuid` and marshal the results on to a Photo struct + +```go + uuid := "123" + photo := Photo{ + UUID: uuid, + } + err := v1.GET("/api/v1/photos/%s", uuid).JSON(&object) + // + fmt.Println(err) + fmt.Println(photo) +``` + +#### String + +Sometimes it is helpful to just see what the Photoprism API returns. +The `String()` method implements the Go idiomatic `String()` and will +return the body of the response for debugging. + +To send a GET request to `/api/v1/photos/:uuid` and see the raw JSON output + +```go + uuid := "123" + fmt.Println(v1.GET("/api/v1/photos/%s", uuid).String()) +``` diff --git a/api/v1/album.go b/api/v1/album.go index 2b3d6ad..b4f8d79 100644 --- a/api/v1/album.go +++ b/api/v1/album.go @@ -2,6 +2,9 @@ package api import "fmt" +// AlbumOptions are the parameters passed to get +// albums by various fields. Populate these as needed +// to pass to the SDK type AlbumOptions struct { ParamType string Q string @@ -18,6 +21,8 @@ const ( DefaultAlbumOptionsCategory = "" ) +// GetAlbums is used to list albums by query fields. +// // GET /api/v1/albums // // Example Params: http://localhost:8080/api/v1/albums?count=24&offset=0&q=&category=&type=album @@ -28,11 +33,11 @@ func (v1 *V1Client) GetAlbums(options *AlbumOptions) ([]Album, error) { // Default to sane options for query options = &AlbumOptions{ - ParamType: "album", - Q: "", - Count: 24, - Offset: 0, - Category: "", + ParamType: DefaultAlbumOptionsParamType, + Q: DefaultAlbumOptionsQ, + Count: DefaultAlbumOptionsCount, + Offset: DefaultAlbumOptionsOffset, + Category: DefaultAlbumOptionsCategory, } } @@ -50,6 +55,8 @@ func (v1 *V1Client) GetAlbums(options *AlbumOptions) ([]Album, error) { return albums, err } +// GetAlbum is used to get an album by an UUID. +// // GET /api/v1/albums/:uuid func (v1 *V1Client) GetAlbum(uuid string) (Album, error) { album := Album{} @@ -66,32 +73,38 @@ func (v1 *V1Client) GetAlbum(uuid string) (Album, error) { // such that an empty Album{} object will still // create a new album. // -//POST /api/v1/albums -func (v1 *V1Client) CreateAlbum(object Album) (Album, error) { - err := v1.POST(&object, "/api/v1/albums").JSON(&object) - return object, err +// POST /api/v1/albums +func (v1 *V1Client) CreateAlbum(album Album) (Album, error) { + err := v1.POST(&album, "/api/v1/albums").JSON(&album) + return album, err } +// UpdateAlbum will update meta information about an album. +// // PUT /api/v1/albums/:uid -func (v1 *V1Client) UpdateAlbum(object Album) (Album, error) { - if object.AlbumUID == "" { - return object, fmt.Errorf("missing album.AlbumUID in album") +func (v1 *V1Client) UpdateAlbum(album Album) (Album, error) { + if album.AlbumUID == "" { + return album, fmt.Errorf("missing album.AlbumUID in album") } - err := v1.PUT(&object, "/api/v1/albums/%s", object.AlbumUID).JSON(&object) - return object, err + err := v1.PUT(&album, "/api/v1/albums/%s", album.AlbumUID).JSON(&album) + return album, err } +// DeleteAlbums will batch delete a set of albums by ID. +// // POST /api/v1/batch/albums/delete -func (v1 *V1Client) DeleteAlbums(uuids []string) error { +func (v1 *V1Client) DeleteAlbums(albumUUIDs []string) error { payload := struct { Albums []string `json:"albums"` }{ - Albums: uuids, + Albums: albumUUIDs, } resp := v1.POST(payload, "/api/v1/batch/albums/delete") return resp.Error } +// LikeAlbum can be used to like an album. +// // POST /api/v1/albums/:uid/like // // Parameters: @@ -101,6 +114,8 @@ func (v1 *V1Client) LikeAlbum(uuid string) error { return resp.Error } +// DislikeAlbum can be used to dislike an album. +// // DELETE /api/v1/albums/:uid/like // // Parameters: @@ -110,16 +125,21 @@ func (v1 *V1Client) DislikeAlbum(uuid string) error { return resp.Error } +// CloneAlbum can be used to clone an album and will +// return the newly cloned album on success. +// // POST /api/v1/albums/:uid/clone -func (v1 *V1Client) CloneAlbum(object Album) (Album, error) { - if object.AlbumUID == "" { - return object, fmt.Errorf("missing album.AlbumUID in album") +func (v1 *V1Client) CloneAlbum(album Album) (Album, error) { + if album.AlbumUID == "" { + return album, fmt.Errorf("missing album.AlbumUID in album") } newAlbum := Album{} - err := v1.POST(&object, "/api/v1/albums/%s/clone", object.AlbumUID).JSON(&newAlbum) + err := v1.POST(&album, "/api/v1/albums/%s/clone", album.AlbumUID).JSON(&newAlbum) return newAlbum, err } +// AddPhotosToAlbum will associate a set of photos by UUID with an album by UUID +// // POST /api/v1/albums/:uid/photos func (v1 *V1Client) AddPhotosToAlbum(albumUUID string, photoIDs []string) error { payload := struct { @@ -131,6 +151,8 @@ func (v1 *V1Client) AddPhotosToAlbum(albumUUID string, photoIDs []string) error return resp.Error } +// DeletePhotosFromAlbum will disassociate a set of photos by UUID from an album by UUID +// // DELETE /api/v1/albums/:uid/photos func (v1 *V1Client) DeletePhotosFromAlbum(albumUUID string, photoIDs []string) error { payload := struct { @@ -142,6 +164,9 @@ func (v1 *V1Client) DeletePhotosFromAlbum(albumUUID string, photoIDs []string) e return resp.Error } +// GetAlbumDownload will return a .zip file of the album's content +// and can be used to download an album from the API. +// // GET /api/v1/albums/:uid/dl func (v1 *V1Client) GetAlbumDownload(uuid string) ([]byte, error) { // NOTE: Even though this method is singular GetAlbum diff --git a/api/v1/client.go b/api/v1/client.go index 5c55af0..c0b5bbd 100644 --- a/api/v1/client.go +++ b/api/v1/client.go @@ -17,6 +17,7 @@ const ( DefaultContentType string = "application/json; charset=utf-8" ) +// V1Client is used to access the V1 Photoprism API. type V1Client struct { downloadToken string token string @@ -25,7 +26,7 @@ type V1Client struct { } // New will only accept a url.URL so that we know -// all errors have been handled up until this point +// all errors have been handled up until this point. func New(connURL *url.URL, token, downloadToken string) *V1Client { return &V1Client{ client: http.Client{}, @@ -35,6 +36,8 @@ func New(connURL *url.URL, token, downloadToken string) *V1Client { } } +// V1Response is the master HTTP Response object +// for all transactions with the Photoprism API. type V1Response struct { HTTPResponse *http.Response StatusCode int diff --git a/api/v1/index.go b/api/v1/index.go index 7fbb3b4..290428e 100644 --- a/api/v1/index.go +++ b/api/v1/index.go @@ -1,11 +1,17 @@ package api +// Index is used to sync the backend storage with +// the database meta information +// // POST /api/v1/index func (v1 *V1Client) Index() error { resp := v1.POST(nil, "/api/v1/index") return resp.Error } +// CancelIndex can be used to attempt to cancel a running index +// operation +// // DELETE /api/v1/index func (v1 *V1Client) CancelIndex() error { resp := v1.DELETE(nil, "/api/v1/index") diff --git a/api/v1/meta.go b/api/v1/meta.go deleted file mode 100644 index 9b20bed..0000000 --- a/api/v1/meta.go +++ /dev/null @@ -1,21 +0,0 @@ -package api - -import "net/http" - -type Meta struct { - requested bool - response *http.Response -} - -// TODO We need an http.Client and an object interface{} -func (m *Meta) Request() error { - m.requested = true - return nil -} - -func (m *Meta) Response() *http.Response { - if !m.requested { - return nil - } - return m.response -} diff --git a/api/v1/photo.go b/api/v1/photo.go index b73b9fe..6b1b390 100644 --- a/api/v1/photo.go +++ b/api/v1/photo.go @@ -1,17 +1,22 @@ package api -// GET /api/v1/photos/:uuid +// GetPhoto can be used to get a photo by UUID +// +//GET /api/v1/photos/:uuid // // Parameters: // uuid: string PhotoUID as returned by the API func (v1 *V1Client) GetPhoto(uuid string) (Photo, error) { - object := Photo{ + photo := Photo{ UUID: uuid, } - err := v1.GET("/api/v1/photos/%s", uuid).JSON(&object) - return object, err + err := v1.GET("/api/v1/photos/%s", uuid).JSON(&photo) + return photo, err } +// PhotoOptions is used while listing photos. These +// fields can be optionally set to query for specific +// photos. type PhotoOptions struct { Count int Offset int @@ -62,9 +67,9 @@ func (v1 *V1Client) GetPhotos(options *PhotoOptions) ([]Photo, error) { // // Parameters: // uuid: string PhotoUUID as returned by the API -func (v1 *V1Client) UpdatePhoto(object Photo) (Photo, error) { - err := v1.PUT(&object, "/api/v1/photos/%s", object.UUID).JSON(&object) - return object, err +func (v1 *V1Client) UpdatePhoto(photo Photo) (Photo, error) { + err := v1.PUT(&photo, "/api/v1/photos/%s", photo.UUID).JSON(&photo) + return photo, err } // GET /api/v1/photos/:uuid/dl diff --git a/api/v1/types.go b/api/v1/types.go index deb1402..f5e3a86 100644 --- a/api/v1/types.go +++ b/api/v1/types.go @@ -9,64 +9,62 @@ type Photos []Photo // Photo represents a photo, all its properties, and link to all its images and sidecar files. type Photo struct { - Meta - //ID uint `gorm:"primary_key" yaml:"-"` - UUID string `gorm:"type:VARBINARY(42);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"` - TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"` - TakenAtLocal time.Time `gorm:"type:datetime;" yaml:"-"` - TakenSrc string `gorm:"type:VARBINARY(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"` - PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"` - PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"` - TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"` - PhotoTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title"` - TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"` - PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"` - DescriptionSrc string `gorm:"type:VARBINARY(8);" json:"DescriptionSrc" yaml:"DescriptionSrc,omitempty"` - PhotoPath string `gorm:"type:VARBINARY(500);index:idx_photos_path_name;" json:"Path" yaml:"-"` - PhotoName string `gorm:"type:VARBINARY(255);index:idx_photos_path_name;" json:"Name" yaml:"-"` - OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"` - PhotoStack int8 `json:"Stack" yaml:"Stack,omitempty"` - PhotoFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"` - PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"` - PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"` - PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"` - TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"-"` - PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"` - PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"` - CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"` - CellAccuracy int `json:"CellAccuracy" yaml:"CellAccuracy,omitempty"` - PhotoAltitude int `json:"Altitude" yaml:"Altitude,omitempty"` - PhotoLat float32 `gorm:"type:FLOAT;index;" json:"Lat" yaml:"Lat,omitempty"` - PhotoLng float32 `gorm:"type:FLOAT;index;" json:"Lng" yaml:"Lng,omitempty"` - PhotoCountry string `gorm:"type:VARBINARY(2);index:idx_photos_country_year_month;default:'zz'" json:"Country" yaml:"-"` - PhotoYear int `gorm:"index:idx_photos_country_year_month;" json:"Year" yaml:"Year"` - PhotoMonth int `gorm:"index:idx_photos_country_year_month;" json:"Month" yaml:"Month"` - PhotoDay int `json:"Day" yaml:"Day"` - PhotoIso int `json:"Iso" yaml:"ISO,omitempty"` - PhotoExposure string `gorm:"type:VARBINARY(64);" json:"Exposure" yaml:"Exposure,omitempty"` - PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"` - PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"` - PhotoQuality int `gorm:"type:SMALLINT" json:"Quality" yaml:"-"` - PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"` - PhotoColor uint8 `json:"Color" yaml:"-"` - CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"` - CameraSerial string `gorm:"type:VARBINARY(255);" json:"CameraSerial" yaml:"CameraSerial,omitempty"` - CameraSrc string `gorm:"type:VARBINARY(8);" json:"CameraSrc" yaml:"-"` - LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"` - Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"` - Camera *Camera `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Camera" yaml:"-"` - Lens *Lens `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Lens" yaml:"-"` - Cell *Cell `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Cell" yaml:"-"` - Place *Place `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Place" yaml:"-"` - Keywords []Keyword `json:"-" yaml:"-"` - Albums []Album `json:"-" yaml:"-"` - Files []File `yaml:"-"` - //Labels []PhotoLabel `yaml:"-"` - CreatedAt time.Time `yaml:"CreatedAt,omitempty"` - UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"` - EditedAt *time.Time `yaml:"EditedAt,omitempty"` - CheckedAt *time.Time `sql:"index" yaml:"-"` - DeletedAt *time.Time `sql:"index" yaml:"DeletedAt,omitempty"` + UUID string `gorm:"type:VARBINARY(42);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"` + TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"` + TakenAtLocal time.Time `gorm:"type:datetime;" yaml:"-"` + TakenSrc string `gorm:"type:VARBINARY(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"` + PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"` + PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"` + TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"` + PhotoTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title"` + TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"` + PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"` + DescriptionSrc string `gorm:"type:VARBINARY(8);" json:"DescriptionSrc" yaml:"DescriptionSrc,omitempty"` + PhotoPath string `gorm:"type:VARBINARY(500);index:idx_photos_path_name;" json:"Path" yaml:"-"` + PhotoName string `gorm:"type:VARBINARY(255);index:idx_photos_path_name;" json:"Name" yaml:"-"` + OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"` + PhotoStack int8 `json:"Stack" yaml:"Stack,omitempty"` + PhotoFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"` + PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"` + PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"` + PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"` + TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"-"` + PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"` + PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"` + CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"` + CellAccuracy int `json:"CellAccuracy" yaml:"CellAccuracy,omitempty"` + PhotoAltitude int `json:"Altitude" yaml:"Altitude,omitempty"` + PhotoLat float32 `gorm:"type:FLOAT;index;" json:"Lat" yaml:"Lat,omitempty"` + PhotoLng float32 `gorm:"type:FLOAT;index;" json:"Lng" yaml:"Lng,omitempty"` + PhotoCountry string `gorm:"type:VARBINARY(2);index:idx_photos_country_year_month;default:'zz'" json:"Country" yaml:"-"` + PhotoYear int `gorm:"index:idx_photos_country_year_month;" json:"Year" yaml:"Year"` + PhotoMonth int `gorm:"index:idx_photos_country_year_month;" json:"Month" yaml:"Month"` + PhotoDay int `json:"Day" yaml:"Day"` + PhotoIso int `json:"Iso" yaml:"ISO,omitempty"` + PhotoExposure string `gorm:"type:VARBINARY(64);" json:"Exposure" yaml:"Exposure,omitempty"` + PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"` + PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"` + PhotoQuality int `gorm:"type:SMALLINT" json:"Quality" yaml:"-"` + PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"` + PhotoColor uint8 `json:"Color" yaml:"-"` + CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"` + CameraSerial string `gorm:"type:VARBINARY(255);" json:"CameraSerial" yaml:"CameraSerial,omitempty"` + CameraSrc string `gorm:"type:VARBINARY(8);" json:"CameraSrc" yaml:"-"` + LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"` + Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"` + Camera *Camera `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Camera" yaml:"-"` + Lens *Lens `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Lens" yaml:"-"` + Cell *Cell `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Cell" yaml:"-"` + Place *Place `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Place" yaml:"-"` + Keywords []Keyword `json:"-" yaml:"-"` + Albums []Album `json:"-" yaml:"-"` + Files []File `yaml:"-"` + Labels []PhotoLabel `yaml:"-"` + CreatedAt time.Time `yaml:"CreatedAt,omitempty"` + UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"` + EditedAt *time.Time `yaml:"EditedAt,omitempty"` + CheckedAt *time.Time `sql:"index" yaml:"-"` + DeletedAt *time.Time `sql:"index" yaml:"DeletedAt,omitempty"` } // Details stores additional metadata fields for each photo to improve search performance. @@ -195,6 +193,8 @@ type PhotoAlbum struct { Album *Album `gorm:"PRELOAD:true" yaml:"-"` } +type Files []File + // File represents an image or sidecar file that belongs to a photo. type File struct { ID uint `gorm:"primary_key" json:"-" yaml:"-"` @@ -298,8 +298,7 @@ type Label struct { New bool `gorm:"-" json:"-" yaml:"-"` } -type Files []File - +// FileInfos represents meta data about a file type FileInfos struct { FileWidth int FileHeight int diff --git a/examples/README.md b/examples/README.md index 452d8d9..301010e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,13 @@ # Examples +Here are good examples and working code snippets to start from. + +A lot of these files are used for development and are subject to change. + +For more complete sample code, see the integration testing suite in `/test`. + +### Running the examples + Run the examples. Make sure to pass both the file you wish to run, as well as `common.go` to include the convenience functions. ```bash diff --git a/internal/README.md b/internal/README.md index 65da972..465cb8f 100644 --- a/internal/README.md +++ b/internal/README.md @@ -1,5 +1,6 @@ # 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 directory is used to store backend code while developing. + +This directory should NEVER contain SDK code. -This is a clockwork. Do NOT use this code. \ No newline at end of file diff --git a/internal/README.md~ b/internal/README.md~ deleted file mode 100644 index 8541178..0000000 --- a/internal/README.md~ +++ /dev/null @@ -1,5 +0,0 @@ -# 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. \ No newline at end of file diff --git a/internal/api/account.go b/internal/api/account.go deleted file mode 100644 index ee70de8..0000000 --- a/internal/api/account.go +++ /dev/null @@ -1,364 +0,0 @@ -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("api: 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) - }) -} diff --git a/internal/api/album.go b/internal/api/album.go deleted file mode 100644 index 02abd18..0000000 --- a/internal/api/album.go +++ /dev/null @@ -1,531 +0,0 @@ -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)) - }) -} diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index 058236f..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,107 +0,0 @@ -/* - -Package api contains PhotoPrism REST API handlers. - -Copyright (c) 2018 - 2021 Michael Mayer - - 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 . - - 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) -} diff --git a/internal/api/batch.go b/internal/api/batch.go deleted file mode 100644 index 2ed3559..0000000 --- a/internal/api/batch.go +++ /dev/null @@ -1,370 +0,0 @@ -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("photos: archiving %s", f.String()) - - if service.Config().BackupYaml() { - photos, err := query.PhotoSelection(f) - - if err != nil { - AbortEntityNotFound(c) - return - } - - for _, p := range photos { - if err := p.Archive(); err != nil { - log.Errorf("archive: %s", err) - } else { - SavePhotoAsYaml(p) - } - } - } else if err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error; err != nil { - log.Errorf("archive: %s", err) - AbortSaveFailed(c) - return - } else if err := entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error; err != nil { - log.Errorf("archive: %s", err) - } - - logError("photos", entity.UpdatePhotoCounts()) - - UpdateClientConfig() - - event.EntitiesArchived("photos", f.Photos) - - c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionArchived)) - }) -} - -// 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("photos: restoring %s", f.String()) - - if service.Config().BackupYaml() { - photos, err := query.PhotoSelection(f) - - if err != nil { - AbortEntityNotFound(c) - return - } - - for _, p := range photos { - if err := p.Restore(); err != nil { - log.Errorf("restore: %s", err) - } else { - SavePhotoAsYaml(p) - } - } - } else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos). - UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil { - log.Errorf("restore: %s", err) - AbortSaveFailed(c) - return - } - - logError("photos", entity.UpdatePhotoCounts()) - - UpdateClientConfig() - - event.EntitiesRestored("photos", f.Photos) - - c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored)) - }) -} - -// 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("approve: %s", err) - } else { - approved = append(approved, p) - SavePhotoAsYaml(p) - } - } - - UpdateClientConfig() - - event.EntitiesUpdated("photos", approved) - - c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionApproved)) - }) -} - -// 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: updating private flag for %s", f.String()) - - if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private", - gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil { - log.Errorf("private: %s", err) - AbortSaveFailed(c) - return - } - - logError("photos", entity.UpdatePhotoCounts()) - - if photos, err := query.PhotoSelection(f); err == nil { - for _, p := range photos { - SavePhotoAsYaml(p) - } - - event.EntitiesUpdated("photos", photos) - } - - UpdateClientConfig() - - FlushCoverCache() - - c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected)) - }) -} - -// 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("photos: 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("delete: %s", err) - } else { - deleted = append(deleted, p) - } - } - - // Update counts and views if needed. - if len(deleted) > 0 { - logError("photos", entity.UpdatePhotoCounts()) - - UpdateClientConfig() - - event.EntitiesDeleted("photos", deleted.UIDs()) - } - - c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted)) - }) -} diff --git a/internal/api/cache.go b/internal/api/cache.go deleted file mode 100644 index 16b02fa..0000000 --- a/internal/api/cache.go +++ /dev/null @@ -1,68 +0,0 @@ -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") -} diff --git a/internal/api/config.go b/internal/api/config.go deleted file mode 100644 index 1cee735..0000000 --- a/internal/api/config.go +++ /dev/null @@ -1,135 +0,0 @@ -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()) - }) -} diff --git a/internal/api/covers.go b/internal/api/covers.go deleted file mode 100644 index 4592952..0000000 --- a/internal/api/covers.go +++ /dev/null @@ -1,243 +0,0 @@ -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("api: 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("%s: %s", albumCover, err) - c.Data(http.StatusOK, "image/svg+xml", albumIconSvg) - 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("api: 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) - } - }) -} diff --git a/internal/api/doc.go b/internal/api/doc.go deleted file mode 100644 index 0cb336c..0000000 --- a/internal/api/doc.go +++ /dev/null @@ -1,8 +0,0 @@ -/* -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 diff --git a/internal/api/download.go b/internal/api/download.go deleted file mode 100644 index 0575c0e..0000000 --- a/internal/api/download.go +++ /dev/null @@ -1,70 +0,0 @@ -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)) - }) -} diff --git a/internal/api/errors.go b/internal/api/errors.go deleted file mode 100644 index 87fa67e..0000000 --- a/internal/api/errors.go +++ /dev/null @@ -1,35 +0,0 @@ -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) - } - }) -} diff --git a/internal/api/event.go b/internal/api/event.go deleted file mode 100644 index 29f5cc0..0000000 --- a/internal/api/event.go +++ /dev/null @@ -1,55 +0,0 @@ -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) -} diff --git a/internal/api/feedback.go b/internal/api/feedback.go deleted file mode 100644 index 75db45b..0000000 --- a/internal/api/feedback.go +++ /dev/null @@ -1,54 +0,0 @@ -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}) - }) -} diff --git a/internal/api/file.go b/internal/api/file.go deleted file mode 100644 index 5a974b1..0000000 --- a/internal/api/file.go +++ /dev/null @@ -1,33 +0,0 @@ -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) - }) -} diff --git a/internal/api/file_delete.go b/internal/api/file_delete.go deleted file mode 100644 index 1859552..0000000 --- a/internal/api/file_delete.go +++ /dev/null @@ -1,88 +0,0 @@ -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) - } - }) -} diff --git a/internal/api/folder.go b/internal/api/folder.go deleted file mode 100644 index 6e41775..0000000 --- a/internal/api/folder.go +++ /dev/null @@ -1,110 +0,0 @@ -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("api: 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()) -} diff --git a/internal/api/folder_cover.go b/internal/api/folder_cover.go deleted file mode 100644 index bc1ce45..0000000 --- a/internal/api/folder_cover.go +++ /dev/null @@ -1,140 +0,0 @@ -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" -) - -const ( - folderCover = "folder-cover" -) - -// GET /api/v1/folders/t/:hash/:token/:type -// -// Parameters: -// uid: string folder uid -// token: string url security token, see config -// type: string thumb type, see thumb.Types -func GetFolderCover(router *gin.RouterGroup) { - router.GET("/folders/t/:uid/:token/:type", func(c *gin.Context) { - if InvalidPreviewToken(c) { - c.Data(http.StatusForbidden, "image/svg+xml", folderIconSvg) - return - } - - start := time.Now() - conf := service.Config() - uid := c.Param("uid") - typeName := c.Param("type") - download := c.Query("download") != "" - - thumbType, ok := thumb.Types[typeName] - - if !ok { - log.Errorf("folder: invalid thumb type %s", txt.Quote(typeName)) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - return - } - - if thumbType.ExceedsSize() && !conf.ThumbUncached() { - typeName, thumbType = thumb.Find(conf.ThumbSize()) - - if typeName == "" { - log.Errorf("folder: invalid thumb size %d", conf.ThumbSize()) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - return - } - } - - cache := service.CoverCache() - cacheKey := CacheKey(folderCover, uid, typeName) - - if cacheData, ok := cache.Get(cacheKey); ok { - log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start)) - - cached := cacheData.(ThumbCache) - - if !fs.FileExists(cached.FileName) { - log.Errorf("%s: %s not found", folderCover, uid) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - return - } - - AddCoverCacheHeader(c) - - if download { - c.FileAttachment(cached.FileName, cached.ShareName) - } else { - c.File(cached.FileName) - } - - return - } - - f, err := query.FolderCoverByUID(uid) - - if err != nil { - log.Debugf("%s: no photos yet, using generic image for %s", folderCover, uid) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - return - } - - fileName := photoprism.FileName(f.FileRoot, f.FileName) - - if !fs.FileExists(fileName) { - log.Errorf("%s: could not find original for %s", folderCover, fileName) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - - // Set missing flag so that the file doesn't show up in search results anymore. - log.Warnf("%s: %s is missing", folderCover, txt.Quote(f.FileName)) - logError(folderCover, f.Update("FileMissing", true)) - return - } - - // Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157 - if thumbType.ExceedsSizeUncached() && !download { - log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", folderCover, 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", folderCover, err) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - return - } else if thumbnail == "" { - log.Errorf("%s: %s has empty thumb name - bug?", folderCover, filepath.Base(fileName)) - c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) - return - } - - cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)}) - log.Debugf("cached %s [%s]", cacheKey, time.Since(start)) - - AddCoverCacheHeader(c) - - if download { - c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0)) - } else { - c.File(thumbnail) - } - }) -} diff --git a/internal/api/geo.go b/internal/api/geo.go deleted file mode 100644 index 70194c1..0000000 --- a/internal/api/geo.go +++ /dev/null @@ -1,106 +0,0 @@ -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) - }) -} diff --git a/internal/api/headers.go b/internal/api/headers.go deleted file mode 100644 index 83b0a51..0000000 --- a/internal/api/headers.go +++ /dev/null @@ -1,71 +0,0 @@ -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()) -} diff --git a/internal/api/import.go b/internal/api/import.go deleted file mode 100644 index 2844b6e..0000000 --- a/internal/api/import.go +++ /dev/null @@ -1,137 +0,0 @@ -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)) - }) -} diff --git a/internal/api/index.go b/internal/api/index.go deleted file mode 100644 index f6330ab..0000000 --- a/internal/api/index.go +++ /dev/null @@ -1,126 +0,0 @@ -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)) - }) -} diff --git a/internal/api/label.go b/internal/api/label.go deleted file mode 100644 index fc15719..0000000 --- a/internal/api/label.go +++ /dev/null @@ -1,162 +0,0 @@ -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{}) - }) -} diff --git a/internal/api/link.go b/internal/api/link.go deleted file mode 100644 index 0cda119..0000000 --- a/internal/api/link.go +++ /dev/null @@ -1,250 +0,0 @@ -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()) - }) -} diff --git a/internal/api/moments_time.go b/internal/api/moments_time.go deleted file mode 100644 index 86a9412..0000000 --- a/internal/api/moments_time.go +++ /dev/null @@ -1,32 +0,0 @@ -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) - }) -} diff --git a/internal/api/parse.go b/internal/api/parse.go deleted file mode 100644 index b2d47e0..0000000 --- a/internal/api/parse.go +++ /dev/null @@ -1,13 +0,0 @@ -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) -} diff --git a/internal/api/photo.go b/internal/api/photo.go deleted file mode 100644 index 7c514f1..0000000 --- a/internal/api/photo.go +++ /dev/null @@ -1,332 +0,0 @@ -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) - - UpdateClientConfig() - - 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) - }) -} diff --git a/internal/api/photo_label.go b/internal/api/photo_label.go deleted file mode 100644 index dc35d13..0000000 --- a/internal/api/photo_label.go +++ /dev/null @@ -1,220 +0,0 @@ -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) - }) -} diff --git a/internal/api/photo_search.go b/internal/api/photo_search.go deleted file mode 100644 index 63cb5af..0000000 --- a/internal/api/photo_search.go +++ /dev/null @@ -1,74 +0,0 @@ -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) - }) -} diff --git a/internal/api/photo_thumb.go b/internal/api/photo_thumb.go deleted file mode 100644 index b34c33d..0000000 --- a/internal/api/photo_thumb.go +++ /dev/null @@ -1,168 +0,0 @@ -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("api: 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) - } - }) -} diff --git a/internal/api/photo_unstack.go b/internal/api/photo_unstack.go deleted file mode 100644 index df304cf..0000000 --- a/internal/api/photo_unstack.go +++ /dev/null @@ -1,199 +0,0 @@ -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 %s (unstack)", 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 for %s (unstack)", txt.Quote(baseName)) - AbortEntityNotFound(c) - return - } else if related.Main == nil { - log.Errorf("photo: no main file found for %s (unstack)", 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) - }) -} diff --git a/internal/api/session.go b/internal/api/session.go deleted file mode 100644 index 0e81a33..0000000 --- a/internal/api/session.go +++ /dev/null @@ -1,141 +0,0 @@ -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")) -} diff --git a/internal/api/settings.go b/internal/api/settings.go deleted file mode 100644 index b82bca1..0000000 --- a/internal/api/settings.go +++ /dev/null @@ -1,65 +0,0 @@ -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) - }) -} diff --git a/internal/api/share.go b/internal/api/share.go deleted file mode 100644 index 536428b..0000000 --- a/internal/api/share.go +++ /dev/null @@ -1,69 +0,0 @@ -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}) - }) -} diff --git a/internal/api/share_preview.go b/internal/api/share_preview.go deleted file mode 100644 index 42d244f..0000000 --- a/internal/api/share_preview.go +++ /dev/null @@ -1,168 +0,0 @@ -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) - }) -} diff --git a/internal/api/status.go b/internal/api/status.go deleted file mode 100644 index 4098472..0000000 --- a/internal/api/status.go +++ /dev/null @@ -1,14 +0,0 @@ -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"}) - }) -} diff --git a/internal/api/svg.go b/internal/api/svg.go deleted file mode 100644 index 2a45e17..0000000 --- a/internal/api/svg.go +++ /dev/null @@ -1,78 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -var photoIconSvg = []byte(` - -`) - -var rawIconSvg = []byte(` - - -`) - -var fileIconSvg = []byte(` -`) - -var videoIconSvg = []byte(` -`) - -var folderIconSvg = []byte(``) - -var albumIconSvg = folderIconSvg - -var labelIconSvg = []byte(` -`) - -var brokenIconSvg = []byte(` - - - -`) - -var uncachedIconSvg = []byte(` - -`) - -// 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) - }) -} diff --git a/internal/api/upload.go b/internal/api/upload.go deleted file mode 100644 index d516b91..0000000 --- a/internal/api/upload.go +++ /dev/null @@ -1,113 +0,0 @@ -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}) - }) -} diff --git a/internal/api/user.go b/internal/api/user.go deleted file mode 100644 index 3cc6279..0000000 --- a/internal/api/user.go +++ /dev/null @@ -1,58 +0,0 @@ -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)) - }) -} diff --git a/internal/api/video.go b/internal/api/video.go deleted file mode 100644 index e792d9c..0000000 --- a/internal/api/video.go +++ /dev/null @@ -1,100 +0,0 @@ -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 - }) -} diff --git a/internal/api/websocket.go b/internal/api/websocket.go deleted file mode 100644 index 8a56fd8..0000000 --- a/internal/api/websocket.go +++ /dev/null @@ -1,193 +0,0 @@ -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) - }) -} diff --git a/internal/api/zip.go b/internal/api/zip.go deleted file mode 100644 index f1eb727..0000000 --- a/internal/api/zip.go +++ /dev/null @@ -1,192 +0,0 @@ -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 -} diff --git a/sample-app/README.md b/sample-app/README.md new file mode 100644 index 0000000..49a1463 --- /dev/null +++ b/sample-app/README.md @@ -0,0 +1,17 @@ +# Sample App + +This is bad code. + +We shell exec the start/stop/create/destroy docker commands (poorly) +and this is how the unit testing suite attempts to start/stop/create/destroy +the local persistent store. + +### Running The Sample App + +```bash +./pcreate # Will create the sample app running locally +./pdestroy # Will destroy the sample app, but the data will persist regardles of running this command +./pstop # Will stop the photoprism app from running/serving +./plogs # Will tail the photoprism logs +./pstart # Will start an already created, and then stopped Photoprism application +``` \ No newline at end of file diff --git a/sample-app/photoprism/storage/albums/album/aqoe8ez1jjge2mq2.yml b/sample-app/photoprism/storage/albums/album/aqoe8ez1jjge2mq2.yml new file mode 100755 index 0000000..73d7b21 --- /dev/null +++ b/sample-app/photoprism/storage/albums/album/aqoe8ez1jjge2mq2.yml @@ -0,0 +1,9 @@ +UID: aqoe8ez1jjge2mq2 +Slug: testalbum +Type: album +Title: TestAlbum +Order: oldest +Country: zz +CreatedAt: 2021-02-12T02:04:11Z +UpdatedAt: 2021-02-12T02:04:11Z +DeletedAt: 2021-02-12T02:04:11.32572869Z diff --git a/sample-app/photoprism/storage/albums/album/aqoe8ez2htvawjx6.yml b/sample-app/photoprism/storage/albums/album/aqoe8ez2htvawjx6.yml new file mode 100755 index 0000000..e28c8d6 --- /dev/null +++ b/sample-app/photoprism/storage/albums/album/aqoe8ez2htvawjx6.yml @@ -0,0 +1,9 @@ +UID: aqoe8ez2htvawjx6 +Slug: testalbum +Type: album +Title: TestAlbum +Order: oldest +Country: zz +CreatedAt: 2021-02-12T02:04:11Z +UpdatedAt: 2021-02-12T02:04:11Z +DeletedAt: 2021-02-12T02:04:11.277362825Z diff --git a/sample-app/photoprism/storage/albums/album/aqoe8ez3kaiy71vi.yml b/sample-app/photoprism/storage/albums/album/aqoe8ez3kaiy71vi.yml new file mode 100755 index 0000000..678f6c8 --- /dev/null +++ b/sample-app/photoprism/storage/albums/album/aqoe8ez3kaiy71vi.yml @@ -0,0 +1,10 @@ +UID: aqoe8ez3kaiy71vi +Slug: testalbum +Type: album +Title: TestAlbum +Description: An updated album description +Order: oldest +Country: zz +CreatedAt: 2021-02-12T02:04:11Z +UpdatedAt: 2021-02-12T02:04:11.178898158Z +DeletedAt: 2021-02-12T02:04:11.190112861Z diff --git a/sample-app/photoprism/storage/albums/album/aqoe8ez3vp0ubysv.yml b/sample-app/photoprism/storage/albums/album/aqoe8ez3vp0ubysv.yml new file mode 100755 index 0000000..ffeed16 --- /dev/null +++ b/sample-app/photoprism/storage/albums/album/aqoe8ez3vp0ubysv.yml @@ -0,0 +1,9 @@ +UID: aqoe8ez3vp0ubysv +Slug: testalbum +Type: album +Title: TestAlbum +Order: oldest +Country: zz +CreatedAt: 2021-02-12T02:04:11Z +UpdatedAt: 2021-02-12T02:04:11Z +DeletedAt: 2021-02-12T02:04:11.250601775Z diff --git a/sample-app/photoprism/storage/cache/sessions.json b/sample-app/photoprism/storage/cache/sessions.json index 6bf2e4c..20007fe 100644 --- a/sample-app/photoprism/storage/cache/sessions.json +++ b/sample-app/photoprism/storage/cache/sessions.json @@ -594,6 +594,11 @@ "tokens": null, "expiration": 1613502660685792657 }, + "b1fe34099243a851154b4dae23220b31d5f7d846f64570eb": { + "user": "uqnzie01i1nypnt9", + "tokens": null, + "expiration": 1613700252681272255 + }, "b50e6818199b7570676580eff73ffa346c4896c9bd9305f0": { "user": "uqnzie01i1nypnt9", "tokens": null, @@ -624,6 +629,11 @@ "tokens": null, "expiration": 1613693719704975163 }, + "bbdbf96d91d72d87fb819d31d7f175b733164975c1802243": { + "user": "uqnzie01i1nypnt9", + "tokens": null, + "expiration": 1613700251152122404 + }, "be6101bcba1fe4f99bc3b1b2f650e659b56d7b29ed028902": { "user": "uqnzie01i1nypnt9", "tokens": null, diff --git a/sample-app/photoprism/storage/index.db b/sample-app/photoprism/storage/index.db index 4230813..9716a43 100644 Binary files a/sample-app/photoprism/storage/index.db and b/sample-app/photoprism/storage/index.db differ diff --git a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_36A3FD61.yml b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_36A3FD61.yml index 6248dfe..70d6a59 100755 --- a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_36A3FD61.yml +++ b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_36A3FD61.yml @@ -9,4 +9,4 @@ Day: -1 Details: Keywords: black, cat, eptcef, voaeias CreatedAt: 2021-02-04T03:17:14.849461459Z -UpdatedAt: 2021-02-11T19:46:41.889198363Z +UpdatedAt: 2021-02-12T02:04:35.996772284Z diff --git a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml index 3572ade..4057778 100755 --- a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml +++ b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml @@ -3,7 +3,7 @@ UID: pqnzigq351j2fqgn Type: image Title: A really great photo! TitleSrc: manual -Description: 'Sample App Description: 2021-02-11 17:24:09.899837223 -0800 PST m=+6.509602721' +Description: 'Sample App Description: 2021-02-11 18:04:11.428496744 -0800 PST m=+6.520817628' DescriptionSrc: manual OriginalName: IMG_3044 Year: -1 @@ -13,5 +13,5 @@ Details: Keywords: green, tambourine KeywordsSrc: manual CreatedAt: 2021-02-04T03:17:14.613092062Z -UpdatedAt: 2021-02-12T01:24:09.908530756Z -EditedAt: 2021-02-12T01:24:10Z +UpdatedAt: 2021-02-12T02:04:35.895262838Z +EditedAt: 2021-02-12T02:04:11Z diff --git a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_76642B51.yml b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_76642B51.yml index dc6e723..0581a7f 100755 --- a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_76642B51.yml +++ b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_76642B51.yml @@ -10,5 +10,5 @@ Day: -1 Details: Keywords: blue, elgexeiu, portrait CreatedAt: 2021-02-04T03:17:14.668332772Z -UpdatedAt: 2021-02-11T19:46:42.041205155Z +UpdatedAt: 2021-02-12T02:04:36.057209628Z EditedAt: 2021-02-09T18:39:46Z diff --git a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_AE1CC552.yml b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_AE1CC552.yml index 35cea8d..1bb0e20 100755 --- a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_AE1CC552.yml +++ b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_AE1CC552.yml @@ -9,4 +9,4 @@ Day: -1 Details: Keywords: grey, seashore CreatedAt: 2021-02-04T03:17:14.738798274Z -UpdatedAt: 2021-02-11T19:46:41.918409745Z +UpdatedAt: 2021-02-12T02:04:36.006365324Z diff --git a/test/README.md b/test/README.md index 76bbba7..a0ca94e 100644 --- a/test/README.md +++ b/test/README.md @@ -1,7 +1,39 @@ # Local Integration Tests -To run the tests +To run the tests. ```bash sudo -E go test . -v +``` + +### Adding a test + +To add a test please try to have both `Happy` and `Sad` tests defined for all new SDK methods. + +Example test: + +#####mymethod_test.go + +```go + +// TestHappyMethod will test my new method +func TestHappyMethod(t *testing.T) { + params := "my good input" + _, err := Client.V1().Method(params) + if err != nil { + t.Errorf("expected success running method: %v", err) + t.FailNow() + } +} + +// TestSadMethod will false positive test my new method +func TestHappyMethod(t *testing.T) { + params := "my bad input" + _, err := Client.V1().Method(params) + if err == nil { + t.Errorf("expected failure running method: %v", err) + t.FailNow() + } +} + ``` \ No newline at end of file