From a8c48ab40ea6c9cfeb0b7f117ddab05364b1971c Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 8 Apr 2020 13:24:06 +0200 Subject: [PATCH] Initial API and entities for link sharing Signed-off-by: Michael Mayer --- Makefile | 12 +-- assets/resources/examples/fixtures.sql | 29 +++--- frontend/src/model/abstract.js | 8 ++ frontend/src/model/album.js | 1 + frontend/src/model/label.js | 1 + frontend/src/model/link.js | 31 ++++++ frontend/src/model/photo.js | 1 + internal/api/api_test.go | 13 ++- internal/api/file.go | 38 +++++++- internal/api/link.go | 125 +++++++++++++++++++++++++ internal/api/link_test.go | 54 +++++++++++ internal/config/db.go | 74 +++++++-------- internal/entity/album.go | 8 +- internal/entity/file.go | 5 +- internal/entity/label.go | 1 + internal/entity/link.go | 42 +++++++++ internal/entity/photo.go | 1 + internal/form/new_link.go | 9 ++ internal/query/album.go | 8 +- internal/query/file.go | 10 +- internal/query/file_test.go | 17 +++- internal/query/label.go | 4 +- internal/query/photo.go | 5 +- internal/server/routes.go | 4 + 24 files changed, 419 insertions(+), 82 deletions(-) create mode 100644 frontend/src/model/link.js create mode 100644 internal/api/link.go create mode 100644 internal/api/link_test.go create mode 100644 internal/entity/link.go create mode 100644 internal/form/new_link.go diff --git a/Makefile b/Makefile index 74f733f12..98f381a12 100644 --- a/Makefile +++ b/Makefile @@ -88,23 +88,23 @@ acceptance-firefox: (cd frontend && npm run acceptance-firefox) test-go: $(info Running all Go unit tests...) - $(GOTEST) -parallel=1 -count=1 -tags=slow -timeout 20m ./pkg/... ./internal/... + $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m ./pkg/... ./internal/... test-verbose: $(info Running all Go unit tests in verbose mode...) - $(GOTEST) -parallel=1 -tags=slow -timeout 20m -v ./pkg/... ./internal/... + $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m -v ./pkg/... ./internal/... test-short: $(info Running short Go unit tests in verbose mode...) - $(GOTEST) -parallel=1 -short -timeout 5m -v ./pkg/... ./internal/... + $(GOTEST) -parallel 1 -count 1 -cpu 1 -short -timeout 5m -v ./pkg/... ./internal/... test-race: $(info Running all Go unit tests with race detection in verbose mode...) - $(GOTEST) -parallel=1 -tags=slow -race -timeout 60m -v ./pkg/... ./internal/... + $(GOTEST) -tags slow -race -timeout 60m -v ./pkg/... ./internal/... test-codecov: $(info Running all Go unit tests with code coverage report for codecov...) - go test -parallel=1 -count=1 -tags=slow -timeout 30m -coverprofile=coverage.txt -covermode=atomic -v ./pkg/... ./internal/... + go test -parallel 1 -count 1 -cpu 1 -failfast -tags slow -timeout 30m -coverprofile coverage.txt -covermode atomic -v ./pkg/... ./internal/... scripts/codecov.sh test-coverage: $(info Running all Go unit tests with code coverage report...) - go test -parallel=1 -count=1 -tags=slow -timeout 30m -coverprofile=coverage.txt -covermode=atomic -v ./pkg/... ./internal/... + go test -parallel 1 -count 1 -cpu 1 -failfast -tags slow -timeout 30m -coverprofile coverage.txt -covermode atomic -v ./pkg/... ./internal/... go tool cover -html=coverage.txt -o coverage.html clean: rm -f $(BINARY_NAME) diff --git a/assets/resources/examples/fixtures.sql b/assets/resources/examples/fixtures.sql index 0a489b989..10d5b3986 100644 --- a/assets/resources/examples/fixtures.sql +++ b/assets/resources/examples/fixtures.sql @@ -6,22 +6,23 @@ INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, ca INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, camera_description, camera_notes, created_at, updated_at, deleted_at) VALUES (6, 'apple-iphone-6', 'iPhone 6', 'Apple', '', '', '', '2020-01-06 02:06:42', '2020-01-06 02:06:42', null); INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, camera_description, camera_notes, created_at, updated_at, deleted_at) VALUES (7, 'apple-iphone-7', 'iPhone 7', 'Apple', '', '', '', '2020-01-06 02:06:51', '2020-01-06 02:06:51', null); INSERT INTO countries (id, country_slug, country_name, country_description, country_notes, country_photo_id) VALUES ('de', 'germany', 'Germany', 'Country Description', 'Country Notes', 0); -INSERT INTO albums (id, album_uuid, album_name, album_slug, album_favorite) VALUES ('2', '3', 'Christmas2030', 'christmas2030', 0); -INSERT INTO albums (id, album_uuid, cover_uuid, album_name, album_slug, album_favorite) VALUES ('1', '4', '654', 'Holiday2030', 'holiday-2030', 1); -INSERT INTO albums (id, album_uuid, cover_uuid, album_name, album_slug, album_favorite) VALUES ('3', '5', '654', 'Berlin2019', 'berlin-2019', 0); +INSERT INTO albums (id, album_uuid, album_name, album_slug, album_favorite) VALUES (2, '3', 'Christmas2030', 'christmas2030', 0); +INSERT INTO albums (id, album_uuid, cover_uuid, album_name, album_slug, album_favorite) VALUES (1, '4', '654', 'Holiday2030', 'holiday-2030', 1); +INSERT INTO albums (id, album_uuid, cover_uuid, album_name, album_slug, album_favorite) VALUES (3, '5', '654', 'Berlin2019', 'berlin-2019', 0); +INSERT INTO links (link_token, link_password, link_expires, share_uuid, can_comment, can_edit, created_at, updated_at) VALUES ('1jxf3jfn2k', 'somepassword', '2050-03-06 02:06:51', '4', 1, 0, '2020-03-06 02:06:51', '2020-03-28 14:06:00'); INSERT INTO photos_albums (album_uuid, photo_uuid) VALUES ('4', '654'); INSERT INTO photos_albums (album_uuid, photo_uuid) VALUES ('5', '658'); -INSERT INTO files (id, photo_id, photo_uuid, file_name, file_primary, file_hash, file_missing) VALUES ('1', '1', '654', 'exampleFileName.jpg', 1, '123xxx', 0); -INSERT INTO files (id, photo_id, photo_uuid, file_name, file_primary, file_hash, file_missing) VALUES ('2', '2', '655', 'exampleDNGFile.dng', 1, '124xxx', 0); -INSERT INTO files (id, photo_id, photo_uuid, file_name, file_primary, file_hash, file_missing) VALUES ('3', '2', '655', 'exampleXmpFile.xmp', 0, '125xxx', 0); -INSERT INTO files (id, photo_id, photo_uuid, file_name, file_primary, file_hash, file_missing) VALUES ('4', '5', '658', 'bridge.jpg', 1, '126xxx', 0); -INSERT INTO files (id, photo_id, photo_uuid, file_name, file_primary, file_hash, file_missing) VALUES ('5', '6', '659', 'reunion.jpg', 1, '127xxx', 0); -INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES ('1', '654', 2790, 2, '48.519235', '9.057996666666666'); -INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES ('2', '655', 2790, 2, '48.519235', '9.057996666666666'); -INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES ('3', '656', 1990, 3, '48.519235', '9.057996666666666'); -INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES ('4', '657', 1990, 4, '48.519235', '9.057996666666666'); -INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES ('5', '658', '2014-07-17 15:42:12', '48.519235', '9.057996666666666', 'Neckarbrücke'); -INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES ('6', '659', '2015-11-11 09:07:18', '-21.34263611111111', '55.466944444444444', 'Reunion'); +INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (1, '1', '654', 'fq8es39w45bnlqdw', 'exampleFileName.jpg', 1, '123xxx', 0); +INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (2, '2', '655', 'fq8ev8o1tl6umi0s', 'exampleDNGFile.dng', 1, '124xxx', 0); +INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (3, '2', '655', 'fq8ev8t1x0bwje4e', 'exampleXmpFile.xmp', 0, '125xxx', 0); +INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (4, '5', '658', 'fq8ev9c3sp88uwzq', 'bridge.jpg', 1, '126xxx', 0); +INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (5, '6', '659', 'fq8evan3urz3i48d', 'reunion.jpg', 1, '127xxx', 0); +INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (1, '654', 2790, 2, '48.519235', '9.057996666666666'); +INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (2, '655', 2790, 2, '48.519235', '9.057996666666666'); +INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (3, '656', 1990, 3, '48.519235', '9.057996666666666'); +INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (4, '657', 1990, 4, '48.519235', '9.057996666666666'); +INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES (5, '658', '2014-07-17 15:42:12', '48.519235', '9.057996666666666', 'Neckarbrücke'); +INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES (6, '659', '2015-11-11 09:07:18', '-21.34263611111111', '55.466944444444444', 'Reunion'); INSERT INTO keywords (id, keyword, skip) VALUES (1, 'bridge', 0); INSERT INTO keywords (id, keyword, skip) VALUES (2, 'beach', 0); INSERT INTO photos_keywords (photo_id, keyword_id) VALUES (5, 1); diff --git a/frontend/src/model/abstract.js b/frontend/src/model/abstract.js index 83e1ac334..973834ac5 100644 --- a/frontend/src/model/abstract.js +++ b/frontend/src/model/abstract.js @@ -114,6 +114,14 @@ class Abstract { return this.constructor.getModelName() + " " + this.getId(); } + addLink(password, expires, comment, edit) { + expires = expires ? parseInt(expires) : 0; + comment = !!comment; + edit = !!edit; + const values = {password, expires, comment, edit}; + return Api.post(this.getEntityResource() + "/link", values).then((response) => Promise.resolve(this.setValues(response.data))); + } + static getCollectionResource() { throw new Error("getCollectionResource() needs to be implemented"); } diff --git a/frontend/src/model/album.js b/frontend/src/model/album.js index fb421a289..e25fca9c2 100644 --- a/frontend/src/model/album.js +++ b/frontend/src/model/album.js @@ -20,6 +20,7 @@ class Album extends Abstract { AlbumRadius: 0, AlbumOrder: "", AlbumTemplate: "", + Links: [], CreatedAt: "", UpdatedAt: "", DeletedAt: null, diff --git a/frontend/src/model/label.js b/frontend/src/model/label.js index e0c4fdb6c..a544c9fb5 100644 --- a/frontend/src/model/label.js +++ b/frontend/src/model/label.js @@ -17,6 +17,7 @@ class Label extends Abstract { LabelFavorite: false, LabelDescription: "", LabelNotes: "", + Links: [], }; } diff --git a/frontend/src/model/link.js b/frontend/src/model/link.js new file mode 100644 index 000000000..b5b8cd5d5 --- /dev/null +++ b/frontend/src/model/link.js @@ -0,0 +1,31 @@ +import Abstract from "model/abstract"; + +class Link extends Abstract { + getDefaults() { + return { + LinkToken: "", + LinkPassword: "", + LinkExpires: "", + ShareUUID: "", + CanComment: false, + CanEdit: false, + CreatedAt: "", + UpdatedAt: "", + Links: [], + }; + } + + getId() { + return this.LinkToken; + } + + static getCollectionResource() { + return "links"; + } + + static getModelName() { + return "Link"; + } +} + +export default Link; diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index c2b663edc..98e3d1f23 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -56,6 +56,7 @@ class Photo extends Abstract { Labels: [], Keywords: [], Albums: [], + Links: [], CreatedAt: "", UpdatedAt: "", DeletedAt: null, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 3c0d1689f..4157741d2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -3,12 +3,13 @@ package api import ( "net/http" "net/http/httptest" + "strings" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/config" ) -// API test helper +// NewApiTest returns new API test helper func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config) { conf = config.TestConfig() gin.SetMode(gin.TestMode) @@ -17,6 +18,7 @@ func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config return app, router, conf } +// Performs API request with empty request body. // See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1 func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder { req, _ := http.NewRequest(method, path, nil) @@ -24,3 +26,12 @@ func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecor r.ServeHTTP(w, req) return w } + +// Performs API request including request body as string. +func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder { + reader := strings.NewReader(body) + req, _ := http.NewRequest(method, path, reader) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} diff --git a/internal/api/file.go b/internal/api/file.go index f95d1b3b9..32b0e8f22 100644 --- a/internal/api/file.go +++ b/internal/api/file.go @@ -5,13 +5,15 @@ import ( "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/txt" ) // GET /api/v1/files/:hash // // Parameters: -// hash: string The sha1 hash of a file +// hash: string SHA-1 hash of the file func GetFile(router *gin.RouterGroup, conf *config.Config) { router.GET("/files/:hash", func(c *gin.Context) { if Unauthorized(c, conf) { @@ -30,3 +32,37 @@ func GetFile(router *gin.RouterGroup, conf *config.Config) { c.JSON(http.StatusOK, p) }) } + +// POST /api/v1/files/:hash/link +// +// Parameters: +// hash: string SHA-1 hash of the file +func LinkFile(router *gin.RouterGroup, conf *config.Config) { + router.POST("/files/:hash/link", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + db := conf.Db() + q := query.New(db) + + m, err := q.FileByUUID(c.Param("hash")) + + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound) + return + } + + if link, err := newLink(c); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) + return + } else { + db.Model(&m).Association("Links").Append(link) + } + + event.Success("created file share link") + + c.JSON(http.StatusOK, m) + }) +} diff --git a/internal/api/link.go b/internal/api/link.go new file mode 100644 index 000000000..c7ba41535 --- /dev/null +++ b/internal/api/link.go @@ -0,0 +1,125 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/photoprism/photoprism/internal/config" + "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" +) + +// newLink returns a new link entity initialized with request data +func newLink(c *gin.Context) (link entity.Link, err error) { + var f form.NewLink + + if err := c.BindJSON(&f); err != nil { + return link, err + } + + link = entity.NewLink(f.Password, f.CanComment, f.CanEdit) + + if f.Expires > 0 { + expires := time.Now().Add(time.Duration(f.Expires) * time.Second) + link.LinkExpires = &expires + } + + return link, nil +} + +// POST /api/v1/albums/:uuid/link +func LinkAlbum(router *gin.RouterGroup, conf *config.Config) { + router.POST("/albums/:uuid/link", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + db := conf.Db() + q := query.New(db) + + m, err := q.AlbumByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound) + return + } + + if link, err := newLink(c); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) + return + } else { + db.Model(&m).Association("Links").Append(link) + } + + event.Success("created album share link") + + c.JSON(http.StatusOK, m) + }) +} + +// POST /api/v1/photos/:uuid/link +func LinkPhoto(router *gin.RouterGroup, conf *config.Config) { + router.POST("/photos/:uuid/link", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + db := conf.Db() + q := query.New(db) + + m, err := q.PhotoByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound) + return + } + + if link, err := newLink(c); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) + return + } else { + db.Model(&m).Association("Links").Append(link) + } + + event.Success("created photo share link") + + c.JSON(http.StatusOK, m) + }) +} + +// POST /api/v1/labels/:uuid/link +func LinkLabel(router *gin.RouterGroup, conf *config.Config) { + router.POST("/labels/:uuid/link", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + db := conf.Db() + q := query.New(db) + + m, err := q.LabelByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound) + return + } + + if link, err := newLink(c); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) + return + } else { + db.Model(&m).Association("Links").Append(link) + } + + event.Success("created label share link") + + c.JSON(http.StatusOK, m) + }) +} diff --git a/internal/api/link_test.go b/internal/api/link_test.go new file mode 100644 index 000000000..a27f68242 --- /dev/null +++ b/internal/api/link_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/stretchr/testify/assert" +) + +func TestLinkAlbum(t *testing.T) { + t.Run("create share link", func(t *testing.T) { + app, router, ctx := NewApiTest() + + var album entity.Album + + LinkAlbum(router, ctx) + + result1 := PerformRequestWithBody(app, "POST", "/api/v1/albums/3/link", `{"password": "foobar", "expires": 0, "edit": true}`) + + assert.Equal(t, http.StatusOK, result1.Code) + + if err := json.Unmarshal(result1.Body.Bytes(), &album); err != nil { + t.Fatal(err) + } + + if len(album.Links) != 1 { + t.Fatalf("one link expected: %d, %+v", len(album.Links), album) + } + + link := album.Links[0] + + assert.Equal(t, "foobar", link.LinkPassword) + assert.Nil(t, link.LinkExpires) + assert.False(t, link.CanComment) + assert.True(t, link.CanEdit) + + result2 := PerformRequestWithBody(app, "POST", "/api/v1/albums/3/link", `{"password": "", "expires": 3600}`) + + assert.Equal(t, http.StatusOK, result2.Code) + + // t.Logf("result1: %s", result1.Body.String()) + // t.Logf("result2: %s", result2.Body.String()) + + if err := json.Unmarshal(result2.Body.Bytes(), &album); err != nil { + t.Fatal(err) + } + + if len(album.Links) != 2 { + t.Fatal("two links expected") + } + }) +} diff --git a/internal/config/db.go b/internal/config/db.go index 01d0d5f31..1a95d9829 100644 --- a/internal/config/db.go +++ b/internal/config/db.go @@ -73,7 +73,6 @@ func (c *Config) MigrateDb() { &entity.Camera{}, &entity.Lens{}, &entity.Country{}, - &entity.Album{}, &entity.PhotoAlbum{}, &entity.Label{}, @@ -81,12 +80,49 @@ func (c *Config) MigrateDb() { &entity.PhotoLabel{}, &entity.Keyword{}, &entity.PhotoKeyword{}, + &entity.Link{}, ) entity.CreateUnknownPlace(db) entity.CreateUnknownCountry(db) } +// DropTables drops all tables in the currently configured database (be careful!). +func (c *Config) DropTables() { + db := c.Db() + + logLevel := log.Level + + log.SetLevel(logrus.FatalLevel) + db.SetLogger(log) + db.LogMode(false) + + db.DropTableIfExists( + &entity.Account{}, + &entity.File{}, + &entity.FileShare{}, + &entity.FileSync{}, + &entity.Photo{}, + &entity.Description{}, + &entity.Event{}, + &entity.Place{}, + &entity.Location{}, + &entity.Camera{}, + &entity.Lens{}, + &entity.Country{}, + &entity.Album{}, + &entity.PhotoAlbum{}, + &entity.Label{}, + &entity.Category{}, + &entity.PhotoLabel{}, + &entity.Keyword{}, + &entity.PhotoKeyword{}, + &entity.Link{}, + ) + + log.SetLevel(logLevel) +} + // connectToDatabase establishes a database connection. // When used with the internal driver, it may create a new database server instance. // It tries to do this 12 times with a 5 second sleep interval in between. @@ -153,42 +189,6 @@ func (c *Config) connectToDatabase(ctx context.Context) error { return err } -// DropTables drops all tables in the currently configured database (be careful!). -func (c *Config) DropTables() { - db := c.Db() - - logLevel := log.Level - - log.SetLevel(logrus.FatalLevel) - db.SetLogger(log) - db.LogMode(false) - - db.DropTableIfExists( - &entity.Account{}, - &entity.File{}, - &entity.FileShare{}, - &entity.FileSync{}, - &entity.Photo{}, - &entity.Description{}, - &entity.Event{}, - &entity.Place{}, - &entity.Location{}, - &entity.Camera{}, - &entity.Lens{}, - &entity.Country{}, - - &entity.Album{}, - &entity.PhotoAlbum{}, - &entity.Label{}, - &entity.Category{}, - &entity.PhotoLabel{}, - &entity.Keyword{}, - &entity.PhotoKeyword{}, - ) - - log.SetLevel(logLevel) -} - // ImportSQL imports a file to the currently configured database. func (c *Config) ImportSQL(filename string) { contents, err := ioutil.ReadFile(filename) diff --git a/internal/entity/album.go b/internal/entity/album.go index 2e0fda4d9..02361cca6 100644 --- a/internal/entity/album.go +++ b/internal/entity/album.go @@ -1,7 +1,6 @@ package entity import ( - "database/sql" "strings" "time" @@ -19,11 +18,10 @@ type Album struct { AlbumName string `gorm:"type:varchar(128);"` AlbumDescription string `gorm:"type:text;"` AlbumNotes string `gorm:"type:text;"` - AlbumFavorite bool AlbumOrder string `gorm:"type:varbinary(32);"` - ShareTemplate string `gorm:"type:varbinary(256);"` - SharePassword string `gorm:"type:varbinary(256);"` - ShareExpires sql.NullTime + AlbumTemplate string `gorm:"type:varbinary(256);"` + AlbumFavorite bool + Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:AlbumUUID"` CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time `sql:"index"` diff --git a/internal/entity/file.go b/internal/entity/file.go index b7154ba7d..163955b87 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -40,8 +40,9 @@ type File struct { FileChroma uint FileNotes string `gorm:"type:text"` FileError string `gorm:"type:varbinary(512)"` - FileShare []FileShare - FileSync []FileSync + Share []FileShare + Sync []FileSync + Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:FileUUID"` CreatedAt time.Time CreatedIn int64 UpdatedAt time.Time diff --git a/internal/entity/label.go b/internal/entity/label.go index f355ac432..1b4516f03 100644 --- a/internal/entity/label.go +++ b/internal/entity/label.go @@ -22,6 +22,7 @@ type Label struct { LabelDescription string `gorm:"type:text;"` LabelNotes string `gorm:"type:text;"` LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"` + Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:LabelUUID"` CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time `sql:"index"` diff --git a/internal/entity/link.go b/internal/entity/link.go new file mode 100644 index 000000000..b065821b2 --- /dev/null +++ b/internal/entity/link.go @@ -0,0 +1,42 @@ +package entity + +import ( + "time" + + "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// Link represents a sharing link. +type Link struct { + LinkToken string `gorm:"type:varbinary(256);primary_key;"` + LinkPassword string `gorm:"type:varbinary(256);"` + LinkExpires *time.Time `gorm:"type:datetime;"` + ShareUUID string `gorm:"type:varbinary(36);index;"` + CanComment bool + CanEdit bool + CreatedAt time.Time `deepcopier:"skip"` + UpdatedAt time.Time `deepcopier:"skip"` + DeletedAt *time.Time `deepcopier:"skip" sql:"index"` +} + +// BeforeCreate creates a new URL token when a new link is created. +func (m *Link) BeforeCreate(scope *gorm.Scope) error { + if err := scope.SetColumn("LinkToken", rnd.Token(10)); err != nil { + return err + } + + return nil +} + +// NewLink creates a sharing link. +func NewLink(password string, canComment, canEdit bool) Link { + result := Link{ + LinkToken: rnd.Token(10), + LinkPassword: password, + CanComment: canComment, + CanEdit: canEdit, + } + + return result +} diff --git a/internal/entity/photo.go b/internal/entity/photo.go index 085ae19b8..7dbd48264 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -51,6 +51,7 @@ type Photo struct { Lens *Lens `json:"Lens"` Location *Location `json:"-"` Place *Place `json:"-"` + Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:PhotoUUID"` Keywords []Keyword `json:"-"` Albums []Album `json:"-"` Files []File diff --git a/internal/form/new_link.go b/internal/form/new_link.go new file mode 100644 index 000000000..e2ae1514c --- /dev/null +++ b/internal/form/new_link.go @@ -0,0 +1,9 @@ +package form + +// Link represents a sharing link form. +type NewLink struct { + Password string `json:"password"` + Expires int `json:"expires"` + CanComment bool `json:"comment"` + CanEdit bool `json:"edit"` +} diff --git a/internal/query/album.go b/internal/query/album.go index 4310d3569..34f7d1e6a 100644 --- a/internal/query/album.go +++ b/internal/query/album.go @@ -23,11 +23,12 @@ type AlbumResult struct { AlbumFavorite bool AlbumDescription string AlbumNotes string + LinkCount int } // AlbumByUUID returns a Album based on the UUID. func (q *Query) AlbumByUUID(albumUUID string) (album entity.Album, err error) { - if err := q.db.Where("album_uuid = ?", albumUUID).First(&album).Error; err != nil { + if err := q.db.Where("album_uuid = ?", albumUUID).Preload("Links").First(&album).Error; err != nil { return album, err } @@ -59,8 +60,11 @@ func (q *Query) Albums(f form.AlbumSearch) (results []AlbumResult, err error) { s := q.db.NewScope(nil).DB() s = s.Table("albums"). - Select(`albums.*, COUNT(photos_albums.album_uuid) AS album_count`). + Select(`albums.*, + COUNT(photos_albums.album_uuid) AS album_count, + COUNT(links.link_token) AS link_count`). Joins("LEFT JOIN photos_albums ON photos_albums.album_uuid = albums.album_uuid"). + Joins("LEFT JOIN links ON links.share_uuid = albums.album_uuid"). Where("albums.deleted_at IS NULL"). Group("albums.id") diff --git a/internal/query/file.go b/internal/query/file.go index a583b1f4b..482220ca2 100644 --- a/internal/query/file.go +++ b/internal/query/file.go @@ -23,16 +23,16 @@ func (q *Query) FilesByUUID(u []string, limit int, offset int) (files []entity.F // FileByPhotoUUID func (q *Query) FileByPhotoUUID(u string) (file entity.File, err error) { - if err := q.db.Where("photo_uuid = ? AND file_primary = 1", u).Preload("Photo").First(&file).Error; err != nil { + if err := q.db.Where("photo_uuid = ? AND file_primary = 1", u).Preload("Links").Preload("Photo").First(&file).Error; err != nil { return file, err } return file, nil } -// FileByID returns a MediaFile given a certain ID. -func (q *Query) FileByID(id string) (file entity.File, err error) { - if err := q.db.Where("id = ?", id).Preload("Photo").First(&file).Error; err != nil { +// FileByUUID returns the file entity for a given UUID. +func (q *Query) FileByUUID(uuid string) (file entity.File, err error) { + if err := q.db.Where("file_uuid = ?", uuid).Preload("Links").Preload("Photo").First(&file).Error; err != nil { return file, err } @@ -41,7 +41,7 @@ func (q *Query) FileByID(id string) (file entity.File, err error) { // FirstFileByHash finds a file with a given hash string. func (q *Query) FileByHash(fileHash string) (file entity.File, err error) { - if err := q.db.Where("file_hash = ?", fileHash).Preload("Photo").First(&file).Error; err != nil { + if err := q.db.Where("file_hash = ?", fileHash).Preload("Links").Preload("Photo").First(&file).Error; err != nil { return file, err } diff --git a/internal/query/file_test.go b/internal/query/file_test.go index 7036bbe17..39dc3313a 100644 --- a/internal/query/file_test.go +++ b/internal/query/file_test.go @@ -54,20 +54,27 @@ func TestQuery_FileByPhotoUUID(t *testing.T) { }) } -func TestQuery_FileByID(t *testing.T) { +func TestQuery_FileByUUID(t *testing.T) { conf := config.TestConfig() search := New(conf.Db()) t.Run("files found", func(t *testing.T) { - file, err := search.FileByID("3") + file, err := search.FileByUUID("fq8es39w45bnlqdw") - assert.Nil(t, err) - assert.Equal(t, "exampleXmpFile.xmp", file.FileName) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "exampleFileName.jpg", file.FileName) }) t.Run("no files found", func(t *testing.T) { - file, err := search.FileByID("111") + file, err := search.FileByUUID("111") + + if err == nil { + t.Fatal("error expected") + } assert.Error(t, err, "record not found") t.Log(file) diff --git a/internal/query/label.go b/internal/query/label.go index 65053dc05..aac61daff 100644 --- a/internal/query/label.go +++ b/internal/query/label.go @@ -30,7 +30,7 @@ type LabelResult struct { // LabelBySlug returns a Label based on the slug name. func (q *Query) LabelBySlug(labelSlug string) (label entity.Label, err error) { - if err := q.db.Where("label_slug = ?", labelSlug).First(&label).Error; err != nil { + if err := q.db.Where("label_slug = ?", labelSlug).Preload("Links").First(&label).Error; err != nil { return label, err } @@ -39,7 +39,7 @@ func (q *Query) LabelBySlug(labelSlug string) (label entity.Label, err error) { // LabelByUUID returns a Label based on the label UUID. func (q *Query) LabelByUUID(labelUUID string) (label entity.Label, err error) { - if err := q.db.Where("label_uuid = ?", labelUUID).First(&label).Error; err != nil { + if err := q.db.Where("label_uuid = ?", labelUUID).Preload("Links").First(&label).Error; err != nil { return label, err } diff --git a/internal/query/photo.go b/internal/query/photo.go index b2c2c068e..9dd66bce1 100644 --- a/internal/query/photo.go +++ b/internal/query/photo.go @@ -337,7 +337,7 @@ func (q *Query) Photos(f form.PhotoSearch) (results []PhotoResult, err error) { // PhotoByID returns a Photo based on the ID. func (q *Query) PhotoByID(photoID uint64) (photo entity.Photo, err error) { - if err := q.db.Where("id = ?", photoID).Preload("Description").First(&photo).Error; err != nil { + if err := q.db.Where("id = ?", photoID).Preload("Links").Preload("Description").First(&photo).Error; err != nil { return photo, err } @@ -346,7 +346,7 @@ func (q *Query) PhotoByID(photoID uint64) (photo entity.Photo, err error) { // PhotoByUUID returns a Photo based on the UUID. func (q *Query) PhotoByUUID(photoUUID string) (photo entity.Photo, err error) { - if err := q.db.Where("photo_uuid = ?", photoUUID).Preload("Description").First(&photo).Error; err != nil { + if err := q.db.Where("photo_uuid = ?", photoUUID).Preload("Links").Preload("Description").First(&photo).Error; err != nil { return photo, err } @@ -362,6 +362,7 @@ func (q *Query) PreloadPhotoByUUID(photoUUID string) (photo entity.Photo, err er Preload("Labels.Label"). Preload("Camera"). Preload("Lens"). + Preload("Links"). Preload("Description"). First(&photo).Error; err != nil { return photo, err diff --git a/internal/server/routes.go b/internal/server/routes.go index 9cf6ff799..f803f9d76 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -32,15 +32,18 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.UpdatePhoto(v1, conf) api.GetPhotos(v1, conf) api.GetPhotoDownload(v1, conf) + api.LinkPhoto(v1, conf) api.LikePhoto(v1, conf) api.DislikePhoto(v1, conf) api.AddPhotoLabel(v1, conf) api.RemovePhotoLabel(v1, conf) api.GetMomentsTime(v1, conf) api.GetFile(v1, conf) + api.LinkFile(v1, conf) api.GetLabels(v1, conf) api.UpdateLabel(v1, conf) + api.LinkLabel(v1, conf) api.LikeLabel(v1, conf) api.DislikeLabel(v1, conf) api.LabelThumbnail(v1, conf) @@ -64,6 +67,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.DeleteAlbum(v1, conf) api.DownloadAlbum(v1, conf) api.GetAlbums(v1, conf) + api.LinkAlbum(v1, conf) api.LikeAlbum(v1, conf) api.DislikeAlbum(v1, conf) api.AlbumThumbnail(v1, conf)