Fix gqlgen (#1105)

This commit is contained in:
Googol Lee
2024-11-02 17:05:29 +01:00
committed by GitHub
parent 84c642ca79
commit 7b69f247d3
39 changed files with 3589 additions and 3497 deletions

View File

@@ -1,20 +1,22 @@
name: Docker builds
on:
pull_request:
branches: [master]
push:
branches: [master]
branches: ['*']
tags:
- v*
pull_request:
branches: [master]
schedule:
# At 01:18 every Thursday. Details in https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule
- cron: '18 1 * * 4'
env:
IS_PUSHING_IMAGES: ${{ github.event_name != 'pull_request' && github.repository == 'photoview/photoview' }}
# IS_PUSHING_IMAGES: true # Switch on when pushing images with forked repos.
DOCKER_REGISTRY: ${{ (github.repository == 'photoview/photoview' && '') || 'ghcr.io/' }}
DOCKER_USERNAME: viktorstrate
DOCKER_IMAGE: viktorstrate/photoview
DOCKER_IMAGE: ${{ (github.repository == 'photoview/photoview' && 'viktorstrate/photoview') || github.repository }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7
@@ -82,7 +84,6 @@ jobs:
with:
platforms: ${{ env.PLATFORMS }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -90,15 +91,16 @@ jobs:
if: ${{ env.IS_PUSHING_IMAGES == 'true' }}
uses: docker/login-action@v3
with:
username: ${{ env.DOCKER_USERNAME }}
password: ${{ env.DOCKER_PASSWORD }}
registry: ${{ env.DOCKER_REGISTRY == 'ghcr.io/' && 'ghcr.io' }}
username: ${{ ( env.DOCKER_REGISTRY == 'ghcr.io/' && github.actor ) || env.DOCKER_USERNAME }}
password: ${{ ( env.DOCKER_REGISTRY == 'ghcr.io/' && github.token ) || env.DOCKER_PASSWORD }}
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: ${{ env.DOCKER_IMAGE }}
images: ${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}
# Docker tags based on the following events/attributes
tags: |
type=schedule
@@ -152,13 +154,13 @@ jobs:
username: ${{ env.DOCKER_USERNAME }}
password: ${{ env.DOCKER_PASSWORD }}
- name: Run Dockle for '${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
- name: Run Dockle for '${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
id: dockle
if: ${{ matrix.tags.tag != '' }}
continue-on-error: true
uses: erzz/dockle-action@v1
with:
image: '${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
image: '${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
report-name: dockle-results-${{ matrix.tags.tag }}
report-format: sarif
failure-threshold: fatal

View File

@@ -2,7 +2,7 @@ name: Tests
on:
push:
branches: [master]
branches: ['*']
pull_request:
branches: [master]
@@ -69,12 +69,13 @@ jobs:
continue-on-error: true
run: |
docker run --name test --network host \
-v "${{ github.workspace }}:/app" \
-e PHOTOVIEW_DATABASE_DRIVER=${{ matrix.database }} \
-e PHOTOVIEW_MYSQL_URL='photoview:photosecret@tcp(localhost:3306)/photoview_test' \
-e PHOTOVIEW_POSTGRES_URL='postgres://photoview:photosecret@localhost:5432/photoview_test' \
-e PHOTOVIEW_SQLITE_PATH=/tmp/photoview.db \
photoview/api \
go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic
/app/scripts/test_all.sh
docker cp test:/app/api/coverage.txt ./api/
- name: Upload coverage

View File

@@ -68,6 +68,7 @@ COPY --from=viktorstrate/dependencies /artifacts.tar.gz /dependencies/
# Split values in `/env`
# hadolint ignore=SC2046
RUN export $(cat /env) \
&& git config --global --add safe.directory /app \
&& cd /dependencies/ \
&& tar xfv artifacts.tar.gz \
&& cp -a include/* /usr/local/include/ \

View File

@@ -14,7 +14,7 @@ resolver:
layout: follow-schema
dir: graphql/resolvers
package: resolvers
filename_template: "{name}.generated.go"
filename_template: "{name}.go"
autobind: []

File diff suppressed because it is too large Load Diff

View File

@@ -95,6 +95,7 @@ type Subscription struct {
}
// A group of media from the same album and the same day, that is grouped together in a timeline view
// NOTE: It isn't used. Just copy from the old schema.graphql.
type TimelineGroup struct {
// The full album containing the media in this timeline group
Album *Album `json:"album"`

View File

@@ -1,410 +0,0 @@
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.55
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, sourceFaceGroupID 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
}
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
if err != nil {
return nil, err
}
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceFaceGroupID)
if err != nil {
return nil, err
}
updateError := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ImageFace{}).
Where(faceGroupIDIsQuestion, sourceFaceGroup.ID).
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
return err
}
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
return err
}
return nil
})
if updateError != nil {
return nil, updateError
}
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), 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 }

View File

@@ -1,27 +1,30 @@
package resolvers
import (
"errors"
"fmt"
// 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.55
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"
)
const faceGroupIDIsQuestion = "face_group_id = ?"
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
const imageFacesIDInQuestion = "image_faces.id IN (?)"
// 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")
}
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 face_detection.GlobalFaceDetector == nil {
return nil, ErrFaceDetectorNotInitialized
}
if err := user.FillAlbums(db); err != nil {
@@ -33,32 +36,308 @@ func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*model
userAlbumIDs[i] = album.ID
}
// Verify that user owns at leat 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)
query := db.
Joins("Media").
Where(faceGroupIDIsQuestion, obj.ID).
Where("album_id IN (?)", 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)
query = models.FormatSQL(query, nil, paginate)
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)
}
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
}
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
if err := user.FillAlbums(tx); err != 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, sourceFaceGroupID 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
}
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
if err != nil {
return nil, err
}
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceFaceGroupID)
if err != nil {
return nil, err
}
updateError := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ImageFace{}).
Where(faceGroupIDIsQuestion, sourceFaceGroup.ID).
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
return err
}
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
return err
}
return nil
})
if updateError != nil {
return nil, updateError
}
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), 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
}
@@ -67,30 +346,65 @@ func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int)
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 {
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 userOwnedImageFaces, nil
return faceGroups, 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
}
}
// 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")
}
return nil
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 }

View File

@@ -0,0 +1,96 @@
package resolvers
import (
"errors"
"fmt"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
const faceGroupIDIsQuestion = "face_group_id = ?"
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 leat 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
}

View File

@@ -20,6 +20,14 @@ enum MediaType {
Video
}
type Coordinates {
"GPS latitude in degrees"
latitude: Float!
"GPS longitude in degrees"
longitude: Float!
}
"EXIF metadata from the camera"
type MediaEXIF {
id: ID!
@@ -49,6 +57,20 @@ type MediaEXIF {
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!

View File

@@ -1,80 +0,0 @@
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.55
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
}

View File

@@ -1,61 +1,80 @@
package resolvers
type geoMedia struct {
MediaID int
MediaTitle string
ThumbnailName string
ThumbnailWidth int
ThumbnailHeight int
Latitude float64
Longitude float64
}
// 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.55
type geoJSONFeatureCollection struct {
Type string `json:"type"`
Features []geoJSONFeature `json:"features"`
}
import (
"context"
"os"
"path"
type geoJSONFeature struct {
Type string `json:"type"`
Properties interface{} `json:"properties"`
Geometry geoJSONFeatureGeometry `json:"geometry"`
}
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/utils"
)
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,
// 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
}
}
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
return geoJSONFeature{
Type: "Feature",
Properties: properties,
Geometry: geometry,
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
}
}
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
coordinates := [2]float64{long, lat}
features := make([]geoJSONFeature, 0)
return geoJSONFeatureGeometry{
Type: "Point",
Coordinates: coordinates,
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
}

View File

@@ -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,
}
}

View File

@@ -1,15 +1,20 @@
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.55
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
@@ -26,3 +31,8 @@ func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models
return notificationChannel, nil
}
// Subscription returns api.SubscriptionResolver implementation.
func (r *Resolver) Subscription() api.SubscriptionResolver { return &subscriptionResolver{r} }
type subscriptionResolver struct{ *Resolver }

View File

@@ -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
}

View File

@@ -3,7 +3,6 @@ package resolvers
import (
"context"
api "github.com/photoview/photoview/api/graphql"
"gorm.io/gorm"
)
@@ -23,25 +22,3 @@ func NewRootResolver(db *gorm.DB) Resolver {
func (r *Resolver) DB(ctx context.Context) *gorm.DB {
return r.database.WithContext(ctx)
}
func (r *Resolver) Mutation() api.MutationResolver {
return &mutationResolver{r}
}
func (r *Resolver) Query() api.QueryResolver {
return &queryResolver{r}
}
func (r *Resolver) Subscription() api.SubscriptionResolver {
return &subscriptionResolver{
Resolver: r,
}
}
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subscriptionResolver struct {
Resolver *Resolver
}

View File

@@ -0,0 +1,18 @@
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.55
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 }

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!
}

View File

@@ -1,17 +1,23 @@
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.55
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"
"github.com/pkg/errors"
"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 {
@@ -27,10 +33,11 @@ func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult,
}, 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, errors.Wrap(err, "get user from database")
return nil, fmt.Errorf("get user from database: %w", err)
}
scanner_queue.AddUserToQueue(&user)
@@ -43,6 +50,7 @@ func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.Sc
}, 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 {
@@ -68,6 +76,7 @@ func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval
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 {

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
}

View File

@@ -1,296 +0,0 @@
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
}
"Credentials used to identify and authenticate a share token"
input ShareTokenCredentials {
token: String!
password: String
}
type Query {
siteInfo: SiteInfo!
"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
"""
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
"Fetch a share token containing an `Album` or `Media`"
shareToken(credentials: ShareTokenCredentials!): ShareToken!
"Check if the `ShareToken` credentials are valid"
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
"Perform a search query on the contents of the media library"
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
}
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
"Scan all users for new media"
scanAll: ScannerResult! @isAdmin
"Scan a single user for new media"
scanUser(userId: ID!): ScannerResult! @isAdmin
"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
"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
"""
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
"Set the filter to be used when generating thumbnails"
setThumbnailDownsampleMethod(method: ThumbnailFilter!): ThumbnailFilter! @isAdmin
"Change user preferences for the logged in user"
changeUserPreferences(language: String): UserPreferences! @isAuthorized
}
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
}
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 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
}
type ScannerResult {
finished: Boolean!
success: Boolean!
progress: Float
message: 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
}
"Supported downsampling filters for thumbnail generation"
enum ThumbnailFilter {
NearestNeighbor,
Box,
Linear,
MitchellNetravali,
CatmullRom,
Lanczos,
}
"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
"The filter to use when generating thumbnails"
thumbnailMethod: ThumbnailFilter! @isAdmin
}
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,
TraditionalChinese,
SimplifiedChinese,
Portuguese,
Basque,
Turkish
}
"Preferences for regular users"
type UserPreferences {
id: ID!
language: LanguageTranslation
}
type Coordinates {
"GPS latitude in degrees"
latitude: Float!
"GPS longitude in degrees"
longitude: Float!
}
"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 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!]!
}
"A group of media from the same album and the same day, that is grouped together in a timeline view"
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!
}

View File

@@ -1,16 +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.55
import (
"context"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models/actions"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
)
func (r *Resolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
// 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

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!
}

View File

@@ -1,53 +1,72 @@
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.55
import (
"context"
"errors"
"fmt"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
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"
)
type shareTokenResolver struct {
*Resolver
// 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)
}
func (r *Resolver) ShareToken() api.ShareTokenResolver {
return &shareTokenResolver{r}
// 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)
}
func (r *shareTokenResolver) Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error) {
return &obj.Owner, nil
// 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)
}
func (r *shareTokenResolver) Album(ctx context.Context, obj *models.ShareToken) (*models.Album, error) {
return obj.Album, nil
}
func (r *shareTokenResolver) Media(ctx context.Context, obj *models.ShareToken) (*models.Media, error) {
return obj.Media, nil
}
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
hasPassword := obj.Password != nil
return hasPassword, nil
// 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)
}
// 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, errors.Wrap(err, "failed to get share token from database")
return nil, fmt.Errorf("failed to get share token from database: %w", err)
}
}
@@ -56,7 +75,7 @@ func (r *queryResolver) ShareToken(ctx context.Context, credentials models.Share
if err == bcrypt.ErrMismatchedHashAndPassword {
return nil, errors.New("unauthorized")
} else {
return nil, errors.Wrap(err, "failed to compare token password hashes")
return nil, fmt.Errorf("failed to compare token password hashes: %w", err)
}
}
}
@@ -64,14 +83,14 @@ func (r *queryResolver) ShareToken(ctx context.Context, credentials models.Share
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, errors.Wrap(err, "failed to get share token from database")
return false, fmt.Errorf("failed to get share token from database: %w", err)
}
}
@@ -87,50 +106,20 @@ func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credenti
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil
} else {
return false, errors.Wrap(err, "could not compare token password hashes")
return false, fmt.Errorf("could not compare token password hashes: %w", err)
}
}
return true, nil
}
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)
// 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
}
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time,
password *string) (*models.ShareToken, error) {
// ShareToken returns api.ShareTokenResolver implementation.
func (r *Resolver) ShareToken() api.ShareTokenResolver { return &shareTokenResolver{r} }
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
}
func (r *mutationResolver) DeleteShareToken(ctx context.Context, tokenValue string) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return actions.DeleteShareToken(r.DB(ctx), user.ID, tokenValue)
}
func (r *mutationResolver) ProtectShareToken(ctx context.Context, tokenValue 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, tokenValue, password)
}
type shareTokenResolver struct{ *Resolver }

View File

@@ -0,0 +1,44 @@
"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
}

View File

@@ -1,5 +1,9 @@
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.55
import (
"context"
@@ -8,18 +12,17 @@ import (
"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))
}
type SiteInfoResolver struct {
*Resolver
}
func (r *Resolver) SiteInfo() api.SiteInfoResolver {
return &SiteInfoResolver{r}
}
func (SiteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
// 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 }

View File

@@ -0,0 +1,17 @@
"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
"The filter to use when generating thumbnails"
thumbnailMethod: ThumbnailFilter! @isAdmin
}
extend type Query {
siteInfo: SiteInfo!
}

View File

@@ -1,16 +1,18 @@
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.55
import (
"context"
"github.com/photoview/photoview/api/graphql/models"
// "github.com/pkg/errors"
"gorm.io/gorm"
)
func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context,
method models.ThumbnailFilter) (models.ThumbnailFilter, error) {
// SetThumbnailDownsampleMethod is the resolver for the setThumbnailDownsampleMethod field.
func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context, method models.ThumbnailFilter) (models.ThumbnailFilter, error) {
db := r.DB(ctx)
// if method > 5 {
@@ -38,5 +40,4 @@ func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context,
// lng := models.LanguageTranslation(*language)
// langTrans = &lng
// }
}

View File

@@ -0,0 +1,14 @@
"Supported downsampling filters for thumbnail generation"
enum ThumbnailFilter {
NearestNeighbor,
Box,
Linear,
MitchellNetravali,
CatmullRom,
Lanczos,
}
extend type Mutation {
"Set the filter to be used when generating thumbnails"
setThumbnailDownsampleMethod(method: ThumbnailFilter!): ThumbnailFilter! @isAdmin
}

View File

@@ -1,5 +1,9 @@
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.55
import (
"context"
"time"
@@ -9,9 +13,8 @@ import (
"github.com/photoview/photoview/api/graphql/models/actions"
)
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool,
fromDate *time.Time) ([]*models.Media, error) {
// 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

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
}

View File

@@ -1,80 +1,26 @@
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.55
import (
"context"
"os"
"errors"
"fmt"
"path"
"strconv"
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"
"github.com/photoview/photoview/api/scanner/face_detection"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type userResolver struct {
*Resolver
}
func (r *Resolver) User() api.UserResolver {
return &userResolver{r}
}
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
}
func (r *userResolver) Albums(ctx context.Context, user *models.User) ([]*models.Album, error) {
user.FillAlbums(r.DB(ctx))
pointerAlbums := make([]*models.Album, len(user.Albums))
for i, album := range user.Albums {
pointerAlbums[i] = &album
}
return pointerAlbums, nil
}
func (r *userResolver) RootAlbums(ctx context.Context, user *models.User) (albums []*models.Album, err error) {
db := r.DB(ctx)
err = db.Model(&user).
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 = ?", user.ID),
).Or("albums.parent_album_id IS NULL").Order("path ASC").
Association("Albums").Find(&albums)
return
}
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
return user, nil
}
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string,
password string) (*models.AuthorizeResult, error) {
// 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 {
@@ -106,8 +52,8 @@ func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string,
}, nil
}
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string,
rootPath string) (*models.AuthorizeResult, error) {
// 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 {
@@ -159,53 +105,8 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri
}, nil
}
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
}
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
}
// Admin queries
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string,
admin *bool) (*models.User, error) {
// 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 {
@@ -236,15 +137,14 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *str
}
if err := db.Save(&user).Error; err != nil {
return nil, errors.Wrap(err, "failed to update user")
return nil, fmt.Errorf("failed to update user: %w", err)
}
return &user, nil
}
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string,
admin bool) (*models.User, error) {
// 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 {
@@ -264,10 +164,12 @@ func (r *mutationResolver) CreateUser(ctx context.Context, username string, pass
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)
@@ -286,6 +188,7 @@ func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath
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)
@@ -339,44 +242,101 @@ func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int,
return &album, nil
}
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
var userAlbumCount int
var deletedAlbumIDs []int = 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
}
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
albumID).Scan(&userAlbumCount).Error; err != nil {
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
}
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
}
userPref.UserID = user.ID
userPref.Language = langTrans
if err := db.Save(&userPref).Error; err != nil {
return nil, err
}
return deletedAlbumIDs, nil
return &userPref, 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))
// 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 := 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
}
}
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
return nil, err
}
return nil
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 }

View File

@@ -0,0 +1,97 @@
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,
TraditionalChinese,
SimplifiedChinese,
Portuguese,
Basque,
Turkish
}
"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
}

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
}

13
scripts/test_all.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
for test in $(dirname $0)/test_*
do
if [ "${test}" != "${test%%/scripts/test_all.sh}" ]
then
continue
fi
echo Running ${test}...
${test}
done

5
scripts/test_api_coverage.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
cd $(dirname $0)/../api
go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic

View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
cd $(dirname $0)/../api
go generate ./...
if [ "$(git status -s 2>/dev/null | head -1)" != "" ]; then
echo '--- FAIL: The generated API code is out of sync with the recent changes. Please run `go generate ./...` under `./api` to regenerate it and commit it to this branch.'
exit 1
fi
echo '--- PASS: All generated code is in sync with the project.'