精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
@@ -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)
}
+188
View File
@@ -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(&regularUser).Association("Albums").Append(&rootAlbum)) {
return
}
if !assert.NoError(t, db.Model(&regularUser).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)
})
}