精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}{}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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!
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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!
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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!
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user