Merge pull request #137 from photoview/v2.0.0

Release v2.0
This commit is contained in:
Viktor Strate Kløvedal
2021-01-17 16:57:38 +01:00
committed by GitHub
79 changed files with 2810 additions and 2479 deletions

3
.gitignore vendored
View File

@@ -1,8 +1,9 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
cache/
photo_cache/
media_cache/
/photos_path
photoview.db
.env

View File

@@ -1,8 +1,8 @@
# Build UI
### Build UI ###
FROM --platform=${BUILDPLATFORM:-linux/amd64} node:10 as ui
ARG API_ENDPOINT
ENV API_ENDPOINT=${API_ENDPOINT}
ARG PHOTOVIEW_API_ENDPOINT
ENV PHOTOVIEW_API_ENDPOINT=${PHOTOVIEW_API_ENDPOINT}
# Set environment variable UI_PUBLIC_URL from build args, uses "/" as default
ARG UI_PUBLIC_URL
@@ -19,8 +19,12 @@ COPY ui /app
# Build frontend
RUN npm run build -- --public-url $UI_PUBLIC_URL
# Build API
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.14-alpine AS api
### Build API ###
FROM --platform=${BUILDPLATFORM:-linux/amd64} alpine:3 AS api
# Install required build dependencies
RUN apk --no-cache add go build-base
COPY --from=tonistiigi/xx:golang / /
ARG TARGETPLATFORM
@@ -33,13 +37,17 @@ WORKDIR /app
COPY api/go.mod api/go.sum /app/
RUN go mod download
# Build go-sqlite3 dependency with CGO
ENV CGO_ENABLED 1
RUN go install github.com/mattn/go-sqlite3
# Copy api source
COPY api /app
RUN go build -v -o photoview .
# Copy api and ui to production environment
FROM alpine:3.12
### Copy api and ui to production environment ###
FROM alpine:3
# Install darktable for converting RAW images, and ffmpeg for encoding videos
# Ignore errors if packages are not supported for the specific platform
@@ -47,13 +55,12 @@ RUN apk --no-cache add darktable; exit 0
RUN apk --no-cache add ffmpeg; exit 0
COPY --from=ui /app/dist /ui
COPY --from=api /app/database/migrations /database/migrations
COPY --from=api /app/photoview /app/photoview
ENV API_LISTEN_IP 127.0.0.1
ENV API_LISTEN_PORT 80
ENV PHOTOVIEW_LISTEN_IP 127.0.0.1
ENV PHOTOVIEW_LISTEN_PORT 80
ENV SERVE_UI 1
ENV PHOTOVIEW_SERVE_UI 1
EXPOSE 80

138
api/database/database.go Normal file
View File

@@ -0,0 +1,138 @@
package database
import (
"context"
"fmt"
"log"
"net/url"
"time"
"github.com/photoview/photoview/api/database/drivers"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func getMysqlAddress() (*url.URL, error) {
addressString := utils.EnvMysqlURL.GetValue()
if addressString == "" {
return nil, errors.New(fmt.Sprintf("Environment variable %s missing, exiting", utils.EnvMysqlURL.GetName()))
}
address, err := url.Parse(addressString)
if err != nil {
return nil, errors.Wrap(err, "Could not parse mysql url")
}
queryValues := address.Query()
queryValues.Add("multiStatements", "true")
queryValues.Add("parseTime", "true")
address.RawQuery = queryValues.Encode()
return address, nil
}
func getSqliteAddress() (*url.URL, error) {
path := utils.EnvSqlitePath.GetValue()
if path == "" {
path = "photoview.db"
}
address, err := url.Parse(path)
if err != nil {
return nil, errors.Wrapf(err, "Could not parse sqlite url (%s)", path)
}
queryValues := address.Query()
queryValues.Add("cache", "shared")
queryValues.Add("mode", "rwc")
// queryValues.Add("_busy_timeout", "60000") // 1 minute
address.RawQuery = queryValues.Encode()
// log.Panicf("%s", address.String())
return address, nil
}
// SetupDatabase connects to the database using environment variables
func SetupDatabase() (*gorm.DB, error) {
config := gorm.Config{}
// Configure database logging
if utils.DevelopmentMode() {
config.Logger = logger.Default.LogMode(logger.Info)
} else {
config.Logger = logger.Default.LogMode(logger.Warn)
}
var databaseDialect gorm.Dialector
switch drivers.DatabaseDriver() {
case drivers.DatabaseDriverMysql:
mysqlAddress, err := getMysqlAddress()
if err != nil {
return nil, err
}
log.Printf("Connecting to database: %s", mysqlAddress)
databaseDialect = mysql.Open(mysqlAddress.String())
case drivers.DatabaseDriverSqlite:
sqliteAddress, err := getSqliteAddress()
if err != nil {
return nil, err
}
databaseDialect = sqlite.Open(sqliteAddress.String())
}
db, err := gorm.Open(databaseDialect, &config)
sqlDB, dbErr := db.DB()
if dbErr != nil {
log.Println(dbErr)
return nil, dbErr
}
sqlDB.SetMaxOpenConns(80)
if err != nil {
for retryCount := 1; retryCount <= 5; retryCount++ {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := sqlDB.PingContext(ctx); err == nil {
cancel()
return db, nil
}
cancel()
log.Printf("WARN: Could not ping database: %s. Will retry after 5 seconds\n", err)
time.Sleep(time.Duration(5) * time.Second)
}
return nil, err
}
return db, nil
}
func MigrateDatabase(db *gorm.DB) error {
db.AutoMigrate(
&models.User{},
&models.AccessToken{},
&models.SiteInfo{},
&models.Media{},
&models.MediaURL{},
&models.Album{},
&models.MediaEXIF{},
&models.VideoMetadata{},
&models.ShareToken{},
&models.UserMediaData{},
)
return nil
}

View File

@@ -0,0 +1,32 @@
package drivers
import (
"strings"
"github.com/photoview/photoview/api/utils"
)
// DatabaseDriverType represents the name of a database driver
type DatabaseDriverType string
const (
DatabaseDriverMysql DatabaseDriverType = "mysql"
DatabaseDriverSqlite DatabaseDriverType = "sqlite"
)
func DatabaseDriver() DatabaseDriverType {
var driver DatabaseDriverType
driverString := strings.ToLower(utils.EnvDatabaseDriver.GetValue())
switch driverString {
case "mysql":
driver = DatabaseDriverMysql
case "sqlite":
driver = DatabaseDriverSqlite
default:
driver = DatabaseDriverMysql
}
return driver
}

View File

@@ -1,9 +0,0 @@
DROP TABLE IF EXISTS site_info;
DROP TABLE IF EXISTS access_token;
DROP TABLE IF EXISTS media_url;
DROP TABLE IF EXISTS share_token;
DROP TABLE IF EXISTS media;
DROP TABLE IF EXISTS video_metadata;
DROP TABLE IF EXISTS media_exif;
DROP TABLE IF EXISTS album;
DROP TABLE IF EXISTS user;

View File

@@ -1,120 +0,0 @@
-- Users and authentication
CREATE TABLE IF NOT EXISTS user (
user_id int NOT NULL AUTO_INCREMENT,
username varchar(128) NOT NULL UNIQUE,
password varchar(256),
root_path varchar(512),
admin boolean NOT NULL DEFAULT 0,
PRIMARY KEY (user_id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS access_token (
token_id int NOT NULL AUTO_INCREMENT,
user_id int NOT NULL,
value char(24) NOT NULL UNIQUE,
expire timestamp NOT NULL,
PRIMARY KEY (token_id),
FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS site_info (
initial_setup boolean NOT NULL DEFAULT TRUE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Video related
CREATE TABLE IF NOT EXISTS video_metadata (
metadata_id int NOT NULL AUTO_INCREMENT,
width int(6) NOT NULL,
height int(6) NOT NULL,
duration double NOT NULL,
codec varchar(128),
framerate double,
bitrate int(24),
color_profile varchar(128),
audio varchar(128),
PRIMARY KEY (metadata_id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Media related
CREATE TABLE IF NOT EXISTS album (
album_id int NOT NULL AUTO_INCREMENT,
title varchar(256) NOT NULL,
parent_album int,
owner_id int NOT NULL,
path varchar(1024) NOT NULL,
path_hash varchar(32) NOT NULL UNIQUE,
PRIMARY KEY (album_id),
FOREIGN KEY (parent_album) REFERENCES album(album_id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES user(user_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS media_exif (
exif_id int NOT NULL AUTO_INCREMENT,
camera varchar(256),
maker varchar(256),
lens varchar(256),
date_shot timestamp NULL,
exposure varchar(256),
aperture float,
iso int(6),
focal_length float,
flash varchar(256),
orientation int(1),
exposure_program int(1),
gps_latitude float,
gps_longitude float,
PRIMARY KEY (exif_id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS media (
media_id int NOT NULL AUTO_INCREMENT,
title varchar(256) NOT NULL,
path varchar(1024) NOT NULL,
path_hash varchar(32) NOT NULL UNIQUE,
album_id int NOT NULL,
exif_id int,
date_shot datetime NOT NULL,
date_imported datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
favorite boolean DEFAULT FALSE,
media_type varchar(64) NOT NULL,
video_metadata_id int,
PRIMARY KEY (media_id),
FOREIGN KEY (album_id) REFERENCES album(album_id) ON DELETE CASCADE,
FOREIGN KEY (exif_id) REFERENCES media_exif(exif_id) ON DELETE CASCADE,
FOREIGN KEY (video_metadata_id) REFERENCES video_metadata(metadata_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS media_url (
url_id int NOT NULL AUTO_INCREMENT,
media_id int NOT NULL,
media_name varchar(512) NOT NULL,
width int NOT NULL,
height int NOT NULL,
purpose varchar(64) NOT NULL,
content_type varchar(64) NOT NULL,
file_size int NOT NULL,
PRIMARY KEY (url_id),
FOREIGN KEY (media_id) REFERENCES media(media_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Public shares
CREATE TABLE IF NOT EXISTS share_token (
token_id int AUTO_INCREMENT,
value char(24) NOT NULL UNIQUE,
owner_id int NOT NULL,
expire timestamp NULL DEFAULT NULL,
password varchar(256),
album_id int,
media_id int,
PRIMARY KEY (token_id)
-- CHECK (album_id IS NOT NULL OR media_id IS NOT NULL)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -1,4 +0,0 @@
ALTER TABLE site_info
DROP COLUMN IF EXISTS periodic_scan_interval,
DROP COLUMN IF EXISTS concurrent_workers;

View File

@@ -1,4 +0,0 @@
ALTER TABLE site_info
ADD COLUMN IF NOT EXISTS periodic_scan_interval int(8) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS concurrent_workers int(8) NOT NULL DEFAULT 3;

View File

@@ -1,4 +0,0 @@
ALTER TABLE media
DROP COLUMN IF EXISTS side_car_path,
DROP COLUMN IF EXISTS side_car_hash;

View File

@@ -1,4 +0,0 @@
ALTER TABLE media
ADD COLUMN IF NOT EXISTS side_car_path varchar(1024) DEFAULT NULL,
ADD COLUMN IF NOT EXISTS side_car_hash varchar(32) DEFAULT NULL;

View File

@@ -1,101 +0,0 @@
package database
import (
"context"
"database/sql"
"log"
"net/url"
"os"
"time"
"github.com/pkg/errors"
// Load mysql driver
_ "github.com/go-sql-driver/mysql"
"github.com/golang-migrate/migrate"
"github.com/golang-migrate/migrate/database/mysql"
// Migrate from file
_ "github.com/golang-migrate/migrate/source/file"
)
// SetupDatabase connects to the database using environment variables
func SetupDatabase() (*sql.DB, error) {
address, err := url.Parse(os.Getenv("MYSQL_URL"))
if err != nil {
return nil, errors.Wrap(err, "Could not parse mysql url")
}
if address.String() == "" {
return nil, errors.New("Environment variable MYSQL_URL missing, exiting")
}
queryValues := address.Query()
queryValues.Add("multiStatements", "true")
queryValues.Add("parseTime", "true")
address.RawQuery = queryValues.Encode()
log.Printf("Connecting to database: %s", address)
var db *sql.DB
db, err = sql.Open("mysql", address.String())
if err != nil {
return nil, errors.New("Could not connect to database, exiting")
}
tryCount := 0
for {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
if tryCount < 4 {
tryCount++
log.Printf("WARN: Could not ping database: %s, Will retry after 1 second", err)
time.Sleep(time.Second)
continue
} else {
return nil, errors.Wrap(err, "Could not ping database, exiting")
}
}
break
}
db.SetMaxOpenConns(80)
return db, nil
}
func MigrateDatabase(db *sql.DB) error {
driver, err := mysql.WithInstance(db, &mysql.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithDatabaseInstance(
"file://database/migrations",
"mysql",
driver,
)
if err != nil {
return err
}
if err := m.Up(); err != nil {
if err.Error() == "no change" {
log.Println("Database is up to date")
} else {
return err
}
} else {
log.Println("Database migrated")
}
return nil
}

View File

@@ -1,18 +1,25 @@
# Copy this file to .env
MYSQL_URL=user:password@tcp(localhost)/dbname
PHOTOVIEW_DATABASE_DRIVER=mysql
PHOTOVIEW_MYSQL_URL=user:password@tcp(localhost)/dbname
API_LISTEN_IP=localhost
API_LISTEN_PORT=4001
# Specifies the filepath for the sqlite database, if PHOTOVIEW_DATABASE_DRIVER is set to 'sqlite'
# PHOTOVIEW_SQLITE_PATH=photoview.db
PHOTOVIEW_LISTEN_IP=localhost
PHOTOVIEW_LISTEN_PORT=4001
# The url from which the server can be accessed publicly
API_ENDPOINT=http://localhost:4001/
UI_ENDPOINT=http://localhost:1234/
PHOTOVIEW_API_ENDPOINT=http://localhost:4001/
PHOTOVIEW_UI_ENDPOINT=http://localhost:1234/
# Path where media should be cached, defaults to ./media_cache
# PHOTOVIEW_MEDIA_CACHE=./media_cache
# Set to 1 for the server to also serve the built static ui files
SERVE_UI=0
# When SERVE_UI is 1, PUBLIC_ENDPOINT is used instead of API_ENDPOINT and UI_ENDPOINT
#PUBLIC_ENDPOINT=http://localhost:4001/
PHOTOVIEW_SERVE_UI=0
# When PHOTOVIEW_SERVE_UI is 1, PHOTOVIEW_PUBLIC_ENDPOINT is used instead of PHOTOVIEW_API_ENDPOINT and PHOTOVIEW_UI_ENDPOINT
#PHOTOVIEW_PUBLIC_ENDPOINT=http://localhost:4001/
# Enter a valid mapbox token, to enable maps feature
# A token can be created for free at https://mapbox.com
@@ -20,4 +27,4 @@ SERVE_UI=0
# Set to 1 to set server in development mode, this enables graphql playground
# Remove this if running in production
DEVELOPMENT=1
PHOTOVIEW_DEVELOPMENT_MODE=1

View File

@@ -4,15 +4,8 @@ go 1.13
require (
github.com/99designs/gqlgen v0.13.0
github.com/Microsoft/go-winio v0.4.15 // indirect
github.com/agnivade/levenshtein v1.1.0 // indirect
github.com/disintegration/imaging v1.6.2
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/docker v1.13.1 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/go-sql-driver/mysql v1.5.0
github.com/golang-migrate/migrate v3.5.4+incompatible
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
@@ -20,15 +13,15 @@ require (
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/joho/godotenv v1.3.0
github.com/mitchellh/mapstructure v1.4.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1
github.com/vektah/gqlparser/v2 v2.1.0
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0
github.com/xor-gate/goexif2 v1.1.0
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5
golang.org/x/net v0.0.0-20201207224615-747e23833adb // indirect
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d // indirect
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9
golang.org/x/image v0.0.0-20201208152932-35266b937fa6
gopkg.in/vansante/go-ffprobe.v2 v2.0.2
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.0.3
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.20.8
)

View File

@@ -1,32 +1,30 @@
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.4.15/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA=
github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
@@ -40,39 +38,48 @@ github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.0 h1:7ks8ZkOP5/ujthUsT07rNv+nkLXCQWKNHuwzOAesEks=
github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
@@ -83,41 +90,44 @@ github.com/xor-gate/goexif2 v1.1.0 h1:OvTZ5iEvsDhRWFjV5xY3wT7uHFna28nSSP7ucau+cX
github.com/xor-gate/goexif2 v1.1.0/go.mod h1:eRjn3VSkAwpNpxEx/CGmd0zg0JFGL3akrSMxnJ581AY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 h1:sYNJzB4J8toYPQTM6pAkcmBRgw9SnQKP9oXCHfgy604=
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201207224615-747e23833adb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/vansante/go-ffprobe.v2 v2.0.2 h1:DdxSfFnlqeawPIVbIQEI6LR6OQHQNR7tNgWb2mWuC4w=
gopkg.in/vansante/go-ffprobe.v2 v2.0.2/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.8 h1:iToaOdZgjNvlc44NFkxfLa3U9q63qwaxt0FdNCiwOMs=
gorm.io/gorm v1.20.8/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

View File

@@ -24,6 +24,9 @@ models:
model: github.com/99designs/gqlgen/graphql.IntID
User:
model: github.com/photoview/photoview/api/graphql/models.User
fields:
albums:
resolver: true
Media:
model: github.com/photoview/photoview/api/graphql/models.Media
MediaURL:
@@ -36,3 +39,5 @@ models:
model: github.com/photoview/photoview/api/graphql/models.Album
ShareToken:
model: github.com/photoview/photoview/api/graphql/models.ShareToken
SiteInfo:
model: github.com/photoview/photoview/api/graphql/models.SiteInfo

View File

@@ -2,7 +2,6 @@ package auth
import (
"context"
"database/sql"
"errors"
"log"
"net/http"
@@ -10,6 +9,7 @@ import (
"github.com/99designs/gqlgen/handler"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
var ErrUnauthorized = errors.New("unauthorized")
@@ -23,7 +23,7 @@ type contextKey struct {
}
// Middleware decodes the share session cookie and packs the session into context
func Middleware(db *sql.DB) func(http.Handler) http.Handler {
func Middleware(db *gorm.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -66,7 +66,7 @@ func UserFromContext(ctx context.Context) *models.User {
return raw
}
func AuthWebsocketInit(db *sql.DB) func(context.Context, handler.InitPayload) (context.Context, error) {
func AuthWebsocketInit(db *gorm.DB) func(context.Context, handler.InitPayload) (context.Context, error) {
return func(ctx context.Context, initPayload handler.InitPayload) (context.Context, error) {
bearer, exists := initPayload["Authorization"].(string)

View File

@@ -2,14 +2,14 @@ package api
import (
"context"
"database/sql"
"errors"
"github.com/99designs/gqlgen/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"gorm.io/gorm"
)
func IsAdmin(database *sql.DB) func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
func IsAdmin(database *gorm.DB) func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
return func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) {
user := auth.UserFromContext(ctx)

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,44 @@
package models
import (
"database/sql"
"crypto/md5"
"encoding/hex"
"gorm.io/gorm"
)
type Album struct {
AlbumID int
Title string
ParentAlbum *int
OwnerID int
Path string
PathHash string
}
func (a *Album) ID() int {
return a.AlbumID
Model
Title string `gorm:"not null"`
ParentAlbumID *int
ParentAlbum *Album `gorm:"constraint:OnDelete:SET NULL;"`
// OwnerID int `gorm:"not null"`
// Owner User
Owners []User `gorm:"many2many:user_albums"`
Path string `gorm:"not null"`
PathHash string `gorm:"unique"`
}
func (a *Album) FilePath() string {
return a.Path
}
func NewAlbumFromRow(row *sql.Row) (*Album, error) {
album := Album{}
if err := row.Scan(&album.AlbumID, &album.Title, &album.ParentAlbum, &album.OwnerID, &album.Path, &album.PathHash); err != nil {
return nil, err
}
return &album, nil
func (a *Album) BeforeSave(tx *gorm.DB) (err error) {
hash := md5.Sum([]byte(a.Path))
a.PathHash = hex.EncodeToString(hash[:])
return nil
}
func NewAlbumsFromRows(rows *sql.Rows) ([]*Album, error) {
albums := make([]*Album, 0)
func (a *Album) GetChildren(db *gorm.DB) (children []*Album, err error) {
err = db.Raw(`
WITH recursive sub_albums AS (
SELECT * FROM albums AS root WHERE id = ?
UNION ALL
SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id
)
for rows.Next() {
var album Album
if err := rows.Scan(&album.AlbumID, &album.Title, &album.ParentAlbum, &album.OwnerID, &album.Path, &album.PathHash); err != nil {
return nil, err
}
albums = append(albums, &album)
}
SELECT * FROM sub_albums
`, a.ID).Find(&children).Error
rows.Close()
return albums, nil
return children, err
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
)
type Model struct {
ID int `gorm:"primarykey"`
ModelTimestamps
}
type ModelTimestamps struct {
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -51,15 +51,6 @@ type SearchResult struct {
Media []*Media `json:"media"`
}
// General information about the site
type SiteInfo struct {
InitialSetup bool `json:"initialSetup"`
// How often automatic scans should be initiated in seconds
PeriodicScanInterval int `json:"periodicScanInterval"`
// How many max concurrent scanner jobs that should run at once
ConcurrentWorkers int `json:"concurrentWorkers"`
}
type MediaType string
const (

View File

@@ -1,33 +1,63 @@
package models
import (
"database/sql"
"path"
"strings"
"time"
"github.com/photoview/photoview/api/utils"
"gorm.io/gorm"
)
type Media struct {
MediaID int
Title string
Path string
PathHash string
AlbumId int
ExifId *int
DateShot time.Time
DateImported time.Time
Favorite bool
Type MediaType
VideoMetadataId *int
Model
Title string `gorm:"not null"`
Path string `gorm:"not null"`
PathHash string `gorm:"not null"`
AlbumID int `gorm:"not null"`
Album Album `gorm:"constraint:OnDelete:CASCADE;"`
ExifID *int
Exif *MediaEXIF `gorm:"constraint:OnDelete:SET NULL;"`
MediaURL []MediaURL `gorm:"constraint:OnDelete:CASCADE;"`
DateShot time.Time `gorm:"not null"`
DateImported time.Time `gorm:"not null"`
// Favorite bool `gorm:"not null, default:false"`
Type MediaType `gorm:"not null"`
VideoMetadataID *int
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:SET NULL;"`
SideCarPath *string
SideCarHash *string
CounterpartPath *string
// Only used internally
CounterpartPath *string `gorm:-`
}
func (p *Media) ID() int {
return p.MediaID
func (Media) TableName() string {
return "media"
}
func (m *Media) BeforeSave(tx *gorm.DB) error {
// Update hashes
m.PathHash = MD5Hash(m.Path)
if m.SideCarPath != nil {
encodedHash := MD5Hash(*m.SideCarPath)
m.SideCarHash = &encodedHash
}
return nil
}
func (m *Media) BeforeDelete(tx *gorm.DB) error {
if err := tx.Model(m).Association("Exif").Clear(); err != nil {
return err
}
if err := tx.Model(m).Association("MediaURL").Clear(); err != nil {
return err
}
return nil
}
type MediaPurpose string
@@ -41,40 +71,15 @@ const (
)
type MediaURL struct {
UrlID int
MediaId int
MediaName string
Width int
Height int
Purpose MediaPurpose
ContentType string
FileSize int
}
func NewMediaFromRow(row *sql.Row) (*Media, error) {
media := Media{}
if err := row.Scan(&media.MediaID, &media.Title, &media.Path, &media.PathHash, &media.AlbumId, &media.ExifId, &media.DateShot, &media.DateImported, &media.Favorite, &media.Type, &media.VideoMetadataId, &media.SideCarPath, &media.SideCarHash); err != nil {
return nil, err
}
return &media, nil
}
func NewMediaFromRows(rows *sql.Rows) ([]*Media, error) {
medias := make([]*Media, 0)
for rows.Next() {
var media Media
if err := rows.Scan(&media.MediaID, &media.Title, &media.Path, &media.PathHash, &media.AlbumId, &media.ExifId, &media.DateShot, &media.DateImported, &media.Favorite, &media.Type, &media.VideoMetadataId, &media.SideCarPath, &media.SideCarHash); err != nil {
return nil, err
}
medias = append(medias, &media)
}
rows.Close()
return medias, nil
Model
MediaID int `gorm:"not null"`
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
MediaName string `gorm:"not null"`
Width int `gorm:"not null"`
Height int `gorm:"not null"`
Purpose MediaPurpose `gorm:"not null"`
ContentType string `gorm:"not null"`
FileSize int64 `gorm:"not null"`
}
func (p *MediaURL) URL() string {
@@ -97,29 +102,3 @@ func SanitizeMediaName(mediaName string) string {
result = strings.ReplaceAll(result, ".", "_")
return result
}
func NewMediaURLFromRow(row *sql.Row) (*MediaURL, error) {
url := MediaURL{}
if err := row.Scan(&url.UrlID, &url.MediaId, &url.MediaName, &url.Width, &url.Height, &url.Purpose, &url.ContentType, &url.FileSize); err != nil {
return nil, err
}
return &url, nil
}
func NewMediaURLFromRows(rows *sql.Rows) ([]*MediaURL, error) {
urls := make([]*MediaURL, 0)
for rows.Next() {
var url MediaURL
if err := rows.Scan(&url.UrlID, &url.MediaId, &url.MediaName, &url.Width, &url.Height, &url.Purpose, &url.ContentType, &url.FileSize); err != nil {
return nil, err
}
urls = append(urls, &url)
}
rows.Close()
return urls, nil
}

View File

@@ -1,12 +1,11 @@
package models
import (
"database/sql"
"time"
)
type MediaEXIF struct {
ExifID int
Model
Camera *string
Maker *string
Lens *string
@@ -22,20 +21,10 @@ type MediaEXIF struct {
GPSLonitude *float64
}
func (MediaEXIF) TableName() string {
return "media_exif"
}
func (exif *MediaEXIF) Media() *Media {
panic("not implemented")
}
func (exif *MediaEXIF) ID() int {
return exif.ExifID
}
func NewMediaExifFromRow(row *sql.Row) (*MediaEXIF, error) {
exif := MediaEXIF{}
if err := row.Scan(&exif.ExifID, &exif.Camera, &exif.Maker, &exif.Lens, &exif.DateShot, &exif.Exposure, &exif.Aperture, &exif.Iso, &exif.FocalLength, &exif.Flash, &exif.Orientation, &exif.ExposureProgram, &exif.GPSLatitude, &exif.GPSLonitude); err != nil {
return nil, err
}
return &exif, nil
}

View File

@@ -1,52 +1,22 @@
package models
import (
"database/sql"
"time"
"github.com/pkg/errors"
)
type ShareToken struct {
TokenID int
Value string
OwnerID int
Model
Value string `gorm:"not null"`
OwnerID int `gorm:"not null"`
Owner User `gorm:"constraint:OnDelete:CASCADE;"`
Expire *time.Time
Password *string
AlbumID *int
Album *Album `gorm:"constraint:OnDelete:CASCADE;"`
MediaID *int
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
}
func (share *ShareToken) Token() string {
return share.Value
}
func (share *ShareToken) ID() int {
return share.TokenID
}
func NewShareTokenFromRow(row *sql.Row) (*ShareToken, error) {
token := ShareToken{}
if err := row.Scan(&token.TokenID, &token.Value, &token.OwnerID, &token.Expire, &token.Password, &token.AlbumID, &token.MediaID); err != nil {
return nil, errors.Wrap(err, "failed to scan share token from database")
}
return &token, nil
}
func NewShareTokensFromRows(rows *sql.Rows) ([]*ShareToken, error) {
tokens := make([]*ShareToken, 0)
for rows.Next() {
var token ShareToken
if err := rows.Scan(&token.TokenID, &token.Value, &token.OwnerID, &token.Expire, &token.Password, &token.AlbumID, &token.MediaID); err != nil {
return nil, errors.Wrap(err, "failed to scan share tokens from database")
}
tokens = append(tokens, &token)
}
rows.Close()
return tokens, nil
}

View File

@@ -1,48 +1,47 @@
package models
import (
"database/sql"
db_drivers "github.com/photoview/photoview/api/database/drivers"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func initializeSiteInfoRow(db *sql.DB) (*SiteInfo, error) {
_, err := db.Exec("INSERT INTO site_info (initial_setup, periodic_scan_interval, concurrent_workers) VALUES (true, 0, 3)")
if err != nil {
return nil, errors.Wrap(err, "initialize site_info row")
}
type SiteInfo struct {
InitialSetup bool `gorm:"not null"`
PeriodicScanInterval int `gorm:"not null"`
ConcurrentWorkers int `gorm:"not null"`
}
siteInfo := &SiteInfo{}
row := db.QueryRow("SELECT * FROM site_info")
if err := row.Scan(&siteInfo.InitialSetup, &siteInfo.PeriodicScanInterval, &siteInfo.ConcurrentWorkers); err != nil {
return nil, errors.Wrap(err, "get site_info row after initialization")
}
return siteInfo, nil
func (SiteInfo) TableName() string {
return "site_info"
}
// GetSiteInfo gets the site info row from the database, and creates it if it does not exist
func GetSiteInfo(db *sql.DB) (*SiteInfo, error) {
rows, err := db.Query("SELECT * FROM site_info")
defer rows.Close()
if err != nil {
return nil, err
}
func GetSiteInfo(db *gorm.DB) (*SiteInfo, error) {
siteInfo := &SiteInfo{}
var siteInfo SiteInfo
if !rows.Next() {
// Entry does not exist
siteInfo, err = initializeSiteInfoRow(db)
if err != nil {
return nil, err
}
} else {
if err := rows.Scan(&siteInfo.InitialSetup, &siteInfo.PeriodicScanInterval, &siteInfo.ConcurrentWorkers); err != nil {
return nil, err
if err := db.First(&siteInfo).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
defaultConcurrentWorkers := 3
if db_drivers.DatabaseDriver() == db_drivers.DatabaseDriverSqlite {
defaultConcurrentWorkers = 1
}
siteInfo = SiteInfo{
InitialSetup: true,
PeriodicScanInterval: 0,
ConcurrentWorkers: defaultConcurrentWorkers,
}
if err := db.Create(&siteInfo).Error; err != nil {
return nil, errors.Wrap(err, "initialize site_info")
}
} else {
return nil, errors.Wrap(err, "get site info from database")
}
}
return siteInfo, nil
return &siteInfo, nil
}

View File

@@ -2,7 +2,6 @@ package models
import (
"crypto/rand"
"database/sql"
"fmt"
"log"
"os"
@@ -10,63 +9,44 @@ import (
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type User struct {
UserID int
Username string
Password *string
RootPath string
Admin bool
Model
Username string `gorm:"unique,size:128"`
Password *string `gorm:"size:256`
// RootPath string `gorm:"size:512`
Albums []Album `gorm:"many2many:user_albums"`
Admin bool `gorm:"default:false"`
}
func (u *User) ID() int {
return u.UserID
type UserMediaData struct {
ModelTimestamps
UserID int `gorm:"primaryKey;autoIncrement:false"`
MediaID int `gorm:"primaryKey;autoIncrement:false"`
Favorite bool `gorm:"not null;default:false"`
}
type AccessToken struct {
Value string
Expire time.Time
Model
UserID int `gorm:"not null"`
User User `gorm:"constraint:OnDelete:CASCADE;"`
Value string `gorm:"not null, size:24`
Expire time.Time `gorm:"not null"`
}
var ErrorInvalidUserCredentials = errors.New("invalid credentials")
func NewUserFromRow(row *sql.Row) (*User, error) {
user := User{}
func AuthorizeUser(db *gorm.DB, username string, password string) (*User, error) {
var user User
if err := row.Scan(&user.UserID, &user.Username, &user.Password, &user.RootPath, &user.Admin); err != nil {
return nil, errors.Wrap(err, "failed to scan user from database")
}
return &user, nil
}
func NewUsersFromRows(rows *sql.Rows) ([]*User, error) {
users := make([]*User, 0)
for rows.Next() {
var user User
if err := rows.Scan(&user.UserID, &user.Username, &user.Password, &user.RootPath, &user.Admin); err != nil {
return nil, errors.Wrap(err, "failed to scan users from database")
}
users = append(users, &user)
}
rows.Close()
return users, nil
}
func AuthorizeUser(database *sql.DB, username string, password string) (*User, error) {
row := database.QueryRow("SELECT * FROM user WHERE username = ?", username)
user, err := NewUserFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
result := db.Where("username = ?", username).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrorInvalidUserCredentials
} else {
return nil, err
}
return nil, errors.Wrap(result.Error, "failed to get user by username when authorizing")
}
if user.Password == nil {
@@ -81,7 +61,7 @@ func AuthorizeUser(database *sql.DB, username string, password string) (*User, e
}
}
return user, nil
return &user, nil
}
var ErrorInvalidRootPath = errors.New("invalid root path")
@@ -96,9 +76,15 @@ func ValidRootPath(rootPath string) bool {
return true
}
func RegisterUser(database *sql.Tx, username string, password *string, rootPath string, admin bool) (*User, error) {
if !ValidRootPath(rootPath) {
return nil, ErrorInvalidRootPath
func RegisterUser(db *gorm.DB, username string, password *string, admin bool) (*User, error) {
// if !ValidRootPath(rootPath) {
// return nil, ErrorInvalidRootPath
// }
user := User{
Username: username,
// RootPath: rootPath,
Admin: admin,
}
if password != nil {
@@ -108,29 +94,18 @@ func RegisterUser(database *sql.Tx, username string, password *string, rootPath
}
hashedPass := string(hashedPassBytes)
if _, err := database.Exec("INSERT INTO user (username, password, root_path, admin) VALUES (?, ?, ?, ?)", username, hashedPass, rootPath, admin); err != nil {
return nil, errors.Wrap(err, "insert new user with password into database")
}
} else {
if _, err := database.Exec("INSERT INTO user (username, root_path, admin) VALUES (?, ?, ?)", username, rootPath, admin); err != nil {
return nil, errors.Wrap(err, "insert user without password into database")
}
user.Password = &hashedPass
}
row := database.QueryRow("SELECT * FROM user WHERE username = ?", username)
if row == nil {
return nil, ErrorInvalidUserCredentials
result := db.Create(&user)
if result.Error != nil {
return nil, errors.Wrap(result.Error, "insert new user with password into database")
}
user, err := NewUserFromRow(row)
if err != nil {
return nil, err
}
return user, nil
return &user, nil
}
func (user *User) GenerateAccessToken(database *sql.Tx) (*AccessToken, error) {
func (user *User) GenerateAccessToken(db *gorm.DB) (*AccessToken, error) {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
return nil, errors.New(fmt.Sprintf("Could not generate token: %s\n", err.Error()))
@@ -142,38 +117,66 @@ func (user *User) GenerateAccessToken(database *sql.Tx) (*AccessToken, error) {
token_value := string(bytes)
expire := time.Now().Add(14 * 24 * time.Hour)
expireString := expire.UTC().Format("2006-01-02 15:04:05")
// expireString := expire.UTC().Format("2006-01-02 15:04:05")
if _, err := database.Exec("INSERT INTO access_token (value, expire, user_id) VALUES (?, ?, ?)", token_value, expireString, user.UserID); err != nil {
return nil, err
}
// if _, err := database.Exec("INSERT INTO access_token (value, expire, user_id) VALUES (?, ?, ?)", token_value, expireString, user.UserID); err != nil {
// return nil, err
// }
token := AccessToken{
UserID: user.ID,
Value: token_value,
Expire: expire,
}
result := db.Create(&token)
if result.Error != nil {
return nil, errors.Wrap(result.Error, "saving access token to database")
}
return &token, nil
}
func VerifyTokenAndGetUser(database *sql.DB, token string) (*User, error) {
func VerifyTokenAndGetUser(db *gorm.DB, token string) (*User, error) {
now := time.Now().UTC().Format("2006-01-02 15:04:05")
row := database.QueryRow("SELECT (user_id) FROM access_token WHERE expire > ? AND value = ?", now, token)
var userId string
if err := row.Scan(&userId); err != nil {
log.Println(err.Error())
return nil, err
var accessToken AccessToken
result := db.Where("expire > ? AND value = ?", time.Now(), token).First(&accessToken)
if result.Error != nil {
return nil, result.Error
}
row = database.QueryRow("SELECT * FROM user WHERE user_id = ?", userId)
user, err := NewUserFromRow(row)
if err != nil {
return nil, err
var user User
result = db.First(&user, accessToken.UserID)
if result.Error != nil {
return nil, result.Error
}
return user, nil
return &user, nil
}
// FillAlbums fill user.Albums with albums from database
func (user *User) FillAlbums(db *gorm.DB) error {
// Albums already present
if len(user.Albums) > 0 {
return nil
}
if err := db.Model(&user).Association("Albums").Find(&user.Albums); err != nil {
return errors.Wrap(err, "fill user albums")
}
return nil
}
func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) {
// user.QueryUserAlbums(db, db.Where("id = ?", album.ID))
// TODO: Implement this
return true, nil
}
func (user *User) OwnsMedia(db *gorm.DB, media *Media) (bool, error) {
// TODO: implement this
return true, nil
}

View File

@@ -1,48 +1,49 @@
package models
import (
"fmt"
"log"
"crypto/md5"
"encoding/hex"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (filter *Filter) FormatSQL(context string) (string, error) {
func (filter *Filter) FormatSQL(tx *gorm.DB) *gorm.DB {
if filter == nil {
return "", nil
}
orderByMap := make(map[string]string)
orderByMap["media_date_shot"] = "media.date_shot"
orderByMap["media_date_imported"] = "media.date_imported"
orderByMap["media_title"] = "media.title"
orderByMap["media_kind"] = "media.media_type, SUBSTRING_INDEX(media.path, '.', -1)"
orderByMap["album_title"] = "album.title"
result := ""
if filter.OrderBy != nil {
order_by, ok := orderByMap[context+"_"+*filter.OrderBy]
if !ok {
log.Printf("Invalid order column: '%s'\n", *filter.OrderBy)
return "", nil
}
direction := "ASC"
if filter.OrderDirection != nil && filter.OrderDirection.IsValid() {
direction = filter.OrderDirection.String()
}
result += fmt.Sprintf(" ORDER BY %s %s", order_by, direction)
return tx
}
if filter.Limit != nil {
offset := 0
if filter.Offset != nil && *filter.Offset >= 0 {
offset = *filter.Offset
}
result += fmt.Sprintf(" LIMIT %d OFFSET %d", *filter.Limit, offset)
tx.Limit(*filter.Limit)
}
log.Printf("SQL Filter: '%s'\n", result)
return result, nil
if filter.Offset != nil {
tx.Offset(*filter.Offset)
}
if filter.OrderBy != nil {
desc := true
if filter.OrderDirection != nil && filter.OrderDirection.IsValid() {
if *filter.OrderDirection == OrderDirectionAsc {
desc = false
}
}
tx.Order(clause.OrderByColumn{
Column: clause.Column{
Name: *filter.OrderBy,
},
Desc: desc,
})
}
return tx
}
// MD5Hash hashes value to a 32 length digest, the result is the same as the MYSQL function md5()
func MD5Hash(value string) string {
hash := md5.Sum([]byte(value))
return hex.EncodeToString(hash[:])
}

View File

@@ -1,33 +1,17 @@
package models
import "database/sql"
type VideoMetadata struct {
MetadataID int
Width int
Height int
Duration float64
Model
Width int `gorm:"not null"`
Height int `gorm:"not null"`
Duration float64 `gorm:"not null"`
Codec *string
Framerate *float64
Bitrate *int
Bitrate *string
ColorProfile *string
Audio *string
}
func (metadata *VideoMetadata) ID() int {
return metadata.MetadataID
}
func (metadata *VideoMetadata) Media() *Media {
panic("not implemented")
}
func NewVideoMetadataFromRow(row *sql.Row) (*VideoMetadata, error) {
meta := VideoMetadata{}
if err := row.Scan(&meta.MetadataID, &meta.Width, &meta.Height, &meta.Duration, &meta.Codec, &meta.Framerate, &meta.Bitrate, &meta.ColorProfile, &meta.Audio); err != nil {
return nil, err
}
return &meta, nil
}

View File

@@ -2,11 +2,12 @@ package resolvers
import (
"context"
"database/sql"
"errors"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
func (r *queryResolver) MyAlbums(ctx context.Context, filter *models.Filter, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) {
@@ -15,41 +16,39 @@ func (r *queryResolver) MyAlbums(ctx context.Context, filter *models.Filter, onl
return nil, auth.ErrUnauthorized
}
filterSQL, err := filter.FormatSQL("album")
if err != nil {
if err := user.FillAlbums(r.Database); err != nil {
return nil, err
}
var rows *sql.Rows
filterFavorites := " AND favorite = 1"
if onlyWithFavorites == nil || *onlyWithFavorites == false {
filterFavorites = ""
if len(user.Albums) == 0 {
return nil, nil
}
filterEmpty := " AND EXISTS (SELECT * FROM media WHERE album_id = album.album_id" + filterFavorites + ") "
if showEmpty != nil && *showEmpty == true && (onlyWithFavorites == nil || *onlyWithFavorites == false) {
filterEmpty = ""
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
if onlyRoot == nil || *onlyRoot == false {
rows, err = r.Database.Query("SELECT * FROM album WHERE owner_id = ?"+filterEmpty+filterSQL, user.UserID)
if err != nil {
return nil, err
}
} else {
rows, err = r.Database.Query(`
SELECT * FROM album WHERE owner_id = ? AND parent_album = (
SELECT album_id FROM album WHERE parent_album IS NULL AND owner_id = ?
)
`+filterEmpty+filterSQL, user.UserID, user.UserID)
if err != nil {
return nil, err
query := r.Database.Model(models.Album{}).Where("id IN (?)", userAlbumIDs)
if onlyRoot != nil && *onlyRoot == true {
query = query.Where("parent_album_id IS NULL")
}
if showEmpty == nil || *showEmpty == false {
subQuery := r.Database.Model(&models.Media{}).Where("album_id = albums.id")
if onlyWithFavorites != nil && *onlyWithFavorites == true {
subQuery = subQuery.Where("favorite = 1")
}
query = query.Where("EXISTS (?)", subQuery)
}
albums, err := models.NewAlbumsFromRows(rows)
if err != nil {
query = filter.FormatSQL(query)
var albums []*models.Album
if err := query.Scan(&albums).Error; err != nil {
return nil, err
}
@@ -62,13 +61,24 @@ func (r *queryResolver) Album(ctx context.Context, id int) (*models.Album, error
return nil, auth.ErrUnauthorized
}
row := r.Database.QueryRow("SELECT * FROM album WHERE album_id = ? AND owner_id = ?", id, user.UserID)
album, err := models.NewAlbumFromRow(row)
var album models.Album
if err := r.Database.First(&album, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("album not found")
}
return nil, err
}
ownsAlbum, err := user.OwnsAlbum(r.Database, &album)
if err != nil {
return nil, err
}
return album, nil
if !ownsAlbum {
return nil, errors.New("forbidden")
}
return &album, nil
}
func (r *Resolver) Album() api.AlbumResolver {
@@ -77,32 +87,21 @@ func (r *Resolver) Album() api.AlbumResolver {
type albumResolver struct{ *Resolver }
func (r *albumResolver) Media(ctx context.Context, obj *models.Album, filter *models.Filter, onlyFavorites *bool) ([]*models.Media, error) {
func (r *albumResolver) Media(ctx context.Context, album *models.Album, filter *models.Filter, onlyFavorites *bool) ([]*models.Media, error) {
filterSQL, err := filter.FormatSQL("media")
if err != nil {
return nil, err
query := r.Database.
Joins("Album").
Where("Album.id = ?", album.ID).
Where("media.id IN (?)", r.Database.Model(&models.MediaURL{}).Select("media_urls.media_id").Where("media_urls.media_id = media.id"))
if onlyFavorites != nil && *onlyFavorites == true {
query = query.Where("media.favorite = 1")
}
filterFavorites := " AND media.favorite = 1 "
if onlyFavorites == nil || *onlyFavorites == false {
filterFavorites = ""
}
query = filter.FormatSQL(query)
mediaRows, err := r.Database.Query(`
SELECT media.* FROM album, media
WHERE album.album_id = ? AND media.album_id = album.album_id
AND media.media_id IN (
SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id
)
`+filterFavorites+filterSQL, obj.AlbumID)
if err != nil {
return nil, err
}
defer mediaRows.Close()
media, err := models.NewMediaFromRows(mediaRows)
if err != nil {
var media []*models.Media
if err := query.Find(&media).Error; err != nil {
return nil, err
}
@@ -111,45 +110,37 @@ func (r *albumResolver) Media(ctx context.Context, obj *models.Album, filter *mo
func (r *albumResolver) Thumbnail(ctx context.Context, obj *models.Album) (*models.Media, error) {
row := r.Database.QueryRow(`
var media models.Media
err := r.Database.Raw(`
WITH recursive sub_albums AS (
SELECT * FROM album AS root WHERE album_id = ?
SELECT * FROM albums AS root WHERE id = ?
UNION ALL
SELECT child.* FROM album AS child JOIN sub_albums ON child.parent_album = sub_albums.album_id
SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id
)
SELECT * FROM media WHERE media.album_id IN (
SELECT album_id FROM sub_albums
) AND media.media_id IN (
SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id
SELECT id FROM sub_albums
) AND media.id IN (
SELECT media_id FROM media_urls WHERE media_urls.media_id = media.id
) LIMIT 1
`, obj.AlbumID)
`, obj.ID).Scan(&media).Error
media, err := models.NewMediaFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
} else {
return nil, err
}
return nil, err
}
return media, nil
return &media, nil
}
func (r *albumResolver) SubAlbums(ctx context.Context, obj *models.Album, filter *models.Filter) ([]*models.Album, error) {
filterSQL, err := filter.FormatSQL("album")
if err != nil {
return nil, err
}
func (r *albumResolver) SubAlbums(ctx context.Context, parent *models.Album, filter *models.Filter) ([]*models.Album, error) {
rows, err := r.Database.Query("SELECT * FROM album WHERE parent_album = ?"+filterSQL, obj.AlbumID)
if err != nil {
return nil, err
}
var albums []*models.Album
albums, err := models.NewAlbumsFromRows(rows)
if err != nil {
query := r.Database.Where("parent_album_id = ?", parent.ID)
query = filter.FormatSQL(query)
if err := query.Find(&albums).Error; err != nil {
return nil, err
}
@@ -164,34 +155,54 @@ func (r *albumResolver) Owner(ctx context.Context, obj *models.Album) (*models.U
panic("not implemented")
}
func (r *albumResolver) Shares(ctx context.Context, obj *models.Album) ([]*models.ShareToken, error) {
rows, err := r.Database.Query("SELECT * FROM share_token WHERE album_id = ?", obj.ID())
if err != nil {
func (r *albumResolver) Shares(ctx context.Context, album *models.Album) ([]*models.ShareToken, error) {
var shareTokens []*models.ShareToken
if err := r.Database.Where("album_id = ?", album.ID).Find(&shareTokens).Error; err != nil {
return nil, err
}
defer rows.Close()
return models.NewShareTokensFromRows(rows)
return shareTokens, nil
}
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
}
rows, err := r.Database.Query(`
var album_path []*models.Album
err := r.Database.Raw(`
WITH recursive path_albums AS (
SELECT * FROM album anchor WHERE anchor.album_id = ?
SELECT * FROM albums anchor WHERE anchor.id = ?
UNION
SELECT parent.* FROM path_albums child JOIN album parent ON parent.album_id = child.parent_album
SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id
)
SELECT * FROM path_albums WHERE album_id != ? AND owner_id = ?
`, obj.AlbumID, obj.AlbumID, user.UserID)
SELECT * FROM path_albums WHERE id != ?
`, obj.ID, obj.ID).Scan(&album_path).Error
// Make sure to only return albums this user owns
for i := len(album_path) - 1; i >= 0; i-- {
album := album_path[i]
owns, err := user.OwnsAlbum(r.Database, album)
if err != nil {
return nil, err
}
if !owns {
album_path = album_path[i+1:]
break
}
}
if err != nil {
return nil, err
}
return models.NewAlbumsFromRows(rows)
return album_path, nil
}

View File

@@ -2,14 +2,14 @@ package resolvers
import (
"context"
"database/sql"
"strings"
"github.com/pkg/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"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *queryResolver) MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error) {
@@ -18,23 +18,29 @@ func (r *queryResolver) MyMedia(ctx context.Context, filter *models.Filter) ([]*
return nil, errors.New("unauthorized")
}
filterSQL, err := filter.FormatSQL("media")
if err != nil {
if err := user.FillAlbums(r.Database); err != nil {
return nil, err
}
rows, err := r.Database.Query(`
SELECT media.* FROM media, album
WHERE media.album_id = album.album_id AND album.owner_id = ?
AND media.media_id IN (
SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id
)
`+filterSQL, user.UserID)
if err != nil {
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
var media []*models.Media
query := r.Database.
Joins("Album").
Where("albums.id IN (?)", userAlbumIDs).
Where("media.id IN (?)", r.Database.Model(&models.MediaURL{}).Select("id").Where("media_url.media_id = media.id"))
query = filter.FormatSQL(query)
if err := query.Scan(&media).Error; err != nil {
return nil, err
}
return models.NewMediaFromRows(rows)
return media, nil
}
func (r *queryResolver) Media(ctx context.Context, id int) (*models.Media, error) {
@@ -43,21 +49,20 @@ func (r *queryResolver) Media(ctx context.Context, id int) (*models.Media, error
return nil, auth.ErrUnauthorized
}
row := r.Database.QueryRow(`
SELECT media.* FROM media
JOIN album ON media.album_id = album.album_id
WHERE media.media_id = ? AND album.owner_id = ?
AND media.media_id IN (
SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id
)
`, id, user.UserID)
var media models.Media
err := r.Database.
Joins("Album").
Where("media.id = ?", id).
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = Album.id AND user_albums.user_id = ?)", user.ID).
Where("media.id IN (?)", r.Database.Model(&models.MediaURL{}).Select("media_id").Where("media_urls.media_id = media.id")).
First(&media).Error
media, err := models.NewMediaFromRow(row)
if err != nil {
return nil, errors.Wrap(err, "could not get media by media_id and user_id from database")
}
return media, nil
return &media, nil
}
func (r *queryResolver) MediaList(ctx context.Context, ids []int) ([]*models.Media, error) {
@@ -70,31 +75,18 @@ func (r *queryResolver) MediaList(ctx context.Context, ids []int) ([]*models.Med
return nil, errors.New("no ids provided")
}
mediaIDQuestions := strings.Repeat("?,", len(ids))[:len(ids)*2-1]
var media []*models.Media
err := r.Database.
Select("media.*").
Joins("Album").
Where("media.id IN ?", ids).
Where("album.owner_id = ?", user.ID).
Scan(&media).Error
queryArgs := make([]interface{}, 0)
for _, id := range ids {
queryArgs = append(queryArgs, id)
}
queryArgs = append(queryArgs, user.UserID)
rows, err := r.Database.Query(`
SELECT media.* FROM media
JOIN album ON media.album_id = album.album_id
WHERE media.media_id IN (`+mediaIDQuestions+`) AND album.owner_id = ?
AND media.media_id IN (
SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id
)
`, queryArgs...)
if err != nil {
return nil, errors.Wrap(err, "could not get media list by media_id and user_id from database")
}
media, err := models.NewMediaFromRows(rows)
if err != nil {
return nil, errors.Wrap(err, "could not convert database rows to media")
}
return media, nil
}
@@ -106,25 +98,20 @@ func (r *Resolver) Media() api.MediaResolver {
return &mediaResolver{r}
}
func (r *mediaResolver) Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) {
rows, err := r.Database.Query("SELECT * FROM share_token WHERE media_id = ?", obj.MediaID)
if err != nil {
return nil, errors.Wrapf(err, "get shares for media (%s)", obj.Path)
func (r *mediaResolver) Shares(ctx context.Context, media *models.Media) ([]*models.ShareToken, error) {
var shareTokens []*models.ShareToken
if err := r.Database.Where("media_id = ?", media.ID).Find(&shareTokens).Error; err != nil {
return nil, errors.Wrapf(err, "get shares for media (%s)", media.Path)
}
return models.NewShareTokensFromRows(rows)
return shareTokens, nil
}
func (r *mediaResolver) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) {
func (r *mediaResolver) Downloads(ctx context.Context, media *models.Media) ([]*models.MediaDownload, error) {
rows, err := r.Database.Query("SELECT * FROM media_url WHERE media_id = ?", obj.MediaID)
if err != nil {
return nil, errors.Wrapf(err, "get downloads for media (%s)", obj.Path)
}
mediaUrls, err := models.NewMediaURLFromRows(rows)
if err != nil {
return nil, err
var mediaUrls []*models.MediaURL
if err := r.Database.Where("media_id = ?", media.ID).Find(&mediaUrls).Error; err != nil {
return nil, errors.Wrapf(err, "get downloads for media (%s)", media.Path)
}
downloads := make([]*models.MediaDownload, 0)
@@ -154,112 +141,97 @@ func (r *mediaResolver) Downloads(ctx context.Context, obj *models.Media) ([]*mo
return downloads, nil
}
func (r *mediaResolver) HighRes(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
// Try high res first, then
web_types_questions := strings.Repeat("?,", len(scanner.WebMimetypes))[:len(scanner.WebMimetypes)*2-1]
args := make([]interface{}, 0)
args = append(args, obj.MediaID, models.PhotoHighRes, models.MediaOriginal)
for _, webtype := range scanner.WebMimetypes {
args = append(args, webtype)
}
func (r *mediaResolver) HighRes(ctx context.Context, media *models.Media) (*models.MediaURL, error) {
var url models.MediaURL
err := r.Database.
Where("media_id = ?", media.ID).
Where("purpose = ? OR (purpose = ? AND content_type IN ?)", models.PhotoHighRes, models.MediaOriginal, scanner.WebMimetypes).
First(&url).Error
row := r.Database.QueryRow(`
SELECT * FROM media_url WHERE media_id = ? AND
(
purpose = ? OR (purpose = ? AND content_type IN (`+web_types_questions+`))
) LIMIT 1
`, args...)
url, err := models.NewMediaURLFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
} else {
return nil, errors.Wrapf(err, "could not query high-res (%s)", obj.Path)
return nil, errors.Wrapf(err, "could not query high-res (%s)", media.Path)
}
}
return url, nil
return &url, nil
}
func (r *mediaResolver) Thumbnail(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
row := r.Database.QueryRow("SELECT * FROM media_url WHERE media_id = ? AND (purpose = ? OR purpose = ?)", obj.MediaID, models.PhotoThumbnail, models.VideoThumbnail)
func (r *mediaResolver) Thumbnail(ctx context.Context, media *models.Media) (*models.MediaURL, error) {
var url models.MediaURL
err := r.Database.
Where("media_id = ?", media.ID).
Where("purpose = ? OR purpose = ?", models.PhotoThumbnail, models.VideoThumbnail).
First(&url).Error
url, err := models.NewMediaURLFromRow(row)
if err != nil {
return nil, errors.Wrapf(err, "could not query thumbnail (%s)", obj.Path)
return nil, errors.Wrapf(err, "could not query thumbnail (%s)", media.Path)
}
return url, nil
return &url, nil
}
func (r *mediaResolver) VideoWeb(ctx context.Context, obj *models.Media) (*models.MediaURL, error) {
row := r.Database.QueryRow("SELECT * FROM media_url WHERE media_id = ? AND (purpose = ?)", obj.MediaID, models.VideoWeb)
func (r *mediaResolver) VideoWeb(ctx context.Context, media *models.Media) (*models.MediaURL, error) {
var url models.MediaURL
err := r.Database.
Where("media_id = ?", media.ID).
Where("purpose = ?", models.VideoWeb).
First(&url).Error
url, err := models.NewMediaURLFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
} else {
return nil, errors.Wrapf(err, "could not query video web-format url (%s)", obj.Path)
return nil, errors.Wrapf(err, "could not query video web-format url (%s)", media.Path)
}
}
return url, nil
return &url, nil
}
func (r *mediaResolver) Album(ctx context.Context, obj *models.Media) (*models.Album, error) {
row := r.Database.QueryRow("SELECT album.* from media JOIN album ON media.album_id = album.album_id WHERE media_id = ?", obj.MediaID)
return models.NewAlbumFromRow(row)
}
func (r *mediaResolver) Exif(ctx context.Context, obj *models.Media) (*models.MediaEXIF, error) {
row := r.Database.QueryRow("SELECT media_exif.* FROM media NATURAL JOIN media_exif WHERE media.media_id = ?", obj.MediaID)
exif, err := models.NewMediaExifFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
} else {
return nil, errors.Wrapf(err, "could not get exif of media from database")
}
func (r *mediaResolver) Favorite(ctx context.Context, media *models.Media) (bool, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return false, auth.ErrUnauthorized
}
return exif, nil
}
func (r *mediaResolver) VideoMetadata(ctx context.Context, obj *models.Media) (*models.VideoMetadata, error) {
row := r.Database.QueryRow("SELECT video_metadata.* FROM media JOIN video_metadata ON media.video_metadata_id = video_metadata.metadata_id WHERE media.media_id = ?", obj.MediaID)
metadata, err := models.NewVideoMetadataFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
} else {
return nil, errors.Wrapf(err, "could not get video metadata of media from database")
}
userMediaData := models.UserMediaData{
UserID: user.ID,
MediaID: media.ID,
Favorite: false,
}
return metadata, nil
if err := r.Database.FirstOrInit(&userMediaData).Error; err != nil {
return false, errors.Wrapf(err, "get user media data from database (user: %d, media: %d)", user.ID, media.ID)
}
return userMediaData.Favorite, nil
}
func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favorite bool) (*models.Media, error) {
user := auth.UserFromContext(ctx)
row := r.Database.QueryRow("SELECT media.* FROM media JOIN album ON media.album_id = album.album_id WHERE media.media_id = ? AND album.owner_id = ?", mediaID, user.UserID)
media, err := models.NewMediaFromRow(row)
if err != nil {
return nil, err
if user == nil {
return nil, auth.ErrUnauthorized
}
_, err = r.Database.Exec("UPDATE media SET favorite = ? WHERE media_id = ?", favorite, media.MediaID)
if err != nil {
return nil, errors.Wrap(err, "failed to update media favorite on database")
userMediaData := models.UserMediaData{
UserID: user.ID,
MediaID: mediaID,
Favorite: favorite,
}
media.Favorite = favorite
if err := r.Database.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil {
return nil, errors.Wrapf(err, "update user favorite media in database")
}
return media, nil
var media models.Media
if err := r.Database.First(&media, mediaID).Error; err != nil {
return nil, errors.Wrap(err, "get media from database after favorite update")
}
return &media, nil
}

View File

@@ -2,12 +2,7 @@ package resolvers
import (
"context"
"errors"
"os"
"path"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/utils"
)
type geoJSONFeatureCollection struct {
@@ -62,69 +57,71 @@ func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGe
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (interface{}, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, errors.New("unauthorized")
}
// user := auth.UserFromContext(ctx)
// if user == nil {
// return nil, errors.New("unauthorized")
// }
rows, err := r.Database.Query(`
SELECT media.media_id, media.title,
url.media_name AS thumbnail_name, url.width AS thumbnail_width, url.height AS thumbnail_height,
exif.gps_latitude, exif.gps_longitude FROM media_exif exif
INNER JOIN media ON exif.exif_id = media.exif_id
INNER JOIN media_url url ON media.media_id = url.media_id
INNER JOIN album ON media.album_id = album.album_id
WHERE exif.gps_latitude IS NOT NULL
AND exif.gps_longitude IS NOT NULL
AND url.purpose = 'thumbnail'
AND album.owner_id = ?;
`, user.UserID)
defer rows.Close()
if err != nil {
return nil, err
}
// rows, err := r.Database.Query(`
// SELECT media.media_id, media.title,
// url.media_name AS thumbnail_name, url.width AS thumbnail_width, url.height AS thumbnail_height,
// exif.gps_latitude, exif.gps_longitude FROM media_exif exif
// INNER JOIN media ON exif.exif_id = media.exif_id
// INNER JOIN media_url url ON media.media_id = url.media_id
// INNER JOIN album ON media.album_id = album.album_id
// WHERE exif.gps_latitude IS NOT NULL
// AND exif.gps_longitude IS NOT NULL
// AND url.purpose = 'thumbnail'
// AND album.owner_id = ?;
// `, user.UserID)
// defer rows.Close()
// if err != nil {
// return nil, err
// }
features := make([]geoJSONFeature, 0)
// features := make([]geoJSONFeature, 0)
for rows.Next() {
// for rows.Next() {
var mediaID int
var mediaTitle string
var thumbnailName string
var thumbnailWidth int
var thumbnailHeight int
var latitude float64
var longitude float64
// var mediaID int
// var mediaTitle string
// var thumbnailName string
// var thumbnailWidth int
// var thumbnailHeight int
// var latitude float64
// var longitude float64
if err := rows.Scan(&mediaID, &mediaTitle, &thumbnailName, &thumbnailWidth, &thumbnailHeight, &latitude, &longitude); err != nil {
return nil, err
}
// if err := rows.Scan(&mediaID, &mediaTitle, &thumbnailName, &thumbnailWidth, &thumbnailHeight, &latitude, &longitude); err != nil {
// return nil, err
// }
geoPoint := makeGeoJSONFeatureGeometryPoint(latitude, longitude)
// geoPoint := makeGeoJSONFeatureGeometryPoint(latitude, longitude)
thumbnailURL := utils.ApiEndpointUrl()
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", thumbnailName)
// thumbnailURL := utils.ApiEndpointUrl()
// thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", thumbnailName)
properties := geoJSONMediaProperties{
MediaID: mediaID,
MediaTitle: mediaTitle,
Thumbnail: struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}{
URL: thumbnailURL.String(),
Width: thumbnailWidth,
Height: thumbnailHeight,
},
}
// properties := geoJSONMediaProperties{
// MediaID: mediaID,
// MediaTitle: mediaTitle,
// Thumbnail: struct {
// URL string `json:"url"`
// Width int `json:"width"`
// Height int `json:"height"`
// }{
// URL: thumbnailURL.String(),
// Width: thumbnailWidth,
// Height: thumbnailHeight,
// },
// }
features = append(features, makeGeoJSONFeature(properties, geoPoint))
}
// features = append(features, makeGeoJSONFeature(properties, geoPoint))
// }
featureCollection := makeGeoJSONFeatureCollection(features)
// featureCollection := makeGeoJSONFeatureCollection(features)
return featureCollection, nil
// return featureCollection, nil
panic("to be migrated")
}
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {

View File

@@ -2,16 +2,16 @@ package resolvers
import (
"context"
"database/sql"
api "github.com/photoview/photoview/api/graphql"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
//go:generate go run github.com/99designs/gqlgen
type Resolver struct {
Database *sql.DB
Database *gorm.DB
}
func (r *Resolver) Mutation() api.MutationResolver {

View File

@@ -4,9 +4,11 @@ import (
"context"
"time"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/database/drivers"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
@@ -25,13 +27,13 @@ func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult,
}
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
row := r.Database.QueryRow("SELECT * FROM user WHERE user_id = ?", userID)
user, err := models.NewUserFromRow(row)
if err != nil {
var user models.User
if err := r.Database.First(&user, userID).Error; err != nil {
return nil, errors.Wrap(err, "get user from database")
}
scanner.AddUserToQueue(user)
scanner.AddUserToQueue(&user)
startMessage := "Scanner started"
return &models.ScannerResult{
@@ -46,21 +48,18 @@ func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval
return 0, errors.New("interval must be 0 or above")
}
_, err := r.Database.Exec("UPDATE site_info SET periodic_scan_interval = ?", interval)
if err != nil {
if err := r.Database.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&models.SiteInfo{}).Update("periodic_scan_interval", interval).Error; err != nil {
return 0, err
}
var dbInterval int
row := r.Database.QueryRow("SELECT periodic_scan_interval FROM site_info")
if err = row.Scan(&dbInterval); err != nil {
var siteInfo models.SiteInfo
if err := r.Database.First(&siteInfo).Error; err != nil {
return 0, err
}
scanner.ChangePeriodicScanInterval(time.Duration(dbInterval) * time.Second)
scanner.ChangePeriodicScanInterval(time.Duration(siteInfo.PeriodicScanInterval) * time.Second)
return dbInterval, nil
return siteInfo.PeriodicScanInterval, nil
}
func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
@@ -68,19 +67,20 @@ func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, work
return 0, errors.New("concurrent workers must at least be 1")
}
_, err := r.Database.Exec("UPDATE site_info SET concurrent_workers = ?", workers)
if err != nil {
if workers > 1 && drivers.DatabaseDriver() == drivers.DatabaseDriverSqlite {
return 0, errors.New("multiple workers not supported for SQLite databases")
}
if err := r.Database.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&models.SiteInfo{}).Update("concurrent_workers", workers).Error; err != nil {
return 0, err
}
var dbWorkers int
row := r.Database.QueryRow("SELECT concurrent_workers FROM site_info")
if err = row.Scan(&dbWorkers); err != nil {
var siteInfo models.SiteInfo
if err := r.Database.First(&siteInfo).Error; err != nil {
return 0, err
}
scanner.ChangeScannerConcurrentWorkers(dbWorkers)
scanner.ChangeScannerConcurrentWorkers(siteInfo.ConcurrentWorkers)
return dbWorkers, nil
return siteInfo.ConcurrentWorkers, nil
}

View File

@@ -3,8 +3,10 @@ package resolvers
import (
"context"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/pkg/errors"
"gorm.io/gorm/clause"
"github.com/photoview/photoview/api/graphql/models"
)
@@ -27,45 +29,42 @@ func (r *Resolver) Search(ctx context.Context, query string, _limitMedia *int, _
wildQuery := "%" + query + "%"
photoRows, err := r.Database.Query(`
SELECT media.* FROM media JOIN album ON media.album_id = album.album_id
WHERE album.owner_id = ? AND ( media.title LIKE ? OR media.path LIKE ? )
ORDER BY (
case when media.title LIKE ? then 2
when media.path LIKE ? then 1
end ) DESC
LIMIT ?
`, user.UserID, wildQuery, wildQuery, wildQuery, wildQuery, limitMedia)
var media []*models.Media
err := r.Database.Joins("Album").
Where("Album.owner_id = ? AND ( media.title LIKE ? OR media.path LIKE ? )", user.ID, wildQuery, wildQuery).
Clauses(clause.OrderBy{
Expression: clause.Expr{
SQL: "(CASE WHEN media.title LIKE ? THEN 2 WHEN media.path LIKE ? THEN 1 END) DESC",
Vars: []interface{}{wildQuery, wildQuery},
WithoutParentheses: true},
}).
Limit(limitMedia).
Find(&media).Error
if err != nil {
return nil, errors.Wrapf(err, "searching media")
}
photos, err := models.NewMediaFromRows(photoRows)
if err != nil {
return nil, err
}
var albums []*models.Album
err = r.Database.Where("owner_id = ? AND (title LIKE ? OR path LIKE ?)", user.ID, wildQuery, wildQuery).
Clauses(clause.OrderBy{
Expression: clause.Expr{
SQL: "(CASE WHEN title LIKE ? THEN 2 WHEN path LIKE ? THEN 1 END) DESC",
Vars: []interface{}{wildQuery, wildQuery},
WithoutParentheses: true},
}).
Limit(limitAlbums).
Find(&albums).Error
albumRows, err := r.Database.Query(`
SELECT * FROM album
WHERE owner_id = ? AND ( title LIKE ? OR path LIKE ? )
ORDER BY (
case when title LIKE ? then 2
when path LIKE ? then 1
end ) DESC
LIMIT ?
`, user.UserID, wildQuery, wildQuery, wildQuery, wildQuery, limitAlbums)
if err != nil {
return nil, errors.Wrapf(err, "searching albums")
}
albums, err := models.NewAlbumsFromRows(albumRows)
if err != nil {
return nil, err
}
result := models.SearchResult{
Query: query,
Media: photos,
Media: media,
Albums: albums,
}

View File

@@ -2,10 +2,11 @@ package resolvers
import (
"context"
"database/sql"
"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"
@@ -23,36 +24,15 @@ func (r *Resolver) ShareToken() api.ShareTokenResolver {
}
func (r *shareTokenResolver) Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error) {
row := r.Database.QueryRow("SELECT * FROM user WHERE user.user_id = ?", obj.OwnerID)
return models.NewUserFromRow(row)
return &obj.Owner, nil
}
func (r *shareTokenResolver) Album(ctx context.Context, obj *models.ShareToken) (*models.Album, error) {
row := r.Database.QueryRow("SELECT * FROM album WHERE album.album_id = ?", obj.AlbumID)
album, err := models.NewAlbumFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
} else {
return nil, errors.Wrap(err, "could not get album of share token from database")
}
}
return album, nil
return obj.Album, nil
}
func (r *shareTokenResolver) Media(ctx context.Context, obj *models.ShareToken) (*models.Media, error) {
row := r.Database.QueryRow("SELECT * FROM media WHERE media.media_id = ?", obj.MediaID)
media, err := models.NewMediaFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
} else {
return nil, errors.Wrap(err, "could not get media of share token from database")
}
}
return media, nil
return obj.Media, nil
}
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
@@ -62,10 +42,9 @@ func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareT
func (r *queryResolver) ShareToken(ctx context.Context, tokenValue string, password *string) (*models.ShareToken, error) {
row := r.Database.QueryRow("SELECT * FROM share_token WHERE value = ?", tokenValue)
token, err := models.NewShareTokenFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
var token models.ShareToken
if err := r.Database.Preload(clause.Associations).Where("value = ?", tokenValue).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")
@@ -82,14 +61,13 @@ func (r *queryResolver) ShareToken(ctx context.Context, tokenValue string, passw
}
}
return token, nil
return &token, nil
}
func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, tokenValue string, password *string) (bool, error) {
row := r.Database.QueryRow("SELECT * FROM share_token WHERE value = ?", tokenValue)
token, err := models.NewShareTokenFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
var token models.ShareToken
if err := r.Database.Where("value = ?", tokenValue).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")
@@ -121,45 +99,44 @@ func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *
return nil, auth.ErrUnauthorized
}
rows, err := r.Database.Query("SELECT owner_id FROM album WHERE album.album_id = ? AND album.owner_id = ?", albumID, user.UserID)
var count int64
err := r.Database.
Model(&models.Album{}).
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = albums.id AND user_albums.user_id = ?)", user.ID).
Count(&count).Error
if err != nil {
return nil, errors.Wrap(err, "failed to validate album owner with database")
}
if rows.Next() == false {
if count == 0 {
return nil, auth.ErrUnauthorized
}
rows.Close()
var hashed_password *string = nil
var hashedPassword *string = nil
if password != nil {
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
return nil, errors.Wrap(err, "failed to hash token password")
}
hashed_str := string(hashedPassBytes)
hashed_password = &hashed_str
hashedStr := string(hashedPassBytes)
hashedPassword = &hashedStr
}
token := utils.GenerateToken()
res, err := r.Database.Exec("INSERT INTO share_token (value, owner_id, expire, password, album_id) VALUES (?, ?, ?, ?, ?)", token, user.UserID, expire, hashed_password, albumID)
if err != nil {
shareToken := models.ShareToken{
Value: utils.GenerateToken(),
OwnerID: user.ID,
Expire: expire,
Password: hashedPassword,
AlbumID: &albumID,
MediaID: nil,
}
if err := r.Database.Create(&shareToken).Error; err != nil {
return nil, errors.Wrap(err, "failed to insert new share token into database")
}
token_id, err := res.LastInsertId()
if err != nil {
return nil, errors.Wrap(err, "could not get database id of new album share token")
}
return &models.ShareToken{
TokenID: int(token_id),
Value: token,
OwnerID: user.UserID,
Expire: expire,
Password: password,
AlbumID: &albumID,
MediaID: nil,
}, nil
return &shareToken, nil
}
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time, password *string) (*models.ShareToken, error) {
@@ -168,40 +145,40 @@ func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *
return nil, auth.ErrUnauthorized
}
rows, err := r.Database.Query("SELECT owner_id FROM album, media WHERE media.media_id = ? AND media.album_id = album.album_id AND album.owner_id = ?", mediaID, user.UserID)
if err != nil {
return nil, errors.Wrap(err, "error validating owner of media with database")
}
if rows.Next() == false {
return nil, auth.ErrUnauthorized
}
rows.Close()
var media models.Media
hashed_password, err := hashSharePassword(password)
err := r.Database.Joins("Album").
Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = Album.id AND user_albums.user_id = ?)", user.ID).
First(&media, mediaID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, auth.ErrUnauthorized
} else {
return nil, errors.Wrap(err, "failed to validate media owner with database")
}
}
hashedPassword, err := hashSharePassword(password)
if err != nil {
return nil, err
}
token := utils.GenerateToken()
res, err := r.Database.Exec("INSERT INTO share_token (value, owner_id, expire, password, media_id) VALUES (?, ?, ?, ?, ?)", token, user.UserID, expire, hashed_password, mediaID)
if err != nil {
shareToken := models.ShareToken{
Value: utils.GenerateToken(),
OwnerID: user.ID,
Expire: expire,
Password: hashedPassword,
AlbumID: nil,
MediaID: &mediaID,
}
if err := r.Database.Create(&shareToken).Error; err != nil {
return nil, errors.Wrap(err, "failed to insert new share token into database")
}
token_id, err := res.LastInsertId()
if err != nil {
return nil, errors.Wrap(err, "could not get database id of new media share token")
}
return &models.ShareToken{
TokenID: int(token_id),
Value: token,
OwnerID: user.UserID,
Expire: expire,
Password: password,
AlbumID: nil,
MediaID: &mediaID,
}, nil
return &shareToken, nil
}
func (r *mutationResolver) DeleteShareToken(ctx context.Context, tokenValue string) (*models.ShareToken, error) {
@@ -215,7 +192,7 @@ func (r *mutationResolver) DeleteShareToken(ctx context.Context, tokenValue stri
return nil, err
}
if _, err := r.Database.Exec("DELETE FROM share_token WHERE token_id = ?", token.TokenID); err != nil {
if err := r.Database.Delete(&token).Error; err != nil {
return nil, errors.Wrapf(err, "failed to delete share token (%s) from database", tokenValue)
}
@@ -233,46 +210,42 @@ func (r *mutationResolver) ProtectShareToken(ctx context.Context, tokenValue str
return nil, err
}
hashed_password, err := hashSharePassword(password)
hashedPassword, err := hashSharePassword(password)
if err != nil {
return nil, err
}
_, err = r.Database.Exec("UPDATE share_token SET password = ? WHERE token_id = ?", hashed_password, token.TokenID)
if err != nil {
token.Password = hashedPassword
if err := r.Database.Save(&token).Error; err != nil {
return nil, errors.Wrap(err, "failed to update password for share token")
}
updatedToken := r.Database.QueryRow("SELECT * FROM share_token WHERE value = ?", tokenValue)
return models.NewShareTokenFromRow(updatedToken)
return token, nil
}
func hashSharePassword(password *string) (*string, error) {
var hashed_password *string = nil
var hashedPassword *string = nil
if password != nil {
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
return nil, errors.Wrap(err, "failed to generate hash for share password")
}
hashed_str := string(hashedPassBytes)
hashed_password = &hashed_str
hashedStr := string(hashedPassBytes)
hashedPassword = &hashedStr
}
return hashed_password, nil
return hashedPassword, nil
}
func getUserToken(db *sql.DB, user *models.User, tokenValue string) (*models.ShareToken, error) {
row := db.QueryRow(`
SELECT share_token.* FROM share_token, user WHERE
share_token.value = ? AND
share_token.owner_id = user.user_id AND
(user.user_id = ? OR user.admin = TRUE)
`, tokenValue, user.UserID)
func getUserToken(db *gorm.DB, user *models.User, tokenValue string) (*models.ShareToken, error) {
var token models.ShareToken
err := db.Where("share_tokens.value = ?", tokenValue).Joins("Owner").Where("Owner.id = ? OR Owner.admin = TRUE", user.ID).First(&token).Error
token, err := models.NewShareTokenFromRow(row)
if err != nil {
return nil, errors.Wrap(err, "failed to get user share token from database")
}
return token, nil
return &token, nil
}

View File

@@ -2,41 +2,64 @@ package resolvers
import (
"context"
"errors"
"log"
"fmt"
"os"
"path"
"strconv"
"strings"
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"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// func (r *Resolver) User() UserResolver {
// return &userResolver{r}
// }
type userResolver struct {
*Resolver
}
// type userResolver struct{ *Resolver }
func (r *Resolver) User() api.UserResolver {
return &userResolver{r}
}
func (r *queryResolver) User(ctx context.Context, filter *models.Filter) ([]*models.User, error) {
filterSQL, err := filter.FormatSQL("user")
if err != nil {
return nil, err
}
var users []*models.User
rows, err := r.Database.Query("SELECT * FROM user" + filterSQL)
if err != nil {
return nil, err
}
defer rows.Close()
users, err := models.NewUsersFromRows(rows)
if err != nil {
if err := filter.FormatSQL(r.Database.Model(models.User{})).Scan(&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.Database)
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) {
err = r.Database.Model(&user).
Where("albums.parent_album_id NOT IN (?)",
r.Database.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").
Association("Albums").Find(&albums)
return
}
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
user := auth.UserFromContext(ctx)
@@ -56,50 +79,19 @@ func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string, p
}, nil
}
tx, err := r.Database.Begin()
if err != nil {
return nil, err
}
var token *models.AccessToken
token, err = user.GenerateAccessToken(tx)
if err != nil {
tx.Rollback()
return nil, err
}
transactionError := r.Database.Transaction(func(tx *gorm.DB) error {
token, err = user.GenerateAccessToken(tx)
if err != nil {
return err
}
tx.Commit()
return nil
})
return &models.AuthorizeResult{
Success: true,
Status: "ok",
Token: &token.Value,
}, nil
}
func (r *mutationResolver) RegisterUser(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) {
tx, err := r.Database.Begin()
if err != nil {
return nil, err
}
user, err := models.RegisterUser(tx, username, &password, rootPath, false)
if err != nil {
tx.Rollback()
return &models.AuthorizeResult{
Success: false,
Status: err.Error(),
}, nil
}
token, err := user.GenerateAccessToken(tx)
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
if transactionError != nil {
return nil, transactionError
}
return &models.AuthorizeResult{
@@ -119,35 +111,40 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri
return nil, errors.New("not initial setup")
}
tx, err := r.Database.Begin()
if err != nil {
return nil, err
}
rootPath = path.Clean(rootPath)
if _, err := tx.Exec("UPDATE site_info SET initial_setup = false"); err != nil {
tx.Rollback()
return nil, err
}
var token *models.AccessToken
user, err := models.RegisterUser(tx, username, &password, rootPath, true)
if err != nil {
tx.Rollback()
transactionError := r.Database.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
}
token, err := user.GenerateAccessToken(tx)
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &models.AuthorizeResult{
Success: true,
Status: "ok",
@@ -156,36 +153,21 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri
}
// Admin queries
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, rootPath *string, password *string, admin *bool) (*models.User, error) {
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) {
user_rows, err := r.Database.Query("SELECT * FROM user WHERE user_id = ?", id)
if err != nil {
if username == nil && password == nil && admin == nil {
return nil, errors.New("no updates requested")
}
var user models.User
if err := r.Database.First(&user, id).Error; err != nil {
return nil, err
}
if user_rows.Next() == false {
return nil, errors.New("user not found")
}
user_rows.Close()
update_str := ""
update_args := make([]interface{}, 0)
if username != nil {
update_str += "username = ?, "
update_args = append(update_args, username)
user.Username = *username
}
if rootPath != nil {
if !models.ValidRootPath(*rootPath) {
return nil, errors.New("invalid root path")
}
update_str += "root_path = ?, "
update_args = append(update_args, rootPath)
}
if admin != nil {
update_str += "admin = ?, "
update_args = append(update_args, admin)
}
if password != nil {
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
@@ -193,54 +175,36 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *str
}
hashedPass := string(hashedPassBytes)
update_str += "password = ?, "
update_args = append(update_args, hashedPass)
user.Password = &hashedPass
}
if len(update_str) == 0 {
return nil, errors.New("no updates requested")
if admin != nil {
user.Admin = *admin
}
update_str = update_str[:len(update_str)-2]
log.Printf("Updating user with update string: %s\n", update_str)
update_args = append(update_args, id)
res, err := r.Database.Exec("UPDATE user SET "+update_str+" WHERE user_id = ?", update_args...)
if err != nil {
return nil, err
}
rows_aff, err := res.RowsAffected()
if err != nil {
return nil, err
}
if rows_aff == 0 {
return nil, errors.New("no users were updated")
if err := r.Database.Save(&user).Error; err != nil {
return nil, errors.Wrap(err, "failed to update user")
}
row := r.Database.QueryRow("SELECT * FROM user WHERE user_id = ?", id)
user, err := models.NewUserFromRow(row)
if err != nil {
return nil, err
}
return user, nil
return &user, nil
}
func (r *mutationResolver) CreateUser(ctx context.Context, username string, rootPath string, password *string, admin bool) (*models.User, error) {
tx, err := r.Database.Begin()
if err != nil {
return nil, err
}
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) {
user, err := models.RegisterUser(tx, username, password, rootPath, admin)
if err != nil {
tx.Rollback()
return nil, err
}
var user *models.User
if err := tx.Commit(); err != nil {
return nil, err
transactionError := r.Database.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
@@ -248,24 +212,136 @@ func (r *mutationResolver) CreateUser(ctx context.Context, username string, root
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
row := r.Database.QueryRow("SELECT * FROM user WHERE user_id = ?", id)
user, err := models.NewUserFromRow(row)
if err != nil {
var user models.User
if err := r.Database.First(&user, id).Error; err != nil {
return nil, err
}
res, err := r.Database.Exec("DELETE FROM user WHERE user_id = ?", id)
if err != nil {
if err := r.Database.Delete(&user).Error; err != nil {
return nil, err
}
rows, err := res.RowsAffected()
if err != nil {
return nil, err
}
if rows == 0 {
return nil, errors.New("no users deleted")
}
return user, nil
return &user, nil
}
func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) {
rootPath = path.Clean(rootPath)
var user models.User
if err := r.Database.First(&user, id).Error; err != nil {
return nil, err
}
if !models.ValidRootPath(rootPath) {
return nil, errors.New("invalid root path")
}
upperPaths := make([]string, 1)
upperPath := rootPath
upperPaths[0] = upperPath
for {
substrIndex := strings.LastIndex(upperPath, "/")
if substrIndex == -1 {
break
}
if substrIndex == 0 {
upperPaths = append(upperPaths, "/")
break
}
upperPath = upperPath[0:substrIndex]
upperPaths = append(upperPaths, upperPath)
}
var upperAlbums []models.Album
if err := r.Database.Model(&user).Association("Albums").Find(&upperAlbums, "albums.path IN (?)", upperPaths); err != nil {
// if err := r.Database.Model(models.Album{}).Where("path IN (?)", upperPaths).Find(&upperAlbums).Error; err != nil {
return nil, err
}
if len(upperAlbums) > 0 {
return nil, errors.New(fmt.Sprintf("user already owns a path containing this path: %s", upperAlbums[0].Path))
}
newAlbum, err := scanner.NewRootAlbum(r.Database, rootPath, &user)
if err != nil {
return nil, err
}
return newAlbum, nil
}
func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) {
var album models.Album
if err := r.Database.First(&album, albumID).Error; err != nil {
return nil, err
}
var deletedAlbumIDs []int = nil
err := r.Database.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)
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
var count int
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?", albumID).Scan(&count).Error; err != nil {
return err
}
if count == 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 err
}
}
return nil
})
if err != nil {
return nil, err
}
if deletedAlbumIDs != nil {
// Delete albums from cache
for _, id := range deletedAlbumIDs {
cacheAlbumPath := path.Join(scanner.MediaCachePath(), strconv.Itoa(id))
if err := os.RemoveAll(cacheAlbumPath); err != nil {
return nil, err
}
}
}
return &album, nil
}

View File

@@ -34,15 +34,15 @@ type Query {
onlyWithFavorites: Boolean
): [Album!]!
"Get album by id, user must own the album or be admin"
album(id: Int!): Album!
album(id: ID!): Album!
"List of media owned by the logged in user"
myMedia(filter: Filter): [Media!]!
"Get media by id, user must own the media or be admin"
media(id: Int!): Media!
media(id: ID!): Media!
"Get a list of media by their ids, user must own the media or be admin"
mediaList(ids: [Int!]!): [Media!]!
mediaList(ids: [ID!]!): [Media!]!
"Get media owned by the logged in user, returned in GeoJson format"
myMediaGeoJson: Any!
@@ -58,13 +58,6 @@ type Query {
type Mutation {
authorizeUser(username: String!, password: String!): AuthorizeResult!
"Registers a new user, must be admin to call"
registerUser(
username: String!
password: String!
rootPath: String!
): AuthorizeResult!
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
initialSetupWizard(
username: String!
@@ -75,34 +68,36 @@ type Mutation {
"Scan all users for new media"
scanAll: ScannerResult! @isAdmin
"Scan a single user for new media"
scanUser(userId: Int!): ScannerResult!
scanUser(userId: ID!): ScannerResult!
"Generate share token for album"
shareAlbum(albumId: Int!, expire: Time, password: String): ShareToken
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken
"Generate share token for media"
shareMedia(mediaId: Int!, expire: Time, password: String): ShareToken
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken
"Delete a share token by it's token value"
deleteShareToken(token: String!): ShareToken
"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
"Mark or unmark a media as being a favorite"
favoriteMedia(mediaId: Int!, favorite: Boolean!): Media
favoriteMedia(mediaId: ID!, favorite: Boolean!): Media
updateUser(
id: Int!
id: ID!
username: String
rootPath: String
password: String
admin: Boolean
): User @isAdmin
createUser(
username: String!
rootPath: String!
password: String
admin: Boolean!
): User @isAdmin
deleteUser(id: Int!): User @isAdmin
deleteUser(id: ID!): User @isAdmin
"Add a root path from where to look for media for the given user"
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
"""
Set how often, in seconds, the server should automatically scan for new media,
@@ -152,7 +147,7 @@ type ScannerResult {
"A token used to publicly access an album or media"
type ShareToken {
id: Int!
id: ID!
token: String!
"The user who created the token"
owner: User!
@@ -177,17 +172,20 @@ type SiteInfo {
}
type User {
id: Int!
id: ID!
username: String!
#albums: [Album]
"Local filepath for the user's photos"
rootPath: String! @isAdmin
# rootPath: String! @isAdmin
"All albums owned by this user"
albums: [Album!]! @isAdmin
"Top level albums owned by this user"
rootAlbums: [Album!]! @isAdmin
admin: Boolean!
#shareTokens: [ShareToken]
}
type Album {
id: Int!
id: ID!
title: String!
"The media inside this album"
media(
@@ -232,7 +230,7 @@ enum MediaType {
}
type Media {
id: Int!
id: ID!
title: String!
"Local filepath for the media"
path: String!
@@ -255,7 +253,7 @@ type Media {
"EXIF metadata from the camera"
type MediaEXIF {
id: Int!
id: ID!
media: Media!
"The model name of the camera"
camera: String
@@ -279,14 +277,14 @@ type MediaEXIF {
}
type VideoMetadata {
id: Int!
id: ID!
media: Media!
width: Int!
height: Int!
duration: Float!
codec: String
framerate: Float
bitrate: Int
bitrate: String
colorProfile: String
audio: String
}

View File

@@ -1,27 +1,30 @@
package routes
import (
"database/sql"
"fmt"
"net/http"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func authenticateMedia(media *models.Media, db *sql.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
func authenticateMedia(media *models.Media, db *gorm.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
user := auth.UserFromContext(r.Context())
if user != nil {
row := db.QueryRow("SELECT owner_id FROM album WHERE album.album_id = ?", media.AlbumId)
var owner_id int
if err := row.Scan(&owner_id); err != nil {
var album models.Album
if err := db.First(&album, media.AlbumID).Error; err != nil {
return false, "internal server error", http.StatusInternalServerError, err
}
if owner_id != user.UserID {
ownsAlbum, err := user.OwnsAlbum(db, &album)
if err != nil {
return false, "internal server error", http.StatusInternalServerError, err
}
if !ownsAlbum {
return false, "invalid credentials", http.StatusForbidden, nil
}
} else {
@@ -31,10 +34,8 @@ func authenticateMedia(media *models.Media, db *sql.DB, r *http.Request) (succes
return false, "unauthorized", http.StatusForbidden, nil
}
row := db.QueryRow("SELECT * FROM share_token WHERE value = ?", token)
shareToken, err := models.NewShareTokenFromRow(row)
if err != nil {
var shareToken models.ShareToken
if err := db.Where("value = ?", token).First(&shareToken).Error; err != nil {
return false, "internal server error", http.StatusInternalServerError, err
}
@@ -56,27 +57,29 @@ func authenticateMedia(media *models.Media, db *sql.DB, r *http.Request) (succes
}
}
if shareToken.AlbumID != nil && media.AlbumId != *shareToken.AlbumID {
if shareToken.AlbumID != nil && media.AlbumID != *shareToken.AlbumID {
// Check child albums
row := db.QueryRow(`
WITH recursive child_albums AS (
SELECT * FROM album WHERE parent_album = ?
UNION ALL
SELECT child.* FROM album child JOIN child_albums parent ON parent.album_id = child.parent_album
)
SELECT * FROM child_albums WHERE album_id = ?
`, *shareToken.AlbumID, media.AlbumId)
_, err := models.NewAlbumFromRow(row)
var count int
err := db.Raw(`
WITH recursive child_albums AS (
SELECT * FROM albums WHERE parent_album_id = ?
UNION ALL
SELECT child.* FROM albums child JOIN child_albums parent ON parent.id = child.parent_album_id
)
SELECT COUNT(id) FROM child_albums WHERE id = ?
`, *shareToken.AlbumID, media.AlbumID).Find(&count).Error
if err != nil {
if err == sql.ErrNoRows {
return false, "unauthorized", http.StatusForbidden, nil
}
return false, "internal server error", http.StatusInternalServerError, err
}
if count == 0 {
return false, "unauthorized", http.StatusForbidden, nil
}
}
if shareToken.MediaID != nil && media.MediaID != *shareToken.MediaID {
if shareToken.MediaID != nil && media.ID != *shareToken.MediaID {
return false, "unauthorized", http.StatusForbidden, nil
}
}

View File

@@ -1,7 +1,6 @@
package routes
import (
"database/sql"
"log"
"net/http"
"os"
@@ -9,33 +8,26 @@ import (
"strconv"
"github.com/gorilla/mux"
"gorm.io/gorm"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner"
)
func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) {
func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
media_name := mux.Vars(r)["name"]
mediaName := mux.Vars(r)["name"]
row := db.QueryRow("SELECT media_url.* FROM media_url JOIN media ON media_url.media_id = media.media_id WHERE media_url.media_name = ?", media_name)
mediaUrl, err := models.NewMediaURLFromRow(row)
if err != nil {
var mediaURL models.MediaURL
result := db.Model(&models.MediaURL{}).Joins("Media").Select("media_urls.*").Where("media_urls.media_name = ?", mediaName).Scan(&mediaURL)
if err := result.Error; err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404"))
return
}
row = db.QueryRow("SELECT * FROM media WHERE media_id = ?", mediaUrl.MediaId)
media, err := models.NewMediaFromRow(row)
if err != nil {
log.Printf("WARN: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
}
media := &mediaURL.Media
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
@@ -48,46 +40,37 @@ func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) {
var cachedPath string
if mediaUrl.Purpose == models.PhotoThumbnail || mediaUrl.Purpose == models.PhotoHighRes || mediaUrl.Purpose == models.VideoThumbnail {
cachedPath = path.Join(scanner.PhotoCache(), strconv.Itoa(media.AlbumId), strconv.Itoa(mediaUrl.MediaId), mediaUrl.MediaName)
} else if mediaUrl.Purpose == models.MediaOriginal {
if mediaURL.Purpose == models.PhotoThumbnail || mediaURL.Purpose == models.PhotoHighRes || mediaURL.Purpose == models.VideoThumbnail {
cachedPath = path.Join(scanner.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
} else if mediaURL.Purpose == models.MediaOriginal {
cachedPath = media.Path
} else {
log.Printf("ERROR: Can not handle media_purpose for photo: %s\n", mediaUrl.Purpose)
log.Printf("ERROR: Can not handle media_purpose for photo: %s\n", mediaURL.Purpose)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
_, err = os.Stat(cachedPath)
if os.IsNotExist((err)) {
tx, err := db.Begin()
if _, err := os.Stat(cachedPath); os.IsNotExist((err)) {
err := db.Transaction(func(tx *gorm.DB) error {
if _, err = scanner.ProcessMedia(tx, media); err != nil {
log.Printf("ERROR: processing image not found in cache (%s): %s\n", cachedPath, err)
return err
}
if _, err = os.Stat(cachedPath); err != nil {
log.Printf("ERROR: after reprocessing image not found in cache (%s): %s\n", cachedPath, err)
return err
}
return nil
})
if err != nil {
log.Printf("ERROR: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
_, err = scanner.ProcessMedia(tx, media)
if err != nil {
log.Printf("ERROR: processing image not found in cache: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
tx.Rollback()
return
}
_, err = os.Stat(cachedPath)
if err != nil {
log.Printf("ERROR: after reprocessing image not found in cache: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
tx.Rollback()
return
}
tx.Commit()
}
// Allow caching the resource for 1 day

View File

@@ -1,7 +1,6 @@
package routes
import (
"database/sql"
"log"
"net/http"
"os"
@@ -11,29 +10,23 @@ import (
"github.com/gorilla/mux"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner"
"gorm.io/gorm"
)
func RegisterVideoRoutes(db *sql.DB, router *mux.Router) {
func RegisterVideoRoutes(db *gorm.DB, router *mux.Router) {
router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
media_name := mux.Vars(r)["name"]
mediaName := mux.Vars(r)["name"]
row := db.QueryRow("SELECT media_url.* FROM media_url JOIN media ON media_url.media_id = media.media_id WHERE media_url.media_name = ?", media_name)
mediaURL, err := models.NewMediaURLFromRow(row)
if err != nil {
var mediaURL models.MediaURL
result := db.Model(&models.MediaURL{}).Select("media_urls.*").Where("media_url.media_name = ?", mediaName).Joins("media ON media_urls.media_id = media.id").Scan(&mediaURL)
if err := result.Error; err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404"))
return
}
row = db.QueryRow("SELECT * FROM media WHERE media_id = ?", mediaURL.MediaId)
media, err := models.NewMediaFromRow(row)
if err != nil {
log.Printf("WARN: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
}
var media = &mediaURL.Media
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
@@ -47,7 +40,7 @@ func RegisterVideoRoutes(db *sql.DB, router *mux.Router) {
var cachedPath string
if mediaURL.Purpose == models.VideoWeb {
cachedPath = path.Join(scanner.PhotoCache(), strconv.Itoa(media.AlbumId), strconv.Itoa(mediaURL.MediaId), mediaURL.MediaName)
cachedPath = path.Join(scanner.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
} else {
log.Printf("ERROR: Can not handle media_purpose for video: %s\n", mediaURL.Purpose)
w.WriteHeader(http.StatusInternalServerError)
@@ -55,36 +48,28 @@ func RegisterVideoRoutes(db *sql.DB, router *mux.Router) {
return
}
_, err = os.Stat(cachedPath)
if err != nil {
if _, err := os.Stat(cachedPath); err != nil {
if os.IsNotExist(err) {
tx, err := db.Begin()
err := db.Transaction(func(tx *gorm.DB) error {
if _, err := scanner.ProcessMedia(tx, media); err != nil {
log.Printf("ERROR: processing video not found in cache: %s\n", err)
return err
}
if _, err := os.Stat(cachedPath); err != nil {
log.Printf("ERROR: after reprocessing video not found in cache: %s\n", err)
return err
}
return nil
})
if err != nil {
log.Printf("ERROR: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
_, err = scanner.ProcessMedia(tx, media)
if err != nil {
log.Printf("ERROR: processing video not found in cache: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
tx.Rollback()
return
}
_, err = os.Stat(cachedPath)
if err != nil {
log.Printf("ERROR: after reprocessing video not found in cache: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
tx.Rollback()
return
}
tx.Commit()
}
}

View File

@@ -3,6 +3,8 @@ package scanner
import (
"path"
"sync"
"github.com/pkg/errors"
)
type AlbumScannerCache struct {
@@ -73,7 +75,7 @@ func (c *AlbumScannerCache) GetMediaType(path string) (*MediaType, error) {
mediaType, err := getMediaType(path)
if err != nil {
return nil, err
return nil, errors.Wrapf(err, "get media type (%s)", path)
}
if mediaType != nil {

View File

@@ -1,70 +1,51 @@
package scanner
import (
"database/sql"
"os"
"path"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func CleanupMedia(db *sql.DB, albumId int, albumMedia []*models.Media) []error {
albumMediaIds := make([]interface{}, len(albumMedia))
for i, photo := range albumMedia {
albumMediaIds[i] = photo.MediaID
func CleanupMedia(db *gorm.DB, albumId int, albumMedia []*models.Media) []error {
albumMediaIds := make([]int, len(albumMedia))
for i, media := range albumMedia {
albumMediaIds[i] = media.ID
}
// Delete missing media
var rows *sql.Rows
var err error
// Will get from database
var mediaList []models.Media
query := db.Where("album_id = ?", albumId)
// Select media from database that was not found on hard disk
if len(albumMedia) > 0 {
media_args := make([]interface{}, 0)
media_args = append(media_args, albumId)
media_args = append(media_args, albumMediaIds...)
media_questions := strings.Repeat("?,", len(albumMediaIds))[:len(albumMediaIds)*2-1]
rows, err = db.Query(
"SELECT media_id FROM media WHERE album_id = ? AND media_id NOT IN ("+media_questions+")",
media_args...,
)
} else {
rows, err = db.Query(
"SELECT media_id FROM media WHERE album_id = ?",
albumId,
)
query.Where("NOT id IN ?", albumMediaIds)
}
if err != nil {
if err := query.Find(&mediaList).Error; err != nil {
return []error{errors.Wrap(err, "get media files to be deleted from database")}
}
defer rows.Close()
deleteErrors := make([]error, 0)
deleted_media_ids := make([]interface{}, 0)
for rows.Next() {
var media_id int
if err := rows.Scan(&media_id); err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "parse media to be removed (media_id %d)", media_id))
continue
mediaIDs := make([]int, 0)
for _, media := range mediaList {
mediaIDs = append(mediaIDs, media.ID)
cachePath := path.Join(MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
err := os.RemoveAll(cachePath)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
}
deleted_media_ids = append(deleted_media_ids, media_id)
cache_path := path.Join(PhotoCache(), strconv.Itoa(albumId), strconv.Itoa(media_id))
err := os.RemoveAll(cache_path)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cache_path))
}
}
if len(deleted_media_ids) > 0 {
media_questions := strings.Repeat("?,", len(deleted_media_ids))[:len(deleted_media_ids)*2-1]
if _, err := db.Exec("DELETE FROM media WHERE media_id IN ("+media_questions+")", deleted_media_ids...); err != nil {
if len(mediaIDs) > 0 {
if err := db.Where("id IN ?", mediaIDs).Delete(models.Media{}).Error; err != nil {
deleteErrors = append(deleteErrors, errors.Wrap(err, "delete old media from database"))
}
}
@@ -72,53 +53,47 @@ func CleanupMedia(db *sql.DB, albumId int, albumMedia []*models.Media) []error {
return deleteErrors
}
func deleteOldUserAlbums(db *sql.DB, scannedAlbums []*models.Album, user *models.User) []error {
func deleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *models.User) []error {
if len(scannedAlbums) == 0 {
return nil
}
albumPaths := make([]interface{}, len(scannedAlbums))
scannedAlbumIDs := make([]interface{}, len(scannedAlbums))
for i, album := range scannedAlbums {
albumPaths[i] = album.Path
scannedAlbumIDs[i] = album.ID
}
// Delete old albums
album_args := make([]interface{}, 0)
album_args = append(album_args, user.UserID)
album_args = append(album_args, albumPaths...)
var albums []models.Album
albums_questions := strings.Repeat("MD5(?),", len(albumPaths))[:len(albumPaths)*7-1]
rows, err := db.Query("SELECT album_id FROM album WHERE album.owner_id = ? AND path_hash NOT IN ("+albums_questions+")", album_args...)
if err != nil {
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
query := db.
Where("id IN (?)", userAlbumIDs).
Where("id NOT IN (?)", scannedAlbumIDs)
if err := query.Find(&albums).Error; err != nil {
return []error{errors.Wrap(err, "get albums to be deleted from database")}
}
defer rows.Close()
deleteErrors := make([]error, 0)
deleted_album_ids := make([]interface{}, 0)
for rows.Next() {
var album_id int
if err := rows.Scan(&album_id); err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "parse album to be removed (album_id %d)", album_id))
continue
}
deleted_album_ids = append(deleted_album_ids, album_id)
cache_path := path.Join("./photo_cache", strconv.Itoa(album_id))
err := os.RemoveAll(cache_path)
deleteAlbumIDs := make([]int, len(albums))
for i, album := range albums {
deleteAlbumIDs[i] = album.ID
cachePath := path.Join(MediaCachePath(), strconv.Itoa(int(album.ID)))
err := os.RemoveAll(cachePath)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cache_path))
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
}
}
if len(deleted_album_ids) > 0 {
albums_questions = strings.Repeat("?,", len(deleted_album_ids))[:len(deleted_album_ids)*2-1]
if _, err := db.Exec("DELETE FROM album WHERE album_id IN ("+albums_questions+")", deleted_album_ids...); err != nil {
ScannerError("Could not delete old albums from database:\n%s\n", err)
deleteErrors = append(deleteErrors, errors.Wrap(err, "delete old albums from database"))
}
if err := db.Where("id IN ?", deleteAlbumIDs).Delete(models.Album{}).Error; err != nil {
ScannerError("Could not delete old albums from database:\n%s\n", err)
deleteErrors = append(deleteErrors, errors.Wrap(err, "delete old albums from database"))
}
return deleteErrors

View File

@@ -1,7 +1,6 @@
package scanner
import (
"database/sql"
"image"
"image/jpeg"
"os"
@@ -11,6 +10,7 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/utils"
"gopkg.in/vansante/go-ffprobe.v2"
"gorm.io/gorm"
)
type PhotoDimensions struct {
@@ -116,7 +116,7 @@ func (img *EncodeMediaData) ContentType() (*MediaType, error) {
return imgType, nil
}
func (img *EncodeMediaData) EncodeHighRes(tx *sql.Tx, outputPath string) error {
func (img *EncodeMediaData) EncodeHighRes(tx *gorm.DB, outputPath string) error {
contentType, err := img.ContentType()
if err != nil {
return err
@@ -166,7 +166,7 @@ func EncodeThumbnail(inputPath string, outputPath string) (*PhotoDimensions, err
}
// PhotoImage reads and decodes the image file and saves it in a cache so the photo in only decoded once
func (img *EncodeMediaData) photoImage(tx *sql.Tx) (image.Image, error) {
func (img *EncodeMediaData) photoImage(tx *gorm.DB) (image.Image, error) {
if img._photoImage != nil {
return img._photoImage, nil
}

View File

@@ -1,36 +1,33 @@
package scanner
import (
"database/sql"
"fmt"
"log"
"math/big"
"os"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/photoview/photoview/api/graphql/models"
"github.com/xor-gate/goexif2/exif"
"github.com/xor-gate/goexif2/mknote"
)
func ScanEXIF(tx *sql.Tx, media *models.Media) (returnExif *models.MediaEXIF, returnErr error) {
func ScanEXIF(tx *gorm.DB, media *models.Media) (returnExif *models.MediaEXIF, returnErr error) {
log.Printf("Scanning for EXIF")
{
// Check if EXIF data already exists
if media.ExifId != nil {
row := tx.QueryRow("SELECT * FROM media_exif WHERE exif_id = ?", media.ExifId)
return models.NewMediaExifFromRow(row)
}
if media.ExifID != nil {
row := tx.QueryRow("SELECT media_exif.* FROM media, media_exif WHERE media.exif_id = media_exif.exif_id AND media.media_id = ?", media.MediaID)
exifData, err := models.NewMediaExifFromRow(row)
if err != nil && err != sql.ErrNoRows {
return nil, err
} else if exifData != nil {
return exifData, nil
var exif models.MediaEXIF
if err := tx.First(&exif, media.ExifID).Error; err != nil {
return nil, errors.Wrap(err, "get EXIF for media from database")
}
return &exif, nil
}
}
@@ -54,49 +51,38 @@ func ScanEXIF(tx *sql.Tx, media *models.Media) (returnExif *models.MediaEXIF, re
return nil, errors.Wrap(err, "Could not decode EXIF")
}
valueNames := make([]string, 0)
exifValues := make([]interface{}, 0)
newExif := models.MediaEXIF{}
model, err := readStringTag(exifTags, exif.Model, media)
if err == nil {
valueNames = append(valueNames, "camera")
exifValues = append(exifValues, model)
newExif.Camera = model
}
maker, err := readStringTag(exifTags, exif.Make, media)
if err == nil {
valueNames = append(valueNames, "maker")
exifValues = append(exifValues, maker)
newExif.Maker = maker
}
lens, err := readStringTag(exifTags, exif.LensModel, media)
if err == nil {
valueNames = append(valueNames, "lens")
exifValues = append(exifValues, lens)
newExif.Lens = lens
}
date, err := exifTags.DateTime()
if err == nil {
valueNames = append(valueNames, "date_shot")
exifValues = append(exifValues, date)
_, err := tx.Exec("UPDATE media SET date_shot = ? WHERE media_id = ?", date, media.MediaID)
if err != nil {
log.Printf("WARN: Failed to update date_shot for media %s: %s", media.Title, err)
}
newExif.DateShot = &date
}
exposure, err := readRationalTag(exifTags, exif.ExposureTime, media)
if err == nil {
valueNames = append(valueNames, "exposure")
exifValues = append(exifValues, exposure.RatString())
exposureStr := exposure.RatString()
newExif.Exposure = &exposureStr
}
apertureRat, err := readRationalTag(exifTags, exif.FNumber, media)
if err == nil {
aperture, _ := apertureRat.Float32()
valueNames = append(valueNames, "aperture")
exifValues = append(exifValues, aperture)
aperture, _ := apertureRat.Float64()
newExif.Aperture = &aperture
}
isoTag, err := exifTags.Get(exif.ISOSpeedRatings)
@@ -107,8 +93,7 @@ func ScanEXIF(tx *sql.Tx, media *models.Media) (returnExif *models.MediaEXIF, re
if err != nil {
log.Printf("WARN: Could not parse EXIF ISOSpeedRatings as integer: %s\n", media.Title)
} else {
valueNames = append(valueNames, "iso")
exifValues = append(exifValues, iso)
newExif.Iso = &iso
}
}
@@ -116,9 +101,9 @@ func ScanEXIF(tx *sql.Tx, media *models.Media) (returnExif *models.MediaEXIF, re
if err == nil {
focalLengthRat, err := focalLengthTag.Rat(0)
if err == nil {
focalLength, _ := focalLengthRat.Float32()
valueNames = append(valueNames, "focal_length")
exifValues = append(exifValues, focalLength)
focalLength, _ := focalLengthRat.Float64()
newExif.FocalLength = &focalLength
} else {
// For some photos, the focal length cannot be read as a rational value,
// but is instead the second value read as an integer
@@ -128,8 +113,8 @@ func ScanEXIF(tx *sql.Tx, media *models.Media) (returnExif *models.MediaEXIF, re
if err != nil {
log.Printf("WARN: Could not parse EXIF FocalLength as rational or integer: %s\n%s\n", media.Title, err)
} else {
valueNames = append(valueNames, "focal_length")
exifValues = append(exifValues, focalLength)
focalLenFloat := float64(focalLength)
newExif.FocalLength = &focalLenFloat
}
}
}
@@ -137,76 +122,36 @@ func ScanEXIF(tx *sql.Tx, media *models.Media) (returnExif *models.MediaEXIF, re
flash, err := exifTags.Flash()
if err == nil {
valueNames = append(valueNames, "flash")
exifValues = append(exifValues, flash)
newExif.Flash = &flash
}
orientation, err := readIntegerTag(exifTags, exif.Orientation, media)
if err == nil {
valueNames = append(valueNames, "orientation")
exifValues = append(exifValues, *orientation)
newExif.Orientation = orientation
}
exposureProgram, err := readIntegerTag(exifTags, exif.ExposureProgram, media)
if err == nil {
valueNames = append(valueNames, "exposure_program")
exifValues = append(exifValues, *exposureProgram)
newExif.ExposureProgram = exposureProgram
}
lat, long, err := exifTags.LatLong()
if err == nil {
valueNames = append(valueNames, "gps_latitude")
exifValues = append(exifValues, lat)
valueNames = append(valueNames, "gps_longitude")
exifValues = append(exifValues, long)
newExif.GPSLatitude = &lat
newExif.GPSLonitude = &long
}
if len(valueNames) == 0 {
// If exif is empty
if newExif == (models.MediaEXIF{}) {
return nil, nil
}
prepareQuestions := ""
for range valueNames {
prepareQuestions += "?,"
}
prepareQuestions = prepareQuestions[0 : len(prepareQuestions)-1]
columns := ""
for _, name := range valueNames {
columns += name + ","
}
columns = columns[0 : len(columns)-1]
// Insert into database
result, err := tx.Exec("INSERT INTO media_exif ("+columns+") VALUES ("+prepareQuestions+")", exifValues...)
if err != nil {
return nil, err
// Add EXIF to database and link to media
if err := tx.Model(&media).Association("Exif").Replace(newExif); err != nil {
return nil, errors.Wrap(err, "save media exif to database")
}
exifID, err := result.LastInsertId()
if err != nil {
return nil, err
}
// Link exif to media in database
result, err = tx.Exec("UPDATE media SET exif_id = ? WHERE media_id = ?", exifID, media.MediaID)
if err != nil {
return nil, err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "linking exif to media in database failed")
}
if rowsAffected == 0 {
return nil, errors.New("linking exif to media in database failed: 0 rows affected")
}
// Return newly created exif row
row := tx.QueryRow("SELECT * FROM media_exif WHERE exif_id = ?", exifID)
return models.NewMediaExifFromRow(row)
return &newExif, nil
}
func readStringTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*string, error) {

View File

@@ -1,7 +1,6 @@
package scanner
import (
"fmt"
"io"
"log"
"os"
@@ -246,7 +245,7 @@ func getMediaType(path string) (*MediaType, error) {
if fileExtType.isSupported() {
return &fileExtType, nil
} else {
return nil, errors.New(fmt.Sprintf("unsupported file type '%s' (%s)", ext, fileExtType))
return nil, nil
}
}

View File

@@ -1,33 +1,34 @@
package scanner
import (
"database/sql"
"log"
"sync"
"time"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
type periodicScanner struct {
ticker *time.Ticker
ticker_changed chan bool
mutex *sync.Mutex
db *sql.DB
db *gorm.DB
}
var mainPeriodicScanner *periodicScanner = nil
func getPeriodicScanInterval(db *sql.DB) (time.Duration, error) {
row := db.QueryRow("SELECT periodic_scan_interval FROM site_info")
var intervalSeconds int
func getPeriodicScanInterval(db *gorm.DB) (time.Duration, error) {
if err := row.Scan(&intervalSeconds); err != nil {
var siteInfo models.SiteInfo
if err := db.First(&siteInfo).Error; err != nil {
return 0, err
}
return time.Duration(intervalSeconds) * time.Second, nil
return time.Duration(siteInfo.PeriodicScanInterval) * time.Second, nil
}
func InitializePeriodicScanner(db *sql.DB) error {
func InitializePeriodicScanner(db *gorm.DB) error {
if mainPeriodicScanner != nil {
panic("periodic scanner has already been initialized")
}

View File

@@ -1,16 +1,16 @@
package scanner
import (
"database/sql"
"fmt"
"log"
"os"
"path"
"strconv"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
// Image decoders
_ "image/gif"
@@ -22,27 +22,25 @@ import (
)
// Higher order function used to check if MediaURL for a given MediaPurpose exists
func makePhotoURLChecker(tx *sql.Tx, mediaID int) (func(purpose models.MediaPurpose) (*models.MediaURL, error), error) {
mediaURLExistsStmt, err := tx.Prepare("SELECT * FROM media_url WHERE media_id = ? AND purpose = ?")
if err != nil {
return nil, err
}
func makePhotoURLChecker(tx *gorm.DB, mediaID int) func(purpose models.MediaPurpose) (*models.MediaURL, error) {
return func(purpose models.MediaPurpose) (*models.MediaURL, error) {
row := mediaURLExistsStmt.QueryRow(mediaID, purpose)
mediaURL, err := models.NewMediaURLFromRow(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
var mediaURL []*models.MediaURL
result := tx.Where("purpose = ?", purpose).Where("media_id = ?", mediaID).Find(&mediaURL)
if result.Error != nil {
return nil, result.Error
}
return mediaURL, nil
}, nil
if result.RowsAffected > 0 {
return mediaURL[0], nil
}
return nil, nil
}
}
func ProcessMedia(tx *sql.Tx, media *models.Media) (bool, error) {
func ProcessMedia(tx *gorm.DB, media *models.Media) (bool, error) {
imageData := EncodeMediaData{
media: media,
}
@@ -65,7 +63,7 @@ func ProcessMedia(tx *sql.Tx, media *models.Media) (bool, error) {
}
}
func processPhoto(tx *sql.Tx, imageData *EncodeMediaData, photoCachePath *string) (bool, error) {
func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *string) (bool, error) {
photo := imageData.media
@@ -73,25 +71,22 @@ func processPhoto(tx *sql.Tx, imageData *EncodeMediaData, photoCachePath *string
didProcess := false
photoUrlFromDB, err := makePhotoURLChecker(tx, photo.MediaID)
if err != nil {
return false, err
}
photoURLFromDB := makePhotoURLChecker(tx, photo.ID)
// original photo url
origURL, err := photoUrlFromDB(models.MediaOriginal)
origURL, err := photoURLFromDB(models.MediaOriginal)
if err != nil {
return false, err
}
// Thumbnail
thumbURL, err := photoUrlFromDB(models.PhotoThumbnail)
thumbURL, err := photoURLFromDB(models.PhotoThumbnail)
if err != nil {
return false, errors.Wrap(err, "error processing photo thumbnail")
}
// Highres
highResURL, err := photoUrlFromDB(models.PhotoHighRes)
highResURL, err := photoURLFromDB(models.PhotoHighRes)
if err != nil {
return false, errors.Wrap(err, "error processing photo highres")
}
@@ -104,18 +99,6 @@ func processPhoto(tx *sql.Tx, imageData *EncodeMediaData, photoCachePath *string
return false, errors.Wrap(err, "could determine if media was photo or video")
}
if mediaType.isRaw() {
err = processRawSideCar(tx, imageData, highResURL, thumbURL, photoCachePath)
if err != nil {
return false, err
}
counterpartFile := scanForCompressedCounterpartFile(photo.Path)
if counterpartFile != nil {
photo.CounterpartPath = counterpartFile
}
}
// Generate high res jpeg
if highResURL == nil {
@@ -133,10 +116,12 @@ func processPhoto(tx *sql.Tx, imageData *EncodeMediaData, photoCachePath *string
baseImagePath = path.Join(*photoCachePath, highres_name)
err = generateSaveHighResJPEG(tx, photo.MediaID, imageData, highres_name, baseImagePath, -1)
newHighResURL, err := generateSaveHighResJPEG(tx, photo, imageData, highres_name, baseImagePath, nil)
if err != nil {
return false, err
}
highResURL = newHighResURL
}
} else {
// Verify that highres photo still exists in cache
@@ -178,10 +163,12 @@ func processPhoto(tx *sql.Tx, imageData *EncodeMediaData, photoCachePath *string
thumbnail_name = models.SanitizeMediaName(thumbnail_name)
thumbnail_name = thumbnail_name + ".jpg"
err = generateSaveThumbnailJPEG(tx, photo.MediaID, thumbnail_name, photoCachePath, baseImagePath, -1)
newThumbURL, err := generateSaveThumbnailJPEG(tx, photo, thumbnail_name, photoCachePath, baseImagePath, nil)
if err != nil {
return false, err
}
thumbURL = newThumbURL
} else {
// Verify that thumbnail photo still exists in cache
thumbPath := path.Join(*photoCachePath, thumbURL.MediaName)
@@ -197,20 +184,32 @@ func processPhoto(tx *sql.Tx, imageData *EncodeMediaData, photoCachePath *string
}
}
if mediaType.isRaw() {
err = processRawSideCar(tx, imageData, highResURL, thumbURL, photoCachePath)
if err != nil {
return false, err
}
counterpartFile := scanForCompressedCounterpartFile(photo.Path)
if counterpartFile != nil {
photo.CounterpartPath = counterpartFile
}
}
return didProcess, nil
}
func makeMediaCacheDir(photo *models.Media) (*string, error) {
func makeMediaCacheDir(media *models.Media) (*string, error) {
// Make root cache dir if not exists
if _, err := os.Stat(PhotoCache()); os.IsNotExist(err) {
if err := os.Mkdir(PhotoCache(), os.ModePerm); err != nil {
if _, err := os.Stat(MediaCachePath()); os.IsNotExist(err) {
if err := os.Mkdir(MediaCachePath(), os.ModePerm); err != nil {
return nil, errors.Wrap(err, "could not make root image cache directory")
}
}
// Make album cache dir if not exists
albumCachePath := path.Join(PhotoCache(), strconv.Itoa(photo.AlbumId))
albumCachePath := path.Join(MediaCachePath(), strconv.Itoa(int(media.AlbumID)))
if _, err := os.Stat(albumCachePath); os.IsNotExist(err) {
if err := os.Mkdir(albumCachePath, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "could not make album image cache directory")
@@ -218,7 +217,7 @@ func makeMediaCacheDir(photo *models.Media) (*string, error) {
}
// Make photo cache dir if not exists
photoCachePath := path.Join(albumCachePath, strconv.Itoa(photo.MediaID))
photoCachePath := path.Join(albumCachePath, strconv.Itoa(int(media.ID)))
if _, err := os.Stat(photoCachePath); os.IsNotExist(err) {
if err := os.Mkdir(photoCachePath, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "could not make photo image cache directory")
@@ -228,7 +227,7 @@ func makeMediaCacheDir(photo *models.Media) (*string, error) {
return &photoCachePath, nil
}
func saveOriginalPhotoToDB(tx *sql.Tx, photo *models.Media, imageData *EncodeMediaData, photoDimensions *PhotoDimensions) error {
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMediaData, photoDimensions *PhotoDimensions) error {
photoName := path.Base(photo.Path)
photoBaseName := photoName[0 : len(photoName)-len(path.Ext(photoName))]
photoBaseExt := path.Ext(photoName)
@@ -246,73 +245,110 @@ func saveOriginalPhotoToDB(tx *sql.Tx, photo *models.Media, imageData *EncodeMed
return errors.Wrap(err, "reading file stats of original photo")
}
_, err = tx.Exec("INSERT INTO media_url (media_id, media_name, width, height, purpose, content_type, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)", photo.MediaID, original_image_name, photoDimensions.Width, photoDimensions.Height, models.MediaOriginal, contentType, fileStats.Size())
if err != nil {
log.Printf("Could not insert original photo url: %d, %s\n", photo.MediaID, photoName)
return err
mediaURL := models.MediaURL{
Media: *photo,
MediaName: original_image_name,
Width: photoDimensions.Width,
Height: photoDimensions.Height,
Purpose: models.MediaOriginal,
ContentType: string(*contentType),
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return errors.Wrapf(err, "inserting original photo url: %d, %s", photo.ID, photoName)
}
return nil
}
func generateSaveHighResJPEG(tx *sql.Tx, mediaID int, imageData *EncodeMediaData, highres_name string, imagePath string, urlID int) error {
func generateSaveHighResJPEG(tx *gorm.DB, media *models.Media, imageData *EncodeMediaData, highres_name string, imagePath string, mediaURL *models.MediaURL) (*models.MediaURL, error) {
err := imageData.EncodeHighRes(tx, imagePath)
if err != nil {
return errors.Wrap(err, "creating high-res cached image")
return nil, errors.Wrap(err, "creating high-res cached image")
}
photoDimensions, err := GetPhotoDimensions(imagePath)
if err != nil {
return err
return nil, err
}
fileStats, err := os.Stat(imagePath)
if err != nil {
return errors.Wrap(err, "reading file stats of highres photo")
return nil, errors.Wrap(err, "reading file stats of highres photo")
}
if urlID < 0 {
_, err = tx.Exec("INSERT INTO media_url (media_id, media_name, width, height, purpose, content_type, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)",
mediaID, highres_name, photoDimensions.Width, photoDimensions.Height, models.PhotoHighRes, "image/jpeg", fileStats.Size())
if mediaURL == nil {
mediaURL = &models.MediaURL{
MediaID: media.ID,
MediaName: highres_name,
Width: photoDimensions.Width,
Height: photoDimensions.Height,
Purpose: models.PhotoHighRes,
ContentType: "image/jpeg",
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not insert highres media url (%d, %s)", media.ID, highres_name)
}
} else {
_, err = tx.Exec("UPDATE media_url SET width = ?, height = ?, file_size= ? WHERE url_id = ?",
photoDimensions.Width, photoDimensions.Height, fileStats.Size(), urlID)
}
if err != nil {
return errors.Wrapf(err, "could not insert highres media url (%d, %s)", mediaID, highres_name)
mediaURL.Width = photoDimensions.Width
mediaURL.Height = photoDimensions.Height
mediaURL.FileSize = fileStats.Size()
if err := tx.Save(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not update media url after side car changes (%d, %s)", media.ID, highres_name)
}
}
return nil
return mediaURL, nil
}
func generateSaveThumbnailJPEG(tx *sql.Tx, mediaID int, thumbnail_name string, photoCachePath *string, baseImagePath string, urlID int) error {
func generateSaveThumbnailJPEG(tx *gorm.DB, media *models.Media, thumbnail_name string, photoCachePath *string, baseImagePath string, mediaURL *models.MediaURL) (*models.MediaURL, error) {
thumbOutputPath := path.Join(*photoCachePath, thumbnail_name)
thumbSize, err := EncodeThumbnail(baseImagePath, thumbOutputPath)
if err != nil {
return errors.Wrap(err, "could not create thumbnail cached image")
return nil, errors.Wrap(err, "could not create thumbnail cached image")
}
fileStats, err := os.Stat(thumbOutputPath)
if err != nil {
return errors.Wrap(err, "reading file stats of thumbnail photo")
return nil, errors.Wrap(err, "reading file stats of thumbnail photo")
}
if urlID < 0 {
_, err = tx.Exec("INSERT INTO media_url (media_id, media_name, width, height, purpose, content_type, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)",
mediaID, thumbnail_name, thumbSize.Width, thumbSize.Height, models.PhotoThumbnail, "image/jpeg", fileStats.Size())
if mediaURL == nil {
mediaURL = &models.MediaURL{
MediaID: media.ID,
MediaName: thumbnail_name,
Width: thumbSize.Width,
Height: thumbSize.Height,
Purpose: models.PhotoThumbnail,
ContentType: "image/jpeg",
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not insert thumbnail media url (%d, %s)", media.ID, thumbnail_name)
}
} else {
_, err = tx.Exec("UPDATE media_url SET width = ?, height = ?, file_size= ? WHERE url_id = ?",
thumbSize.Width, thumbSize.Height, fileStats.Size(), urlID)
mediaURL.Width = thumbSize.Width
mediaURL.Height = thumbSize.Height
mediaURL.FileSize = fileStats.Size()
if err := tx.Save(&mediaURL).Error; err != nil {
return nil, errors.Wrapf(err, "could not update media url after side car changes (%d, %s)", media.ID, thumbnail_name)
}
}
if err != nil {
return err
}
return nil
return mediaURL, nil
}
func processRawSideCar(tx *sql.Tx, imageData *EncodeMediaData, highResURL *models.MediaURL, thumbURL *models.MediaURL, photoCachePath *string) error {
func processRawSideCar(tx *gorm.DB, imageData *EncodeMediaData, highResURL *models.MediaURL, thumbURL *models.MediaURL, photoCachePath *string) error {
photo := imageData.media
sideCarFileHasChanged := false
var currentFileHash *string
@@ -326,6 +362,7 @@ func processRawSideCar(tx *sql.Tx, imageData *EncodeMediaData, highResURL *model
} else if photo.SideCarPath != nil { // sidecar has been deleted since last scan
sideCarFileHasChanged = true
}
if sideCarFileHasChanged {
fmt.Printf("Detected changed sidecar file for %s recreating JPG's to reflect changes\n", photo.Path)
@@ -333,7 +370,7 @@ func processRawSideCar(tx *sql.Tx, imageData *EncodeMediaData, highResURL *model
baseImagePath := path.Join(*photoCachePath, highResURL.MediaName) // update base image path for thumbnail
tempHighResPath := baseImagePath + ".hold"
os.Rename(baseImagePath, tempHighResPath)
err := generateSaveHighResJPEG(tx, photo.MediaID, imageData, highResURL.MediaName, baseImagePath, highResURL.UrlID)
_, err := generateSaveHighResJPEG(tx, photo, imageData, highResURL.MediaName, baseImagePath, highResURL)
if err != nil {
os.Rename(tempHighResPath, baseImagePath)
return errors.Wrap(err, "recreating high-res cached image")
@@ -344,18 +381,21 @@ func processRawSideCar(tx *sql.Tx, imageData *EncodeMediaData, highResURL *model
thumbPath := path.Join(*photoCachePath, thumbURL.MediaName)
tempThumbPath := thumbPath + ".hold" // hold onto the original image incase for some reason we fail to recreate one with the new settings
os.Rename(thumbPath, tempThumbPath)
err = generateSaveThumbnailJPEG(tx, photo.MediaID, thumbURL.MediaName, photoCachePath, baseImagePath, thumbURL.UrlID)
_, err = generateSaveThumbnailJPEG(tx, photo, thumbURL.MediaName, photoCachePath, baseImagePath, thumbURL)
if err != nil {
os.Rename(tempThumbPath, thumbPath)
return errors.Wrap(err, "recreating thumbnail cached image")
}
os.Remove(tempThumbPath)
photo.SideCarHash = currentFileHash
photo.SideCarPath = currentSideCarPath
// save new side car hash
_, err = tx.Exec("UPDATE media SET side_car_hash = ?, side_car_path = ? WHERE media_id = ?", currentFileHash, currentSideCarPath, photo.MediaID)
if err != nil {
if err := tx.Save(&photo).Error; err != nil {
return errors.Wrapf(err, "could not update side car hash for media: %s", photo.Path)
}
}
return nil
}

View File

@@ -2,7 +2,6 @@ package scanner
import (
"context"
"database/sql"
"fmt"
"log"
"os"
@@ -14,25 +13,23 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/utils"
"gopkg.in/vansante/go-ffprobe.v2"
"gorm.io/gorm"
)
func processVideo(tx *sql.Tx, mediaData *EncodeMediaData, videoCachePath *string) (bool, error) {
func processVideo(tx *gorm.DB, mediaData *EncodeMediaData, videoCachePath *string) (bool, error) {
video := mediaData.media
didProcess := false
log.Printf("Processing video: %s", video.Path)
mediaUrlFromDB, err := makePhotoURLChecker(tx, video.MediaID)
if err != nil {
return false, err
}
mediaURLFromDB := makePhotoURLChecker(tx, video.ID)
videoWebURL, err := mediaUrlFromDB(models.VideoWeb)
videoWebURL, err := mediaURLFromDB(models.VideoWeb)
if err != nil {
return false, errors.Wrap(err, "error processing video web-format")
}
videoThumbnailURL, err := mediaUrlFromDB(models.VideoThumbnail)
videoThumbnailURL, err := mediaURLFromDB(models.VideoThumbnail)
if err != nil {
return false, errors.Wrap(err, "error processing video thumbnail")
}
@@ -62,9 +59,17 @@ func processVideo(tx *sql.Tx, mediaData *EncodeMediaData, videoCachePath *string
return false, errors.Wrap(err, "reading file stats of web-optimized video")
}
_, err = tx.Exec("INSERT INTO media_url (media_id, media_name, width, height, purpose, content_type, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)",
video.MediaID, web_video_name, webMetadata.Width, webMetadata.Height, models.VideoWeb, "video/mp4", fileStats.Size())
if err != nil {
mediaURL := models.MediaURL{
MediaID: video.ID,
MediaName: web_video_name,
Width: webMetadata.Width,
Height: webMetadata.Height,
Purpose: models.VideoWeb,
ContentType: "video/mp4",
FileSize: fileStats.Size(),
}
if err := tx.Create(&mediaURL).Error; err != nil {
return false, errors.Wrapf(err, "failed to insert encoded web-video into database (%s)", video.Title)
}
}
@@ -94,9 +99,17 @@ func processVideo(tx *sql.Tx, mediaData *EncodeMediaData, videoCachePath *string
return false, errors.Wrap(err, "reading file stats of video thumbnail")
}
_, err = tx.Exec("INSERT INTO media_url (media_id, media_name, width, height, purpose, content_type, file_size) VALUES (?, ?, ?, ?, ?, ?, ?)",
video.MediaID, video_thumb_name, thumbDimensions.Width, thumbDimensions.Height, models.VideoThumbnail, "image/jpeg", fileStats.Size())
if err != nil {
thumbMediaURL := models.MediaURL{
MediaID: video.ID,
MediaName: video_thumb_name,
Width: thumbDimensions.Width,
Height: thumbDimensions.Height,
Purpose: models.VideoThumbnail,
ContentType: "image/jpeg",
FileSize: fileStats.Size(),
}
if err := tx.Create(&thumbMediaURL).Error; err != nil {
return false, errors.Wrapf(err, "failed to insert video thumbnail image into database (%s)", video.Title)
}
}

View File

@@ -1,16 +1,16 @@
package scanner
import (
"database/sql"
"fmt"
"log"
"sync"
"time"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/notification"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type ScannerJob struct {
@@ -18,7 +18,7 @@ type ScannerJob struct {
cache *AlbumScannerCache
}
func (job *ScannerJob) Run(db *sql.DB) {
func (job *ScannerJob) Run(db *gorm.DB) {
scanAlbum(job.album, job.cache, db)
}
@@ -31,13 +31,13 @@ type ScannerQueue struct {
idle_chan chan bool
in_progress []ScannerJob
up_next []ScannerJob
db *sql.DB
db *gorm.DB
settings ScannerQueueSettings
}
var global_scanner_queue ScannerQueue
func InitializeScannerQueue(db *sql.DB) error {
func InitializeScannerQueue(db *gorm.DB) error {
var concurrentWorkers int
{
@@ -146,14 +146,11 @@ func (queue *ScannerQueue) notify() bool {
}
func AddAllToQueue() error {
rows, err := global_scanner_queue.db.Query("SELECT * FROM user")
if err != nil {
return errors.Wrap(err, "get all users from database")
}
users, err := models.NewUsersFromRows(rows)
if err != nil {
return errors.Wrap(err, "parse all users from db")
var users []*models.User
result := global_scanner_queue.db.Find(&users)
if result.Error != nil {
return errors.Wrap(result.Error, "get all users from database")
}
for _, user := range users {
@@ -167,7 +164,7 @@ func AddUserToQueue(user *models.User) error {
album_cache := MakeAlbumCache()
albums, album_errors := findAlbumsForUser(global_scanner_queue.db, user, album_cache)
for _, err := range album_errors {
return errors.Wrapf(err, "find albums for user (user_id: %d)", user.UserID)
return errors.Wrapf(err, "find albums for user (user_id: %d)", user.ID)
}
global_scanner_queue.mutex.Lock()
@@ -199,7 +196,7 @@ func (queue *ScannerQueue) jobOnQueue(job *ScannerJob) (bool, error) {
scannerJobs := append(queue.in_progress, queue.up_next...)
for _, scannerJob := range scannerJobs {
if scannerJob.album.AlbumID == job.album.AlbumID {
if scannerJob.album.ID == job.album.ID {
return true, nil
}
}

View File

@@ -6,11 +6,18 @@ import (
"github.com/photoview/photoview/api/graphql/models"
)
func makeAlbumWithID(id int) *models.Album {
var album models.Album
album.ID = id
return &album
}
func TestScannerQueue_AddJob(t *testing.T) {
scannerJobs := []ScannerJob{
{album: &models.Album{AlbumID: 100}, cache: MakeAlbumCache()},
{album: &models.Album{AlbumID: 20}, cache: MakeAlbumCache()},
{album: makeAlbumWithID(100), cache: MakeAlbumCache()},
{album: makeAlbumWithID(20), cache: MakeAlbumCache()},
}
mockScannerQueue := ScannerQueue{
@@ -21,7 +28,7 @@ func TestScannerQueue_AddJob(t *testing.T) {
}
t.Run("add new job to scanner queue", func(t *testing.T) {
newJob := ScannerJob{album: &models.Album{AlbumID: 42}, cache: MakeAlbumCache()}
newJob := ScannerJob{album: makeAlbumWithID(42), cache: MakeAlbumCache()}
startingJobs := len(mockScannerQueue.up_next)
@@ -41,7 +48,7 @@ func TestScannerQueue_AddJob(t *testing.T) {
t.Run("add existing job to scanner queue", func(t *testing.T) {
startingJobs := len(mockScannerQueue.up_next)
err := mockScannerQueue.addJob(&ScannerJob{album: &models.Album{AlbumID: 20}, cache: MakeAlbumCache()})
err := mockScannerQueue.addJob(&ScannerJob{album: makeAlbumWithID(20), cache: MakeAlbumCache()})
if err != nil {
t.Errorf(".AddJob() returned an unexpected error: %s", err)
}
@@ -57,8 +64,8 @@ func TestScannerQueue_AddJob(t *testing.T) {
func TestScannerQueue_JobOnQueue(t *testing.T) {
scannerJobs := []ScannerJob{
{album: &models.Album{AlbumID: 100}, cache: MakeAlbumCache()},
{album: &models.Album{AlbumID: 20}, cache: MakeAlbumCache()},
{album: makeAlbumWithID(100), cache: MakeAlbumCache()},
{album: makeAlbumWithID(20), cache: MakeAlbumCache()},
}
mockScannerQueue := ScannerQueue{
@@ -74,10 +81,10 @@ func TestScannerQueue_JobOnQueue(t *testing.T) {
ScannerJob
}{
{"album which owner is already on the queue", true, ScannerJob{
album: &models.Album{AlbumID: 100}, cache: MakeAlbumCache(),
album: makeAlbumWithID(100), cache: MakeAlbumCache(),
}},
{"album that is not on the queue", false, ScannerJob{
album: &models.Album{AlbumID: 321}, cache: MakeAlbumCache(),
album: makeAlbumWithID(321), cache: MakeAlbumCache(),
}},
}

View File

@@ -1,7 +1,6 @@
package scanner
import (
"database/sql"
"fmt"
"io/ioutil"
"path"
@@ -10,9 +9,45 @@ import (
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/notification"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB) {
func NewRootAlbum(db *gorm.DB, rootPath string, owner *models.User) (*models.Album, error) {
owners := []models.User{
*owner,
}
var matchedAlbums []models.Album
if err := db.Where("path_hash = ?", models.MD5Hash(rootPath)).Find(&matchedAlbums).Error; err != nil {
return nil, err
}
if len(matchedAlbums) > 0 {
album := matchedAlbums[0]
if err := db.Model(&owner).Association("Albums").Append(&album); err != nil {
return nil, errors.Wrap(err, "failed to add owner to already existing album")
}
return &album, nil
} else {
album := models.Album{
Title: path.Base(rootPath),
Path: rootPath,
Owners: owners,
}
if err := db.Create(&album).Error; err != nil {
return nil, err
}
return &album, nil
}
}
func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
album_notify_key := utils.GenerateToken()
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
@@ -37,37 +72,35 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB) {
album_has_changes := false
for count, photo := range albumPhotos {
tx, err := db.Begin()
if err != nil {
ScannerError("Failed to begin database transaction: %s", err)
}
// tx, err := db.Begin()
processing_was_needed, err := ProcessMedia(tx, photo)
if err != nil {
tx.Rollback()
ScannerError("Failed to process photo (%s): %s", photo.Path, err)
continue
}
transactionError := db.Transaction(func(tx *gorm.DB) error {
processing_was_needed, err := ProcessMedia(tx, photo)
if err != nil {
return errors.Wrapf(err, "failed to process photo (%s)", photo.Path)
}
if processing_was_needed {
album_has_changes = true
progress := float64(count) / float64(len(albumPhotos)) * 100.0
notification.BroadcastNotification(&models.Notification{
Key: album_notify_key,
Type: models.NotificationTypeProgress,
Header: fmt.Sprintf("Processing media for album '%s'", album.Title),
Content: fmt.Sprintf("Processed media at %s", photo.Path),
Progress: &progress,
})
}
if processing_was_needed {
album_has_changes = true
progress := float64(count) / float64(len(albumPhotos)) * 100.0
notification.BroadcastNotification(&models.Notification{
Key: album_notify_key,
Type: models.NotificationTypeProgress,
Header: fmt.Sprintf("Processing media for album '%s'", album.Title),
Content: fmt.Sprintf("Processed media at %s", photo.Path),
Progress: &progress,
})
}
err = tx.Commit()
if err != nil {
ScannerError("Failed to commit database transaction: %s", err)
return nil
})
if transactionError != nil {
ScannerError("Failed to begin database transaction: %s", transactionError)
}
}
cleanup_errors := CleanupMedia(db, album.AlbumID, albumPhotos)
cleanup_errors := CleanupMedia(db, album.ID, albumPhotos)
for _, err := range cleanup_errors {
ScannerError("Failed to delete old media: %s", err)
}
@@ -85,7 +118,7 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB) {
}
}
func findMediaForAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB, onScanPhoto func(photo *models.Media, newPhoto bool)) ([]*models.Media, error) {
func findMediaForAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB, onScanPhoto func(photo *models.Media, newPhoto bool)) ([]*models.Media, error) {
albumPhotos := make([]*models.Media, 0)
@@ -104,24 +137,24 @@ func findMediaForAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB
continue
}
tx, err := db.Begin()
err := db.Transaction(func(tx *gorm.DB) error {
photo, isNewPhoto, err := ScanMedia(tx, photoPath, album.ID, cache)
if err != nil {
return errors.Wrapf(err, "Scanning media error (%s)", photoPath)
}
onScanPhoto(photo, isNewPhoto)
albumPhotos = append(albumPhotos, photo)
return nil
})
if err != nil {
ScannerError("Could not begin database transaction for image %s: %s\n", photoPath, err)
ScannerError("Error scanning media for album (%d): %s\n", album.ID, err)
continue
}
photo, isNewPhoto, err := ScanMedia(tx, photoPath, album.AlbumID, cache)
if err != nil {
ScannerError("Scanning media error (%s): %s", photoPath, err)
tx.Rollback()
continue
}
onScanPhoto(photo, isNewPhoto)
albumPhotos = append(albumPhotos, photo)
tx.Commit()
}
}

View File

@@ -2,7 +2,6 @@ package scanner
import (
"crypto/md5"
"database/sql"
"encoding/hex"
"io"
"log"
@@ -11,8 +10,9 @@ import (
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func fileExists(testPath string) bool {
@@ -102,20 +102,22 @@ func hashSideCarFile(path *string) *string {
return &hash
}
func ScanMedia(tx *sql.Tx, mediaPath string, albumId int, cache *AlbumScannerCache) (*models.Media, bool, error) {
func ScanMedia(tx *gorm.DB, mediaPath string, albumId int, cache *AlbumScannerCache) (*models.Media, bool, error) {
mediaName := path.Base(mediaPath)
// Check if image already exists
// Check if media already exists
{
row := tx.QueryRow("SELECT * FROM media WHERE path_hash = MD5(?)", mediaPath)
photo, err := models.NewMediaFromRow(row)
if err != sql.ErrNoRows {
if err == nil {
log.Printf("Media already scanned: %s\n", mediaPath)
return photo, false, nil
} else {
return nil, false, errors.Wrap(err, "scan media fetch from database")
}
var media []*models.Media
result := tx.Where("path_hash = ?", models.MD5Hash(mediaPath)).Find(&media)
if result.Error != nil {
return nil, false, errors.Wrap(result.Error, "scan media fetch from database")
}
if result.RowsAffected > 0 {
log.Printf("Media already scanned: %s\n", mediaPath)
return media[0], false, nil
}
}
@@ -126,16 +128,15 @@ func ScanMedia(tx *sql.Tx, mediaPath string, albumId int, cache *AlbumScannerCac
return nil, false, errors.Wrap(err, "could determine if media was photo or video")
}
var mediaTypeText string
var mediaTypeText models.MediaType
var sideCarPath *string = nil
var sideCarHash *string = nil
var sideCarPath *string
sideCarPath = nil
var sideCarHash *string
sideCarHash = nil
if mediaType.isVideo() {
mediaTypeText = "video"
mediaTypeText = models.MediaTypeVideo
} else {
mediaTypeText = "photo"
mediaTypeText = models.MediaTypePhoto
// search for sidecar files
if mediaType.isRaw() {
sideCarPath = scanForSideCarFile(mediaPath)
@@ -150,31 +151,30 @@ func ScanMedia(tx *sql.Tx, mediaPath string, albumId int, cache *AlbumScannerCac
return nil, false, err
}
result, err := tx.Exec("INSERT INTO media (title, path, path_hash, side_car_path, side_car_hash, album_id, media_type, date_shot) VALUES (?, ?, MD5(path), ?, ?, ?, ?, ?)", mediaName, mediaPath, sideCarPath, sideCarHash, albumId, mediaTypeText, stat.ModTime())
if err != nil {
media := models.Media{
Title: mediaName,
Path: mediaPath,
SideCarPath: sideCarPath,
SideCarHash: sideCarHash,
AlbumID: albumId,
Type: mediaTypeText,
DateShot: stat.ModTime(),
}
if err := tx.Create(&media).Error; err != nil {
return nil, false, errors.Wrap(err, "could not insert media into database")
}
media_id, err := result.LastInsertId()
if err != nil {
return nil, false, err
}
row := tx.QueryRow("SELECT * FROM media WHERE media_id = ?", media_id)
media, err := models.NewMediaFromRow(row)
if err != nil {
return nil, false, errors.Wrap(err, "failed to get media by id from database")
}
_, err = ScanEXIF(tx, media)
_, err = ScanEXIF(tx, &media)
if err != nil {
log.Printf("WARN: ScanEXIF for %s failed: %s\n", mediaName, err)
}
if media.Type == models.MediaTypeVideo {
if err = ScanVideoMetadata(tx, media); err != nil {
if err = ScanVideoMetadata(tx, &media); err != nil {
log.Printf("WARN: ScanVideoMetadata for %s failed: %s\n", mediaName, err)
}
}
return media, true, nil
return &media, true, nil
}

View File

@@ -2,88 +2,127 @@ package scanner
import (
"container/list"
"database/sql"
"fmt"
"io/ioutil"
"log"
"os"
"path"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/notification"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func findAlbumsForUser(db *sql.DB, user *models.User, album_cache *AlbumScannerCache) ([]*models.Album, []error) {
func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScannerCache) ([]*models.Album, []error) {
// Check if user directory exists on the file system
if _, err := os.Stat(user.RootPath); err != nil {
if os.IsNotExist(err) {
return nil, []error{errors.Errorf("Photo directory for user '%s' does not exist '%s'\n", user.Username, user.RootPath)}
} else {
return nil, []error{errors.Errorf("Could not read photo directory for user '%s': %s\n", user.Username, user.RootPath)}
}
if err := user.FillAlbums(db); err != nil {
return nil, []error{err}
}
userAlbumIDs := make([]int, len(user.Albums))
for i, album := range user.Albums {
userAlbumIDs[i] = album.ID
}
var userRootAlbums []*models.Album
if err := db.Where("id IN (?)", userAlbumIDs).Where("parent_album_id IS NULL").Find(&userRootAlbums).Error; err != nil {
return nil, []error{err}
}
scanErrors := make([]error, 0)
type scanInfo struct {
path string
parentId *int
path string
parent *models.Album
}
scanQueue := list.New()
scanQueue.PushBack(scanInfo{
path: user.RootPath,
parentId: nil,
})
for _, album := range userRootAlbums {
// Check if user album directory exists on the file system
if _, err := os.Stat(album.Path); err != nil {
if os.IsNotExist(err) {
scanErrors = append(scanErrors, errors.Errorf("Album directory for user '%s' does not exist '%s'\n", user.Username, album.Path))
} else {
scanErrors = append(scanErrors, errors.Errorf("Could not read album directory for user '%s': %s\n", user.Username, album.Path))
}
} else {
scanQueue.PushBack(scanInfo{
path: album.Path,
parent: nil,
})
}
}
userAlbums := make([]*models.Album, 0)
albumErrors := make([]error, 0)
// newPhotos := make([]*models.Photo, 0)
for scanQueue.Front() != nil {
albumInfo := scanQueue.Front().Value.(scanInfo)
scanQueue.Remove(scanQueue.Front())
albumPath := albumInfo.path
albumParentId := albumInfo.parentId
albumParent := albumInfo.parent
// Read path
dirContent, err := ioutil.ReadDir(albumPath)
if err != nil {
albumErrors = append(albumErrors, errors.Wrapf(err, "read directory (%s)", albumPath))
scanErrors = append(scanErrors, errors.Wrapf(err, "read directory (%s)", albumPath))
continue
}
tx, err := db.Begin()
if err != nil {
albumErrors = append(albumErrors, errors.Wrap(err, "begin database transaction"))
continue
}
// Will become new album or album from db
var album *models.Album
log.Printf("Scanning directory: %s", albumPath)
transErr := db.Transaction(func(tx *gorm.DB) error {
log.Printf("Scanning directory: %s", albumPath)
// Make album if not exists
albumTitle := path.Base(albumPath)
_, err = tx.Exec("INSERT IGNORE INTO album (title, parent_album, owner_id, path, path_hash) VALUES (?, ?, ?, ?, MD5(path))", albumTitle, albumParentId, user.UserID, albumPath)
if err != nil {
albumErrors = append(albumErrors, errors.Wrap(err, "insert album into database"))
tx.Rollback()
continue
}
// check if album already exists
var albumResult []models.Album
result := tx.Where("path_hash = ?", models.MD5Hash(albumPath)).Find(&albumResult)
if result.Error != nil {
return result.Error
}
row := tx.QueryRow("SELECT * FROM album WHERE path_hash = MD5(?)", albumPath)
album, err := models.NewAlbumFromRow(row)
if err != nil {
albumErrors = append(albumErrors, errors.Wrapf(err, "get album from database (%s)", albumPath))
tx.Rollback()
continue
}
userAlbums = append(userAlbums, album)
// album does not exist, create new
if len(albumResult) == 0 {
albumTitle := path.Base(albumPath)
// Commit album transaction
if err := tx.Commit(); err != nil {
albumErrors = append(albumErrors, errors.Wrap(err, "commit database transaction"))
var albumParentID *int
parentOwners := make([]models.User, 0)
if albumParent != nil {
albumParentID = &albumParent.ID
if err := db.Model(&albumParent).Association("Owners").Find(&parentOwners); err != nil {
return err
}
}
album = &models.Album{
Title: albumTitle,
ParentAlbumID: albumParentID,
Path: albumPath,
}
if err := tx.Create(&album).Error; err != nil {
return errors.Wrap(err, "insert album into database")
}
if err := tx.Model(&album).Association("Owners").Append(parentOwners); err != nil {
return errors.Wrap(err, "add owners to album")
}
} else {
album = &albumResult[0]
}
userAlbums = append(userAlbums, album)
return nil
})
if transErr != nil {
scanErrors = append(scanErrors, errors.Wrap(transErr, "begin database transaction"))
continue
}
@@ -98,17 +137,17 @@ func findAlbumsForUser(db *sql.DB, user *models.User, album_cache *AlbumScannerC
if item.IsDir() && directoryContainsPhotos(subalbumPath, album_cache) {
scanQueue.PushBack(scanInfo{
path: subalbumPath,
parentId: &album.AlbumID,
path: subalbumPath,
parent: album,
})
}
}
}
deleteErrors := deleteOldUserAlbums(db, userAlbums, user)
albumErrors = append(albumErrors, deleteErrors...)
scanErrors = append(scanErrors, deleteErrors...)
return userAlbums, albumErrors
return userAlbums, scanErrors
}
func directoryContainsPhotos(rootPath string, cache *AlbumScannerCache) bool {
@@ -168,10 +207,11 @@ func ScannerError(format string, args ...interface{}) {
})
}
func PhotoCache() string {
photoCache := os.Getenv("PHOTO_CACHE")
// MediaCachePath returns the path for where the media cache is located on the file system
func MediaCachePath() string {
photoCache := utils.EnvMediaCachePath.GetValue()
if photoCache == "" {
photoCache = "./photo_cache"
photoCache = "./media_cache"
}
return photoCache

View File

@@ -1,16 +1,16 @@
package scanner
import (
"database/sql"
"fmt"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/photoview/photoview/api/graphql/models"
"github.com/pkg/errors"
"gorm.io/gorm"
)
func ScanVideoMetadata(tx *sql.Tx, video *models.Media) error {
func ScanVideoMetadata(tx *gorm.DB, video *models.Media) error {
data, err := readVideoMetadata(video.Path)
if err != nil {
@@ -52,18 +52,21 @@ func ScanVideoMetadata(tx *sql.Tx, video *models.Media) error {
}
}
result, err := tx.Exec("INSERT INTO video_metadata (width, height, duration, codec, framerate, bitrate, color_profile, audio) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", stream.Width, stream.Height, data.Format.DurationSeconds, stream.CodecLongName, framerate, stream.BitRate, stream.Profile, audioText)
if err != nil {
return errors.Wrapf(err, "failed to insert video metadata into database (%s)", video.Title)
videoMetadata := models.VideoMetadata{
Width: stream.Width,
Height: stream.Height,
Duration: data.Format.DurationSeconds,
Codec: &stream.CodecLongName,
Framerate: framerate,
Bitrate: &stream.BitRate,
ColorProfile: &stream.Profile,
Audio: &audioText,
}
metadata_id, err := result.LastInsertId()
if err != nil {
return err
}
video.VideoMetadata = &videoMetadata
if _, err = tx.Exec("UPDATE media SET video_metadata_id = ? WHERE media_id = ?", metadata_id, video.MediaID); err != nil {
return err
if err := tx.Save(video).Error; err != nil {
return errors.Wrapf(err, "failed to add video metadata to database (%s)", video.Title)
}
return nil

View File

@@ -3,7 +3,6 @@ package main
import (
"log"
"net/http"
"os"
"path"
"github.com/gorilla/handlers"
@@ -29,13 +28,12 @@ func main() {
log.Println("No .env file found")
}
devMode := os.Getenv("DEVELOPMENT") == "1"
devMode := utils.DevelopmentMode()
db, err := database.SetupDatabase()
if err != nil {
log.Panicf("Could not connect to database: %s\n", err)
}
defer db.Close()
// Migrate database
if err := database.MigrateDatabase(db); err != nil {
@@ -93,7 +91,7 @@ func main() {
videoRouter := endpointRouter.PathPrefix("/video").Subrouter()
routes.RegisterVideoRoutes(db, videoRouter)
shouldServeUI := os.Getenv("SERVE_UI") == "1"
shouldServeUI := utils.ShouldServeUI()
if shouldServeUI {
spa := routes.NewSpaHandler("/ui", "index.html")
@@ -112,7 +110,7 @@ func main() {
log.Printf("Photoview UI public endpoint ready at %s\n", uiEndpoint.String())
if !shouldServeUI {
log.Printf("Notice: UI is not served by the the api (SERVE_UI=0)")
log.Printf("Notice: UI is not served by the the api (%s=0)", utils.EnvServeUI.GetName())
}
}

View File

@@ -30,7 +30,7 @@ func WebsocketUpgrader(devMode bool) websocket.Upgrader {
if uiEndpoint.Host == originURL.Host {
return true
} else {
log.Printf("Not allowing websocket request from %s because it doesn't match UI_ENDPOINT %s", originURL.Host, uiEndpoint.Host)
log.Printf("Not allowing websocket request from %s because it doesn't match PHOTOVIEW_UI_ENDPOINT %s", originURL.Host, uiEndpoint.Host)
return false
}
}

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"log"
"net/url"
"os"
"path"
"strconv"
)
@@ -12,7 +11,7 @@ import (
func ApiListenUrl() *url.URL {
const defaultPort = "4001"
shouldServeUI := os.Getenv("SERVE_UI") == "1"
shouldServeUI := ShouldServeUI()
apiPrefix := "/"
if shouldServeUI {
@@ -21,19 +20,19 @@ func ApiListenUrl() *url.URL {
var listenAddr string
listenAddr = os.Getenv("API_LISTEN_IP")
listenAddr = EnvListenIP.GetValue()
if listenAddr == "" {
listenAddr = "127.0.0.1"
}
listenPortStr := os.Getenv("API_LISTEN_PORT")
listenPortStr := EnvListenPort.GetValue()
if listenPortStr == "" {
listenPortStr = defaultPort
}
listenPort, err := strconv.Atoi(listenPortStr)
if err != nil {
log.Fatalf("API_LISTEN_PORT must be a number: '%s'\n%s", listenPortStr, err)
log.Fatalf("%s must be a number: '%s'\n%s", EnvListenPort.GetName(), listenPortStr, err)
}
apiUrl, err := url.Parse(fmt.Sprintf("http://%s:%d", listenAddr, listenPort))
@@ -46,16 +45,16 @@ func ApiListenUrl() *url.URL {
}
func ApiEndpointUrl() *url.URL {
apiEndpointStr := os.Getenv("API_ENDPOINT")
apiEndpointStr := EnvAPIEndpoint.GetValue()
shouldServeUI := os.Getenv("SERVE_UI") == "1"
shouldServeUI := ShouldServeUI()
if shouldServeUI {
apiEndpointStr = os.Getenv("PUBLIC_ENDPOINT")
apiEndpointStr = EnvPublicEndpoint.GetValue()
}
apiEndpointUrl, err := url.Parse(apiEndpointStr)
if err != nil {
log.Fatalf("ERROR: Environment variable API_ENDPOINT is not a proper url")
log.Fatalf("ERROR: Environment variable %s is not a proper url (%s)", EnvAPIEndpoint.GetName(), EnvAPIEndpoint.GetValue())
}
if shouldServeUI {
@@ -66,16 +65,16 @@ func ApiEndpointUrl() *url.URL {
}
func UiEndpointUrl() *url.URL {
uiEndpointStr := os.Getenv("UI_ENDPOINT")
uiEndpointStr := EnvUIEndpoint.GetValue()
shouldServeUI := os.Getenv("SERVE_UI") == "1"
shouldServeUI := ShouldServeUI()
if shouldServeUI {
uiEndpointStr = os.Getenv("PUBLIC_ENDPOINT")
uiEndpointStr = EnvPublicEndpoint.GetValue()
}
uiEndpointUrl, err := url.Parse(uiEndpointStr)
if err != nil {
log.Fatalf("ERROR: Environment variable UI_ENDPOINT is not a proper url")
log.Fatalf("ERROR: Environment variable %s is not a proper url (%s)", EnvUIEndpoint.GetName(), EnvUIEndpoint.GetValue())
}
return uiEndpointUrl

View File

@@ -0,0 +1,50 @@
package utils
import "os"
// EnvironmentVariable represents the name of an environment variable used to configure Photoview
type EnvironmentVariable string
// General options
const (
EnvDevelopmentMode EnvironmentVariable = "PHOTOVIEW_DEVELOPMENT_MODE"
EnvServeUI EnvironmentVariable = "PHOTOVIEW_SERVE_UI"
EnvMediaCachePath EnvironmentVariable = "PHOTOVIEW_MEDIA_CACHE"
)
// Network related
const (
EnvListenIP EnvironmentVariable = "PHOTOVIEW_LISTEN_IP"
EnvListenPort EnvironmentVariable = "PHOTOVIEW_LISTEN_PORT"
EnvAPIEndpoint EnvironmentVariable = "PHOTOVIEW_API_ENDPOINT"
EnvUIEndpoint EnvironmentVariable = "PHOTOVIEW_UI_ENDPOINT"
EnvPublicEndpoint EnvironmentVariable = "PHOTOVIEW_PUBLIC_ENDPOINT"
)
// Database related
const (
EnvDatabaseDriver EnvironmentVariable = "PHOTOVIEW_DATABASE_DRIVER"
EnvMysqlURL EnvironmentVariable = "PHOTOVIEW_MYSQL_URL"
EnvSqlitePath EnvironmentVariable = "PHOTOVIEW_SQLITE_PATH"
)
// GetName returns the name of the environment variable itself
func (v EnvironmentVariable) GetName() string {
return string(v)
}
// GetValue returns the value of the environment
func (v EnvironmentVariable) GetValue() string {
return os.Getenv(string(v))
}
// ShouldServeUI whether or not the "serve ui" option is enabled
func ShouldServeUI() bool {
return EnvServeUI.GetValue() == "1"
}
// DevelopmentMode describes whether or not the server is running in development mode,
// and should thus print debug informations and enable other features related to developing.
func DevelopmentMode() bool {
return EnvDevelopmentMode.GetValue() == "1"
}

View File

@@ -21,15 +21,15 @@ services:
- db
environment:
- MYSQL_URL=photoview:photosecret@tcp(db)/photoview
- API_LISTEN_IP=photoview
- API_LISTEN_PORT=80
- PHOTO_CACHE=/app/cache
- PHOTOVIEW_MYSQL_URL=photoview:photosecret@tcp(db)/photoview
- PHOTOVIEW_LISTEN_IP=photoview
- PHOTOVIEW_LISTEN_PORT=80
- PHOTOVIEW_MEDIA_CACHE=/app/cache
# Change This: The publicly exposed url
# For example if the server is available from the domain example.com,
# change this value to http://example.com/
- PUBLIC_ENDPOINT=http://localhost:8000/
- PHOTOVIEW_PUBLIC_ENDPOINT=http://localhost:8000/
# Optional: To enable map related features, you need to create a mapbox token.
# A token can be generated for free here https://account.mapbox.com/access-tokens/

View File

@@ -1 +1 @@
API_ENDPOINT=http://localhost:4001/
PHOTOVIEW_API_ENDPOINT=http://localhost:4001/

View File

@@ -8,7 +8,7 @@ import Layout from '../../Layout'
const albumQuery = gql`
query albumQuery(
$id: Int!
$id: ID!
$onlyFavorites: Boolean
$mediaOrderBy: String
$mediaOrderDirection: OrderDirection

View File

@@ -5,7 +5,7 @@ import { useLazyQuery } from '@apollo/client'
import PresentView from '../../components/photoGallery/presentView/PresentView'
const QUERY_MEDIA = gql`
query placePageQueryMedia($mediaIDs: [Int!]!) {
query placePageQueryMedia($mediaIDs: [ID!]!) {
mediaList(ids: $mediaIDs) {
id
title

View File

@@ -4,7 +4,7 @@ import styled from 'styled-components'
import Layout from '../../Layout'
import ScannerSection from './ScannerSection'
import UsersTable from './UsersTable'
import UsersTable from './Users/UsersTable'
export const SectionTitle = styled.h2`
margin-top: ${({ nospace }) => (nospace ? '0' : '1.4em')} !important;

View File

@@ -1,298 +0,0 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { gql, useMutation } from '@apollo/client'
import {
Button,
Checkbox,
Form,
Icon,
Input,
Modal,
Table,
} from 'semantic-ui-react'
const updateUserMutation = gql`
mutation updateUser(
$id: Int!
$username: String
$rootPath: String
$admin: Boolean
) {
updateUser(
id: $id
username: $username
rootPath: $rootPath
admin: $admin
) {
id
username
rootPath
admin
}
}
`
const deleteUserMutation = gql`
mutation deleteUser($id: Int!) {
deleteUser(id: $id) {
id
username
}
}
`
const changeUserPasswordMutation = gql`
mutation changeUserPassword($userId: Int!, $password: String!) {
updateUser(id: $userId, password: $password) {
id
}
}
`
const scanUserMutation = gql`
mutation scanUser($userId: Int!) {
scanUser(userId: $userId) {
success
}
}
`
const ChangePasswordModal = ({ onClose, user, ...props }) => {
const [passwordInput, setPasswordInput] = useState('')
const [changePassword] = useMutation(changeUserPasswordMutation, {
onCompleted: () => {
onClose && onClose()
},
})
return (
<Modal {...props}>
<Modal.Header>Change password</Modal.Header>
<Modal.Content>
<p>
Change password for <b>{user.username}</b>
</p>
<Form>
<Form.Field>
<label>New password</label>
<Input
placeholder="password"
onChange={e => setPasswordInput(e.target.value)}
type="password"
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => onClose && onClose()}>Cancel</Button>
<Button
positive
onClick={() => {
changePassword({
variables: {
userId: user.id,
password: passwordInput,
},
})
}}
>
Change password
</Button>
</Modal.Actions>
</Modal>
)
}
ChangePasswordModal.propTypes = {
onClose: PropTypes.func,
user: PropTypes.object.isRequired,
}
const UserRow = ({ user, refetchUsers }) => {
const [state, setState] = useState({
...user,
editing: false,
})
const [showConfirmDelete, setConfirmDelete] = useState(false)
const [showChangePassword, setChangePassword] = useState(false)
function updateInput(event, key) {
setState({
...state,
[key]: event.target.value,
})
}
const [updateUser, { loading: updateUserLoading }] = useMutation(
updateUserMutation,
{
onCompleted: data => {
setState({
...data.updateUser,
editing: false,
})
refetchUsers()
},
}
)
const [deleteUser] = useMutation(deleteUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
const [scanUser, { called: scanUserCalled }] = useMutation(scanUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
if (state.editing) {
return (
<Table.Row>
<Table.Cell>
<Input
style={{ width: '100%' }}
placeholder={user.username}
value={state.username}
onChange={e => updateInput(e, 'username')}
/>
</Table.Cell>
<Table.Cell>
<Input
style={{ width: '100%' }}
placeholder={user.rootPath}
value={state.rootPath}
onChange={e => updateInput(e, 'rootPath')}
/>
</Table.Cell>
<Table.Cell>
<Checkbox
toggle
checked={state.admin}
onChange={(_, data) => {
setState({
...state,
admin: data.checked,
})
}}
/>
</Table.Cell>
<Table.Cell>
<Button.Group>
<Button
negative
onClick={() =>
setState({
...state.oldState,
})
}
>
Cancel
</Button>
<Button
loading={updateUserLoading}
disabled={updateUserLoading}
positive
onClick={() =>
updateUser({
variables: {
id: user.id,
username: state.username,
rootPath: state.rootPath,
admin: state.admin,
},
})
}
>
Save
</Button>
</Button.Group>
</Table.Cell>
</Table.Row>
)
}
return (
<Table.Row>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell>{user.rootPath}</Table.Cell>
<Table.Cell>
{user.admin ? <Icon name="checkmark" size="large" /> : null}
</Table.Cell>
<Table.Cell>
<Button.Group>
<Button
onClick={() => {
setState({ ...state, editing: true, oldState: state })
}}
>
<Icon name="edit" />
Edit
</Button>
<Button
disabled={scanUserCalled}
onClick={() => scanUser({ variables: { userId: user.id } })}
>
<Icon name="sync" />
Scan
</Button>
<Button onClick={() => setChangePassword(true)}>
<Icon name="key" />
Change password
</Button>
<ChangePasswordModal
user={user}
open={showChangePassword}
onClose={() => setChangePassword(false)}
/>
<Button
negative
onClick={() => {
setConfirmDelete(true)
}}
>
<Icon name="delete" />
Delete
</Button>
<Modal open={showConfirmDelete}>
<Modal.Header>Delete user</Modal.Header>
<Modal.Content>
<p>
{`Are you sure, you want to delete `}
<b>{user.username}</b>?
</p>
<p>{`This action cannot be undone`}</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
<Button
negative
onClick={() => {
setConfirmDelete(false)
deleteUser({
variables: {
id: user.id,
},
})
}}
>
Delete {user.username}
</Button>
</Modal.Actions>
</Modal>
</Button.Group>
</Table.Cell>
</Table.Row>
)
}
UserRow.propTypes = {
user: PropTypes.object.isRequired,
refetchUsers: PropTypes.func.isRequired,
}
export default UserRow

View File

@@ -0,0 +1,81 @@
import React from 'react'
import { Button, Checkbox, Input, Table } from 'semantic-ui-react'
import { EditRootPaths } from './EditUserRowRootPaths'
import { UserRowProps } from './UserRow'
const EditUserRow = ({
user,
state,
setState,
updateUser,
updateUserLoading,
}) => {
function updateInput(event, key) {
setState(state => ({
...state,
[key]: event.target.value,
}))
}
return (
<Table.Row>
<Table.Cell>
<Input
style={{ width: '100%' }}
placeholder={user.username}
value={state.username}
onChange={e => updateInput(e, 'username')}
/>
</Table.Cell>
<Table.Cell>
<EditRootPaths user={user} />
</Table.Cell>
<Table.Cell>
<Checkbox
toggle
checked={state.admin}
onChange={(_, data) => {
setState(state => ({
...state,
admin: data.checked,
}))
}}
/>
</Table.Cell>
<Table.Cell>
<Button.Group>
<Button
negative
onClick={() =>
setState(state => ({
...state.oldState,
}))
}
>
Cancel
</Button>
<Button
loading={updateUserLoading}
disabled={updateUserLoading}
positive
onClick={() =>
updateUser({
variables: {
id: user.id,
username: state.username,
admin: state.admin,
},
})
}
>
Save
</Button>
</Button.Group>
</Table.Cell>
</Table.Row>
)
}
EditUserRow.propTypes = UserRowProps
export default EditUserRow

View File

@@ -0,0 +1,135 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { gql, useMutation } from '@apollo/client'
import { Button, Icon, Input } from 'semantic-ui-react'
import styled from 'styled-components'
import { USERS_QUERY } from './UsersTable'
const userAddRootPathMutation = gql`
mutation userAddRootPath($id: ID!, $rootPath: String!) {
userAddRootPath(id: $id, rootPath: $rootPath) {
id
}
}
`
const userRemoveAlbumPathMutation = gql`
mutation userRemoveAlbumPathMutation($userId: ID!, $albumId: ID!) {
userRemoveRootAlbum(userId: $userId, albumId: $albumId) {
id
}
}
`
const RootPathListItem = styled.li`
display: flex;
justify-content: space-between;
align-items: center;
`
const EditRootPath = ({ album, user }) => {
const [removeAlbumPath, { loading }] = useMutation(
userRemoveAlbumPathMutation,
{
refetchQueries: [
{
query: USERS_QUERY,
},
],
}
)
return (
<RootPathListItem>
<span>{album.filePath}</span>
<Button
negative
disabled={loading}
onClick={() =>
removeAlbumPath({
variables: {
userId: user.id,
albumId: album.id,
},
})
}
>
<Icon name="remove" />
Remove
</Button>
</RootPathListItem>
)
}
EditRootPath.propTypes = {
album: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
}
const NewRootPathInput = styled(Input)`
width: 100%;
margin-top: 24px;
`
const EditNewRootPath = ({ userID }) => {
const [value, setValue] = useState('')
const [addRootPath, { loading }] = useMutation(userAddRootPathMutation, {
refetchQueries: [
{
query: USERS_QUERY,
},
],
})
return (
<li>
<NewRootPathInput
style={{ width: '100%' }}
value={value}
onChange={e => setValue(e.target.value)}
disabled={loading}
action={{
positive: true,
icon: 'add',
content: 'Add',
onClick: () => {
setValue('')
addRootPath({
variables: {
id: userID,
rootPath: value,
},
})
},
}}
/>
</li>
)
}
EditNewRootPath.propTypes = {
userID: PropTypes.string.isRequired,
}
const RootPathList = styled.ul`
margin: 0;
padding: 0;
list-style: none;
`
export const EditRootPaths = ({ user }) => {
const editRows = user.rootAlbums.map(album => (
<EditRootPath key={album.id} album={album} user={user} />
))
return (
<RootPathList>
{editRows}
<EditNewRootPath userID={user.id} />
</RootPathList>
)
}
EditRootPaths.propTypes = {
user: PropTypes.object.isRequired,
}

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { gql, useMutation } from '@apollo/client'
import { Button, Form, Input, Modal } from 'semantic-ui-react'
const changeUserPasswordMutation = gql`
mutation changeUserPassword($userId: ID!, $password: String!) {
updateUser(id: $userId, password: $password) {
id
}
}
`
const ChangePasswordModal = ({ onClose, user, ...props }) => {
const [passwordInput, setPasswordInput] = useState('')
const [changePassword] = useMutation(changeUserPasswordMutation, {
onCompleted: () => {
onClose && onClose()
},
})
return (
<Modal {...props}>
<Modal.Header>Change password</Modal.Header>
<Modal.Content>
<p>
Change password for <b>{user.username}</b>
</p>
<Form>
<Form.Field>
<label>New password</label>
<Input
placeholder="password"
onChange={e => setPasswordInput(e.target.value)}
type="password"
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => onClose && onClose()}>Cancel</Button>
<Button
positive
onClick={() => {
changePassword({
variables: {
userId: user.id,
password: passwordInput,
},
})
}}
>
Change password
</Button>
</Modal.Actions>
</Modal>
)
}
ChangePasswordModal.propTypes = {
onClose: PropTypes.func,
user: PropTypes.object.isRequired,
}
export default ChangePasswordModal

View File

@@ -0,0 +1,111 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { gql, useMutation } from '@apollo/client'
import EditUserRow from './EditUserRow'
import ViewUserRow from './ViewUserRow'
const updateUserMutation = gql`
mutation updateUser($id: ID!, $username: String, $admin: Boolean) {
updateUser(id: $id, username: $username, admin: $admin) {
id
username
admin
}
}
`
const deleteUserMutation = gql`
mutation deleteUser($id: ID!) {
deleteUser(id: $id) {
id
username
}
}
`
const scanUserMutation = gql`
mutation scanUser($userId: ID!) {
scanUser(userId: $userId) {
success
}
}
`
const UserRow = ({ user, refetchUsers }) => {
const [state, setState] = useState({
...user,
editing: false,
newRootPath: '',
})
const [showConfirmDelete, setConfirmDelete] = useState(false)
const [showChangePassword, setChangePassword] = useState(false)
const [updateUser, { loading: updateUserLoading }] = useMutation(
updateUserMutation,
{
onCompleted: data => {
setState({
...data.updateUser,
editing: false,
})
refetchUsers()
},
}
)
const [deleteUser] = useMutation(deleteUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
const [scanUser, { called: scanUserCalled }] = useMutation(scanUserMutation, {
onCompleted: () => {
refetchUsers()
},
})
const props = {
user,
state,
setState,
scanUser,
updateUser,
updateUserLoading,
deleteUser,
setChangePassword,
setConfirmDelete,
scanUserCalled,
showChangePassword,
showConfirmDelete,
}
if (state.editing) {
return <EditUserRow {...props} />
}
return <ViewUserRow {...props} />
}
UserRow.propTypes = {
user: PropTypes.object.isRequired,
refetchUsers: PropTypes.func.isRequired,
}
export const UserRowProps = {
user: PropTypes.object.isRequired,
state: PropTypes.object.isRequired,
setState: PropTypes.func.isRequired,
scanUser: PropTypes.func.isRequired,
updateUser: PropTypes.func.isRequired,
updateUserLoading: PropTypes.bool.isRequired,
deleteUser: PropTypes.func.isRequired,
setChangePassword: PropTypes.func.isRequired,
setConfirmDelete: PropTypes.func.isRequired,
scanUserCalled: PropTypes.func.isRequired,
showChangePassword: PropTypes.func.isRequired,
showConfirmDelete: PropTypes.func.isRequired,
}
export default UserRow

View File

@@ -4,15 +4,19 @@ import { Table, Loader, Button, Icon } from 'semantic-ui-react'
import { useQuery, gql } from '@apollo/client'
import UserRow from './UserRow'
import AddUserRow from './AddUserRow'
import { SectionTitle } from './SettingsPage'
import { SectionTitle } from '../SettingsPage'
const USERS_QUERY = gql`
export const USERS_QUERY = gql`
query settingsUsersQuery {
user {
id
username
rootPath
# rootPath
admin
rootAlbums {
id
filePath
}
}
}
`

View File

@@ -0,0 +1,109 @@
import React from 'react'
import { Button, Icon, Table, Modal } from 'semantic-ui-react'
import styled from 'styled-components'
import ChangePasswordModal from './UserChangePassword'
import { UserRowProps } from './UserRow'
const PathList = styled.ul`
margin: 0;
padding: 0 0 0 12px;
list-style: none;
`
const ViewUserRow = ({
user,
// state,
setState,
scanUser,
deleteUser,
setChangePassword,
setConfirmDelete,
scanUserCalled,
showChangePassword,
showConfirmDelete,
}) => {
const paths = (
<PathList>
{user.rootAlbums.map(album => (
<li key={album.id}>{album.filePath}</li>
))}
</PathList>
)
return (
<Table.Row>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell>{paths}</Table.Cell>
<Table.Cell>
{user.admin ? <Icon name="checkmark" size="large" /> : null}
</Table.Cell>
<Table.Cell>
<Button.Group>
<Button
onClick={() => {
setState(state => ({ ...state, editing: true, oldState: state }))
}}
>
<Icon name="edit" />
Edit
</Button>
<Button
disabled={scanUserCalled}
onClick={() => scanUser({ variables: { userId: user.id } })}
>
<Icon name="sync" />
Scan
</Button>
<Button onClick={() => setChangePassword(true)}>
<Icon name="key" />
Change password
</Button>
<ChangePasswordModal
user={user}
open={showChangePassword}
onClose={() => setChangePassword(false)}
/>
<Button
negative
onClick={() => {
setConfirmDelete(true)
}}
>
<Icon name="delete" />
Delete
</Button>
<Modal open={showConfirmDelete}>
<Modal.Header>Delete user</Modal.Header>
<Modal.Content>
<p>
{`Are you sure, you want to delete `}
<b>{user.username}</b>?
</p>
<p>{`This action cannot be undone`}</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
<Button
negative
onClick={() => {
setConfirmDelete(false)
deleteUser({
variables: {
id: user.id,
},
})
}}
>
Delete {user.username}
</Button>
</Modal.Actions>
</Modal>
</Button.Group>
</Table.Cell>
</Table.Row>
)
}
ViewUserRow.propTypes = UserRowProps
export default ViewUserRow

View File

@@ -61,6 +61,8 @@ export const SHARE_TOKEN_QUERY = gql`
}
highRes {
url
width
height
}
videoWeb {
url

View File

@@ -13,8 +13,8 @@ import urlJoin from 'url-join'
import { clearTokenCookie } from './authentication'
import { MessageState } from './components/messages/Messages'
export const GRAPHQL_ENDPOINT = process.env.API_ENDPOINT
? urlJoin(process.env.API_ENDPOINT, '/graphql')
export const GRAPHQL_ENDPOINT = process.env.PHOTOVIEW_API_ENDPOINT
? urlJoin(process.env.PHOTOVIEW_API_ENDPOINT, '/graphql')
: urlJoin(location.origin, '/api/graphql')
const httpLink = new HttpLink({

View File

@@ -37,7 +37,7 @@ const SettingsIcon = props => {
}
const ALBUM_PATH_QUERY = gql`
query albumPathQuery($id: Int!) {
query albumPathQuery($id: ID!) {
album(id: $id) {
id
path {

View File

@@ -7,7 +7,7 @@ import { Icon } from 'semantic-ui-react'
import { ProtectedImage } from './ProtectedMedia'
const markFavoriteMutation = gql`
mutation markMediaFavorite($mediaId: Int!, $favorite: Boolean!) {
mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) {
favoriteMedia(mediaId: $mediaId, favorite: $favorite) {
id
favorite

View File

@@ -4,7 +4,7 @@ import { useQuery, gql } from '@apollo/client'
import SidebarShare from './Sharing'
const albumQuery = gql`
query getAlbumSidebar($id: Int!) {
query getAlbumSidebar($id: ID!) {
album(id: $id) {
id
title
@@ -32,7 +32,7 @@ const AlbumSidebar = ({ albumId }) => {
}
AlbumSidebar.propTypes = {
albumId: PropTypes.number.isRequired,
albumId: PropTypes.string.isRequired,
}
export default AlbumSidebar

View File

@@ -9,7 +9,7 @@ import SidebarDownload from './SidebarDownload'
import SidebarItem from './SidebarItem'
const mediaQuery = gql`
query sidebarPhoto($id: Int!) {
query sidebarPhoto($id: ID!) {
media(id: $id) {
id
title

View File

@@ -13,10 +13,11 @@ import copy from 'copy-to-clipboard'
import { authToken } from '../../authentication'
const sharePhotoQuery = gql`
query sidbarGetPhotoShares($id: Int!) {
query sidbarGetPhotoShares($id: ID!) {
media(id: $id) {
id
shares {
id
token
hasPassword
}
@@ -25,10 +26,11 @@ const sharePhotoQuery = gql`
`
const shareAlbumQuery = gql`
query sidbarGetAlbumShares($id: Int!) {
query sidbarGetAlbumShares($id: ID!) {
album(id: $id) {
id
shares {
id
token
hasPassword
}
@@ -37,7 +39,7 @@ const shareAlbumQuery = gql`
`
const addPhotoShareMutation = gql`
mutation sidebarPhotoAddShare($id: Int!, $password: String, $expire: Time) {
mutation sidebarPhotoAddShare($id: ID!, $password: String, $expire: Time) {
shareMedia(mediaId: $id, password: $password, expire: $expire) {
token
}
@@ -45,7 +47,7 @@ const addPhotoShareMutation = gql`
`
const addAlbumShareMutation = gql`
mutation sidebarAlbumAddShare($id: Int!, $password: String, $expire: Time) {
mutation sidebarAlbumAddShare($id: ID!, $password: String, $expire: Time) {
shareAlbum(albumId: $id, password: $password, expire: $expire) {
token
}
@@ -211,7 +213,7 @@ const ShareItemMoreDropdown = ({ id, share, isPhoto }) => {
}
ShareItemMoreDropdown.propTypes = {
id: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
isPhoto: PropTypes.bool.isRequired,
share: PropTypes.object.isRequired,
}

View File

@@ -7,7 +7,7 @@ import { useLazyQuery, gql } from '@apollo/client'
import { authToken } from '../../authentication'
export const SIDEBAR_DOWNLOAD_QUERY = gql`
query sidebarDownloadQuery($mediaId: Int!) {
query sidebarDownloadQuery($mediaId: ID!) {
media(id: $mediaId) {
id
downloads {