精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MyAlbums(db *gorm.DB, user *models.User, order *models.Ordering, paginate *models.Pagination,
|
||||
onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) {
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(user.Albums) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.Model(models.Album{}).Where("id IN (?)", userAlbumIDs)
|
||||
|
||||
if onlyRoot != nil && *onlyRoot {
|
||||
|
||||
singleRootAlbumID := getSingleRootAlbumID(user)
|
||||
|
||||
if singleRootAlbumID != -1 && len(user.Albums) > 1 {
|
||||
query = query.Where("parent_album_id = ?", singleRootAlbumID)
|
||||
} else {
|
||||
query = query.Where("parent_album_id IS NULL OR parent_album_id NOT IN (?)", userAlbumIDs)
|
||||
}
|
||||
}
|
||||
|
||||
query = favoritesQuery(showEmpty, db, onlyWithFavorites, user, query)
|
||||
|
||||
query = models.FormatSQL(query, order, paginate)
|
||||
|
||||
var albums []*models.Album
|
||||
if err := query.Find(&albums).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func getSingleRootAlbumID(user *models.User) int {
|
||||
var singleRootAlbumID int = -1
|
||||
for _, album := range user.Albums {
|
||||
if album.ParentAlbumID == nil {
|
||||
if singleRootAlbumID == -1 {
|
||||
singleRootAlbumID = album.ID
|
||||
} else {
|
||||
singleRootAlbumID = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return singleRootAlbumID
|
||||
}
|
||||
|
||||
func favoritesQuery(showEmpty *bool, db *gorm.DB, onlyWithFavorites *bool, user *models.User, query *gorm.DB) *gorm.DB {
|
||||
if showEmpty == nil || !*showEmpty {
|
||||
subQuery := db.Model(&models.Media{}).Where("album_id = albums.id")
|
||||
|
||||
if onlyWithFavorites != nil && *onlyWithFavorites {
|
||||
favoritesSubquery := db.
|
||||
Model(&models.UserMediaData{UserID: user.ID}).
|
||||
Where("user_media_data.media_id = media.id").
|
||||
Where("user_media_data.favorite = true")
|
||||
|
||||
subQuery = subQuery.Where("EXISTS (?)", favoritesSubquery)
|
||||
}
|
||||
|
||||
query = query.Where("EXISTS (?)", subQuery)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func Album(db *gorm.DB, user *models.User, id int) (*models.Album, error) {
|
||||
var album models.Album
|
||||
if err := db.First(&album, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("album not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func AlbumPath(db *gorm.DB, user *models.User, album *models.Album) ([]*models.Album, error) {
|
||||
var albumPath []*models.Album
|
||||
|
||||
err := db.Raw(`
|
||||
WITH recursive path_albums AS (
|
||||
SELECT * FROM albums anchor WHERE anchor.id = ?
|
||||
UNION
|
||||
SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id
|
||||
)
|
||||
SELECT * FROM path_albums WHERE id != ?
|
||||
`, album.ID, album.ID).Scan(&albumPath).Error
|
||||
|
||||
// Make sure to only return albums this user owns
|
||||
for i := len(albumPath) - 1; i >= 0; i-- {
|
||||
album := albumPath[i]
|
||||
|
||||
owns, err := user.OwnsAlbum(db, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !owns {
|
||||
albumPath = albumPath[i+1:]
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return albumPath, nil
|
||||
}
|
||||
|
||||
func SetAlbumCover(db *gorm.DB, user *models.User, mediaID int) (*models.Album, error) {
|
||||
var media models.Media
|
||||
|
||||
if err := db.Find(&media, mediaID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var album models.Album
|
||||
|
||||
if err := db.Find(&album, &media.AlbumID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
if err := db.Model(&album).Update("cover_id", mediaID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func ResetAlbumCover(db *gorm.DB, user *models.User, albumID int) (*models.Album, error) {
|
||||
var album models.Album
|
||||
if err := db.Find(&album, albumID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownsAlbum, err := user.OwnsAlbum(db, &album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ownsAlbum {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
|
||||
if err := db.Model(&album).Update("cover_id", nil).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &album, nil
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAlbumPath(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
album := models.Album{
|
||||
Title: "Three",
|
||||
Path: "/one/two/three",
|
||||
ParentAlbum: &models.Album{
|
||||
Title: "Two",
|
||||
Path: "/one/two",
|
||||
ParentAlbum: &models.Album{
|
||||
Title: "One",
|
||||
Path: "/one",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&album).Error)
|
||||
|
||||
user, err := models.RegisterUser(db, "user", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
db.Model(&user).Association("Albums").Append(album.ParentAlbum.ParentAlbum)
|
||||
|
||||
albumPath, err := actions.AlbumPath(db, user, &album)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, albumPath, 2)
|
||||
assert.Equal(t, "Two", albumPath[0].Title)
|
||||
assert.Equal(t, "One", albumPath[1].Title)
|
||||
}
|
||||
|
||||
func TestAlbumCover(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&rootAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
children := []models.Album{
|
||||
{
|
||||
Title: "child1",
|
||||
Path: "/photos/child1",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/photos/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&children).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
photos := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic2",
|
||||
Path: "/photos/pic2",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/child1/pic3",
|
||||
AlbumID: children[0].ID,
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/child1/pic4",
|
||||
AlbumID: children[0].ID,
|
||||
},
|
||||
{
|
||||
Title: "pic5",
|
||||
Path: "/photos/child2/pic5",
|
||||
AlbumID: children[1].ID,
|
||||
},
|
||||
{
|
||||
Title: "pic6",
|
||||
Path: "/photos/child2/pic6",
|
||||
AlbumID: children[1].ID,
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&photos).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&children[0]).Update("cover_id", &photos[3].ID).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
photoUrls := []models.MediaURL{
|
||||
{
|
||||
MediaID: photos[0].ID,
|
||||
Media: &photos[0],
|
||||
},
|
||||
{
|
||||
MediaID: photos[1].ID,
|
||||
Media: &photos[1],
|
||||
},
|
||||
{
|
||||
MediaID: photos[2].ID,
|
||||
Media: &photos[2],
|
||||
},
|
||||
{
|
||||
MediaID: photos[3].ID,
|
||||
Media: &photos[3],
|
||||
},
|
||||
{
|
||||
MediaID: photos[4].ID,
|
||||
Media: &photos[4],
|
||||
},
|
||||
{
|
||||
MediaID: photos[5].ID,
|
||||
Media: &photos[5],
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&photoUrls).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
userPass := "password"
|
||||
regularUser, err := models.RegisterUser(db, "user1", &userPass, false)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(®ularUser).Association("Albums").Append(&rootAlbum)) {
|
||||
return
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(®ularUser).Association("Albums").Append(&children)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Single test since we cannot rely on the tests being performed sequentially
|
||||
t.Run("Album get and reset cover photos", func(t *testing.T) {
|
||||
{
|
||||
album, err := actions.Album(db, regularUser, rootAlbum.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
albumThumb, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should return the latest photo since no coverID has been set
|
||||
assert.EqualValues(t, "pic6", albumThumb.Title)
|
||||
}
|
||||
|
||||
{
|
||||
album, err := actions.Album(db, regularUser, children[0].ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
albumThumb, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// coverID has already been set
|
||||
assert.EqualValues(t, "pic4", albumThumb.Title)
|
||||
}
|
||||
|
||||
resetAlbum, err := actions.ResetAlbumCover(db, regularUser, children[0].ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Nil(t, resetAlbum.CoverID)
|
||||
|
||||
resetThumb, err := resetAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "pic4", resetThumb.Title)
|
||||
})
|
||||
|
||||
t.Run("Album change cover photos", func(t *testing.T) {
|
||||
assert.Nil(t, children[1].CoverID)
|
||||
|
||||
album, err := actions.SetAlbumCover(db, regularUser, photos[4].ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, children[1].ID, album.ID)
|
||||
assert.NotNil(t, album.CoverID)
|
||||
assert.Equal(t, photos[4].ID, *album.CoverID)
|
||||
|
||||
albumThumb, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, photos[4].ID, albumThumb.ID)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestAlbumsSingleRootExpand(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
boolFalse := false
|
||||
boolTrue := true
|
||||
|
||||
unrelatedAlbum := models.Album{
|
||||
Title: "unrelated_album",
|
||||
Path: "/another_place",
|
||||
}
|
||||
err := db.Create(&unrelatedAlbum).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
user, err := models.RegisterUser(db, "user", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/root",
|
||||
}
|
||||
|
||||
err = db.Model(&user).Association("Albums").Replace(&rootAlbum)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("Single root album, no children", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
})
|
||||
|
||||
childAlbums := []models.Album{
|
||||
{
|
||||
Title: "child1",
|
||||
Path: "/root/child1",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/root/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child3",
|
||||
Path: "/root/child3",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
err = db.Model(&user).Association("Albums").Append(&childAlbums)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("Single root album, multiple children", func(t *testing.T) {
|
||||
|
||||
returnedAlbums, err := actions.MyAlbums(db, user, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 3)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Related to #658
|
||||
func TestNonRootAlbumPath(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
boolTrue := true
|
||||
boolFalse := false
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/root",
|
||||
}
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "child",
|
||||
Path: "/root/child",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Create(&rootAlbum).Error)
|
||||
|
||||
// Register user
|
||||
user, err := models.RegisterUser(db, "user", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Assign album to user
|
||||
err = db.Model(&user).Association("Albums").Append(&childAlbum)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// The child album is a "local root album" for the user, as it does not have access to the root album
|
||||
t.Run("User should only see child album", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
assert.Equal(t, "child", returnedAlbums[0].Title)
|
||||
})
|
||||
}
|
||||
|
||||
// Related to #658
|
||||
func TestNonRootAlbumPathMultipleUsers(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
boolTrue := true
|
||||
boolFalse := false
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/root",
|
||||
}
|
||||
|
||||
child1Album := models.Album{
|
||||
Title: "child1",
|
||||
Path: "/root/child1",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
child2Album := models.Album{
|
||||
Title: "child2",
|
||||
Path: "/root/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
// Admin should have access to all albums
|
||||
adminAlbums := []*models.Album{&rootAlbum, &child1Album, &child2Album}
|
||||
|
||||
assert.NoError(t, db.Create(&rootAlbum).Error)
|
||||
|
||||
// Register users
|
||||
admin, err := models.RegisterUser(db, "admin", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
user1, err := models.RegisterUser(db, "user1", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
user2, err := models.RegisterUser(db, "user2", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Assign albums to users
|
||||
err = db.Model(&admin).Association("Albums").Append(&adminAlbums)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = db.Model(&user1).Association("Albums").Append(&child1Album)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = db.Model(&user2).Association("Albums").Append(&child2Album)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("Admin should see all albums", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, admin, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 2)
|
||||
assert.Equal(t, "child1", returnedAlbums[0].Title)
|
||||
assert.Equal(t, "child2", returnedAlbums[1].Title)
|
||||
})
|
||||
|
||||
t.Run("User 1 should only see child1 album", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user1, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
assert.Equal(t, "child1", returnedAlbums[0].Title)
|
||||
})
|
||||
|
||||
t.Run("User 2 should only see child2 album", func(t *testing.T) {
|
||||
returnedAlbums, err := actions.MyAlbums(db, user2, nil, nil, &boolTrue, &boolTrue, &boolFalse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, returnedAlbums, 1)
|
||||
assert.Equal(t, "child2", returnedAlbums[0].Title)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MyMedia(db *gorm.DB, user *models.User, order *models.Ordering, paginate *models.Pagination) ([]*models.Media, error) {
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := db.Where("media.album_id IN (SELECT user_albums.album_id FROM user_albums WHERE user_albums.user_id = ?)",
|
||||
user.ID)
|
||||
query = models.FormatSQL(query, order, paginate)
|
||||
|
||||
var media []*models.Media
|
||||
if err := query.Find(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return media, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMyMedia(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "user", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "subalbum",
|
||||
Path: "/photos/subalbum",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&childAlbum).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum))
|
||||
|
||||
media := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic2",
|
||||
Path: "/photos/pic2",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/subalbum/pic3",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/subalbum/pic4",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
anotherUser, err := models.RegisterUser(db, "user2", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
anotherAlbum := models.Album{
|
||||
Title: "AnotherAlbum",
|
||||
Path: "/another",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherAlbum).Error)
|
||||
|
||||
anotherMedia := models.Media{
|
||||
Title: "anotherPic",
|
||||
Path: "/another/anotherPic",
|
||||
AlbumID: anotherAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherMedia).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&anotherUser).Association("Albums").Append(&anotherAlbum))
|
||||
|
||||
t.Run("Simple query", func(t *testing.T) {
|
||||
myMedia, err := actions.MyMedia(db, user, nil, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, myMedia, 4)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func Search(db *gorm.DB, query string, userID int, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
||||
limitMediaInternal := 10
|
||||
limitAlbumsInternal := 10
|
||||
|
||||
if limitMedia != nil {
|
||||
limitMediaInternal = *limitMedia
|
||||
}
|
||||
|
||||
if limitAlbums != nil {
|
||||
limitAlbumsInternal = *limitAlbums
|
||||
}
|
||||
|
||||
wildQuery := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
var media []*models.Media
|
||||
|
||||
userSubquery := db.Table("user_albums").Where("user_id = ?", userID)
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
userSubquery = userSubquery.Where("album_id = \"Album\".id")
|
||||
} else {
|
||||
userSubquery = userSubquery.Where("album_id = Album.id")
|
||||
}
|
||||
|
||||
err := db.Joins("Album").
|
||||
Where("EXISTS (?)", userSubquery).
|
||||
Where("LOWER(media.title) LIKE ? OR LOWER(media.path) LIKE ?", wildQuery, wildQuery).
|
||||
Clauses(clause.OrderBy{
|
||||
Expression: clause.Expr{
|
||||
SQL: "(CASE WHEN LOWER(media.title) LIKE ? THEN 2 WHEN LOWER(media.path) LIKE ? THEN 1 END) DESC",
|
||||
Vars: []interface{}{wildQuery, wildQuery},
|
||||
WithoutParentheses: true},
|
||||
}).
|
||||
Limit(limitMediaInternal).Find(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "searching media")
|
||||
}
|
||||
|
||||
var albums []*models.Album
|
||||
|
||||
err = db.
|
||||
Where("EXISTS (?)", db.Table("user_albums").Where("user_id = ?", userID).Where("album_id = albums.id")).
|
||||
Where("albums.title LIKE ? OR albums.path LIKE ?", wildQuery, wildQuery).
|
||||
Clauses(clause.OrderBy{
|
||||
Expression: clause.Expr{
|
||||
SQL: "(CASE WHEN albums.title LIKE ? THEN 2 WHEN albums.path LIKE ? THEN 1 END) DESC",
|
||||
Vars: []interface{}{wildQuery, wildQuery},
|
||||
WithoutParentheses: true},
|
||||
}).
|
||||
Limit(limitAlbumsInternal).
|
||||
Find(&albums).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "searching albums")
|
||||
}
|
||||
|
||||
result := models.SearchResult{
|
||||
Query: query,
|
||||
Media: media,
|
||||
Albums: albums,
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user, err := models.RegisterUser(db, "user", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root_album",
|
||||
Path: "/media/",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Create(&rootAlbum).Error)
|
||||
assert.NoError(t, db.Model(&rootAlbum).Association("Owners").Append(user))
|
||||
|
||||
type Result struct {
|
||||
ID int
|
||||
UserID int
|
||||
AlbumID int
|
||||
}
|
||||
|
||||
mediaTitles := []string{
|
||||
"SOME_IMAGE.jpg",
|
||||
"imageA.jpg",
|
||||
"imageB.jpg",
|
||||
"imageC.jpg",
|
||||
"movie.mp4",
|
||||
"person.png",
|
||||
"123.png",
|
||||
"ABC.gif",
|
||||
"dog.mov",
|
||||
"cat.mov",
|
||||
"IMG_3255.JPG",
|
||||
"IMG_5532.JPG",
|
||||
"IMG_5533.JPG",
|
||||
"IMG_5534.JPG",
|
||||
"IMG_5535.JPG",
|
||||
"IMG_5536.JPG",
|
||||
}
|
||||
|
||||
for _, mediaTitle := range mediaTitles {
|
||||
image := models.Media{
|
||||
Title: mediaTitle,
|
||||
Path: fmt.Sprintf("/media/%s", mediaTitle),
|
||||
AlbumID: rootAlbum.ID,
|
||||
}
|
||||
assert.NoError(t, db.Create(&image).Error)
|
||||
}
|
||||
|
||||
type SearchTest = struct {
|
||||
query string
|
||||
userID int
|
||||
limitMedia *int
|
||||
limitAlbum *int
|
||||
|
||||
expectedMediaCount int
|
||||
expectedAlbumCount int
|
||||
}
|
||||
|
||||
searchTests := []SearchTest{
|
||||
{
|
||||
query: "image",
|
||||
userID: user.ID,
|
||||
expectedMediaCount: 4,
|
||||
expectedAlbumCount: 0,
|
||||
},
|
||||
{
|
||||
query: "g",
|
||||
userID: user.ID,
|
||||
expectedMediaCount: 10,
|
||||
expectedAlbumCount: 0,
|
||||
},
|
||||
{
|
||||
query: "media",
|
||||
userID: user.ID,
|
||||
expectedMediaCount: 10,
|
||||
expectedAlbumCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range searchTests {
|
||||
t.Run(fmt.Sprintf("Search query: '%s'", test.query), func(t *testing.T) {
|
||||
result, err := actions.Search(db, test.query, test.userID, test.limitMedia, test.limitAlbum)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, result.Query, test.query)
|
||||
assert.Len(t, result.Albums, test.expectedAlbumCount)
|
||||
assert.Len(t, result.Media, test.expectedMediaCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddMediaShare(db *gorm.DB, user *models.User, mediaID int, expire *time.Time, password *string) (*models.ShareToken,
|
||||
error) {
|
||||
|
||||
var media models.Media
|
||||
|
||||
var query string
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
query = "EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = \"Album\".id AND user_albums.user_id = ?)"
|
||||
} else {
|
||||
query = "EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = Album.id AND user_albums.user_id = ?)"
|
||||
}
|
||||
|
||||
err := db.Joins("Album").
|
||||
Where(query, user.ID).
|
||||
First(&media, mediaID).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, auth.ErrUnauthorized
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "failed to validate media owner with database")
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := hashSharePassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shareToken := models.ShareToken{
|
||||
Value: utils.GenerateToken(),
|
||||
OwnerID: user.ID,
|
||||
Expire: expire,
|
||||
Password: hashedPassword,
|
||||
AlbumID: nil,
|
||||
MediaID: &mediaID,
|
||||
}
|
||||
|
||||
if err := db.Create(&shareToken).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to insert new share token into database")
|
||||
}
|
||||
|
||||
return &shareToken, nil
|
||||
}
|
||||
|
||||
func AddAlbumShare(db *gorm.DB, user *models.User, albumID int, expire *time.Time, password *string) (*models.ShareToken,
|
||||
error) {
|
||||
|
||||
var count int64
|
||||
err := db.
|
||||
Model(&models.Album{}).
|
||||
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = albums.id AND user_albums.user_id = ?)",
|
||||
user.ID).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to validate album owner with database")
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var hashedPassword *string = nil
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to hash token password")
|
||||
}
|
||||
hashedStr := string(hashedPassBytes)
|
||||
hashedPassword = &hashedStr
|
||||
}
|
||||
|
||||
shareToken := models.ShareToken{
|
||||
Value: utils.GenerateToken(),
|
||||
OwnerID: user.ID,
|
||||
Expire: expire,
|
||||
Password: hashedPassword,
|
||||
AlbumID: &albumID,
|
||||
MediaID: nil,
|
||||
}
|
||||
|
||||
if err := db.Create(&shareToken).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to insert new share token into database")
|
||||
}
|
||||
|
||||
return &shareToken, nil
|
||||
}
|
||||
|
||||
func DeleteShareToken(db *gorm.DB, userID int, tokenValue string) (*models.ShareToken, error) {
|
||||
token, err := getUserToken(db, userID, tokenValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Delete(&token).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to delete share token (%s) from database", tokenValue)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func ProtectShareToken(db *gorm.DB, userID int, tokenValue string, password *string) (*models.ShareToken, error) {
|
||||
token, err := getUserToken(db, userID, tokenValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hashedPassword, err := hashSharePassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.Password = hashedPassword
|
||||
|
||||
if err := db.Save(&token).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update password for share token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func SetExpireShareToken(db *gorm.DB, userID int, tokenValue string, expire *time.Time) (*models.ShareToken, error) {
|
||||
token, err := getUserToken(db, userID, tokenValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.Expire = expire
|
||||
|
||||
if err := db.Save(&token).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update the expiration date for share token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func hashSharePassword(password *string) (*string, error) {
|
||||
var hashedPassword *string = nil
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to generate hash for share password")
|
||||
}
|
||||
hashedStr := string(hashedPassBytes)
|
||||
hashedPassword = &hashedStr
|
||||
}
|
||||
|
||||
return hashedPassword, nil
|
||||
}
|
||||
|
||||
func getUserToken(db *gorm.DB, userID int, tokenValue string) (*models.ShareToken, error) {
|
||||
|
||||
var query string
|
||||
if drivers.POSTGRES.MatchDatabase(db) {
|
||||
query = "\"Owner\".id = ? OR \"Owner\".admin = TRUE"
|
||||
} else {
|
||||
query = "Owner.id = ? OR Owner.admin = TRUE"
|
||||
}
|
||||
|
||||
var token models.ShareToken
|
||||
err := db.Where("share_tokens.value = ?", tokenValue).Joins("Owner").Where(query, userID).First(&token).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get user share token from database")
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShareToken(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "user", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "subalbum",
|
||||
Path: "/photos/subalbum",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&childAlbum).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum))
|
||||
|
||||
media := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/subalbum/pic3",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/subalbum/pic4",
|
||||
AlbumID: childAlbum.ID,
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
expireTime := time.Unix(1632866400, 0)
|
||||
sharePassword := "secretSharePassword"
|
||||
|
||||
var mediaShare *models.ShareToken
|
||||
var albumShare *models.ShareToken
|
||||
|
||||
t.Run("Add album share", func(t *testing.T) {
|
||||
share, err := actions.AddAlbumShare(db, user, rootAlbum.ID, &expireTime, nil)
|
||||
albumShare = share
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, share)
|
||||
|
||||
assert.NotEmpty(t, share.Value)
|
||||
assert.Equal(t, rootAlbum.ID, *share.AlbumID)
|
||||
assert.Nil(t, share.MediaID)
|
||||
})
|
||||
|
||||
t.Run("Add media share", func(t *testing.T) {
|
||||
share, err := actions.AddMediaShare(db, user, media[0].ID, &expireTime, &sharePassword)
|
||||
mediaShare = share
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, share)
|
||||
|
||||
assert.NotEmpty(t, share.Value)
|
||||
assert.Equal(t, media[0].ID, *share.MediaID)
|
||||
assert.Nil(t, share.AlbumID)
|
||||
})
|
||||
|
||||
t.Run("Delete share token", func(t *testing.T) {
|
||||
deletedShare, err := actions.DeleteShareToken(db, user.ID, mediaShare.Value)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mediaShare.ID, deletedShare.ID)
|
||||
})
|
||||
|
||||
t.Run("Protect share token", func(t *testing.T) {
|
||||
|
||||
assert.Empty(t, albumShare.Password)
|
||||
|
||||
share, err := actions.ProtectShareToken(db, user.ID, albumShare.Value, &sharePassword)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, share.Password)
|
||||
|
||||
share, err = actions.ProtectShareToken(db, user.ID, albumShare.Value, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, share.Password)
|
||||
})
|
||||
|
||||
t.Run("Set Expiration date for share token", func(t *testing.T) {
|
||||
assert.NotEmpty(t, albumShare.Expire)
|
||||
time_ := time.Date(2025, 12, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
share, err := actions.SetExpireShareToken(db, user.ID, albumShare.Value, &time_)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, time_, *share.Expire)
|
||||
|
||||
share, err = actions.SetExpireShareToken(db, user.ID, albumShare.Value, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, share.Expire)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func MyTimeline(db *gorm.DB, user *models.User, paginate *models.Pagination, onlyFavorites *bool,
|
||||
fromDate *time.Time) ([]*models.Media, error) {
|
||||
|
||||
const albumsTitleASC = "albums.title ASC"
|
||||
|
||||
query := db.
|
||||
Joins("JOIN albums ON media.album_id = albums.id").
|
||||
Where("albums.id IN (?)", db.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID))
|
||||
|
||||
switch drivers.GetDatabaseDriverType(db) {
|
||||
case drivers.POSTGRES:
|
||||
query = query.
|
||||
Order("DATE_TRUNC('year', date_shot) DESC").
|
||||
Order("DATE_TRUNC('month', date_shot) DESC").
|
||||
Order("DATE_TRUNC('day', date_shot) DESC").
|
||||
Order(albumsTitleASC).
|
||||
Order("media.date_shot DESC")
|
||||
case drivers.SQLITE:
|
||||
query = query.
|
||||
Order("strftime('%Y-%m-%d', media.date_shot) DESC"). // convert to YYYY-MM-DD
|
||||
Order(albumsTitleASC).
|
||||
Order("TIME(media.date_shot) DESC")
|
||||
default:
|
||||
query = query.
|
||||
Order("YEAR(media.date_shot) DESC").
|
||||
Order("MONTH(media.date_shot) DESC").
|
||||
Order("DAY(media.date_shot) DESC").
|
||||
Order(albumsTitleASC).
|
||||
Order("TIME(media.date_shot) DESC")
|
||||
}
|
||||
|
||||
if fromDate != nil {
|
||||
query = query.Where("media.date_shot < ?", fromDate)
|
||||
}
|
||||
|
||||
if onlyFavorites != nil && *onlyFavorites {
|
||||
query = query.
|
||||
Where("media.id IN (?)", db.Table("user_media_data").
|
||||
Select("user_media_data.media_id").
|
||||
Where("user_media_data.user_id = ?", user.ID).
|
||||
Where("user_media_data.favorite"))
|
||||
}
|
||||
|
||||
query = models.FormatSQL(query, nil, paginate)
|
||||
|
||||
var media []*models.Media
|
||||
if err := query.Find(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return media, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMyTimeline(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "user", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "subalbum",
|
||||
Path: "/photos/subalbum",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&childAlbum).Error)
|
||||
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum))
|
||||
|
||||
media := []models.Media{
|
||||
{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
DateShot: time.Unix(1632758400, 0), // Sep 27 2021 16:00:00
|
||||
},
|
||||
{
|
||||
Title: "pic2",
|
||||
Path: "/photos/pic2",
|
||||
AlbumID: rootAlbum.ID,
|
||||
DateShot: time.Unix(1628762400, 0), // Aug 12 2021 10:00:00
|
||||
},
|
||||
{
|
||||
Title: "pic3",
|
||||
Path: "/photos/subalbum/pic3",
|
||||
AlbumID: childAlbum.ID,
|
||||
DateShot: time.Unix(1632763800, 0), // Sep 27 2021 17:30:00
|
||||
},
|
||||
{
|
||||
Title: "pic4",
|
||||
Path: "/photos/subalbum/pic4",
|
||||
AlbumID: childAlbum.ID,
|
||||
DateShot: time.Unix(1628775900, 0), // Aug 12 2021 13:45:00
|
||||
},
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
_, err = user.FavoriteMedia(db, media[0].ID, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Add media not owned by first user
|
||||
anotherUser, err := models.RegisterUser(db, "user2", &password, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
anotherAlbum := models.Album{
|
||||
Title: "AnotherAlbum",
|
||||
Path: "/another",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherAlbum).Error)
|
||||
|
||||
anotherMedia := models.Media{
|
||||
Title: "anotherPic",
|
||||
Path: "/another/anotherPic",
|
||||
AlbumID: anotherAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&anotherMedia).Error)
|
||||
assert.NoError(t, db.Model(&anotherUser).Association("Albums").Append(&anotherAlbum))
|
||||
|
||||
t.Run("MyTimeline with no filters", func(t *testing.T) {
|
||||
timelineMedia, err := actions.MyTimeline(db, user, nil, nil, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, timelineMedia, 4)
|
||||
|
||||
for i, title := range []string{"pic1", "pic3", "pic2", "pic4"} {
|
||||
assert.Equalf(t, timelineMedia[i].Title, title, "Element %d didn't match: got %s expected %s", i, timelineMedia[i].Title, title)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
t.Run("MyTimeline with only favorites", func(t *testing.T) {
|
||||
favorites := true
|
||||
timelineMedia, err := actions.MyTimeline(db, user, nil, &favorites, nil)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, timelineMedia, 1)
|
||||
})
|
||||
|
||||
t.Run("MyTimeline before date", func(t *testing.T) {
|
||||
beforeDate := time.Unix(1629792000, 0) // Aug 24 2021 08:00:00
|
||||
timelineMedia, err := actions.MyTimeline(db, user, nil, nil, &beforeDate)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, timelineMedia, 2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func DeleteUser(db *gorm.DB, userID int) (*models.User, error) {
|
||||
|
||||
// make sure the last admin user is not deleted
|
||||
var adminUsers []*models.User
|
||||
db.Model(&models.User{}).Where("admin = true").Limit(2).Find(&adminUsers)
|
||||
if len(adminUsers) == 1 && adminUsers[0].ID == userID {
|
||||
return nil, errors.New("deleting sole admin user is not allowed")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
deletedAlbumIDs := make([]int, 0)
|
||||
|
||||
var err error
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err = tx.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userAlbums := user.Albums
|
||||
if err = tx.Model(&user).Association("Albums").Find(&userAlbums); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Model(&user).Association("Albums").Clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletedAlbumIDs, err = deleteNotOwnedAlbums(userAlbums, tx, deletedAlbumIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Delete(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If there is only one associated user, clean up the cache folder and delete the album row
|
||||
return &user, cleanup(deletedAlbumIDs)
|
||||
}
|
||||
|
||||
func cleanup(deletedAlbumIDs []int) error {
|
||||
var err error
|
||||
for _, deletedAlbumID := range deletedAlbumIDs {
|
||||
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(deletedAlbumID)))
|
||||
if err = os.RemoveAll(cachePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteNotOwnedAlbums(userAlbums []models.Album, tx *gorm.DB, deletedAlbumIDs []int) ([]int, error) {
|
||||
for _, album := range userAlbums {
|
||||
var associatedUsers = tx.Model(album).Association("Owners").Count()
|
||||
|
||||
if associatedUsers == 0 {
|
||||
deletedAlbumIDs = append(deletedAlbumIDs, album.ID)
|
||||
if err := tx.Delete(album).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return deletedAlbumIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
t.Run("Delete regular user", func(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
adminUser, err := models.RegisterUser(db, "admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
regularUser, err := models.RegisterUser(db, "regular", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var dbUsers []*models.User
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
|
||||
deletedUser, err := actions.DeleteUser(db, regularUser.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, regularUser.ID, deletedUser.ID)
|
||||
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 1)
|
||||
assert.Equal(t, adminUser.ID, dbUsers[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Try to delete sole admin user", func(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
adminUser, err := models.RegisterUser(db, "admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = models.RegisterUser(db, "regular", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var dbUsers []*models.User
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
|
||||
_, err = actions.DeleteUser(db, adminUser.ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
})
|
||||
|
||||
t.Run("Delete admin user when multiple admins exist", func(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
adminUser1, err := models.RegisterUser(db, "admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
adminUser2, err := models.RegisterUser(db, "another_admin", nil, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var dbUsers []*models.User
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 2)
|
||||
|
||||
deletedUser, err := actions.DeleteUser(db, adminUser1.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, adminUser1.ID, deletedUser.ID)
|
||||
|
||||
err = db.Model(models.User{}).Find(&dbUsers).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dbUsers, 1)
|
||||
assert.Equal(t, adminUser2.ID, dbUsers[0].ID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Album struct {
|
||||
Model
|
||||
Title string `gorm:"not null"`
|
||||
ParentAlbumID *int `gorm:"index"`
|
||||
ParentAlbum *Album `gorm:"constraint:OnDelete:SET NULL;"`
|
||||
// OwnerID int `gorm:"not null"`
|
||||
// Owner User
|
||||
Owners []User `gorm:"many2many:user_albums;constraint:OnDelete:CASCADE;"`
|
||||
Path string `gorm:"not null"`
|
||||
PathHash string `gorm:"unique"`
|
||||
CoverID *int
|
||||
}
|
||||
|
||||
func (a *Album) FilePath() string {
|
||||
return a.Path
|
||||
}
|
||||
|
||||
func (a *Album) BeforeSave(tx *gorm.DB) (err error) {
|
||||
hash := md5.Sum([]byte(a.Path))
|
||||
a.PathHash = hex.EncodeToString(hash[:])
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChildren performs a recursive query to get all the children of the album.
|
||||
// An optional filter can be provided that can be used to modify the query on the children.
|
||||
func (a *Album) GetChildren(db *gorm.DB, filter func(*gorm.DB) *gorm.DB) (children []*Album, err error) {
|
||||
return GetChildrenFromAlbums(db, filter, []int{a.ID})
|
||||
}
|
||||
|
||||
func GetChildrenFromAlbums(db *gorm.DB, filter func(*gorm.DB) *gorm.DB, albumIDs []int) (children []*Album, err error) {
|
||||
query := db.Model(&Album{}).Table("sub_albums")
|
||||
|
||||
if filter != nil {
|
||||
query = filter(query)
|
||||
}
|
||||
|
||||
err = db.Raw(`
|
||||
WITH recursive sub_albums AS (
|
||||
SELECT * FROM albums AS root WHERE id IN (?)
|
||||
UNION ALL
|
||||
SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id
|
||||
)
|
||||
|
||||
?
|
||||
`, albumIDs, query).Find(&children).Error
|
||||
|
||||
return children, err
|
||||
}
|
||||
|
||||
func (a *Album) GetParents(db *gorm.DB, filter func(*gorm.DB) *gorm.DB) (parents []*Album, err error) {
|
||||
return GetParentsFromAlbums(db, filter, a.ID)
|
||||
}
|
||||
|
||||
func GetParentsFromAlbums(db *gorm.DB, filter func(*gorm.DB) *gorm.DB, albumID int) (parents []*Album, err error) {
|
||||
query := db.Model(&Album{}).Table("super_albums")
|
||||
|
||||
if filter != nil {
|
||||
query = filter(query)
|
||||
}
|
||||
|
||||
err = db.Raw(`
|
||||
WITH recursive super_albums AS (
|
||||
SELECT * FROM albums AS leaf WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT parent.* from albums AS parent JOIN super_albums ON parent.id = super_albums.parent_album_id
|
||||
)
|
||||
|
||||
?
|
||||
`, albumID, query).Find(&parents).Error
|
||||
|
||||
return parents, err
|
||||
}
|
||||
|
||||
func (a *Album) Thumbnail(db *gorm.DB) (*Media, error) {
|
||||
var media Media
|
||||
|
||||
if a.CoverID != nil {
|
||||
if err := db.First(&media, *a.CoverID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH RECURSIVE sub_albums AS (
|
||||
SELECT id FROM albums WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT children.id FROM albums AS children
|
||||
INNER JOIN sub_albums ON children.parent_album_id = sub_albums.id
|
||||
)
|
||||
SELECT * FROM media
|
||||
WHERE media.album_id IN (SELECT id FROM sub_albums)
|
||||
ORDER BY media.id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
if err := db.Raw(query, a.ID).Scan(&media).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if media.ID == 0 {
|
||||
return nil, nil // Return nil for empty albums
|
||||
}
|
||||
|
||||
return &media, nil
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAlbumGetChildrenAndParents(t *testing.T) {
|
||||
const photosPath = "/photos"
|
||||
const photosChild1Path = "/photos/child1"
|
||||
const photosChild1SubchildPath = "/photos/child1/subchild"
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: photosPath,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&rootAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
children := []models.Album{
|
||||
{
|
||||
Title: "child1",
|
||||
Path: photosChild1Path,
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/photos/child2",
|
||||
ParentAlbumID: &rootAlbum.ID,
|
||||
},
|
||||
{
|
||||
Title: "not_child",
|
||||
Path: "/videos",
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&children).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
subChild := models.Album{
|
||||
Title: "subchild",
|
||||
Path: photosChild1SubchildPath,
|
||||
ParentAlbumID: &children[0].ID,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&subChild).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
verifyResult := func(t *testing.T, expectedAlbums []*models.Album, result []*models.Album) {
|
||||
assert.Equal(t, len(expectedAlbums), len(result))
|
||||
|
||||
for _, expected := range expectedAlbums {
|
||||
foundExpected := false
|
||||
for _, item := range result {
|
||||
if item.Title == expected.Title && item.Path == expected.Path {
|
||||
foundExpected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundExpected {
|
||||
assert.Failf(t, "albums did not match", "expected to find item: %v", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Album get children", func(t *testing.T) {
|
||||
rootChildren, err := rootAlbum.GetChildren(db, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
expectedChildren := []*models.Album{
|
||||
{
|
||||
Title: "root",
|
||||
Path: photosPath,
|
||||
},
|
||||
{
|
||||
Title: "child1",
|
||||
Path: photosChild1Path,
|
||||
},
|
||||
{
|
||||
Title: "child2",
|
||||
Path: "/photos/child2",
|
||||
},
|
||||
{
|
||||
Title: "subchild",
|
||||
Path: photosChild1SubchildPath,
|
||||
},
|
||||
}
|
||||
|
||||
verifyResult(t, expectedChildren, rootChildren)
|
||||
})
|
||||
|
||||
t.Run("Album get parents", func(t *testing.T) {
|
||||
parents, err := subChild.GetParents(db, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
expectedParents := []*models.Album{
|
||||
{
|
||||
Title: "root",
|
||||
Path: photosPath,
|
||||
},
|
||||
{
|
||||
Title: "child1",
|
||||
Path: photosChild1Path,
|
||||
},
|
||||
{
|
||||
Title: "subchild",
|
||||
Path: photosChild1SubchildPath,
|
||||
},
|
||||
}
|
||||
|
||||
verifyResult(t, expectedParents, parents)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestAlbumThumbnail(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
mediaAlbum := models.Album{
|
||||
Title: "Media album",
|
||||
Path: "/media_album",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&mediaAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
media := models.Media{
|
||||
Path: "thumb.jpg",
|
||||
AlbumID: mediaAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&media).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("Thumbnail from CoverID", func(t *testing.T) {
|
||||
album := models.Album{
|
||||
Title: "Album with cover",
|
||||
Path: "/cover_album",
|
||||
CoverID: &media.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&album).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := album.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, media.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("Thumbnail from child media", func(t *testing.T) {
|
||||
parentAlbum := models.Album{
|
||||
Title: "Parent album",
|
||||
Path: "/parent",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "Child album",
|
||||
Path: "/parent/child",
|
||||
ParentAlbumID: &parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childMedia := models.Media{
|
||||
Path: "child_media.jpg",
|
||||
AlbumID: childAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := parentAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, childMedia.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("Empty album with no media", func(t *testing.T) {
|
||||
emptyAlbum := models.Album{
|
||||
Title: "Empty album",
|
||||
Path: "/empty",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&emptyAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := emptyAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, result, "Empty albums should have nil thumbnail")
|
||||
})
|
||||
|
||||
t.Run("Thumbnail from grandchild media", func(t *testing.T) {
|
||||
// Create grandparent-parent-child relationship with media only in child
|
||||
grandparentAlbum := models.Album{
|
||||
Title: "Grandparent",
|
||||
Path: "/grandparent",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&grandparentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
parentAlbum := models.Album{
|
||||
Title: "Parent",
|
||||
Path: "/grandparent/parent",
|
||||
ParentAlbumID: &grandparentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childAlbum := models.Album{
|
||||
Title: "Child",
|
||||
Path: "/grandparent/parent/child",
|
||||
ParentAlbumID: &parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
childMedia := models.Media{
|
||||
Path: "deep_media.jpg",
|
||||
AlbumID: childAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := grandparentAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, childMedia.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("CoverID takes precedence over any media", func(t *testing.T) {
|
||||
// Create album with both direct media and a cover ID
|
||||
priorityAlbum := models.Album{
|
||||
Title: "Priority album",
|
||||
Path: "/priority",
|
||||
CoverID: &media.ID, // Using existing media as cover
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&priorityAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add direct media to the album with unique path
|
||||
directMedia := models.Media{
|
||||
Path: fmt.Sprintf("direct_media_%d.jpg", time.Now().UnixNano()),
|
||||
AlbumID: priorityAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&directMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Test that CoverID takes precedence
|
||||
result, err := priorityAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, media.ID, result.ID, "CoverID should take precedence over direct media")
|
||||
})
|
||||
|
||||
t.Run("Some media is returned when multiple exist in hierarchy", func(t *testing.T) {
|
||||
// Create a parent album
|
||||
parentAlbum := models.Album{
|
||||
Title: "Parent album",
|
||||
Path: "/parent_media_test",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add direct media to parent with unique path
|
||||
parentMedia := models.Media{
|
||||
Path: fmt.Sprintf("parent_media_%d.jpg", time.Now().UnixNano()),
|
||||
AlbumID: parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&parentMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create child album with media
|
||||
childAlbum := models.Album{
|
||||
Title: "Child album",
|
||||
Path: "/parent_media_test/child",
|
||||
ParentAlbumID: &parentAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add child media with unique path
|
||||
childMedia := models.Media{
|
||||
Path: fmt.Sprintf("child_media_%d.jpg", time.Now().UnixNano()),
|
||||
AlbumID: childAlbum.ID,
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&childMedia).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Test that some media is returned
|
||||
result, err := parentAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.True(t, result.ID == parentMedia.ID || result.ID == childMedia.ID,
|
||||
"Should return either direct media or child album media")
|
||||
t.Logf("For reference - Selected: %d, Parent media: %d, Child media: %d",
|
||||
result.ID, parentMedia.ID, childMedia.ID)
|
||||
})
|
||||
|
||||
t.Run("Database order determines which media is selected", func(t *testing.T) {
|
||||
// Create album with multiple media
|
||||
multiMediaAlbum := models.Album{
|
||||
Title: "Album with multiple media",
|
||||
Path: "/multi_media",
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&multiMediaAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add multiple media to the album with unique paths
|
||||
mediaItems := []models.Media{
|
||||
{Path: fmt.Sprintf("media1_%d.jpg", time.Now().UnixNano()), AlbumID: multiMediaAlbum.ID},
|
||||
// Sleep briefly to ensure different timestamps
|
||||
{Path: fmt.Sprintf("media2_%d.jpg", time.Now().UnixNano()+1), AlbumID: multiMediaAlbum.ID},
|
||||
{Path: fmt.Sprintf("media3_%d.jpg", time.Now().UnixNano()+2), AlbumID: multiMediaAlbum.ID},
|
||||
}
|
||||
if !assert.NoError(t, db.Save(&mediaItems).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Test which media is selected
|
||||
result, err := multiMediaAlbum.Thumbnail(db)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
// Log which item was selected for documentation purposes
|
||||
t.Logf("Selected media ID: %d", result.ID)
|
||||
for i, item := range mediaItems {
|
||||
t.Logf("Media %d: ID %d, Path %s", i+1, item.ID, item.Path)
|
||||
}
|
||||
|
||||
// Verify one of our media items was selected
|
||||
found := false
|
||||
for _, item := range mediaItems {
|
||||
if result.ID == item.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "One of the album's media should be selected")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID int `gorm:"primarykey"`
|
||||
ModelTimestamps
|
||||
}
|
||||
|
||||
type ModelTimestamps struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql/driver"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
type FaceGroup struct {
|
||||
Model
|
||||
Label *string
|
||||
ImageFaces []ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
type ImageFace struct {
|
||||
Model
|
||||
FaceGroupID int `gorm:"not null;index"`
|
||||
FaceGroup *FaceGroup
|
||||
MediaID int `gorm:"not null;index"`
|
||||
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Descriptor FaceDescriptor `gorm:"not null"`
|
||||
Rectangle FaceRectangle `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (f *ImageFace) FillMedia(db *gorm.DB) error {
|
||||
if f.Media.ID != 0 {
|
||||
// media already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Model(&f).Association("Media").Find(&f.Media); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type FaceDescriptor [128]float32 // same as go-face's Descriptor
|
||||
|
||||
// GormDataType datatype used in database
|
||||
func (FaceDescriptor) GormDBDataType(db *gorm.DB, field *schema.Field) string {
|
||||
switch drivers.GetDatabaseDriverType(db) {
|
||||
case drivers.MYSQL, drivers.SQLITE:
|
||||
return "BLOB"
|
||||
case drivers.POSTGRES:
|
||||
return "BYTEA"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Scan tells GORM how to convert database data to Go format
|
||||
func (fd *FaceDescriptor) Scan(value interface{}) error {
|
||||
byteValue := value.([]byte)
|
||||
reader := bytes.NewReader(byteValue)
|
||||
binary.Read(reader, binary.LittleEndian, fd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value tells GORM how to save into the database
|
||||
func (fd FaceDescriptor) Value() (driver.Value, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
if err := binary.Write(buf, binary.LittleEndian, fd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// FaceRectangle stores a relative rectangle of a face in an image.
|
||||
type FaceRectangle struct {
|
||||
MinX, MaxX float64
|
||||
MinY, MaxY float64
|
||||
}
|
||||
|
||||
// GormDataType datatype used in database
|
||||
func (fr FaceRectangle) GormDataType() string {
|
||||
return "VARCHAR(64)"
|
||||
}
|
||||
|
||||
// Scan tells GORM how to convert database data to Go format
|
||||
func (fr *FaceRectangle) Scan(value interface{}) error {
|
||||
stringArray, ok := value.(string)
|
||||
if !ok {
|
||||
byteArray := value.([]uint8)
|
||||
stringArray = string(byteArray)
|
||||
}
|
||||
|
||||
slices := strings.Split(stringArray, ":")
|
||||
|
||||
if len(slices) != 4 {
|
||||
return fmt.Errorf("invalid face rectangle format, expected 4 values, got %d", len(slices))
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
fr.MinX, err = strconv.ParseFloat(slices[0], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fr.MaxX, err = strconv.ParseFloat(slices[1], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fr.MinY, err = strconv.ParseFloat(slices[2], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fr.MaxY, err = strconv.ParseFloat(slices[3], 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value tells GORM how to save into the database
|
||||
func (fr FaceRectangle) Value() (driver.Value, error) {
|
||||
result := fmt.Sprintf("%f:%f:%f:%f", fr.MinX, fr.MaxX, fr.MinY, fr.MaxY)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthorizeResult struct {
|
||||
Success bool `json:"success"`
|
||||
// A textual status message describing the result, can be used to show an error message when `success` is false
|
||||
Status string `json:"status"`
|
||||
// An access token used to authenticate new API requests as the newly authorized user. Is present when success is true
|
||||
Token *string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type Coordinates struct {
|
||||
// GPS latitude in degrees
|
||||
Latitude float64 `json:"latitude"`
|
||||
// GPS longitude in degrees
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type MediaDownload struct {
|
||||
// A description of the role of the media file
|
||||
Title string `json:"title"`
|
||||
MediaURL *MediaURL `json:"mediaUrl"`
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
// A key used to identify the notification, new notification updates with the same key, should replace the old notifications
|
||||
Key string `json:"key"`
|
||||
Type NotificationType `json:"type"`
|
||||
// The text for the title of the notification
|
||||
Header string `json:"header"`
|
||||
// The text for the body of the notification
|
||||
Content string `json:"content"`
|
||||
// A value between 0 and 1 when the notification type is `Progress`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
// Whether or not the message of the notification is positive, the UI might reflect this with a green color
|
||||
Positive bool `json:"positive"`
|
||||
// Whether or not the message of the notification is negative, the UI might reflect this with a red color
|
||||
Negative bool `json:"negative"`
|
||||
// Time in milliseconds before the notification should close
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// Used to specify how to sort items
|
||||
type Ordering struct {
|
||||
// A column in the database to order by
|
||||
OrderBy *string `json:"order_by,omitempty"`
|
||||
OrderDirection *OrderDirection `json:"order_direction,omitempty"`
|
||||
}
|
||||
|
||||
// Used to specify pagination on a list of items
|
||||
type Pagination struct {
|
||||
// How many items to maximally fetch
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
// How many items to skip from the beginning of the query, specified by the `Ordering`
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
}
|
||||
|
||||
type ScannerResult struct {
|
||||
Finished bool `json:"finished"`
|
||||
Success bool `json:"success"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
// The string that was searched for
|
||||
Query string `json:"query"`
|
||||
// A list of albums that matched the query
|
||||
Albums []*Album `json:"albums"`
|
||||
// A list of media that matched the query
|
||||
Media []*Media `json:"media"`
|
||||
}
|
||||
|
||||
// Credentials used to identify and authenticate a share token
|
||||
type ShareTokenCredentials struct {
|
||||
Token string `json:"token"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
}
|
||||
|
||||
// A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||
// NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||
type TimelineGroup struct {
|
||||
// The full album containing the media in this timeline group
|
||||
Album *Album `json:"album"`
|
||||
// The media contained in this timeline group
|
||||
Media []*Media `json:"media"`
|
||||
// The total amount of media in this timeline group
|
||||
MediaTotal int `json:"mediaTotal"`
|
||||
// The day shared for all media in this timeline group
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
// Supported language translations of the user interface
|
||||
type LanguageTranslation string
|
||||
|
||||
const (
|
||||
LanguageTranslationEnglish LanguageTranslation = "English"
|
||||
LanguageTranslationFrench LanguageTranslation = "French"
|
||||
LanguageTranslationItalian LanguageTranslation = "Italian"
|
||||
LanguageTranslationSwedish LanguageTranslation = "Swedish"
|
||||
LanguageTranslationDanish LanguageTranslation = "Danish"
|
||||
LanguageTranslationSpanish LanguageTranslation = "Spanish"
|
||||
LanguageTranslationPolish LanguageTranslation = "Polish"
|
||||
LanguageTranslationUkrainian LanguageTranslation = "Ukrainian"
|
||||
LanguageTranslationGerman LanguageTranslation = "German"
|
||||
LanguageTranslationRussian LanguageTranslation = "Russian"
|
||||
LanguageTranslationTraditionalChineseTw LanguageTranslation = "TraditionalChineseTW"
|
||||
LanguageTranslationTraditionalChineseHk LanguageTranslation = "TraditionalChineseHK"
|
||||
LanguageTranslationSimplifiedChinese LanguageTranslation = "SimplifiedChinese"
|
||||
LanguageTranslationPortuguese LanguageTranslation = "Portuguese"
|
||||
LanguageTranslationBasque LanguageTranslation = "Basque"
|
||||
LanguageTranslationTurkish LanguageTranslation = "Turkish"
|
||||
LanguageTranslationJapanese LanguageTranslation = "Japanese"
|
||||
LanguageTranslationDutch LanguageTranslation = "Dutch"
|
||||
)
|
||||
|
||||
var AllLanguageTranslation = []LanguageTranslation{
|
||||
LanguageTranslationEnglish,
|
||||
LanguageTranslationFrench,
|
||||
LanguageTranslationItalian,
|
||||
LanguageTranslationSwedish,
|
||||
LanguageTranslationDanish,
|
||||
LanguageTranslationSpanish,
|
||||
LanguageTranslationPolish,
|
||||
LanguageTranslationUkrainian,
|
||||
LanguageTranslationGerman,
|
||||
LanguageTranslationRussian,
|
||||
LanguageTranslationTraditionalChineseTw,
|
||||
LanguageTranslationTraditionalChineseHk,
|
||||
LanguageTranslationSimplifiedChinese,
|
||||
LanguageTranslationPortuguese,
|
||||
LanguageTranslationBasque,
|
||||
LanguageTranslationTurkish,
|
||||
LanguageTranslationJapanese,
|
||||
LanguageTranslationDutch,
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) IsValid() bool {
|
||||
switch e {
|
||||
case LanguageTranslationEnglish, LanguageTranslationFrench, LanguageTranslationItalian, LanguageTranslationSwedish, LanguageTranslationDanish, LanguageTranslationSpanish, LanguageTranslationPolish, LanguageTranslationUkrainian, LanguageTranslationGerman, LanguageTranslationRussian, LanguageTranslationTraditionalChineseTw, LanguageTranslationTraditionalChineseHk, LanguageTranslationSimplifiedChinese, LanguageTranslationPortuguese, LanguageTranslationBasque, LanguageTranslationTurkish, LanguageTranslationJapanese, LanguageTranslationDutch:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *LanguageTranslation) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = LanguageTranslation(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid LanguageTranslation", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *LanguageTranslation) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e LanguageTranslation) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Specified the type a particular notification is of
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
// A regular message with no special additions
|
||||
NotificationTypeMessage NotificationType = "Message"
|
||||
// A notification with an attached progress indicator
|
||||
NotificationTypeProgress NotificationType = "Progress"
|
||||
// Close a notification with a given key
|
||||
NotificationTypeClose NotificationType = "Close"
|
||||
)
|
||||
|
||||
var AllNotificationType = []NotificationType{
|
||||
NotificationTypeMessage,
|
||||
NotificationTypeProgress,
|
||||
NotificationTypeClose,
|
||||
}
|
||||
|
||||
func (e NotificationType) IsValid() bool {
|
||||
switch e {
|
||||
case NotificationTypeMessage, NotificationTypeProgress, NotificationTypeClose:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e NotificationType) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *NotificationType) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = NotificationType(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid NotificationType", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e NotificationType) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *NotificationType) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e NotificationType) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Used to specify which order to sort items in
|
||||
type OrderDirection string
|
||||
|
||||
const (
|
||||
// Sort accending A-Z
|
||||
OrderDirectionAsc OrderDirection = "ASC"
|
||||
// Sort decending Z-A
|
||||
OrderDirectionDesc OrderDirection = "DESC"
|
||||
)
|
||||
|
||||
var AllOrderDirection = []OrderDirection{
|
||||
OrderDirectionAsc,
|
||||
OrderDirectionDesc,
|
||||
}
|
||||
|
||||
func (e OrderDirection) IsValid() bool {
|
||||
switch e {
|
||||
case OrderDirectionAsc, OrderDirectionDesc:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e OrderDirection) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *OrderDirection) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = OrderDirection(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid OrderDirection", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e OrderDirection) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *OrderDirection) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e OrderDirection) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
Model
|
||||
Title string `gorm:"not null"`
|
||||
Path string `gorm:"not null"`
|
||||
PathHash string `gorm:"not null;unique"`
|
||||
AlbumID int `gorm:"not null;index"`
|
||||
Album Album `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
ExifID *int `gorm:"index"`
|
||||
Exif *MediaEXIF `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
MediaURL []MediaURL `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
DateShot time.Time `gorm:"not null"`
|
||||
Type MediaType `gorm:"not null;index"`
|
||||
VideoMetadataID *int `gorm:"index"`
|
||||
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
SideCarPath *string
|
||||
SideCarHash *string `gorm:"unique"`
|
||||
Faces []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Blurhash *string `gorm:""`
|
||||
}
|
||||
|
||||
func (Media) TableName() string {
|
||||
return "media"
|
||||
}
|
||||
|
||||
func (m *Media) BeforeSave(tx *gorm.DB) error {
|
||||
// Update path hash
|
||||
m.PathHash = MD5Hash(m.Path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) Date() time.Time {
|
||||
return m.DateShot
|
||||
}
|
||||
|
||||
func (m *Media) GetThumbnail() (*MediaURL, error) {
|
||||
if len(m.MediaURL) == 0 {
|
||||
return nil, errors.New("media.MediaURL is empty")
|
||||
}
|
||||
|
||||
for _, url := range m.MediaURL {
|
||||
if url.Purpose == PhotoThumbnail || url.Purpose == VideoThumbnail {
|
||||
url.Media = m
|
||||
return &url, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *Media) GetHighRes() (*MediaURL, error) {
|
||||
if len(m.MediaURL) == 0 {
|
||||
return nil, errors.New("media.MediaURL is empty")
|
||||
}
|
||||
|
||||
for _, url := range m.MediaURL {
|
||||
if url.Purpose == PhotoHighRes {
|
||||
url.Media = m
|
||||
return &url, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *Media) CachePath() (string, error) {
|
||||
return utils.CachePathForMedia(m.AlbumID, m.ID)
|
||||
}
|
||||
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
MediaTypePhoto MediaType = "photo"
|
||||
MediaTypeVideo MediaType = "video"
|
||||
)
|
||||
|
||||
var AllMediaType = []MediaType{
|
||||
MediaTypePhoto,
|
||||
MediaTypeVideo,
|
||||
}
|
||||
|
||||
type MediaPurpose string
|
||||
|
||||
const (
|
||||
PhotoThumbnail MediaPurpose = "thumbnail"
|
||||
PhotoHighRes MediaPurpose = "high-res"
|
||||
MediaOriginal MediaPurpose = "original"
|
||||
VideoWeb MediaPurpose = "video-web"
|
||||
VideoThumbnail MediaPurpose = "video-thumbnail"
|
||||
)
|
||||
|
||||
type MediaURL struct {
|
||||
Model
|
||||
MediaID int `gorm:"not null;index"`
|
||||
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
MediaName string `gorm:"not null;index"`
|
||||
Width int `gorm:"not null"`
|
||||
Height int `gorm:"not null"`
|
||||
Purpose MediaPurpose `gorm:"not null;index"`
|
||||
ContentType string `gorm:"not null"`
|
||||
FileSize int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (p *MediaURL) URL() string {
|
||||
|
||||
imageURL := utils.ApiEndpointUrl()
|
||||
if p.Purpose != VideoWeb {
|
||||
imageURL.Path = path.Join(imageURL.Path, "photo", p.MediaName)
|
||||
} else {
|
||||
imageURL.Path = path.Join(imageURL.Path, "video", p.MediaName)
|
||||
}
|
||||
|
||||
return imageURL.String()
|
||||
}
|
||||
|
||||
func (p *MediaURL) CachedPath() (string, error) {
|
||||
var cachedPath string
|
||||
|
||||
if p.Media == nil {
|
||||
return "", errors.New("mediaURL.Media is nil")
|
||||
}
|
||||
|
||||
if p.Purpose == PhotoThumbnail || p.Purpose == PhotoHighRes || p.Purpose == VideoThumbnail || p.Purpose == VideoWeb {
|
||||
cachedPath = path.Join(utils.MediaCachePath(), strconv.Itoa(int(p.Media.AlbumID)), strconv.Itoa(int(p.MediaID)),
|
||||
p.MediaName)
|
||||
} else if p.Purpose == MediaOriginal {
|
||||
cachedPath = p.Media.Path
|
||||
} else {
|
||||
return "", errors.New(fmt.Sprintf("cannot determine cache path for purpose (%s)", p.Purpose))
|
||||
}
|
||||
|
||||
return cachedPath, nil
|
||||
}
|
||||
|
||||
func SanitizeMediaName(mediaName string) string {
|
||||
result := mediaName
|
||||
result = strings.ReplaceAll(result, "/", "")
|
||||
result = strings.ReplaceAll(result, "\\", "")
|
||||
result = strings.ReplaceAll(result, " ", "_")
|
||||
result = strings.ReplaceAll(result, ".", "_")
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaEXIF struct {
|
||||
Model
|
||||
Description *string
|
||||
Camera *string
|
||||
Maker *string
|
||||
Lens *string
|
||||
DateShot *time.Time
|
||||
OffsetSecShot *int
|
||||
Exposure *float64
|
||||
Aperture *float64
|
||||
Iso *int64
|
||||
FocalLength *float64
|
||||
Flash *int64
|
||||
Orientation *int64
|
||||
ExposureProgram *int64
|
||||
GPSLatitude *float64
|
||||
GPSLongitude *float64
|
||||
}
|
||||
|
||||
func (MediaEXIF) TableName() string {
|
||||
return "media_exif"
|
||||
}
|
||||
|
||||
func (exif *MediaEXIF) Media() *Media {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (exif *MediaEXIF) Coordinates() *Coordinates {
|
||||
if exif.GPSLatitude == nil || exif.GPSLongitude == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Coordinates{
|
||||
Latitude: *exif.GPSLatitude,
|
||||
Longitude: *exif.GPSLongitude,
|
||||
}
|
||||
}
|
||||
|
||||
const rfc3339WithoutTimezone = "2006-01-02T15:04:05.999"
|
||||
|
||||
func (exif *MediaEXIF) DateShotWithOffset() *string {
|
||||
if exif.DateShot == nil {
|
||||
return nil
|
||||
}
|
||||
dateShot := exif.DateShot.UTC()
|
||||
dateNoTimezone := dateShot.Format(rfc3339WithoutTimezone)
|
||||
|
||||
if exif.OffsetSecShot == nil {
|
||||
return &dateNoTimezone
|
||||
}
|
||||
|
||||
offsetAbs := *exif.OffsetSecShot
|
||||
sign := "+"
|
||||
if offsetAbs < 0 {
|
||||
offsetAbs = -offsetAbs
|
||||
sign = "-"
|
||||
}
|
||||
hour := offsetAbs / 60 / 60
|
||||
minute := offsetAbs / 60 % 60
|
||||
date := fmt.Sprintf("%s%s%02d:%02d", dateNoTimezone, sign, hour, minute)
|
||||
|
||||
return &date
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
// Different database has different behavior when storing date with timezone.
|
||||
// - SQLite: keep the original timezone
|
||||
// - MySQL/MariaDB: store in UTC
|
||||
// - PostgreSQL: store in the timezone of the db client
|
||||
// We cannot maintain consistent behavior across different databases without the extra offset field.
|
||||
|
||||
const (
|
||||
layout = "2006:01:02 15:04:05"
|
||||
layoutWithOffset = "2006:01:02 15:04:05-07:00"
|
||||
)
|
||||
|
||||
func TestDatabaseReproduceDateWithOffset(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date string
|
||||
offsetSec *int
|
||||
want string
|
||||
}{
|
||||
{"NoSubSecNoOffset", "2025:11:01 14:02:03", nil, "2025-11-01T14:02:03"},
|
||||
{"SubSecNoOffset", "2025:11:01 14:02:03.123", nil, "2025-11-01T14:02:03.123"},
|
||||
{"NoSubSecWithOffset", "2025:11:01 14:02:03", new(60 * 60), "2025-11-01T14:02:03+01:00"},
|
||||
{"SubSecWithOffset", "2025:11:01 14:02:03.123", new(60 * 60), "2025-11-01T14:02:03.123+01:00"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
date, err := time.ParseInLocation(layout, tc.date, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time %q error: %v", tc.date, err)
|
||||
}
|
||||
|
||||
exif := models.MediaEXIF{
|
||||
DateShot: &date,
|
||||
OffsetSecShot: tc.offsetSec,
|
||||
}
|
||||
|
||||
if err := db.Save(&exif).Error; err != nil {
|
||||
t.Fatalf("store exif error: %v", err)
|
||||
}
|
||||
|
||||
var got models.MediaEXIF
|
||||
if err := db.Where("id = ?", exif.ID).First(&got).Error; err != nil {
|
||||
t.Fatalf("get exif error: %v", err)
|
||||
}
|
||||
|
||||
if got, want := *got.DateShotWithOffset(), tc.want; got != want {
|
||||
t.Errorf("got = %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const mimeJpeg = "image/jpeg"
|
||||
|
||||
func TestSanitizeMediaName(t *testing.T) {
|
||||
tests := [][2]string{
|
||||
{"filename.png", "filename_png"},
|
||||
{"../..\\escape", "____escape"},
|
||||
{"..", "__"},
|
||||
{"..\\/", "__"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("sanitize: %s", test[0]), func(t *testing.T) {
|
||||
assert.Equal(t, test[1], models.SanitizeMediaName(test[0]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaURLCachePath(t *testing.T) {
|
||||
mediaUrl := models.MediaURL{}
|
||||
mediaUrl.Media = nil
|
||||
|
||||
_, err := mediaUrl.CachedPath()
|
||||
assert.EqualError(t, err, "mediaURL.Media is nil")
|
||||
|
||||
mediaUrl = models.MediaURL{
|
||||
Purpose: models.PhotoThumbnail,
|
||||
MediaID: 1,
|
||||
Media: &models.Media{
|
||||
Model: models.Model{
|
||||
ID: 1,
|
||||
},
|
||||
Title: "media.jpg",
|
||||
AlbumID: 2,
|
||||
},
|
||||
MediaName: "media_thumb.jpg",
|
||||
}
|
||||
|
||||
path, err := mediaUrl.CachedPath()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "media_cache/2/1/media_thumb.jpg", path)
|
||||
|
||||
}
|
||||
|
||||
func TestMediaURLGetURL(t *testing.T) {
|
||||
t.Setenv(string(utils.EnvAPIEndpoint), "")
|
||||
|
||||
photo := models.MediaURL{
|
||||
MediaName: "photo.jpg",
|
||||
ContentType: mimeJpeg,
|
||||
Purpose: models.PhotoHighRes,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/api/photo/photo.jpg", photo.URL())
|
||||
|
||||
video := models.MediaURL{
|
||||
MediaName: "video.mp4",
|
||||
ContentType: "video/mp4",
|
||||
Purpose: models.VideoWeb,
|
||||
}
|
||||
|
||||
assert.Equal(t, "/api/video/video.mp4", video.URL())
|
||||
}
|
||||
|
||||
func TestMediaGetThumbnail(t *testing.T) {
|
||||
photo := models.Media{
|
||||
Title: "test.png",
|
||||
Path: "path/test.png",
|
||||
Type: models.MediaTypePhoto,
|
||||
MediaURL: []models.MediaURL{
|
||||
{
|
||||
MediaName: "photo.jpg",
|
||||
ContentType: mimeJpeg,
|
||||
Purpose: models.PhotoHighRes,
|
||||
},
|
||||
{
|
||||
MediaName: "thumbnail.jpg",
|
||||
ContentType: mimeJpeg,
|
||||
Purpose: models.PhotoThumbnail,
|
||||
},
|
||||
{
|
||||
MediaName: "photo.png",
|
||||
ContentType: "image/png",
|
||||
Purpose: models.MediaOriginal,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
thumb, err := photo.GetThumbnail()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, thumb.MediaName, "thumbnail.jpg")
|
||||
assert.NotNil(t, thumb.Media)
|
||||
|
||||
video := models.Media{
|
||||
Title: "video-test.mp4",
|
||||
Path: "path/test.mp4",
|
||||
Type: models.MediaTypeVideo,
|
||||
MediaURL: []models.MediaURL{
|
||||
{
|
||||
MediaName: "video.mp4",
|
||||
ContentType: "video/mp4",
|
||||
Purpose: models.VideoWeb,
|
||||
},
|
||||
{
|
||||
MediaName: "video-thumbnail.jpg",
|
||||
ContentType: "image/jpg",
|
||||
Purpose: models.VideoThumbnail,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
thumb, err = video.GetThumbnail()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, thumb.MediaName, "video-thumbnail.jpg")
|
||||
assert.NotNil(t, thumb.Media)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
test_utils.IntegrationTestRun(m)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ShareToken struct {
|
||||
Model
|
||||
Value string `gorm:"not null"`
|
||||
OwnerID int `gorm:"not null;index"`
|
||||
Owner User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Expire *time.Time `gorm:"index"`
|
||||
Password *string
|
||||
AlbumID *int `gorm:"index"`
|
||||
Album *Album `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
MediaID *int `gorm:"index"`
|
||||
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
func (share *ShareToken) Token() string {
|
||||
return share.Value
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
db_drivers "github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SiteInfo struct {
|
||||
InitialSetup bool `gorm:"not null"`
|
||||
PeriodicScanInterval int `gorm:"not null"`
|
||||
ConcurrentWorkers int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SiteInfo) TableName() string {
|
||||
return "site_info"
|
||||
}
|
||||
|
||||
func DefaultSiteInfo(db *gorm.DB) SiteInfo {
|
||||
defaultConcurrentWorkers := 3
|
||||
if db_drivers.SQLITE.MatchDatabase(db) {
|
||||
defaultConcurrentWorkers = 1
|
||||
}
|
||||
|
||||
return SiteInfo{
|
||||
InitialSetup: true,
|
||||
PeriodicScanInterval: 0,
|
||||
ConcurrentWorkers: defaultConcurrentWorkers,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSiteInfo gets the site info row from the database, and creates it if it does not exist
|
||||
func GetSiteInfo(db *gorm.DB) (*SiteInfo, error) {
|
||||
|
||||
var siteInfo []*SiteInfo
|
||||
|
||||
if err := db.Limit(1).Find(&siteInfo).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get site info from database")
|
||||
}
|
||||
|
||||
if len(siteInfo) == 0 {
|
||||
newSiteInfo := DefaultSiteInfo(db)
|
||||
|
||||
if err := db.Create(&newSiteInfo).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "initialize site_info")
|
||||
}
|
||||
|
||||
return &newSiteInfo, nil
|
||||
} else {
|
||||
return siteInfo[0], nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSiteInfo(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
site_info, err := models.GetSiteInfo(db)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, models.DefaultSiteInfo(db), *site_info)
|
||||
|
||||
site_info.InitialSetup = false
|
||||
site_info.PeriodicScanInterval = 360
|
||||
site_info.ConcurrentWorkers = 10
|
||||
|
||||
if !assert.NoError(t, db.Session(&gorm.Session{AllowGlobalUpdate: true}).Save(&site_info).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
site_info, err = models.GetSiteInfo(db)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, models.SiteInfo{
|
||||
InitialSetup: false,
|
||||
PeriodicScanInterval: 360,
|
||||
ConcurrentWorkers: 10,
|
||||
}, *site_info)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Model
|
||||
Username string `gorm:"unique;size:128"`
|
||||
Password *string `gorm:"size:256"`
|
||||
// RootPath string `gorm:"size:512`
|
||||
Albums []Album `gorm:"many2many:user_albums;constraint:OnDelete:CASCADE;"`
|
||||
Admin bool `gorm:"default:false"`
|
||||
}
|
||||
|
||||
type UserMediaData struct {
|
||||
ModelTimestamps
|
||||
UserID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
MediaID int `gorm:"primaryKey;autoIncrement:false"`
|
||||
Favorite bool `gorm:"not null;default:false"`
|
||||
}
|
||||
|
||||
type UserAlbums struct {
|
||||
UserID int `gorm:"primaryKey;autoIncrement:false;constraint:OnDelete:CASCADE;"`
|
||||
AlbumID int `gorm:"primaryKey;autoIncrement:false;constraint:OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
type AccessToken struct {
|
||||
Model
|
||||
UserID int `gorm:"not null;index"`
|
||||
User User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Value string `gorm:"not null;size:24;index"`
|
||||
Expire time.Time `gorm:"not null;index"`
|
||||
}
|
||||
|
||||
type UserPreferences struct {
|
||||
Model
|
||||
UserID int `gorm:"not null;index"`
|
||||
User User `gorm:"constraint:OnDelete:CASCADE;"`
|
||||
Language *LanguageTranslation
|
||||
}
|
||||
|
||||
func (u *UserPreferences) BeforeSave(tx *gorm.DB) error {
|
||||
|
||||
if u.Language != nil && *u.Language == "" {
|
||||
u.Language = nil
|
||||
}
|
||||
|
||||
if u.Language != nil {
|
||||
langStr := string(*u.Language)
|
||||
foundMatch := false
|
||||
for _, lang := range AllLanguageTranslation {
|
||||
if string(lang) == langStr {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
return errors.New("invalid language value")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrorInvalidUserCredentials = errors.New("invalid credentials")
|
||||
|
||||
func AuthorizeUser(db *gorm.DB, username string, password string) (*User, error) {
|
||||
var user User
|
||||
|
||||
result := db.Where("username = ?", username).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrorInvalidUserCredentials
|
||||
}
|
||||
return nil, errors.Wrap(result.Error, "failed to get user by username when authorizing")
|
||||
}
|
||||
|
||||
if user.Password == nil {
|
||||
return nil, errors.New("user does not have a password")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
return nil, ErrorInvalidUserCredentials
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "compare user password hash")
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func RegisterUser(db *gorm.DB, username string, password *string, admin bool) (*User, error) {
|
||||
user := User{
|
||||
Username: username,
|
||||
Admin: admin,
|
||||
}
|
||||
|
||||
if password != nil {
|
||||
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to hash password")
|
||||
}
|
||||
hashedPass := string(hashedPassBytes)
|
||||
|
||||
user.Password = &hashedPass
|
||||
}
|
||||
|
||||
result := db.Create(&user)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(result.Error, "insert new user with password into database")
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (user *User) GenerateAccessToken(db *gorm.DB) (*AccessToken, error) {
|
||||
bytes := make([]byte, 24)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return nil, errors.New(fmt.Sprintf("Could not generate token: %s\n", err.Error()))
|
||||
}
|
||||
const CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
for i, b := range bytes {
|
||||
bytes[i] = CHARACTERS[b%byte(len(CHARACTERS))]
|
||||
}
|
||||
|
||||
tokenValue := string(bytes)
|
||||
expire := time.Now().Add(14 * 24 * time.Hour)
|
||||
|
||||
token := AccessToken{
|
||||
UserID: user.ID,
|
||||
Value: tokenValue,
|
||||
Expire: expire,
|
||||
}
|
||||
|
||||
result := db.Create(&token)
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(result.Error, "saving access token to database")
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// FillAlbums fill user.Albums with albums from database
|
||||
func (user *User) FillAlbums(db *gorm.DB) error {
|
||||
// Albums already present
|
||||
if len(user.Albums) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Model(&user).Association("Albums").Find(&user.Albums); err != nil {
|
||||
return errors.Wrap(err, "fill user albums")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) {
|
||||
filter := func(query *gorm.DB) *gorm.DB {
|
||||
return query.Where(
|
||||
"EXISTS (SELECT 1 FROM user_albums WHERE user_albums.user_id = ? AND user_albums.album_id = id LIMIT 1)",
|
||||
user.ID)
|
||||
}
|
||||
|
||||
ownedParents, err := album.GetParents(db, filter)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(ownedParents) > 0, nil
|
||||
}
|
||||
|
||||
// FavoriteMedia sets/clears a media as favorite for the user
|
||||
func (user *User) FavoriteMedia(db *gorm.DB, mediaID int, favorite bool) (*Media, error) {
|
||||
userMediaData := UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: mediaID,
|
||||
Favorite: favorite,
|
||||
}
|
||||
|
||||
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil {
|
||||
return nil, errors.Wrapf(err, "update user favorite media in database")
|
||||
}
|
||||
|
||||
var media Media
|
||||
if err := db.First(&media, mediaID).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get media from database after favorite update")
|
||||
}
|
||||
|
||||
return &media, nil
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/dataloader"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/test_utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUserRegistrationAuthorization(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
t.Run("Register user", func(t *testing.T) {
|
||||
password := "1234"
|
||||
user, err := models.RegisterUser(db, "admin", &password, true)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, user)
|
||||
assert.EqualValues(t, "admin", user.Username)
|
||||
assert.NotNil(t, user.Password)
|
||||
assert.NotEqualValues(t, "1234", user.Password) // should be hashed
|
||||
assert.True(t, user.Admin)
|
||||
})
|
||||
|
||||
t.Run("Authorize user", func(t *testing.T) {
|
||||
user, err := models.AuthorizeUser(db, "admin", "1234")
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, user)
|
||||
assert.EqualValues(t, "admin", user.Username)
|
||||
})
|
||||
|
||||
t.Run("Authorize invalid credentials", func(t *testing.T) {
|
||||
user, err := models.AuthorizeUser(db, "invalid_username", "1234")
|
||||
assert.ErrorIs(t, err, models.ErrorInvalidUserCredentials)
|
||||
assert.Nil(t, user)
|
||||
|
||||
user, err = models.AuthorizeUser(db, "admin", "invalid_password")
|
||||
assert.ErrorIs(t, err, models.ErrorInvalidUserCredentials)
|
||||
assert.Nil(t, user)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessToken(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
pass := "<hashed_password>"
|
||||
user := models.User{
|
||||
Username: "user1",
|
||||
Password: &pass,
|
||||
Admin: false,
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
access_token, err := user.GenerateAccessToken(db)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotNil(t, access_token)
|
||||
assert.Equal(t, user.ID, access_token.UserID)
|
||||
assert.NotEmpty(t, access_token.Value)
|
||||
assert.True(t, access_token.Expire.After(time.Now()))
|
||||
}
|
||||
|
||||
func TestUserFillAlbums(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user := models.User{
|
||||
Username: "user",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
err := user.FillAlbums(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, user.Albums)
|
||||
|
||||
albums := []models.Album{
|
||||
{
|
||||
Title: "album1",
|
||||
Path: "/photos/album1",
|
||||
},
|
||||
{
|
||||
Title: "album2",
|
||||
Path: "/photos/album2",
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&albums)) {
|
||||
return
|
||||
}
|
||||
|
||||
user.Albums = make([]models.Album, 0)
|
||||
|
||||
err = user.FillAlbums(db)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, user.Albums, 2)
|
||||
|
||||
}
|
||||
|
||||
func TestUserOwnsAlbum(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user := models.User{
|
||||
Username: "user",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&user).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
albums := []models.Album{
|
||||
{
|
||||
Title: "album1",
|
||||
Path: "/photos/album1",
|
||||
},
|
||||
{
|
||||
Title: "album2",
|
||||
Path: "/photos/album2",
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&albums)) {
|
||||
return
|
||||
}
|
||||
|
||||
subAlbums := []models.Album{
|
||||
{
|
||||
Title: "subalbum1",
|
||||
Path: "/photos/album2/subalbum1",
|
||||
ParentAlbumID: &albums[1].ID,
|
||||
},
|
||||
{
|
||||
Title: "another_sub",
|
||||
Path: "/photos/album2/another_sub",
|
||||
ParentAlbumID: &albums[1].ID,
|
||||
},
|
||||
{
|
||||
Title: "subalbum2",
|
||||
Path: "/photos/album1/subalbum2",
|
||||
ParentAlbumID: &albums[0].ID,
|
||||
},
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&subAlbums)) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, album := range albums {
|
||||
owns, err := user.OwnsAlbum(db, &album)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, owns)
|
||||
}
|
||||
|
||||
for _, album := range subAlbums {
|
||||
owns, err := user.OwnsAlbum(db, &album)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, owns)
|
||||
}
|
||||
|
||||
separateAlbum := models.Album{
|
||||
Title: "separate_album",
|
||||
Path: "/my_media/album123",
|
||||
}
|
||||
|
||||
if !assert.NoError(t, db.Save(&separateAlbum).Error) {
|
||||
return
|
||||
}
|
||||
|
||||
owns, err := user.OwnsAlbum(db, &separateAlbum)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, owns)
|
||||
}
|
||||
|
||||
func TestUserFavoriteMedia(t *testing.T) {
|
||||
db := test_utils.DatabaseTest(t)
|
||||
|
||||
user, err := models.RegisterUser(db, "user1", nil, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rootAlbum := models.Album{
|
||||
Title: "root",
|
||||
Path: "/photos",
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&rootAlbum).Error)
|
||||
assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum))
|
||||
|
||||
media := models.Media{
|
||||
Title: "pic1",
|
||||
Path: "/photos/pic1",
|
||||
AlbumID: rootAlbum.ID,
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Save(&media).Error)
|
||||
|
||||
// test that it starts out being false
|
||||
favourite, err := dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: media.ID,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, favourite)
|
||||
|
||||
favMedia, err := user.FavoriteMedia(db, media.ID, true)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, favMedia)
|
||||
|
||||
// test that it is now true
|
||||
favourite, err = dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{
|
||||
UserID: user.ID,
|
||||
MediaID: media.ID,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, favourite)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func FormatSQL(tx *gorm.DB, order *Ordering, paginate *Pagination) *gorm.DB {
|
||||
|
||||
if paginate != nil {
|
||||
if paginate.Limit != nil {
|
||||
tx.Limit(*paginate.Limit)
|
||||
}
|
||||
|
||||
if paginate.Offset != nil {
|
||||
tx.Offset(*paginate.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
if order != nil && order.OrderBy != nil {
|
||||
desc := false
|
||||
if order.OrderDirection != nil && order.OrderDirection.IsValid() {
|
||||
if *order.OrderDirection == OrderDirectionDesc {
|
||||
desc = true
|
||||
}
|
||||
}
|
||||
|
||||
tx.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{
|
||||
Name: *order.OrderBy,
|
||||
},
|
||||
Desc: desc,
|
||||
})
|
||||
}
|
||||
|
||||
return tx
|
||||
}
|
||||
|
||||
// MD5Hash hashes value to a 32 length digest, the result is the same as the MYSQL function md5()
|
||||
func MD5Hash(value string) string {
|
||||
hash := md5.Sum([]byte(value))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMD5Hash(t *testing.T) {
|
||||
assert.Equal(t, "5eb63bbbe01eeed093cb22bb8f5acdc3", models.MD5Hash("hello world"))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
type VideoMetadata struct {
|
||||
Model
|
||||
Width int `gorm:"not null"`
|
||||
Height int `gorm:"not null"`
|
||||
Duration float64 `gorm:"not null"`
|
||||
Codec *string
|
||||
Framerate *float64
|
||||
Bitrate *string
|
||||
ColorProfile *string
|
||||
Audio *string
|
||||
}
|
||||
|
||||
func (metadata *VideoMetadata) Media() *Media {
|
||||
panic("not implemented")
|
||||
}
|
||||
Reference in New Issue
Block a user