精简后的项目:仅保留 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
+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
}