精简后的项目:仅保留 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
+131
View File
@@ -0,0 +1,131 @@
package auth
import (
"context"
"errors"
"net/http"
"regexp"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/photoview/photoview/api/dataloader"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"gorm.io/gorm"
)
var ErrUnauthorized = errors.New("unauthorized")
var bearerRegex = regexp.MustCompile("^(?i)Bearer ([a-zA-Z0-9]{24})$")
const INVALID_AUTH_TOKEN = "invalid authorization token"
const INTERNAL_SERVER_ERROR = "internal server error"
// A private key for context that only this package can access. This is important
// to prevent collisions between different context uses
var userCtxKey = &contextKey{"user"}
type contextKey struct {
name string
}
// Middleware decodes the share session cookie and packs the session into context
func Middleware(db *gorm.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tokenCookie, err := r.Cookie("auth-token"); err == nil {
loaders := dataloader.For(r.Context())
if loaders == nil {
log.Error(r.Context(), "Dataloader not available in HTTP context")
http.Error(w, INTERNAL_SERVER_ERROR, http.StatusInternalServerError)
return
}
user, err := loaders.UserFromAccessToken.Load(tokenCookie.Value)
// Check for dataloader errors (database failures, etc.)
if err != nil {
log.Error(r.Context(), "Error loading user from token", "error", err)
http.Error(w, INVALID_AUTH_TOKEN, http.StatusUnauthorized)
return
}
// If user is nil, the token doesn't exist or is invalid
if user == nil {
log.Error(r.Context(), "Token not found in database")
http.Error(w, INVALID_AUTH_TOKEN, http.StatusUnauthorized)
return
}
// put it in context
ctx := AddUserToContext(r.Context(), user)
// and call the next with our new context
r = r.WithContext(ctx)
} else {
log.Info(r.Context(), "Did not find auth-token cookie")
}
next.ServeHTTP(w, r)
})
}
}
func AddUserToContext(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, userCtxKey, user)
}
func TokenFromBearer(bearer *string) (*string, error) {
matches := bearerRegex.FindStringSubmatch(*bearer)
if len(matches) != 2 {
return nil, errors.New("invalid bearer format")
}
token := matches[1]
return &token, nil
}
// UserFromContext finds the user from the context. REQUIRES Middleware to have run.
func UserFromContext(ctx context.Context) *models.User {
raw, _ := ctx.Value(userCtxKey).(*models.User)
return raw
}
func AuthWebsocketInit() func(context.Context, transport.InitPayload) (context.Context, *transport.InitPayload, error) {
return func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) {
bearer, exists := initPayload["Authorization"].(string)
if !exists {
return ctx, nil, nil
}
token, err := TokenFromBearer(&bearer)
if err != nil {
log.Error(ctx, "Invalid bearer format (websocket)", "error", err)
return nil, nil, err
}
loaders := dataloader.For(ctx)
if loaders == nil {
log.Error(ctx, "Dataloader not available in websocket context")
return nil, nil, errors.New(INTERNAL_SERVER_ERROR)
}
user, err := loaders.UserFromAccessToken.Load(*token)
if err != nil {
log.Error(ctx, "Error loading user from token (websocket)", "error", err)
return nil, nil, errors.New(INVALID_AUTH_TOKEN)
}
// Check if token exists in database
if user == nil {
log.Error(ctx, "Token not found in database (websocket)")
return nil, nil, errors.New(INVALID_AUTH_TOKEN)
}
// put it in context
userCtx := context.WithValue(ctx, userCtxKey, user)
// and return it so the resolvers can see it
// Return nil for the InitPayload acknowledgment (no custom ack payload needed)
return userCtx, nil, nil
}
}
+244
View File
@@ -0,0 +1,244 @@
package auth_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/photoview/photoview/api/dataloader"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/test_utils"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
test_utils.UnitTestRun(m)
}
func TestTokenFromBearer(t *testing.T) {
testsValues := []struct {
name string
bearer string
out string
valid bool
}{
{"Valid bearer", "Bearer ZY9YfxFa3TapSAD37XUBFryo", "ZY9YfxFa3TapSAD37XUBFryo", true},
{"Case insensitive bearer", "bEaReR ZY9YfxFa3TapSAD37XUBFryo", "ZY9YfxFa3TapSAD37XUBFryo", true},
{"Missing bearer start", "ZY9YfxFa3TapSAD37XUBFryo", "", false},
{"Empty input", "", "", false},
{"Invalid token value", "Bearer THIS_IS_INVALID", "", false},
}
for _, test := range testsValues {
t.Run(test.name, func(t *testing.T) {
token, err := auth.TokenFromBearer(&test.bearer)
if test.valid {
assert.NoError(t, err)
assert.NotNil(t, token)
assert.Equal(t, test.out, *token)
} else {
assert.Error(t, err)
assert.Nil(t, token)
}
})
}
}
func TestAuthWebsocketInit(t *testing.T) {
db := test_utils.DatabaseTest(t)
// Create test user and token
password := "test_password"
user, err := models.RegisterUser(db, "testuser", &password, false)
assert.NoError(t, err)
token, err := user.GenerateAccessToken(db)
assert.NoError(t, err)
testCases := []struct {
name string
initPayload transport.InitPayload
expectError bool
expectUser bool
expectNilCtx bool
}{
{
name: "Valid authorization",
initPayload: transport.InitPayload{"Authorization": "Bearer " + token.Value},
expectError: false,
expectUser: true,
},
{
name: "Missing authorization",
initPayload: transport.InitPayload{},
expectError: false,
expectUser: false,
},
{
name: "Invalid bearer format",
initPayload: transport.InitPayload{"Authorization": "InvalidFormat"},
expectError: true,
expectNilCtx: true,
},
{
name: "Invalid token",
initPayload: transport.InitPayload{"Authorization": "Bearer INVALID_TOKEN_123456"},
expectError: true,
expectNilCtx: true,
},
{
name: "Empty token",
initPayload: transport.InitPayload{"Authorization": "Bearer "},
expectError: true,
expectNilCtx: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
initFunc := auth.AuthWebsocketInit()
ctx := context.Background()
req := httptest.NewRequest("GET", "/", nil)
req = req.WithContext(ctx)
var contextWithLoaders context.Context
handler := dataloader.Middleware(db)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contextWithLoaders = r.Context()
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
resultCtx, ackPayload, err := initFunc(contextWithLoaders, tc.initPayload)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
if tc.expectNilCtx {
assert.Nil(t, resultCtx)
} else {
assert.NotNil(t, resultCtx)
}
// Verify InitPayload acknowledgment is always nil (as per PR implementation)
assert.Nil(t, ackPayload)
if tc.expectUser {
retrievedUser := auth.UserFromContext(resultCtx)
assert.NotNil(t, retrievedUser)
assert.Equal(t, user.ID, retrievedUser.ID)
assert.Equal(t, "testuser", retrievedUser.Username)
} else if !tc.expectNilCtx {
retrievedUser := auth.UserFromContext(resultCtx)
assert.Nil(t, retrievedUser)
}
})
}
}
func TestMiddleware(t *testing.T) {
db := test_utils.DatabaseTest(t)
// Create test user and token
password := "test_password"
user, err := models.RegisterUser(db, "testuser", &password, false)
assert.NoError(t, err)
token, err := user.GenerateAccessToken(db)
assert.NoError(t, err)
testCases := []struct {
name string
cookieValue string
setCookie bool
expectStatus int
expectUser bool
}{
{
name: "Valid token cookie",
cookieValue: token.Value,
setCookie: true,
expectStatus: 200,
expectUser: true,
},
{
name: "No cookie",
setCookie: false,
expectStatus: 200,
expectUser: false,
},
{
name: "Invalid token",
cookieValue: "INVALID_TOKEN",
setCookie: true,
expectStatus: 401,
expectUser: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/graphql", nil)
if tc.setCookie {
req.AddCookie(&http.Cookie{
Name: "auth-token",
Value: tc.cookieValue,
})
}
var capturedContext context.Context
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedContext = r.Context()
})
authHandler := auth.Middleware(db)(handler)
fullHandler := dataloader.Middleware(db)(authHandler)
recorder := httptest.NewRecorder()
fullHandler.ServeHTTP(recorder, req)
assert.Equal(t, tc.expectStatus, recorder.Code)
if tc.expectUser {
retrievedUser := auth.UserFromContext(capturedContext)
assert.NotNil(t, retrievedUser)
assert.Equal(t, user.ID, retrievedUser.ID)
} else if recorder.Code == 200 {
// Handler was called, verify no user in context
retrievedUser := auth.UserFromContext(capturedContext)
assert.Nil(t, retrievedUser)
}
})
}
}
func TestContextUserOperations(t *testing.T) {
db := test_utils.DatabaseTest(t)
password := "test"
user, err := models.RegisterUser(db, "testuser", &password, false)
assert.NoError(t, err)
t.Run("AddUserToContext and UserFromContext", func(t *testing.T) {
ctx := context.Background()
// Initially no user
retrieved := auth.UserFromContext(ctx)
assert.Nil(t, retrieved)
// Add user
ctxWithUser := auth.AddUserToContext(ctx, user)
// Retrieve user
retrieved = auth.UserFromContext(ctxWithUser)
assert.NotNil(t, retrieved)
assert.Equal(t, user.ID, retrieved.ID)
assert.Equal(t, "testuser", retrieved.Username)
})
}
+27
View File
@@ -0,0 +1,27 @@
package api
import (
"context"
"errors"
"github.com/99designs/gqlgen/graphql"
"github.com/photoview/photoview/api/graphql/auth"
)
func IsAdmin(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
user := auth.UserFromContext(ctx)
if user == nil || user.Admin == false {
return nil, errors.New("user must be admin")
}
return next(ctx)
}
func IsAuthorized(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return next(ctx)
}
+52
View File
@@ -0,0 +1,52 @@
package graphql_endpoint
import (
"time"
graphql_handler "github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport"
photoview_graphql "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/resolvers"
"github.com/photoview/photoview/api/server"
"github.com/photoview/photoview/api/utils"
"github.com/vektah/gqlparser/v2/ast"
"gorm.io/gorm"
)
func GraphqlEndpoint(db *gorm.DB) *graphql_handler.Server {
graphqlResolver := resolvers.NewRootResolver(db)
graphqlDirective := photoview_graphql.DirectiveRoot{}
graphqlDirective.IsAdmin = photoview_graphql.IsAdmin
graphqlDirective.IsAuthorized = photoview_graphql.IsAuthorized
graphqlConfig := photoview_graphql.Config{
Resolvers: &graphqlResolver,
Directives: graphqlDirective,
}
graphqlServer := graphql_handler.New(photoview_graphql.NewExecutableSchema(graphqlConfig))
graphqlServer.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
Upgrader: server.WebsocketUpgrader(utils.DevelopmentMode()),
InitFunc: auth.AuthWebsocketInit(),
})
graphqlServer.AddTransport(transport.Options{})
graphqlServer.AddTransport(transport.GET{})
graphqlServer.AddTransport(transport.POST{})
graphqlServer.AddTransport(transport.MultipartForm{})
graphqlServer.SetQueryCache(lru.New[*ast.QueryDocument](1000))
graphqlServer.Use(extension.AutomaticPersistedQuery{
Cache: lru.New[string](100),
})
if utils.DevelopmentMode() {
graphqlServer.Use(extension.Introspection{})
}
return graphqlServer
}
@@ -0,0 +1,22 @@
package graphql_endpoint_test
import (
"testing"
graphql_endpoint "github.com/photoview/photoview/api/graphql/endpoint"
"github.com/photoview/photoview/api/test_utils"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
test_utils.UnitTestRun(m)
}
func TestGraphqlEndpoint(t *testing.T) {
db := test_utils.DatabaseTest(t)
t.Run("creates server successfully", func(t *testing.T) {
server := graphql_endpoint.GraphqlEndpoint(db)
assert.NotNil(t, server)
})
}
File diff suppressed because it is too large Load Diff
@@ -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)
})
}
+115
View File
@@ -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
}
+369
View File
@@ -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")
})
}
+15
View File
@@ -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
}
+130
View File
@@ -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
}
+316
View File
@@ -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
}
+156
View File
@@ -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
}
+70
View File
@@ -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
}
+63
View File
@@ -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)
}
})
}
}
+127
View File
@@ -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)
}
+11
View File
@@ -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)
}
+22
View File
@@ -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
}
+52
View File
@@ -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
}
}
+41
View File
@@ -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)
}
+200
View File
@@ -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
}
+231
View File
@@ -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)
}
+46
View File
@@ -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[:])
}
+12
View File
@@ -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"))
}
+17
View File
@@ -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")
}
+83
View File
@@ -0,0 +1,83 @@
package notification
import (
"errors"
"log"
"sync"
"github.com/photoview/photoview/api/graphql/models"
)
type NotificationChannel = chan<- *models.Notification
type NotificationListener struct {
listenerID int
user models.User
channel NotificationChannel
}
func NewListener(user models.User, channel NotificationChannel) *NotificationListener {
nextNotificationId++
return &NotificationListener{
listenerID: nextNotificationId,
user: user,
channel: channel,
}
}
var notificationListeners []*NotificationListener = make([]*NotificationListener, 0)
var nextNotificationId = 0
var notificationLock = &sync.Mutex{}
func RegisterListener(user *models.User, channel NotificationChannel) int {
log.Println("Registering notification listener")
notificationLock.Lock()
defer notificationLock.Unlock()
notificationListeners = append(notificationListeners, NewListener(*user, channel))
return nextNotificationId
}
func DeregisterListener(listenerID int) error {
notificationLock.Lock()
defer notificationLock.Unlock()
for i, listener := range notificationListeners {
log.Println("Deregistering notification listener")
if listener.listenerID == listenerID {
if len(notificationListeners) > 1 {
lastIndex := len(notificationListeners) - 1
lastListener := notificationListeners[lastIndex]
notificationListeners[i] = lastListener
notificationListeners[lastIndex] = nil
notificationListeners = notificationListeners[:lastIndex]
} else {
notificationListeners = make([]*NotificationListener, 0)
}
return nil
}
}
return errors.New("ListenerID not found, while trying to deregister it")
}
func BroadcastNotification(notification *models.Notification) {
if notification == nil {
return
}
notificationLock.Lock()
defer notificationLock.Unlock()
for _, listener := range notificationListeners {
listener.channel <- notification
}
}
+167
View File
@@ -0,0 +1,167 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"errors"
"fmt"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
"gorm.io/gorm"
)
// Media is the resolver for the media field.
func (r *albumResolver) Media(ctx context.Context, obj *models.Album, order *models.Ordering, paginate *models.Pagination, onlyFavorites *bool) ([]*models.Media, error) {
db := r.DB(ctx)
query := db.
Where("media.album_id = ?", obj.ID).
Where("media.id IN (?)", db.Model(&models.MediaURL{}).
Select("media_urls.media_id").
Where("media_urls.media_id = media.id"))
if onlyFavorites != nil && *onlyFavorites == true {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("cannot get favorite media without being authorized")
}
favoriteQuery := db.Model(&models.UserMediaData{
UserID: user.ID,
}).Where("user_media_data.media_id = media.id").Where("user_media_data.favorite = true")
query = query.Where("EXISTS (?)", favoriteQuery)
}
query = models.FormatSQL(query, order, paginate)
var media []*models.Media
if err := query.Find(&media).Error; err != nil {
return nil, err
}
return media, nil
}
// SubAlbums is the resolver for the subAlbums field.
func (r *albumResolver) SubAlbums(ctx context.Context, obj *models.Album, order *models.Ordering, paginate *models.Pagination) ([]*models.Album, error) {
var albums []*models.Album
query := r.DB(ctx).Where("parent_album_id = ?", obj.ID)
query = models.FormatSQL(query, order, paginate)
if err := query.Find(&albums).Error; err != nil {
return nil, err
}
return albums, nil
}
// Owner is the resolver for the owner field.
func (r *albumResolver) Owner(ctx context.Context, obj *models.Album) (*models.User, error) {
panic("not implemented")
}
// Thumbnail is the resolver for the thumbnail field.
func (r *albumResolver) Thumbnail(ctx context.Context, obj *models.Album) (*models.Media, error) {
return obj.Thumbnail(r.DB(ctx))
}
// Path is the resolver for the path field.
func (r *albumResolver) Path(ctx context.Context, obj *models.Album) ([]*models.Album, error) {
user := auth.UserFromContext(ctx)
if user == nil {
empty := make([]*models.Album, 0)
return empty, nil
}
return actions.AlbumPath(r.DB(ctx), user, obj)
}
// Shares is the resolver for the shares field.
func (r *albumResolver) Shares(ctx context.Context, obj *models.Album) ([]*models.ShareToken, error) {
var shareTokens []*models.ShareToken
if err := r.DB(ctx).Where("album_id = ?", obj.ID).Find(&shareTokens).Error; err != nil {
return nil, err
}
return shareTokens, nil
}
// Takes album_id, resets album.cover_id to 0 (null)
func (r *mutationResolver) ResetAlbumCover(ctx context.Context, albumID int) (*models.Album, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
return actions.ResetAlbumCover(r.DB(ctx), user, albumID)
}
// SetAlbumCover is the resolver for the setAlbumCover field.
func (r *mutationResolver) SetAlbumCover(ctx context.Context, coverID int) (*models.Album, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
return actions.SetAlbumCover(r.DB(ctx), user, coverID)
}
// MyAlbums is the resolver for the myAlbums field.
func (r *queryResolver) MyAlbums(ctx context.Context, order *models.Ordering, paginate *models.Pagination, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.MyAlbums(r.DB(ctx), user, order, paginate, onlyRoot, showEmpty, onlyWithFavorites)
}
// Album is the resolver for the album field.
func (r *queryResolver) Album(ctx context.Context, id int, tokenCredentials *models.ShareTokenCredentials) (*models.Album, error) {
db := r.DB(ctx)
if tokenCredentials != nil {
shareToken, err := r.ShareToken(ctx, *tokenCredentials)
if err != nil {
return nil, err
}
if shareToken.Album != nil {
if *shareToken.AlbumID == id {
return shareToken.Album, nil
}
subAlbum, err := shareToken.Album.GetChildren(db, func(query *gorm.DB) *gorm.DB {
return query.Where("sub_albums.id = ?", id)
})
if err != nil {
return nil, fmt.Errorf("find sub album of share token (%s): %w", tokenCredentials.Token, err)
}
if len(subAlbum) > 0 {
return subAlbum[0], nil
}
}
}
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.Album(db, user, id)
}
// Album returns api.AlbumResolver implementation.
func (r *Resolver) Album() api.AlbumResolver { return &albumResolver{r} }
type albumResolver struct{ *Resolver }
+60
View File
@@ -0,0 +1,60 @@
type Album {
id: ID!
title: String!
"The media inside this album"
media(
order: Ordering,
paginate: Pagination
"Return only the favorited media"
onlyFavorites: Boolean
): [Media!]!
"The albums contained in this album"
subAlbums(
order: Ordering,
paginate: Pagination
): [Album!]!
"The album which contains this album"
parentAlbum: Album
"The user who owns this album"
owner: User!
"The path on the filesystem of the server, where this album is located"
filePath: String!
"An image in this album used for previewing this album"
thumbnail: Media
"A breadcrumb list of all parent albums down to this one"
path: [Album!]!
"A list of share tokens pointing to this album, owned by the logged in user"
shares: [ShareToken!]!
}
extend type Query {
"List of albums owned by the logged in user."
myAlbums(
order: Ordering,
paginate: Pagination
"Return only albums from the root directory of the user"
onlyRoot: Boolean
"Return also albums with no media directly in them"
showEmpty: Boolean
"Show only albums having favorites"
onlyWithFavorites: Boolean
): [Album!]! @isAuthorized
"""
Get album by id, user must own the album or be admin
If valid tokenCredentials are provided, the album may be retrived without further authentication
"""
album(id: ID!, tokenCredentials: ShareTokenCredentials): Album!
}
extend type Mutation {
"Reset the assigned cover photo for an album"
resetAlbumCover(albumID: ID!): Album! @isAuthorized
"Assign a cover photo to an album"
setAlbumCover(coverID: ID!): Album! @isAuthorized
}
+442
View File
@@ -0,0 +1,442 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"errors"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"gorm.io/gorm"
)
// ImageFaces is the resolver for the imageFaces field.
func (r *faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
if err := user.FillAlbums(db); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
query := db.
Joins("Media").
Where(faceGroupIDIsQuestion, obj.ID).
Where("album_id IN (?)", userAlbumIDs)
query = models.FormatSQL(query, nil, paginate)
var imageFaces []*models.ImageFace
if err := query.Find(&imageFaces).Error; err != nil {
return nil, err
}
return imageFaces, nil
}
// ImageFaceCount is the resolver for the imageFaceCount field.
func (r *faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return -1, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return -1, ErrFaceDetectorNotInitialized
}
if err := user.FillAlbums(db); err != nil {
return -1, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
query := db.
Model(&models.ImageFace{}).
Joins("Media").
Where(faceGroupIDIsQuestion, obj.ID).
Where("album_id IN (?)", userAlbumIDs)
var count int64
if err := query.Count(&count).Error; err != nil {
return -1, err
}
return int(count), nil
}
// Media is the resolver for the media field.
func (r *imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
if err := obj.FillMedia(r.DB(ctx)); err != nil {
return nil, err
}
return &obj.Media, nil
}
// FaceGroup is the resolver for the faceGroup field.
func (r *imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
if obj.FaceGroup != nil {
return obj.FaceGroup, nil
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
var faceGroup models.FaceGroup
if err := r.DB(ctx).Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
return nil, err
}
obj.FaceGroup = &faceGroup
return &faceGroup, nil
}
// SetFaceGroupLabel is the resolver for the setFaceGroupLabel field.
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
faceGroup, err := userOwnedFaceGroup(db, user, faceGroupID)
if err != nil {
return nil, err
}
if err := db.Model(faceGroup).Update("label", label).Error; err != nil {
return nil, err
}
return faceGroup, nil
}
// CombineFaceGroups is the resolver for the combineFaceGroups field.
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupIDs []int) (*models.FaceGroup, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
if len(sourceFaceGroupIDs) < 1 {
return nil, errors.New("at least one source face group ID is required")
}
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
if err != nil {
return nil, err
}
var sourceFaceGroups []*models.FaceGroup
// Validate the source groups before beginning the merge
for _, sourceID := range sourceFaceGroupIDs {
if sourceID == destinationFaceGroup.ID {
return nil, errors.New("source face groups cannot include the destination face group")
}
// Ensure the user owns at least one picture with each source
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceID)
if err != nil {
return nil, err
}
sourceFaceGroups = append(sourceFaceGroups, sourceFaceGroup)
}
// Perform the merge
updateError := db.Transaction(func(tx *gorm.DB) error {
if err := tx.
Model(&models.ImageFace{}).
Where(faceGroupIDsInQuestion, sourceFaceGroupIDs).
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
return err
}
// delete the source face groups
if err := deleteFaceGroups(sourceFaceGroups, tx); err != nil {
return err
}
subQuery := tx.Model(&models.ImageFace{}).
Select("MIN(id)").
Where("face_group_id = ?", destinationFaceGroup.ID).
Group("media_id")
err := tx.Where("face_group_id = ?", destinationFaceGroup.ID).
Where("id NOT IN (?)", subQuery).
Delete(&models.ImageFace{}).
Error
if err != nil {
return err
}
return nil
})
if updateError != nil {
return nil, updateError
}
face_detection.GlobalFaceDetector.MergeImageFaces(sourceFaceGroupIDs, int32(destinationFaceGroupID))
return destinationFaceGroup, nil
}
// MoveImageFaces is the resolver for the moveImageFaces field.
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
userOwnedImageFaceIDs := make([]int, 0)
var destFaceGroup *models.FaceGroup
transErr := db.Transaction(func(tx *gorm.DB) error {
var err error
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
if err != nil {
return err
}
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
if err != nil {
return err
}
for _, imageFace := range userOwnedImageFaces {
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
}
var sourceFaceGroups []*models.FaceGroup
if err := tx.
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
Where(imageFacesIDInQuestion, userOwnedImageFaceIDs).
Find(&sourceFaceGroups).Error; err != nil {
return err
}
if err := tx.
Model(&models.ImageFace{}).
Where("id IN (?)", userOwnedImageFaceIDs).
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
return err
}
// delete face groups if they have become empty
if err := deleteEmptyFaceGroups(sourceFaceGroups, tx); err != nil {
return err
}
return nil
})
if transErr != nil {
return nil, transErr
}
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
return destFaceGroup, nil
}
// RecognizeUnlabeledFaces is the resolver for the recognizeUnlabeledFaces field.
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
var updatedImageFaces []*models.ImageFace
transactionError := db.Transaction(func(tx *gorm.DB) error {
var err error
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
return err
})
if transactionError != nil {
return nil, transactionError
}
return updatedImageFaces, nil
}
// DetachImageFaces is the resolver for the detachImageFaces field.
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
userOwnedImageFaceIDs := make([]int, 0)
newFaceGroup := models.FaceGroup{}
transactionError := db.Transaction(func(tx *gorm.DB) error {
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
if err != nil {
return err
}
for _, imageFace := range userOwnedImageFaces {
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
}
if err := tx.Save(&newFaceGroup).Error; err != nil {
return err
}
if err := tx.
Model(&models.ImageFace{}).
Where("id IN (?)", userOwnedImageFaceIDs).
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
return err
}
return nil
})
if transactionError != nil {
return nil, transactionError
}
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
return &newFaceGroup, nil
}
// MyFaceGroups is the resolver for the myFaceGroups field.
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
if err := user.FillAlbums(db); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
faceGroupQuery := db.
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
Where("image_faces.media_id IN (?)",
db.Select("media.id").Table("media").Where(mediaAlbumIDInQuestion, userAlbumIDs)).
Group("image_faces.face_group_id").
Group("face_groups.id").
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END").
Order("COUNT(image_faces.id) DESC")
faceGroupQuery = models.FormatSQL(faceGroupQuery, nil, paginate)
var faceGroups []*models.FaceGroup
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
return nil, err
}
return faceGroups, nil
}
// FaceGroup is the resolver for the faceGroup field.
func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGroup, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
if face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
if err := user.FillAlbums(db); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
faceGroupQuery := db.
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
Joins("LEFT JOIN media ON image_faces.media_id = media.id").
Where("face_groups.id = ?", id).
Where(mediaAlbumIDInQuestion, userAlbumIDs)
var faceGroup models.FaceGroup
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
return nil, err
}
return &faceGroup, nil
}
// FaceGroup returns api.FaceGroupResolver implementation.
func (r *Resolver) FaceGroup() api.FaceGroupResolver { return &faceGroupResolver{r} }
// ImageFace returns api.ImageFaceResolver implementation.
func (r *Resolver) ImageFace() api.ImageFaceResolver { return &imageFaceResolver{r} }
type faceGroupResolver struct{ *Resolver }
type imageFaceResolver struct{ *Resolver }
+53
View File
@@ -0,0 +1,53 @@
"A bounding box of where a face is present on an image. The values map from 0 to 1 as a fraction of the image width/height"
type FaceRectangle {
minX: Float!
maxX: Float!
minY: Float!
maxY: Float!
}
"A collection of faces of a particular person"
type FaceGroup {
id: ID!
"The name of the person"
label: String
imageFaces(paginate: Pagination): [ImageFace!]!
"The total number of images in this collection"
imageFaceCount: Int!
}
"A single face on a particular image"
type ImageFace {
id: ID!
"A reference to the image the face appears on"
media: Media!
"A bounding box of where on the image the face is present"
rectangle: FaceRectangle!
"The `FaceGroup` that contains this `ImageFace`"
faceGroup: FaceGroup!
}
extend type Query {
"Get a list of `FaceGroup`s for the logged in user"
myFaceGroups(paginate: Pagination): [FaceGroup!]! @isAuthorized
"Get a particular `FaceGroup` specified by its ID"
faceGroup(id: ID!): FaceGroup! @isAuthorized
}
extend type Mutation {
"Assign a label to a face group, set label to null to remove the current one"
setFaceGroupLabel(faceGroupID: ID!, label: String): FaceGroup! @isAuthorized
"Merge two face groups into a single one, all ImageFaces from source will be moved to destination"
combineFaceGroups(destinationFaceGroupID: ID!, sourceFaceGroupIDs: [ID!]!): FaceGroup! @isAuthorized
"Move a list of ImageFaces to another face group"
moveImageFaces(imageFaceIDs: [ID!]!, destinationFaceGroupID: ID!): FaceGroup! @isAuthorized
"Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match"
recognizeUnlabeledFaces: [ImageFace!]! @isAuthorized
"Move a list of ImageFaces to a new face group"
detachImageFaces(imageFaceIDs: [ID!]!): FaceGroup! @isAuthorized
}
+106
View File
@@ -0,0 +1,106 @@
package resolvers
import (
"errors"
"fmt"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
const faceGroupIDIsQuestion = "face_group_id = ?"
const faceGroupIDsInQuestion = "face_group_id IN (?)"
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
const imageFacesIDInQuestion = "image_faces.id IN (?)"
var ErrFaceDetectorNotInitialized = errors.New("face detector not initialized")
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
if user.Admin {
var faceGroup models.FaceGroup
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
return nil, err
}
return &faceGroup, nil
}
if err := user.FillAlbums(db); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
// Verify that user owns at least one of the images in the face group
imageFaceQuery := db.
Select("image_faces.id").
Table("image_faces").
Joins("JOIN media ON media.id = image_faces.media_id").
Where(mediaAlbumIDInQuestion, userAlbumIDs)
faceGroupQuery := db.
Model(&models.FaceGroup{}).
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
Where("face_groups.id = ?", faceGroupID).
Where(imageFacesIDInQuestion, imageFaceQuery)
var faceGroup models.FaceGroup
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("face group does not exist or is not owned by the user: %w", err)
}
return nil, err
}
return &faceGroup, nil
}
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
if err := user.FillAlbums(tx); err != nil {
return nil, err
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
var userOwnedImageFaces []*models.ImageFace
if err := tx.
Joins("JOIN media ON media.id = image_faces.media_id").
Where(mediaAlbumIDInQuestion, userAlbumIDs).
Where(imageFacesIDInQuestion, imageFaceIDs).
Find(&userOwnedImageFaces).Error; err != nil {
return nil, err
}
return userOwnedImageFaces, nil
}
func deleteEmptyFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
for _, faceGroup := range sourceFaceGroups {
var count int64
if err := tx.Model(&models.ImageFace{}).Where(faceGroupIDIsQuestion, faceGroup.ID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := tx.Delete(&faceGroup).Error; err != nil {
return err
}
}
}
return nil
}
func deleteFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
for _, faceGroup := range sourceFaceGroups {
if err := tx.Delete(&faceGroup).Error; err != nil {
return fmt.Errorf("Delete FaceGroup(%d) error: %w", faceGroup.ID, err)
}
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package resolvers
import (
"context"
"testing"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/test_utils"
)
func TestCombineFaceGroups(t *testing.T) {
test_utils.FilesystemTest(t)
db := test_utils.DatabaseTest(t)
face_detection.InitializeFaceDetector(db)
pass := "1234"
user, err := models.RegisterUser(db, "test_user", &pass, true)
if err != nil {
t.Fatal("register user error:", err)
}
db.AutoMigrate(&models.ImageFace{}, &models.FaceGroup{}, &models.Media{}, &models.Album{})
tests := []struct {
name string
dest int
src []int
}{
{
name: "merge multiple combinations with duplicates",
dest: 1,
src: []int{2, 3},
},
{
name: "merge two combinations with duplicates",
dest: 1,
src: []int{2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db.Exec("DELETE FROM image_faces")
db.Exec("DELETE FROM face_groups")
db.Exec("DELETE FROM media")
db.Exec("DELETE FROM albums")
testAlbum := models.Album{Title: "Test Album"}
if err := db.Create(&testAlbum).Error; err != nil {
t.Fatal(err)
}
testMedia := []models.Media{
{Model: models.Model{ID: 1}, Path: "test1", AlbumID: testAlbum.ID},
{Model: models.Model{ID: 2}, Path: "test2", AlbumID: testAlbum.ID},
{Model: models.Model{ID: 3}, Path: "test3", AlbumID: testAlbum.ID},
{Model: models.Model{ID: 4}, Path: "test4", AlbumID: testAlbum.ID},
}
if err := db.Create(&testMedia).Error; err != nil {
t.Fatal(err)
}
testFaceGroup := []models.FaceGroup{
{Model: models.Model{ID: 1}},
{Model: models.Model{ID: 2}},
{Model: models.Model{ID: 3}},
{Model: models.Model{ID: 4}},
}
if err := db.Create(&testFaceGroup).Error; err != nil {
t.Fatal(err)
}
testDataList := []models.ImageFace{
{FaceGroupID: 1, MediaID: 1},
{FaceGroupID: 1, MediaID: 2},
{FaceGroupID: 1, MediaID: 3},
{FaceGroupID: 2, MediaID: 3},
{FaceGroupID: 2, MediaID: 4},
{FaceGroupID: 3, MediaID: 4},
{FaceGroupID: 3, MediaID: 1},
}
if err := db.Create(&testDataList).Error; err != nil {
t.Fatal(err)
}
r := &mutationResolver{
Resolver: &Resolver{
database: db,
},
}
ctx := auth.AddUserToContext(context.Background(), user)
combineFace, err := r.CombineFaceGroups(ctx, tt.dest, tt.src)
if err != nil {
t.Fatal("test CombineFaceGroups err:", err)
}
m := make(map[int]struct{})
for _, imageface := range combineFace.ImageFaces {
if _, ok := m[imageface.MediaID]; ok {
t.Fatal("filtering failed at", imageface.MediaID)
}
m[imageface.MediaID] = struct{}{}
}
})
}
}
+236
View File
@@ -0,0 +1,236 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"fmt"
"github.com/photoview/photoview/api/dataloader"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
"github.com/photoview/photoview/api/scanner/face_detection"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Thumbnail is the resolver for the thumbnail field.
func (r *mediaResolver) Thumbnail(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
return dataloader.For(ctx).MediaThumbnail.Load(obj.ID)
}
// HighRes is the resolver for the highRes field.
func (r *mediaResolver) HighRes(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
if obj.Type != models.MediaTypePhoto {
return nil, nil
}
return dataloader.For(ctx).MediaHighres.Load(obj.ID)
}
// VideoWeb is the resolver for the videoWeb field.
func (r *mediaResolver) VideoWeb(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
if obj.Type != models.MediaTypeVideo {
return nil, nil
}
return dataloader.For(ctx).MediaVideoWeb.Load(obj.ID)
}
// Album is the resolver for the album field.
func (r *mediaResolver) Album(ctx context.Context, obj *models.Media) (*models.Album, error) {
var album models.Album
err := r.DB(ctx).Find(&album, obj.AlbumID).Error
if err != nil {
return nil, err
}
return &album, nil
}
// Exif is the resolver for the exif field.
func (r *mediaResolver) Exif(ctx context.Context, obj *models.Media) (*models.MediaEXIF, error) {
if obj.Exif != nil {
return obj.Exif, nil
}
var exif models.MediaEXIF
if err := r.DB(ctx).Model(obj).Association("Exif").Find(&exif); err != nil {
return nil, err
}
return &exif, nil
}
// Favorite is the resolver for the favorite field.
func (r *mediaResolver) Favorite(ctx context.Context, obj *models.Media) (bool, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return false, auth.ErrUnauthorized
}
return dataloader.For(ctx).UserMediaFavorite.Load(&models.UserMediaData{
UserID: user.ID,
MediaID: obj.ID,
})
}
// Type is the resolver for the type field.
func (r *mediaResolver) Type(ctx context.Context, obj *models.Media) (models.MediaType, error) {
formattedType := models.MediaType(cases.Title(language.Und).String(string(obj.Type)))
return formattedType, nil
}
// Shares is the resolver for the shares field.
func (r *mediaResolver) Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) {
var shareTokens []*models.ShareToken
if err := r.DB(ctx).Where("media_id = ?", obj.ID).Find(&shareTokens).Error; err != nil {
return nil, fmt.Errorf("get shares for media (%s): %w", obj.Path, err)
}
return shareTokens, nil
}
// Downloads is the resolver for the downloads field.
func (r *mediaResolver) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) {
var mediaUrls []*models.MediaURL
if err := r.DB(ctx).Where("media_id = ?", obj.ID).Find(&mediaUrls).Error; err != nil {
return nil, fmt.Errorf("get downloads for media (%s): %w", obj.Path, err)
}
downloads := make([]*models.MediaDownload, 0)
for _, url := range mediaUrls {
var title string
switch {
case url.Purpose == models.MediaOriginal:
title = "Original"
case url.Purpose == models.PhotoThumbnail:
title = "Small"
case url.Purpose == models.PhotoHighRes:
title = "Large"
case url.Purpose == models.VideoThumbnail:
title = "Video thumbnail"
case url.Purpose == models.VideoWeb:
title = "Web optimized video"
}
downloads = append(downloads, &models.MediaDownload{
Title: title,
MediaURL: url,
})
}
return downloads, nil
}
// Faces is the resolver for the faces field.
func (r *mediaResolver) Faces(ctx context.Context, obj *models.Media) ([]*models.ImageFace, error) {
if face_detection.GlobalFaceDetector == nil {
return []*models.ImageFace{}, nil
}
if obj.Faces != nil {
return obj.Faces, nil
}
var faces []*models.ImageFace
if err := r.DB(ctx).Model(obj).Association("Faces").Find(&faces); err != nil {
return nil, err
}
return faces, nil
}
// FavoriteMedia is the resolver for the favoriteMedia field.
func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favorite bool) (*models.Media, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return user.FavoriteMedia(r.DB(ctx), mediaID, favorite)
}
// MyMedia is the resolver for the myMedia field.
func (r *queryResolver) MyMedia(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.Media, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, fmt.Errorf("unauthorized")
}
return actions.MyMedia(r.DB(ctx), user, order, paginate)
}
// Media is the resolver for the media field.
func (r *queryResolver) Media(ctx context.Context, id int, tokenCredentials *models.ShareTokenCredentials) (*models.Media, error) {
db := r.DB(ctx)
if tokenCredentials != nil {
shareToken, err := r.ShareToken(ctx, *tokenCredentials)
if err != nil {
return nil, err
}
if *shareToken.MediaID == id {
return shareToken.Media, nil
}
}
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
var media models.Media
err := db.
Joins("Album").
Where("media.id = ?", id).
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = media.album_id AND user_albums.user_id = ?)",
user.ID).
Where("media.id IN (?)", db.Model(&models.MediaURL{}).Select("media_id").Where("media_urls.media_id = media.id")).
First(&media).Error
if err != nil {
return nil, fmt.Errorf("could not get media by media_id and user_id from database: %w", err)
}
return &media, nil
}
// MediaList is the resolver for the mediaList field.
func (r *queryResolver) MediaList(ctx context.Context, ids []int) ([]*models.Media, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
if len(ids) == 0 {
return nil, fmt.Errorf("no ids provided")
}
var media []*models.Media
err := db.Model(&media).
Joins("LEFT JOIN user_albums ON user_albums.album_id = media.album_id").
Where("media.id IN ?", ids).
Where("user_albums.user_id = ?", user.ID).
Find(&media).Error
if err != nil {
return nil, fmt.Errorf("could not get media list by media_id and user_id from database: %w", err)
}
return media, nil
}
// Media returns api.MediaResolver implementation.
func (r *Resolver) Media() api.MediaResolver { return &mediaResolver{r} }
type mediaResolver struct{ *Resolver }
+123
View File
@@ -0,0 +1,123 @@
type MediaURL {
"URL for previewing the image"
url: String!
"Width of the image in pixels"
width: Int!
"Height of the image in pixels"
height: Int!
"The file size of the resource in bytes"
fileSize: Int!
}
type MediaDownload {
"A description of the role of the media file"
title: String!
mediaUrl: MediaURL!
}
enum MediaType {
Photo
Video
}
type Coordinates {
"GPS latitude in degrees"
latitude: Float!
"GPS longitude in degrees"
longitude: Float!
}
"EXIF metadata from the camera"
type MediaEXIF {
id: ID!
media: Media!
"The description of the image"
description: String
"The model name of the camera"
camera: String
"The maker of the camera"
maker: String
"The name of the lens"
lens: String
"The date when the photo is shot"
dateShot: String
"The exposure time of the image"
exposure: Float
"The aperature stops of the image"
aperture: Float
"The ISO setting of the image"
iso: Int
"The focal length of the lens, when the image was taken"
focalLength: Float
"A formatted description of the flash settings, when the image was taken"
flash: Int
"An index describing the mode for adjusting the exposure of the image"
exposureProgram: Int
"GPS coordinates of where the image was taken"
coordinates: Coordinates
}
"Metadata specific to video media"
type VideoMetadata {
id: ID!
media: Media!
width: Int!
height: Int!
duration: Float!
codec: String
framerate: Float
bitrate: String
colorProfile: String
audio: String
}
type Media {
id: ID!
title: String!
"Local filepath for the media"
path: String!
"URL to display the media in a smaller resolution"
thumbnail: MediaURL
"URL to display the photo in full resolution, will be null for videos"
highRes: MediaURL
"URL to get the video in a web format that can be played in the browser, will be null for photos"
videoWeb: MediaURL
"The album that holds the media"
album: Album!
exif: MediaEXIF
videoMetadata: VideoMetadata
favorite: Boolean!
type: MediaType!
"The date the image was shot or the date it was imported as a fallback"
date: Time!
"A short string that can be used to generate a blured version of the media, to show while the original is loading"
blurhash: String
"A list of share tokens pointing to this media, owned byt the logged in user"
shares: [ShareToken!]!
"A list of different versions of files for this media that can be downloaded by the user"
downloads: [MediaDownload!]!
"A list of faces present on the image"
faces: [ImageFace!]!
}
extend type Query {
"List of media owned by the logged in user"
myMedia(order: Ordering, paginate: Pagination): [Media!]! @isAuthorized
"""
Get media by id, user must own the media or be admin.
If valid tokenCredentials are provided, the media may be retrived without further authentication
"""
media(id: ID!, tokenCredentials: ShareTokenCredentials): Media!
"Get a list of media by their ids, user must own the media or be admin"
mediaList(ids: [ID!]!): [Media!]!
}
extend type Mutation {
"Mark or unmark a media as being a favorite"
favoriteMedia(mediaId: ID!, favorite: Boolean!): Media! @isAuthorized
}
+81
View File
@@ -0,0 +1,81 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"os"
"path"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/utils"
)
// MyMediaGeoJSON is the resolver for the myMediaGeoJson field.
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (any, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
var media []*geoMedia
err := r.DB(ctx).Table("media").
Select("media.id AS media_id, media.title AS media_title, "+
"media_urls.media_name AS thumbnail_name, media_urls.width AS thumbnail_width, "+
"media_urls.height AS thumbnail_height, media_exif.gps_latitude AS latitude, "+
"media_exif.gps_longitude AS longitude").
Joins("INNER JOIN media_exif ON media.exif_id = media_exif.id").
Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
Joins("INNER JOIN user_albums ON media.album_id = user_albums.album_id").
Where("media_exif.gps_latitude IS NOT NULL").
Where("media_exif.gps_longitude IS NOT NULL").
Where("media_urls.purpose = 'thumbnail'").
Where("user_albums.user_id = ?", user.ID).
Scan(&media).Error
if err != nil {
return nil, err
}
features := make([]geoJSONFeature, 0)
for _, item := range media {
geoPoint := makeGeoJSONFeatureGeometryPoint(item.Latitude, item.Longitude)
thumbnailURL := utils.ApiEndpointUrl()
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", item.ThumbnailName)
properties := geoJSONMediaProperties{
MediaID: item.MediaID,
MediaTitle: item.MediaTitle,
Thumbnail: struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}{
URL: thumbnailURL.String(),
Width: item.ThumbnailWidth,
Height: item.ThumbnailHeight,
},
}
features = append(features, makeGeoJSONFeature(properties, geoPoint))
}
featureCollection := makeGeoJSONFeatureCollection(features)
return featureCollection, nil
}
// MapboxToken is the resolver for the mapboxToken field.
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
if mapboxTokenEnv == "" {
return nil, nil
}
return &mapboxTokenEnv, nil
}
@@ -0,0 +1,7 @@
extend type Query {
"Get media owned by the logged in user, returned in GeoJson format"
myMediaGeoJson: Any! @isAuthorized
"Get the mapbox api token, returns null if mapbox is not enabled"
mapboxToken: String
}
@@ -0,0 +1,61 @@
package resolvers
type geoMedia struct {
MediaID int
MediaTitle string
ThumbnailName string
ThumbnailWidth int
ThumbnailHeight int
Latitude float64
Longitude float64
}
type geoJSONFeatureCollection struct {
Type string `json:"type"`
Features []geoJSONFeature `json:"features"`
}
type geoJSONFeature struct {
Type string `json:"type"`
Properties interface{} `json:"properties"`
Geometry geoJSONFeatureGeometry `json:"geometry"`
}
type geoJSONMediaProperties struct {
MediaID int `json:"media_id"`
MediaTitle string `json:"media_title"`
Thumbnail struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"thumbnail"`
}
type geoJSONFeatureGeometry struct {
Type string `json:"type"`
Coordinates [2]float64 `json:"coordinates"`
}
func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
return geoJSONFeatureCollection{
Type: "FeatureCollection",
Features: features,
}
}
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
return geoJSONFeature{
Type: "Feature",
Properties: properties,
Geometry: geometry,
}
}
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
coordinates := [2]float64{long, lat}
return geoJSONFeatureGeometry{
Type: "Point",
Coordinates: coordinates,
}
}
+39
View File
@@ -0,0 +1,39 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/notification"
)
// Notification is the resolver for the notification field.
func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models.Notification, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
notificationChannel := make(chan *models.Notification, 1)
listenerID := notification.RegisterListener(user, notificationChannel)
go func() {
<-ctx.Done()
notification.DeregisterListener(listenerID)
}()
return notificationChannel, nil
}
// Subscription returns api.SubscriptionResolver implementation.
func (r *Resolver) Subscription() api.SubscriptionResolver { return &subscriptionResolver{r} }
type subscriptionResolver struct{ *Resolver }
@@ -0,0 +1,31 @@
type Notification {
"A key used to identify the notification, new notification updates with the same key, should replace the old notifications"
key: String!
type: NotificationType!
"The text for the title of the notification"
header: String!
"The text for the body of the notification"
content: String!
"A value between 0 and 1 when the notification type is `Progress`"
progress: Float
"Whether or not the message of the notification is positive, the UI might reflect this with a green color"
positive: Boolean!
"Whether or not the message of the notification is negative, the UI might reflect this with a red color"
negative: Boolean!
"Time in milliseconds before the notification should close"
timeout: Int
}
type Subscription {
notification: Notification!
}
"Specified the type a particular notification is of"
enum NotificationType {
"A regular message with no special additions"
Message
"A notification with an attached progress indicator"
Progress
"Close a notification with a given key"
Close
}
+24
View File
@@ -0,0 +1,24 @@
package resolvers
import (
"context"
"gorm.io/gorm"
)
//go:generate go tool github.com/99designs/gqlgen
type Resolver struct {
database *gorm.DB
}
func NewRootResolver(db *gorm.DB) Resolver {
return Resolver{
database: db,
}
}
// DB returns a database instance that is tied to the given context
func (r *Resolver) DB(ctx context.Context) *gorm.DB {
return r.database.WithContext(ctx)
}
+19
View File
@@ -0,0 +1,19 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
api "github.com/photoview/photoview/api/graphql"
)
// Mutation returns api.MutationResolver implementation.
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
// Query returns api.QueryResolver implementation.
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
+47
View File
@@ -0,0 +1,47 @@
directive @isAuthorized on FIELD_DEFINITION
directive @isAdmin on FIELD_DEFINITION
scalar Time
scalar Any
"Used to specify which order to sort items in"
enum OrderDirection {
"Sort accending A-Z"
ASC
"Sort decending Z-A"
DESC
}
"Used to specify pagination on a list of items"
input Pagination {
"How many items to maximally fetch"
limit: Int
"How many items to skip from the beginning of the query, specified by the `Ordering`"
offset: Int
}
"Used to specify how to sort items"
input Ordering {
"A column in the database to order by"
order_by: String
order_direction: OrderDirection
}
type Query
type Mutation
"""
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 {
"The full album containing the media in this timeline group"
album: Album!
"The media contained in this timeline group"
media: [Media!]!
"The total amount of media in this timeline group"
mediaTotal: Int!
"The day shared for all media in this timeline group"
date: Time!
}
+108
View File
@@ -0,0 +1,108 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"errors"
"fmt"
"time"
"github.com/photoview/photoview/api/database/drivers"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/periodic_scanner"
"github.com/photoview/photoview/api/scanner/scanner_queue"
"gorm.io/gorm"
)
// ScanAll is the resolver for the scanAll field.
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
err := scanner_queue.AddAllToQueue()
if err != nil {
return nil, err
}
startMessage := "Scanner started"
return &models.ScannerResult{
Finished: false,
Success: true,
Message: &startMessage,
}, nil
}
// ScanUser is the resolver for the scanUser field.
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
var user models.User
if err := r.DB(ctx).First(&user, userID).Error; err != nil {
return nil, fmt.Errorf("get user from database: %w", err)
}
scanner_queue.AddUserToQueue(&user)
startMessage := "Scanner started"
return &models.ScannerResult{
Finished: false,
Success: true,
Message: &startMessage,
}, nil
}
// SetPeriodicScanInterval is the resolver for the setPeriodicScanInterval field.
func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) {
db := r.DB(ctx)
if interval < 0 {
return 0, errors.New("interval must be 0 or above")
}
if err := db.
Session(&gorm.Session{AllowGlobalUpdate: true}).
Model(&models.SiteInfo{}).
Update("periodic_scan_interval", interval).
Error; err != nil {
return 0, err
}
var siteInfo models.SiteInfo
if err := db.First(&siteInfo).Error; err != nil {
return 0, err
}
periodic_scanner.ChangePeriodicScanInterval(time.Duration(siteInfo.PeriodicScanInterval) * time.Second)
return siteInfo.PeriodicScanInterval, nil
}
// SetScannerConcurrentWorkers is the resolver for the setScannerConcurrentWorkers field.
func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
db := r.DB(ctx)
if workers < 1 {
return 0, errors.New("concurrent workers must at least be 1")
}
if workers > 1 && drivers.DatabaseDriverFromEnv() == drivers.SQLITE {
return 0, errors.New("multiple workers not supported for SQLite databases")
}
if err := db.
Session(&gorm.Session{AllowGlobalUpdate: true}).
Model(&models.SiteInfo{}).
Update("concurrent_workers", workers).
Error; err != nil {
return 0, err
}
var siteInfo models.SiteInfo
if err := db.First(&siteInfo).Error; err != nil {
return 0, err
}
scanner_queue.ChangeScannerConcurrentWorkers(siteInfo.ConcurrentWorkers)
return siteInfo.ConcurrentWorkers, nil
}
+23
View File
@@ -0,0 +1,23 @@
type ScannerResult {
finished: Boolean!
success: Boolean!
progress: Float
message: String
}
extend type Mutation {
"Scan all users for new media"
scanAll: ScannerResult! @isAdmin
"Scan a single user for new media"
scanUser(userId: ID!): ScannerResult! @isAdmin
"""
Set how often, in seconds, the server should automatically scan for new media,
a value of 0 will disable periodic scans
"""
setPeriodicScanInterval(interval: Int!): Int! @isAdmin
"Set max number of concurrent scanner jobs running at once"
setScannerConcurrentWorkers(workers: Int!): Int! @isAdmin
}
+24
View File
@@ -0,0 +1,24 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
)
// Search is the resolver for the search field.
func (r *queryResolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.Search(r.DB(ctx), query, user.ID, limitMedia, limitAlbums)
}
+13
View File
@@ -0,0 +1,13 @@
type SearchResult {
"The string that was searched for"
query: String!
"A list of albums that matched the query"
albums: [Album!]!
"A list of media that matched the query"
media: [Media!]!
}
extend type Query {
"Perform a search query on the contents of the media library"
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
}
+167
View File
@@ -0,0 +1,167 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"errors"
"fmt"
"time"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ShareAlbum is the resolver for the shareAlbum field.
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time, password *string) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.AddAlbumShare(r.DB(ctx), user, albumID, expire, password)
}
// ShareMedia is the resolver for the shareMedia field.
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time, password *string) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
}
// DeleteShareToken is the resolver for the deleteShareToken field.
func (r *mutationResolver) DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.DeleteShareToken(r.DB(ctx), user.ID, token)
}
// ProtectShareToken is the resolver for the protectShareToken field.
func (r *mutationResolver) ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.ProtectShareToken(r.DB(ctx), user.ID, token, password)
}
// SetExpireShareToken is the resolver for the setExpireShareToken field.
func (r *mutationResolver) SetExpireShareToken(ctx context.Context, token string, expire *time.Time) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.SetExpireShareToken(r.DB(ctx), user.ID, token, expire)
}
// ShareToken is the resolver for the shareToken field.
func (r *queryResolver) ShareToken(ctx context.Context, credentials models.ShareTokenCredentials) (*models.ShareToken, error) {
var token models.ShareToken
if err := r.DB(ctx).Preload(clause.Associations).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("share not found")
} else {
return nil, fmt.Errorf("failed to get share token from database: %w", err)
}
}
now := time.Now()
fakeTime := time.Date(
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
now.Second(),
0,
time.UTC,
)
if token.Expire != nil && fakeTime.After(*token.Expire) {
return nil, errors.New("share expired")
}
if token.Password != nil {
if err := bcrypt.CompareHashAndPassword([]byte(*token.Password), []byte(*credentials.Password)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return nil, errors.New("unauthorized")
} else {
return nil, fmt.Errorf("failed to compare token password hashes: %w", err)
}
}
}
return &token, nil
}
// ShareTokenValidatePassword is the resolver for the shareTokenValidatePassword field.
func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credentials models.ShareTokenCredentials) (bool, error) {
var token models.ShareToken
if err := r.DB(ctx).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, errors.New("share not found")
} else {
return false, fmt.Errorf("failed to get share token from database: %w", err)
}
}
now := time.Now()
fakeTime := time.Date(
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
now.Second(),
0,
time.UTC,
)
//Assume the client’s local time is UTC, without applying any timezone conversion.
if token.Expire != nil && fakeTime.After(*token.Expire) {
return false, errors.New("share expired")
}
if token.Password == nil {
return true, nil
}
if credentials.Password == nil {
return false, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(*token.Password), []byte(*credentials.Password)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil
} else {
return false, fmt.Errorf("could not compare token password hashes: %w", err)
}
}
return true, nil
}
// HasPassword is the resolver for the hasPassword field.
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
hasPassword := obj.Password != nil
return hasPassword, nil
}
// ShareToken returns api.ShareTokenResolver implementation.
func (r *Resolver) ShareToken() api.ShareTokenResolver { return &shareTokenResolver{r} }
type shareTokenResolver struct{ *Resolver }
+47
View File
@@ -0,0 +1,47 @@
"Credentials used to identify and authenticate a share token"
input ShareTokenCredentials {
token: String!
password: String
}
"A token used to publicly access an album or media"
type ShareToken {
id: ID!
token: String!
"The user who created the token"
owner: User!
"Optional expire date"
expire: Time
"Whether or not a password is needed to access the share"
hasPassword: Boolean!
"The album this token shares"
album: Album
"The media this token shares"
media: Media
}
extend type Query {
"Fetch a share token containing an `Album` or `Media`"
shareToken(credentials: ShareTokenCredentials!): ShareToken!
"Check if the `ShareToken` credentials are valid"
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
}
extend type Mutation {
"Generate share token for album"
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
"Generate share token for media"
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
"Delete a share token by it's token value"
deleteShareToken(token: String!): ShareToken! @isAuthorized
"Set a password for a token, if null is passed for the password argument, the password will be cleared"
protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
"Set a Expiration Time for a token"
setExpireShareToken(token: String!,expire: Time):ShareToken! @isAuthorized
}
+123
View File
@@ -0,0 +1,123 @@
package resolvers
import (
"context"
"testing"
"time"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/test_utils"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/bcrypt"
)
func TestMain(m *testing.M) {
test_utils.IntegrationTestRun(m)
}
func TestShareTokenValidatePassword(t *testing.T) {
test_utils.FilesystemTest(t)
db := test_utils.DatabaseTest(t)
pass := "1234"
user, err := models.RegisterUser(db, "test_user", &pass, true)
if err != nil {
t.Fatal("register user error:", err)
}
hashBytes, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
hashedPassword := string(hashBytes)
now := time.Now()
expiredTime := now.Add(-24 * time.Hour)
expiredTime = time.Date(
expiredTime.Year(),
expiredTime.Month(),
expiredTime.Day(),
expiredTime.Hour(),
expiredTime.Minute(),
expiredTime.Second(),
0,
time.UTC,
)
futureTime := now.Add(24 * time.Hour)
futureTime = time.Date(
futureTime.Year(),
futureTime.Month(),
futureTime.Day(),
futureTime.Hour(),
futureTime.Minute(),
futureTime.Second(),
0,
time.UTC,
)
db.AutoMigrate(&models.ShareToken{})
testDataList := []models.ShareToken{
{
Value: "EXPIRED_TOKEN",
OwnerID: user.ID,
Expire: &expiredTime,
},
{
Value: "CORRECT_PASS",
OwnerID: user.ID,
Expire: &futureTime,
Password: &hashedPassword,
},
}
db.Create(testDataList)
tests := []struct {
name string
credentials models.ShareTokenCredentials
wantResult bool
wantErr bool
wantErrMsg string
}{
{
name: "Case 1: Token not exist",
credentials: models.ShareTokenCredentials{
Token: "NOT_EXIST",
},
wantResult: false,
wantErr: true,
wantErrMsg: "share not found",
},
{
name: "Case 2: Token expired",
credentials: models.ShareTokenCredentials{
Token: "EXPIRED_TOKEN",
},
wantResult: false,
wantErr: true,
wantErrMsg: "share expired",
},
{
name: "Case 3: correct pass",
credentials: models.ShareTokenCredentials{
Token: "CORRECT_PASS",
Password: &pass,
},
wantResult: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &queryResolver{
Resolver: &Resolver{
database: db,
},
}
got, err := r.ShareTokenValidatePassword(context.Background(), tt.credentials)
if tt.wantErr {
assert.Error(t, err)
if tt.wantErrMsg != "" {
assert.Contains(t, err.Error(), tt.wantErrMsg)
}
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantResult, got)
})
}
}
+29
View File
@@ -0,0 +1,29 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
)
// SiteInfo is the resolver for the siteInfo field.
func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) {
return models.GetSiteInfo(r.DB(ctx))
}
// FaceDetectionEnabled is the resolver for the faceDetectionEnabled field.
func (r *siteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
return face_detection.GlobalFaceDetector != nil, nil
}
// SiteInfo returns api.SiteInfoResolver implementation.
func (r *Resolver) SiteInfo() api.SiteInfoResolver { return &siteInfoResolver{r} }
type siteInfoResolver struct{ *Resolver }
+15
View File
@@ -0,0 +1,15 @@
"General information about the site"
type SiteInfo {
"Whether or not the initial setup wizard should be shown"
initialSetup: Boolean!
"Whether or not face detection is enabled and working"
faceDetectionEnabled: Boolean!
"How often automatic scans should be initiated in seconds"
periodicScanInterval: Int! @isAdmin
"How many max concurrent scanner jobs that should run at once"
concurrentWorkers: Int! @isAdmin
}
extend type Query {
siteInfo: SiteInfo!
}
+25
View File
@@ -0,0 +1,25 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"time"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
)
// MyTimeline is the resolver for the myTimeline field.
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.MyTimeline(r.DB(ctx), user, paginate, onlyFavorites, fromDate)
}
+11
View File
@@ -0,0 +1,11 @@
extend type Query {
"""
Get a list of media, ordered first by day, then by album if multiple media was found for the same day.
"""
myTimeline(
paginate: Pagination,
onlyFavorites: Boolean,
"Only fetch media that is older than this date"
fromDate: Time
): [Media!]! @isAuthorized
}
+343
View File
@@ -0,0 +1,343 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.90
import (
"context"
"errors"
"fmt"
"path"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
"github.com/photoview/photoview/api/scanner"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AuthorizeUser is the resolver for the authorizeUser field.
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) {
db := r.DB(ctx)
user, err := models.AuthorizeUser(db, username, password)
if err != nil {
return &models.AuthorizeResult{
Success: false,
Status: err.Error(),
}, nil
}
var token *models.AccessToken
transactionError := db.Transaction(func(tx *gorm.DB) error {
token, err = user.GenerateAccessToken(tx)
if err != nil {
return err
}
return nil
})
if transactionError != nil {
return nil, transactionError
}
return &models.AuthorizeResult{
Success: true,
Status: "ok",
Token: &token.Value,
}, nil
}
// InitialSetupWizard is the resolver for the initialSetupWizard field.
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) {
db := r.DB(ctx)
siteInfo, err := models.GetSiteInfo(db)
if err != nil {
return nil, err
}
if !siteInfo.InitialSetup {
return nil, errors.New("not initial setup")
}
rootPath = path.Clean(rootPath)
var token *models.AccessToken
transactionError := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("UPDATE site_info SET initial_setup = false").Error; err != nil {
return err
}
user, err := models.RegisterUser(tx, username, &password, true)
if err != nil {
return err
}
_, err = scanner.NewRootAlbum(tx, rootPath, user)
if err != nil {
return err
}
token, err = user.GenerateAccessToken(tx)
if err != nil {
return err
}
return nil
})
if transactionError != nil {
return &models.AuthorizeResult{
Success: false,
Status: err.Error(),
}, nil
}
return &models.AuthorizeResult{
Success: true,
Status: "ok",
Token: &token.Value,
}, nil
}
// UpdateUser is the resolver for the updateUser field.
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) {
db := r.DB(ctx)
if username == nil && password == nil && admin == nil {
return nil, errors.New("no updates requested")
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
return nil, err
}
if username != nil {
user.Username = *username
}
if password != nil {
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
return nil, err
}
hashedPass := string(hashedPassBytes)
user.Password = &hashedPass
}
if admin != nil {
user.Admin = *admin
}
if err := db.Save(&user).Error; err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
return &user, nil
}
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) {
var user *models.User
transactionError := r.DB(ctx).Transaction(func(tx *gorm.DB) error {
var err error
user, err = models.RegisterUser(tx, username, password, admin)
if err != nil {
return err
}
return nil
})
if transactionError != nil {
return nil, transactionError
}
return user, nil
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
return actions.DeleteUser(r.DB(ctx), id)
}
// UserAddRootPath is the resolver for the userAddRootPath field.
func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) {
db := r.DB(ctx)
rootPath = path.Clean(rootPath)
var user models.User
if err := db.First(&user, id).Error; err != nil {
return nil, err
}
newAlbum, err := scanner.NewRootAlbum(db, rootPath, &user)
if err != nil {
return nil, err
}
return newAlbum, nil
}
// UserRemoveRootAlbum is the resolver for the userRemoveRootAlbum field.
func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) {
db := r.DB(ctx)
var album models.Album
if err := db.First(&album, albumID).Error; err != nil {
return nil, err
}
var deletedAlbumIDs []int = nil
transactionError := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Raw("DELETE FROM user_albums WHERE user_id = ? AND album_id = ?", userID, albumID).Error; err != nil {
return err
}
children, err := album.GetChildren(tx, nil)
if err != nil {
return err
}
childAlbumIDs := make([]int, len(children))
for i, child := range children {
childAlbumIDs[i] = child.ID
}
result := tx.Exec("DELETE FROM user_albums WHERE user_id = ? and album_id IN (?)", userID, childAlbumIDs)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("No relation deleted")
}
// Cleanup if no user owns the album anymore
deletedAlbumIDs, err = cleanup(tx, albumID, childAlbumIDs)
if err != nil {
return err
}
return nil
})
if transactionError != nil {
return nil, transactionError
}
if err := clearCacheAndReloadFaces(db, deletedAlbumIDs); err != nil {
return nil, err
}
return &album, nil
}
// ChangeUserPreferences is the resolver for the changeUserPreferences field.
func (r *mutationResolver) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) {
db := r.DB(ctx)
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
var langTrans *models.LanguageTranslation = nil
if language != nil {
lng := models.LanguageTranslation(*language)
langTrans = &lng
}
var userPref models.UserPreferences
if err := db.Where("user_id = ?", user.ID).FirstOrInit(&userPref).Error; err != nil {
return nil, err
}
userPref.UserID = user.ID
userPref.Language = langTrans
if err := db.Save(&userPref).Error; err != nil {
return nil, err
}
return &userPref, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.User, error) {
var users []*models.User
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// MyUser is the resolver for the myUser field.
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return user, nil
}
// MyUserPreferences is the resolver for the myUserPreferences field.
func (r *queryResolver) MyUserPreferences(ctx context.Context) (*models.UserPreferences, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
userPref := models.UserPreferences{
UserID: user.ID,
}
if err := r.DB(ctx).Where("user_id = ?", user.ID).FirstOrCreate(&userPref).Error; err != nil {
return nil, err
}
return &userPref, nil
}
// Albums is the resolver for the albums field.
func (r *userResolver) Albums(ctx context.Context, obj *models.User) ([]*models.Album, error) {
obj.FillAlbums(r.DB(ctx))
pointerAlbums := make([]*models.Album, len(obj.Albums))
for i, album := range obj.Albums {
pointerAlbums[i] = &album
}
return pointerAlbums, nil
}
// RootAlbums is the resolver for the rootAlbums field.
func (r *userResolver) RootAlbums(ctx context.Context, obj *models.User) (albums []*models.Album, err error) {
db := r.DB(ctx)
err = db.Model(obj).
Where("albums.parent_album_id NOT IN (?)",
db.Table("user_albums").
Select("albums.id").
Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", obj.ID),
).Or("albums.parent_album_id IS NULL").Order("path ASC").
Association("Albums").Find(&albums)
return
}
// User returns api.UserResolver implementation.
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
type userResolver struct{ *Resolver }
+100
View File
@@ -0,0 +1,100 @@
type User {
id: ID!
username: String!
"All albums owned by this user"
albums: [Album!]! @isAdmin
"Top level albums owned by this user"
rootAlbums: [Album!]! @isAdmin
"Whether or not the user has admin privileges"
admin: Boolean!
}
"Supported language translations of the user interface"
enum LanguageTranslation {
English,
French,
Italian,
Swedish,
Danish,
Spanish,
Polish,
Ukrainian,
German,
Russian,
TraditionalChineseTW,
TraditionalChineseHK,
SimplifiedChinese,
Portuguese,
Basque,
Turkish,
Japanese,
Dutch,
}
"Preferences for regular users"
type UserPreferences {
id: ID!
language: LanguageTranslation
}
type AuthorizeResult {
success: Boolean!
"A textual status message describing the result, can be used to show an error message when `success` is false"
status: String!
"An access token used to authenticate new API requests as the newly authorized user. Is present when success is true"
token: String
}
extend type Query {
"List of registered users, must be admin to call"
user(order: Ordering, paginate: Pagination): [User!]! @isAdmin
"Information about the currently logged in user"
myUser: User! @isAuthorized
"User preferences for the logged in user"
myUserPreferences: UserPreferences! @isAuthorized
}
extend type Mutation {
"Authorizes a user and returns a token used to identify the new session"
authorizeUser(username: String!, password: String!): AuthorizeResult!
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
initialSetupWizard(
username: String!
password: String!
rootPath: String!
): AuthorizeResult
"Update a user, fields left as `null` will not be changed"
updateUser(
id: ID!
username: String
password: String
admin: Boolean
): User! @isAdmin
"Create a new user"
createUser(
username: String!
password: String
admin: Boolean!
): User! @isAdmin
"Delete an existing user"
deleteUser(id: ID!): User! @isAdmin
"Add a root path from where to look for media for the given user, specified by their user id."
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
"""
Remove a root path from a user, specified by the id of the user and the top album representing the root path.
This album was returned when creating the path using `userAddRootPath`.
A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
"""
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
"Change user preferences for the logged in user"
changeUserPreferences(language: String): UserPreferences! @isAuthorized
}
+54
View File
@@ -0,0 +1,54 @@
package resolvers
import (
"os"
"path"
"strconv"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/utils"
"gorm.io/gorm"
)
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
var userAlbumCount int
var deletedAlbumIDs []int = nil
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
albumID).Scan(&userAlbumCount).Error; err != nil {
return nil, err
}
if userAlbumCount == 0 {
deletedAlbumIDs = append(childAlbumIDs, albumID)
childAlbumIDs = nil
// Delete albums from database
if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil {
deletedAlbumIDs = nil
return nil, err
}
}
return deletedAlbumIDs, nil
}
func clearCacheAndReloadFaces(db *gorm.DB, deletedAlbumIDs []int) error {
if deletedAlbumIDs != nil {
// Delete albums from cache
for _, id := range deletedAlbumIDs {
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
if err := os.RemoveAll(cacheAlbumPath); err != nil {
return err
}
}
// Reload faces as media might have been deleted
if face_detection.GlobalFaceDetector != nil {
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
return err
}
}
}
return nil
}