mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-08-03 22:19:07 +00:00
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -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
138
api/database/database.go
Normal 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
|
||||
}
|
||||
32
api/database/drivers/database_drivers.go
Normal file
32
api/database/drivers/database_drivers.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
ALTER TABLE site_info
|
||||
DROP COLUMN IF EXISTS periodic_scan_interval,
|
||||
DROP COLUMN IF EXISTS concurrent_workers;
|
||||
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
ALTER TABLE media
|
||||
DROP COLUMN IF EXISTS side_car_path,
|
||||
DROP COLUMN IF EXISTS side_car_hash;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
17
api/go.mod
17
api/go.mod
@@ -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
|
||||
)
|
||||
|
||||
58
api/go.sum
58
api/go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
15
api/graphql/models/base.go
Normal file
15
api/graphql/models/base.go
Normal 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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
50
api/utils/environment_variables.go
Normal file
50
api/utils/environment_variables.go
Normal 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"
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -1 +1 @@
|
||||
API_ENDPOINT=http://localhost:4001/
|
||||
PHOTOVIEW_API_ENDPOINT=http://localhost:4001/
|
||||
@@ -8,7 +8,7 @@ import Layout from '../../Layout'
|
||||
|
||||
const albumQuery = gql`
|
||||
query albumQuery(
|
||||
$id: Int!
|
||||
$id: ID!
|
||||
$onlyFavorites: Boolean
|
||||
$mediaOrderBy: String
|
||||
$mediaOrderDirection: OrderDirection
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
81
ui/src/Pages/SettingsPage/Users/EditUserRow.js
Normal file
81
ui/src/Pages/SettingsPage/Users/EditUserRow.js
Normal 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
|
||||
135
ui/src/Pages/SettingsPage/Users/EditUserRowRootPaths.js
Normal file
135
ui/src/Pages/SettingsPage/Users/EditUserRowRootPaths.js
Normal 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,
|
||||
}
|
||||
66
ui/src/Pages/SettingsPage/Users/UserChangePassword.js
Normal file
66
ui/src/Pages/SettingsPage/Users/UserChangePassword.js
Normal 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
|
||||
111
ui/src/Pages/SettingsPage/Users/UserRow.js
Normal file
111
ui/src/Pages/SettingsPage/Users/UserRow.js
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
109
ui/src/Pages/SettingsPage/Users/ViewUserRow.js
Normal file
109
ui/src/Pages/SettingsPage/Users/ViewUserRow.js
Normal 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
|
||||
@@ -61,6 +61,8 @@ export const SHARE_TOKEN_QUERY = gql`
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
videoWeb {
|
||||
url
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user