Initial API and entities for link sharing

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-04-08 13:24:06 +02:00
parent f1e2d86e7c
commit a8c48ab40e
24 changed files with 419 additions and 82 deletions

View file

@ -88,23 +88,23 @@ acceptance-firefox:
(cd frontend && npm run acceptance-firefox) (cd frontend && npm run acceptance-firefox)
test-go: test-go:
$(info Running all Go unit tests...) $(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: test-verbose:
$(info Running all Go unit tests in verbose mode...) $(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: test-short:
$(info Running short Go unit tests in verbose mode...) $(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: test-race:
$(info Running all Go unit tests with race detection in verbose mode...) $(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: test-codecov:
$(info Running all Go unit tests with code coverage report for 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 scripts/codecov.sh
test-coverage: test-coverage:
$(info Running all Go unit tests with code coverage report...) $(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 go tool cover -html=coverage.txt -o coverage.html
clean: clean:
rm -f $(BINARY_NAME) rm -f $(BINARY_NAME)

View file

@ -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 (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 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 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, 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 (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, 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 ('4', '654');
INSERT INTO photos_albums (album_uuid, photo_uuid) VALUES ('5', '658'); 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_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_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_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_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_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_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_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_name, file_primary, file_hash, file_missing) VALUES ('5', '6', '659', 'reunion.jpg', 1, '127xxx', 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 (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 (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 (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, 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 (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 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 (1, 'bridge', 0);
INSERT INTO keywords (id, keyword, skip) VALUES (2, 'beach', 0); INSERT INTO keywords (id, keyword, skip) VALUES (2, 'beach', 0);
INSERT INTO photos_keywords (photo_id, keyword_id) VALUES (5, 1); INSERT INTO photos_keywords (photo_id, keyword_id) VALUES (5, 1);

View file

@ -114,6 +114,14 @@ class Abstract {
return this.constructor.getModelName() + " " + this.getId(); 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() { static getCollectionResource() {
throw new Error("getCollectionResource() needs to be implemented"); throw new Error("getCollectionResource() needs to be implemented");
} }

View file

@ -20,6 +20,7 @@ class Album extends Abstract {
AlbumRadius: 0, AlbumRadius: 0,
AlbumOrder: "", AlbumOrder: "",
AlbumTemplate: "", AlbumTemplate: "",
Links: [],
CreatedAt: "", CreatedAt: "",
UpdatedAt: "", UpdatedAt: "",
DeletedAt: null, DeletedAt: null,

View file

@ -17,6 +17,7 @@ class Label extends Abstract {
LabelFavorite: false, LabelFavorite: false,
LabelDescription: "", LabelDescription: "",
LabelNotes: "", LabelNotes: "",
Links: [],
}; };
} }

View file

@ -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;

View file

@ -56,6 +56,7 @@ class Photo extends Abstract {
Labels: [], Labels: [],
Keywords: [], Keywords: [],
Albums: [], Albums: [],
Links: [],
CreatedAt: "", CreatedAt: "",
UpdatedAt: "", UpdatedAt: "",
DeletedAt: null, DeletedAt: null,

View file

@ -3,12 +3,13 @@ package api
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config" "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) { func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config) {
conf = config.TestConfig() conf = config.TestConfig()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@ -17,6 +18,7 @@ func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config
return app, router, conf return app, router, conf
} }
// Performs API request with empty request body.
// See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1 // See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder { func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil) req, _ := http.NewRequest(method, path, nil)
@ -24,3 +26,12 @@ func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecor
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
return w 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
}

View file

@ -5,13 +5,15 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
) )
// GET /api/v1/files/:hash // GET /api/v1/files/:hash
// //
// Parameters: // 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) { func GetFile(router *gin.RouterGroup, conf *config.Config) {
router.GET("/files/:hash", func(c *gin.Context) { router.GET("/files/:hash", func(c *gin.Context) {
if Unauthorized(c, conf) { if Unauthorized(c, conf) {
@ -30,3 +32,37 @@ func GetFile(router *gin.RouterGroup, conf *config.Config) {
c.JSON(http.StatusOK, p) 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)
})
}

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

@ -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)
})
}

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

@ -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")
}
})
}

View file

@ -73,7 +73,6 @@ func (c *Config) MigrateDb() {
&entity.Camera{}, &entity.Camera{},
&entity.Lens{}, &entity.Lens{},
&entity.Country{}, &entity.Country{},
&entity.Album{}, &entity.Album{},
&entity.PhotoAlbum{}, &entity.PhotoAlbum{},
&entity.Label{}, &entity.Label{},
@ -81,12 +80,49 @@ func (c *Config) MigrateDb() {
&entity.PhotoLabel{}, &entity.PhotoLabel{},
&entity.Keyword{}, &entity.Keyword{},
&entity.PhotoKeyword{}, &entity.PhotoKeyword{},
&entity.Link{},
) )
entity.CreateUnknownPlace(db) entity.CreateUnknownPlace(db)
entity.CreateUnknownCountry(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. // connectToDatabase establishes a database connection.
// When used with the internal driver, it may create a new database server instance. // 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. // 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 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. // ImportSQL imports a file to the currently configured database.
func (c *Config) ImportSQL(filename string) { func (c *Config) ImportSQL(filename string) {
contents, err := ioutil.ReadFile(filename) contents, err := ioutil.ReadFile(filename)

View file

@ -1,7 +1,6 @@
package entity package entity
import ( import (
"database/sql"
"strings" "strings"
"time" "time"
@ -19,11 +18,10 @@ type Album struct {
AlbumName string `gorm:"type:varchar(128);"` AlbumName string `gorm:"type:varchar(128);"`
AlbumDescription string `gorm:"type:text;"` AlbumDescription string `gorm:"type:text;"`
AlbumNotes string `gorm:"type:text;"` AlbumNotes string `gorm:"type:text;"`
AlbumFavorite bool
AlbumOrder string `gorm:"type:varbinary(32);"` AlbumOrder string `gorm:"type:varbinary(32);"`
ShareTemplate string `gorm:"type:varbinary(256);"` AlbumTemplate string `gorm:"type:varbinary(256);"`
SharePassword string `gorm:"type:varbinary(256);"` AlbumFavorite bool
ShareExpires sql.NullTime Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:AlbumUUID"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"` DeletedAt *time.Time `sql:"index"`

View file

@ -40,8 +40,9 @@ type File struct {
FileChroma uint FileChroma uint
FileNotes string `gorm:"type:text"` FileNotes string `gorm:"type:text"`
FileError string `gorm:"type:varbinary(512)"` FileError string `gorm:"type:varbinary(512)"`
FileShare []FileShare Share []FileShare
FileSync []FileSync Sync []FileSync
Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:FileUUID"`
CreatedAt time.Time CreatedAt time.Time
CreatedIn int64 CreatedIn int64
UpdatedAt time.Time UpdatedAt time.Time

View file

@ -22,6 +22,7 @@ type Label struct {
LabelDescription string `gorm:"type:text;"` LabelDescription string `gorm:"type:text;"`
LabelNotes string `gorm:"type:text;"` LabelNotes string `gorm:"type:text;"`
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"` LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"`
Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:LabelUUID"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"` DeletedAt *time.Time `sql:"index"`

42
internal/entity/link.go Normal file
View file

@ -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
}

View file

@ -51,6 +51,7 @@ type Photo struct {
Lens *Lens `json:"Lens"` Lens *Lens `json:"Lens"`
Location *Location `json:"-"` Location *Location `json:"-"`
Place *Place `json:"-"` Place *Place `json:"-"`
Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:PhotoUUID"`
Keywords []Keyword `json:"-"` Keywords []Keyword `json:"-"`
Albums []Album `json:"-"` Albums []Album `json:"-"`
Files []File Files []File

View file

@ -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"`
}

View file

@ -23,11 +23,12 @@ type AlbumResult struct {
AlbumFavorite bool AlbumFavorite bool
AlbumDescription string AlbumDescription string
AlbumNotes string AlbumNotes string
LinkCount int
} }
// AlbumByUUID returns a Album based on the UUID. // AlbumByUUID returns a Album based on the UUID.
func (q *Query) AlbumByUUID(albumUUID string) (album entity.Album, err error) { 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 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 := q.db.NewScope(nil).DB()
s = s.Table("albums"). 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 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"). Where("albums.deleted_at IS NULL").
Group("albums.id") Group("albums.id")

View file

@ -23,16 +23,16 @@ func (q *Query) FilesByUUID(u []string, limit int, offset int) (files []entity.F
// FileByPhotoUUID // FileByPhotoUUID
func (q *Query) FileByPhotoUUID(u string) (file entity.File, err error) { 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, err
} }
return file, nil return file, nil
} }
// FileByID returns a MediaFile given a certain ID. // FileByUUID returns the file entity for a given UUID.
func (q *Query) FileByID(id string) (file entity.File, err error) { func (q *Query) FileByUUID(uuid string) (file entity.File, err error) {
if err := q.db.Where("id = ?", id).Preload("Photo").First(&file).Error; err != nil { if err := q.db.Where("file_uuid = ?", uuid).Preload("Links").Preload("Photo").First(&file).Error; err != nil {
return file, err 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. // FirstFileByHash finds a file with a given hash string.
func (q *Query) FileByHash(fileHash string) (file entity.File, err error) { 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 return file, err
} }

View file

@ -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() conf := config.TestConfig()
search := New(conf.Db()) search := New(conf.Db())
t.Run("files found", func(t *testing.T) { t.Run("files found", func(t *testing.T) {
file, err := search.FileByID("3") file, err := search.FileByUUID("fq8es39w45bnlqdw")
assert.Nil(t, err) if err != nil {
assert.Equal(t, "exampleXmpFile.xmp", file.FileName) t.Fatal(err)
}
assert.Equal(t, "exampleFileName.jpg", file.FileName)
}) })
t.Run("no files found", func(t *testing.T) { 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") assert.Error(t, err, "record not found")
t.Log(file) t.Log(file)

View file

@ -30,7 +30,7 @@ type LabelResult struct {
// LabelBySlug returns a Label based on the slug name. // LabelBySlug returns a Label based on the slug name.
func (q *Query) LabelBySlug(labelSlug string) (label entity.Label, err error) { 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 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. // LabelByUUID returns a Label based on the label UUID.
func (q *Query) LabelByUUID(labelUUID string) (label entity.Label, err error) { 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 return label, err
} }

View file

@ -337,7 +337,7 @@ func (q *Query) Photos(f form.PhotoSearch) (results []PhotoResult, err error) {
// PhotoByID returns a Photo based on the ID. // PhotoByID returns a Photo based on the ID.
func (q *Query) PhotoByID(photoID uint64) (photo entity.Photo, err error) { 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 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. // PhotoByUUID returns a Photo based on the UUID.
func (q *Query) PhotoByUUID(photoUUID string) (photo entity.Photo, err error) { 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 return photo, err
} }
@ -362,6 +362,7 @@ func (q *Query) PreloadPhotoByUUID(photoUUID string) (photo entity.Photo, err er
Preload("Labels.Label"). Preload("Labels.Label").
Preload("Camera"). Preload("Camera").
Preload("Lens"). Preload("Lens").
Preload("Links").
Preload("Description"). Preload("Description").
First(&photo).Error; err != nil { First(&photo).Error; err != nil {
return photo, err return photo, err

View file

@ -32,15 +32,18 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.UpdatePhoto(v1, conf) api.UpdatePhoto(v1, conf)
api.GetPhotos(v1, conf) api.GetPhotos(v1, conf)
api.GetPhotoDownload(v1, conf) api.GetPhotoDownload(v1, conf)
api.LinkPhoto(v1, conf)
api.LikePhoto(v1, conf) api.LikePhoto(v1, conf)
api.DislikePhoto(v1, conf) api.DislikePhoto(v1, conf)
api.AddPhotoLabel(v1, conf) api.AddPhotoLabel(v1, conf)
api.RemovePhotoLabel(v1, conf) api.RemovePhotoLabel(v1, conf)
api.GetMomentsTime(v1, conf) api.GetMomentsTime(v1, conf)
api.GetFile(v1, conf) api.GetFile(v1, conf)
api.LinkFile(v1, conf)
api.GetLabels(v1, conf) api.GetLabels(v1, conf)
api.UpdateLabel(v1, conf) api.UpdateLabel(v1, conf)
api.LinkLabel(v1, conf)
api.LikeLabel(v1, conf) api.LikeLabel(v1, conf)
api.DislikeLabel(v1, conf) api.DislikeLabel(v1, conf)
api.LabelThumbnail(v1, conf) api.LabelThumbnail(v1, conf)
@ -64,6 +67,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteAlbum(v1, conf) api.DeleteAlbum(v1, conf)
api.DownloadAlbum(v1, conf) api.DownloadAlbum(v1, conf)
api.GetAlbums(v1, conf) api.GetAlbums(v1, conf)
api.LinkAlbum(v1, conf)
api.LikeAlbum(v1, conf) api.LikeAlbum(v1, conf)
api.DislikeAlbum(v1, conf) api.DislikeAlbum(v1, conf)
api.AlbumThumbnail(v1, conf) api.AlbumThumbnail(v1, conf)