1
Fork 0

Merge remote-tracking branch 'upstream/master' into use-hash-instead-of-full-path-to-avoid-key-length-maximum

This commit is contained in:
stz184 2020-06-24 13:42:43 +03:00
commit e70da6bb26
35 changed files with 4790 additions and 2248 deletions

42
.github/workflows/api.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: API
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get -v -t -d ./...
if [ -f Gopkg.toml ]; then
curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
dep ensure
fi
- name: Build
run: go build -v .
- name: Test
run: go test -v ./...

View File

@ -35,10 +35,10 @@ COPY api /app
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o photoview .
# Copy api and ui to production environment
FROM alpine:latest
FROM alpine:3.12
# Install darktable
RUN apk add darktable
# Install darktable for converting RAW images
RUN apk --no-cache add darktable
COPY --from=ui /app/dist /ui
COPY --from=api /app/database/migrations /database/migrations

View File

@ -0,0 +1,2 @@
-- Add favorite attribute to photos
ALTER TABLE photo DROP favorite

View File

@ -0,0 +1,2 @@
-- Add favorite attribute to photos
ALTER TABLE photo ADD favorite BOOL DEFAULT false

View File

@ -3,8 +3,9 @@ module github.com/viktorstrate/photoview/api
go 1.13
require (
github.com/99designs/gqlgen v0.11.2
github.com/99designs/gqlgen v0.11.3
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.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
@ -13,16 +14,24 @@ require (
github.com/go-sql-driver/mysql v1.5.0
github.com/golang-migrate/migrate v3.5.4+incompatible
github.com/gorilla/mux v1.7.4
github.com/gorilla/websocket v1.4.1
github.com/h2non/filetype v1.0.12
github.com/gorilla/websocket v1.4.2
github.com/h2non/filetype v1.1.0
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/joho/godotenv v1.3.0
github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
github.com/pkg/errors v0.8.1
github.com/urfave/cli v1.22.3 // indirect
github.com/matryer/moq v0.0.0-20200607124540-4638a53893e6 // indirect
github.com/mitchellh/mapstructure v1.3.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1
github.com/urfave/cli v1.22.4 // indirect
github.com/urfave/cli/v2 v2.2.0 // indirect
github.com/vektah/dataloaden v0.3.0 // indirect
github.com/vektah/gqlparser v1.3.1
github.com/vektah/gqlparser/v2 v2.0.1
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0
github.com/xor-gate/goexif2 v1.1.0
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9
golang.org/x/image v0.0.0-20200618115811-c13761719519
golang.org/x/mod v0.3.0 // indirect
golang.org/x/tools v0.0.0-20200622192924-4fd1c64487bf // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
)

View File

@ -2,6 +2,8 @@ github.com/99designs/gqlgen v0.10.2 h1:FfjCqIWejHDJeLpQTI0neoZo5vDO3sdo5oNCucet3
github.com/99designs/gqlgen v0.10.2/go.mod h1:aDB7oabSAyZ4kUHLEySsLxnWrBy3lA0A2gWKU+qoHwI=
github.com/99designs/gqlgen v0.11.2 h1:qatIx2DY7YyaUIBd47ORY3Aj/+pJsPLoL7tyuuISJR0=
github.com/99designs/gqlgen v0.11.2/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
@ -15,6 +17,8 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig
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/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/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=
@ -46,12 +50,18 @@ github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTM
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/filetype v1.0.10 h1:z+SJfnL6thYJ9kAST+6nPRXp1lMxnOVbMZHNYHMar0s=
github.com/h2non/filetype v1.0.10/go.mod h1:isekKqOuhMj+s/7r3rIeTErIRy4Rub5uBWHfvMusLMU=
github.com/h2non/filetype v1.0.12 h1:yHCsIe0y2cvbDARtJhGBTD2ecvqMSTvlIcph9En/Zao=
github.com/h2non/filetype v1.0.12/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA=
github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
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/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=
@ -63,19 +73,27 @@ 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 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/matryer/moq v0.0.0-20200607124540-4638a53893e6 h1:Cx1ZvZ3SQTli1nKee9qvJ/NJP3vt11s+ilM7NF3QSL8=
github.com/matryer/moq v0.0.0-20200607124540-4638a53893e6/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/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.3.2 h1:mRS76wmkOn3KkKAyXDu42V+6ebnXWIztFSYGN7GeoRg=
github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nf/cr2 v0.0.0-20180623103828-4699471a17ed h1:QP63yO3XEt8tJ1DgBsNjbOyBGjy2eHy9ITK4Eisr9rg=
github.com/nf/cr2 v0.0.0-20180623103828-4699471a17ed/go.mod h1:HazDB3gS/i//QXMMRmTAV7Ni9gAi4mDNTH2HjZ5aVgU=
github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ=
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
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 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
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 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI=
@ -103,10 +121,16 @@ github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.3 h1:FpNT6zq26xNpHZy08emi755QwzLPs6Pukqjlc7RfOMU=
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA=
github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
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/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84=
github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser v1.2.0 h1:ntkSCX7F5ZJKl+HIVnmLaO269MruasVpNiMOjX9kgo0=
github.com/vektah/gqlparser v1.2.0/go.mod h1:bkVf0FX+Stjg/MHnm8mEyubuaArhNEqfQhF+OTiAL74=
github.com/vektah/gqlparser v1.3.1 h1:8b0IcD3qZKWJQHSzynbDlrtP3IxVydZ2DZepCGofqfU=
@ -117,22 +141,33 @@ github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlV
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
github.com/xor-gate/goexif2 v1.1.0 h1:OvTZ5iEvsDhRWFjV5xY3wT7uHFna28nSSP7ucau+cXQ=
github.com/xor-gate/goexif2 v1.1.0/go.mod h1:eRjn3VSkAwpNpxEx/CGmd0zg0JFGL3akrSMxnJ581AY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4 h1:QmwruyY+bKbDDL0BaglrbZABEali68eoMFhTZpCjYVA=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
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 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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=
@ -146,9 +181,15 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd h1:oMEQDWVXVNpceQoVd1JN3CQ7LYJJzs5qWqZIUcxXHHw=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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/tools v0.0.0-20200622192924-4fd1c64487bf h1:nXhK+swoyjE2slxjyxxa36VklQeCrnFyGuIZQGUsuxY=
golang.org/x/tools v0.0.0-20200622192924-4fd1c64487bf/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -158,5 +199,7 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

View File

@ -75,7 +75,9 @@ type ComplexityRoot struct {
CreateUser func(childComplexity int, username string, rootPath string, password *string, admin bool) int
DeleteShareToken func(childComplexity int, token string) int
DeleteUser func(childComplexity int, id int) int
FavoritePhoto func(childComplexity int, photoID int, favorite bool) int
InitialSetupWizard func(childComplexity int, username string, password string, rootPath string) int
ProtectShareToken func(childComplexity int, token string, password *string) int
RegisterUser func(childComplexity int, username string, password string, rootPath string) int
ScanAll func(childComplexity int) int
ScanUser func(childComplexity int, userID int) int
@ -99,6 +101,7 @@ type ComplexityRoot struct {
Album func(childComplexity int) int
Downloads func(childComplexity int) int
Exif func(childComplexity int) int
Favorite func(childComplexity int) int
HighRes func(childComplexity int) int
ID func(childComplexity int) int
Path func(childComplexity int) int
@ -136,15 +139,16 @@ type ComplexityRoot struct {
}
Query struct {
Album func(childComplexity int, id int) int
MyAlbums func(childComplexity int, filter *models.Filter, onlyRoot *bool, showEmpty *bool) int
MyPhotos func(childComplexity int, filter *models.Filter) int
MyUser func(childComplexity int) int
Photo func(childComplexity int, id int) int
Search func(childComplexity int, query string, limitPhotos *int, limitAlbums *int) int
ShareToken func(childComplexity int, token string, password *string) int
SiteInfo func(childComplexity int) int
User func(childComplexity int, filter *models.Filter) int
Album func(childComplexity int, id int) int
MyAlbums func(childComplexity int, filter *models.Filter, onlyRoot *bool, showEmpty *bool) int
MyPhotos func(childComplexity int, filter *models.Filter) int
MyUser func(childComplexity int) int
Photo func(childComplexity int, id int) int
Search func(childComplexity int, query string, limitPhotos *int, limitAlbums *int) int
ShareToken func(childComplexity int, token string, password *string) int
ShareTokenValidatePassword func(childComplexity int, token string, password *string) int
SiteInfo func(childComplexity int) int
User func(childComplexity int, filter *models.Filter) int
}
ScannerResult struct {
@ -161,12 +165,13 @@ type ComplexityRoot struct {
}
ShareToken struct {
Album func(childComplexity int) int
Expire func(childComplexity int) int
ID func(childComplexity int) int
Owner func(childComplexity int) int
Photo func(childComplexity int) int
Token func(childComplexity int) int
Album func(childComplexity int) int
Expire func(childComplexity int) int
HasPassword func(childComplexity int) int
ID func(childComplexity int) int
Owner func(childComplexity int) int
Photo func(childComplexity int) int
Token func(childComplexity int) int
}
SiteInfo struct {
@ -204,6 +209,8 @@ type MutationResolver interface {
ShareAlbum(ctx context.Context, albumID int, expire *time.Time, password *string) (*models.ShareToken, error)
SharePhoto(ctx context.Context, photoID int, expire *time.Time, password *string) (*models.ShareToken, error)
DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error)
ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
FavoritePhoto(ctx context.Context, photoID int, favorite bool) (*models.Photo, error)
UpdateUser(ctx context.Context, id int, username *string, rootPath *string, password *string, admin *bool) (*models.User, error)
CreateUser(ctx context.Context, username string, rootPath string, password *string, admin bool) (*models.User, error)
DeleteUser(ctx context.Context, id int) (*models.User, error)
@ -213,6 +220,7 @@ type PhotoResolver interface {
HighRes(ctx context.Context, obj *models.Photo) (*models.PhotoURL, error)
Album(ctx context.Context, obj *models.Photo) (*models.Album, error)
Exif(ctx context.Context, obj *models.Photo) (*models.PhotoEXIF, error)
Shares(ctx context.Context, obj *models.Photo) ([]*models.ShareToken, error)
Downloads(ctx context.Context, obj *models.Photo) ([]*models.PhotoDownload, error)
}
@ -225,11 +233,13 @@ type QueryResolver interface {
MyPhotos(ctx context.Context, filter *models.Filter) ([]*models.Photo, error)
Photo(ctx context.Context, id int) (*models.Photo, error)
ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
ShareTokenValidatePassword(ctx context.Context, token string, password *string) (bool, error)
Search(ctx context.Context, query string, limitPhotos *int, limitAlbums *int) (*models.SearchResult, error)
}
type ShareTokenResolver interface {
Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error)
HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error)
Album(ctx context.Context, obj *models.ShareToken) (*models.Album, error)
Photo(ctx context.Context, obj *models.ShareToken) (*models.Photo, error)
}
@ -401,6 +411,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.DeleteUser(childComplexity, args["id"].(int)), true
case "Mutation.favoritePhoto":
if e.complexity.Mutation.FavoritePhoto == nil {
break
}
args, err := ec.field_Mutation_favoritePhoto_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.FavoritePhoto(childComplexity, args["photoId"].(int), args["favorite"].(bool)), true
case "Mutation.initialSetupWizard":
if e.complexity.Mutation.InitialSetupWizard == nil {
break
@ -413,6 +435,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.InitialSetupWizard(childComplexity, args["username"].(string), args["password"].(string), args["rootPath"].(string)), true
case "Mutation.protectShareToken":
if e.complexity.Mutation.ProtectShareToken == nil {
break
}
args, err := ec.field_Mutation_protectShareToken_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.ProtectShareToken(childComplexity, args["token"].(string), args["password"].(*string)), true
case "Mutation.registerUser":
if e.complexity.Mutation.RegisterUser == nil {
break
@ -557,6 +591,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Photo.Exif(childComplexity), true
case "Photo.favorite":
if e.complexity.Photo.Favorite == nil {
break
}
return e.complexity.Photo.Favorite(childComplexity), true
case "Photo.highRes":
if e.complexity.Photo.HighRes == nil {
break
@ -811,6 +852,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.ShareToken(childComplexity, args["token"].(string), args["password"].(*string)), true
case "Query.shareTokenValidatePassword":
if e.complexity.Query.ShareTokenValidatePassword == nil {
break
}
args, err := ec.field_Query_shareTokenValidatePassword_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Query.ShareTokenValidatePassword(childComplexity, args["token"].(string), args["password"].(*string)), true
case "Query.siteInfo":
if e.complexity.Query.SiteInfo == nil {
break
@ -893,6 +946,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ShareToken.Expire(childComplexity), true
case "ShareToken.hasPassword":
if e.complexity.ShareToken.HasPassword == nil {
break
}
return e.complexity.ShareToken.HasPassword(childComplexity), true
case "ShareToken.id":
if e.complexity.ShareToken.ID == nil {
break
@ -1085,6 +1145,7 @@ type Query {
photo(id: Int!): Photo!
shareToken(token: String!, password: String): ShareToken!
shareTokenValidatePassword(token: String!, password: String): Boolean!
search(query: String!, limitPhotos: Int, limitAlbums: Int): SearchResult!
}
@ -1117,6 +1178,11 @@ type Mutation {
sharePhoto(photoId: Int!, 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 photo as being a favorite"
favoritePhoto(photoId: Int!, favorite: Boolean!): Photo
updateUser(
id: Int!
@ -1178,6 +1244,8 @@ type ShareToken {
owner: User!
"Optional expire date"
expire: Time
"Whether or not a password is needed to access the share"
hasPassword: Boolean!
"The album this token shares"
album: Album
@ -1248,6 +1316,7 @@ type Photo {
"The album that holds the photo"
album: Album!
exif: PhotoEXIF
favorite: Boolean!
shares: [ShareToken!]!
downloads: [PhotoDownload!]!
@ -1407,6 +1476,28 @@ func (ec *executionContext) field_Mutation_deleteUser_args(ctx context.Context,
return args, nil
}
func (ec *executionContext) field_Mutation_favoritePhoto_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 int
if tmp, ok := rawArgs["photoId"]; ok {
arg0, err = ec.unmarshalNInt2int(ctx, tmp)
if err != nil {
return nil, err
}
}
args["photoId"] = arg0
var arg1 bool
if tmp, ok := rawArgs["favorite"]; ok {
arg1, err = ec.unmarshalNBoolean2bool(ctx, tmp)
if err != nil {
return nil, err
}
}
args["favorite"] = arg1
return args, nil
}
func (ec *executionContext) field_Mutation_initialSetupWizard_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -1437,6 +1528,28 @@ func (ec *executionContext) field_Mutation_initialSetupWizard_args(ctx context.C
return args, nil
}
func (ec *executionContext) field_Mutation_protectShareToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 string
if tmp, ok := rawArgs["token"]; ok {
arg0, err = ec.unmarshalNString2string(ctx, tmp)
if err != nil {
return nil, err
}
}
args["token"] = arg0
var arg1 *string
if tmp, ok := rawArgs["password"]; ok {
arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp)
if err != nil {
return nil, err
}
}
args["password"] = arg1
return args, nil
}
func (ec *executionContext) field_Mutation_registerUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -1703,6 +1816,28 @@ func (ec *executionContext) field_Query_search_args(ctx context.Context, rawArgs
return args, nil
}
func (ec *executionContext) field_Query_shareTokenValidatePassword_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 string
if tmp, ok := rawArgs["token"]; ok {
arg0, err = ec.unmarshalNString2string(ctx, tmp)
if err != nil {
return nil, err
}
}
args["token"] = arg0
var arg1 *string
if tmp, ok := rawArgs["password"]; ok {
arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp)
if err != nil {
return nil, err
}
}
args["password"] = arg1
return args, nil
}
func (ec *executionContext) field_Query_shareToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -2548,6 +2683,82 @@ func (ec *executionContext) _Mutation_deleteShareToken(ctx context.Context, fiel
return ec.marshalOShareToken2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_protectShareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Mutation_protectShareToken_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().ProtectShareToken(rctx, args["token"].(string), args["password"].(*string))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*models.ShareToken)
fc.Result = res
return ec.marshalOShareToken2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_favoritePhoto(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Mutation_favoritePhoto_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().FavoritePhoto(rctx, args["photoId"].(int), args["favorite"].(bool))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*models.Photo)
fc.Result = res
return ec.marshalOPhoto2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhoto(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_updateUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -3223,6 +3434,40 @@ func (ec *executionContext) _Photo_exif(ctx context.Context, field graphql.Colle
return ec.marshalOPhotoEXIF2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhotoEXIF(ctx, field.Selections, res)
}
func (ec *executionContext) _Photo_favorite(ctx context.Context, field graphql.CollectedField, obj *models.Photo) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Photo",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Favorite, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _Photo_shares(ctx context.Context, field graphql.CollectedField, obj *models.Photo) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -4238,6 +4483,47 @@ func (ec *executionContext) _Query_shareToken(ctx context.Context, field graphql
return ec.marshalNShareToken2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐShareToken(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_shareTokenValidatePassword(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Query",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Query_shareTokenValidatePassword_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Query().ShareTokenValidatePassword(rctx, args["token"].(string), args["password"].(*string))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_search(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -4713,6 +4999,40 @@ func (ec *executionContext) _ShareToken_expire(ctx context.Context, field graphq
return ec.marshalOTime2ᚖtimeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) _ShareToken_hasPassword(ctx context.Context, field graphql.CollectedField, obj *models.ShareToken) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "ShareToken",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.ShareToken().HasPassword(rctx, obj)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _ShareToken_album(ctx context.Context, field graphql.CollectedField, obj *models.ShareToken) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -6311,6 +6631,10 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
out.Values[i] = ec._Mutation_sharePhoto(ctx, field)
case "deleteShareToken":
out.Values[i] = ec._Mutation_deleteShareToken(ctx, field)
case "protectShareToken":
out.Values[i] = ec._Mutation_protectShareToken(ctx, field)
case "favoritePhoto":
out.Values[i] = ec._Mutation_favoritePhoto(ctx, field)
case "updateUser":
out.Values[i] = ec._Mutation_updateUser(ctx, field)
case "createUser":
@ -6463,6 +6787,11 @@ func (ec *executionContext) _Photo(ctx context.Context, sel ast.SelectionSet, ob
res = ec._Photo_exif(ctx, field, obj)
return res
})
case "favorite":
out.Values[i] = ec._Photo_favorite(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
case "shares":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
@ -6757,6 +7086,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
}
return res
})
case "shareTokenValidatePassword":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_shareTokenValidatePassword(ctx, field)
if res == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
return res
})
case "search":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
@ -6896,6 +7239,20 @@ func (ec *executionContext) _ShareToken(ctx context.Context, sel ast.SelectionSe
})
case "expire":
out.Values[i] = ec._ShareToken_expire(ctx, field, obj)
case "hasPassword":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._ShareToken_hasPassword(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
return res
})
case "album":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {

View File

@ -14,6 +14,7 @@ type Photo struct {
PathHash string
AlbumId int
ExifId *int
Favorite bool
}
func (p *Photo) ID() int {
@ -41,7 +42,7 @@ type PhotoURL struct {
func NewPhotoFromRow(row *sql.Row) (*Photo, error) {
photo := Photo{}
if err := row.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.PathHash, &photo.AlbumId, &photo.ExifId); err != nil {
if err := row.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.PathHash, &photo.AlbumId, &photo.ExifId, &photo.Favorite); err != nil {
return nil, err
}
@ -53,7 +54,7 @@ func NewPhotosFromRows(rows *sql.Rows) ([]*Photo, error) {
for rows.Next() {
var photo Photo
if err := rows.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.PathHash, &photo.AlbumId, &photo.ExifId); err != nil {
if err := rows.Scan(&photo.PhotoID, &photo.Title, &photo.Path, &photo.PathHash, &photo.AlbumId, &photo.ExifId, &photo.Favorite); err != nil {
return nil, err
}
photos = append(photos, &photo)

View File

@ -2,9 +2,10 @@ package notification
import (
"errors"
"github.com/viktorstrate/photoview/api/graphql/models"
"log"
"sync"
"github.com/viktorstrate/photoview/api/graphql/models"
)
type NotificationChannel = chan<- *models.Notification
@ -68,6 +69,10 @@ func DeregisterListener(listenerID int) error {
func BroadcastNotification(notification *models.Notification) {
if notification == nil {
return
}
log.Printf("Broadcasting notification: %s\n", notification.Header)
notificationLock.Lock()

View File

@ -171,3 +171,24 @@ func (r *photoResolver) Exif(ctx context.Context, obj *models.Photo) (*models.Ph
return exif, nil
}
func (r *mutationResolver) FavoritePhoto(ctx context.Context, photoID int, favorite bool) (*models.Photo, error) {
user := auth.UserFromContext(ctx)
row := r.Database.QueryRow("SELECT photo.* FROM photo JOIN album ON photo.album_id = album.album_id WHERE photo.photo_id = ? AND album.owner_id = ?", photoID, user.UserID)
photo, err := models.NewPhotoFromRow(row)
if err != nil {
return nil, err
}
_, err = r.Database.Exec("UPDATE photo SET favorite = ? WHERE photo_id = ?", favorite, photo.PhotoID)
if err != nil {
return nil, err
}
photo.Favorite = favorite
return photo, nil
}

View File

@ -2,20 +2,16 @@ package resolvers
import (
"context"
"fmt"
"github.com/pkg/errors"
"github.com/viktorstrate/photoview/api/graphql/models"
"github.com/viktorstrate/photoview/api/scanner"
)
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
if err := scanner.ScanAll(r.Database); err != nil {
errorMessage := fmt.Sprintf("Error starting scanner: %s", err.Error())
return &models.ScannerResult{
Finished: false,
Success: false,
Message: &errorMessage,
}, nil
err := scanner.AddAllToQueue()
if err != nil {
return nil, err
}
startMessage := "Scanner started"
@ -28,17 +24,15 @@ func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult,
}
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
if err := scanner.ScanUser(r.Database, userID); err != nil {
errorMessage := fmt.Sprintf("Error scanning user: %s", err.Error())
return &models.ScannerResult{
Finished: false,
Success: false,
Message: &errorMessage,
}, nil
row := r.Database.QueryRow("SELECT * FROM user WHERE user_id = ?", userID)
user, err := models.NewUserFromRow(row)
if err != nil {
return nil, errors.Wrap(err, "get user from database")
}
startMessage := "Scanner started"
scanner.AddUserToQueue(user)
startMessage := "Scanner started"
return &models.ScannerResult{
Finished: false,
Success: true,

View File

@ -3,9 +3,10 @@ package resolvers
import (
"context"
"database/sql"
"errors"
"time"
"github.com/pkg/errors"
api "github.com/viktorstrate/photoview/api/graphql"
"github.com/viktorstrate/photoview/api/graphql/auth"
"github.com/viktorstrate/photoview/api/graphql/models"
@ -54,10 +55,15 @@ func (r *shareTokenResolver) Photo(ctx context.Context, obj *models.ShareToken)
return photo, nil
}
func (r *queryResolver) ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) {
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
hasPassword := obj.Password != nil
return hasPassword, nil
}
row := r.Database.QueryRow("SELECT * FROM share_token WHERE value = ? AND (password = ? OR password IS NULL)", token, password)
result, err := models.NewShareTokenFromRow(row)
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 {
return nil, errors.New("share not found")
@ -66,7 +72,47 @@ func (r *queryResolver) ShareToken(ctx context.Context, token string, password *
}
}
return result, nil
if token.Password != nil {
if err := bcrypt.CompareHashAndPassword([]byte(*token.Password), []byte(*password)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return nil, errors.New("unauthorized")
} else {
return nil, errors.New("internal server error")
}
}
}
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 {
return false, errors.New("share not found")
} else {
return false, err
}
}
if token.Password == nil {
return true, nil
}
if password == nil {
return false, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(*token.Password), []byte(*password)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil
} else {
return false, err
}
}
return true, nil
}
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time, password *string) (*models.ShareToken, error) {
@ -131,14 +177,9 @@ func (r *mutationResolver) SharePhoto(ctx context.Context, photoID int, expire *
}
rows.Close()
var hashed_password *string = nil
if password != nil {
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
return nil, err
}
hashed_str := string(hashedPassBytes)
hashed_password = &hashed_str
hashed_password, err := hashSharePassword(password)
if err != nil {
return nil, err
}
token := utils.GenerateToken()
@ -169,7 +210,59 @@ func (r *mutationResolver) DeleteShareToken(ctx context.Context, tokenValue stri
return nil, auth.ErrUnauthorized
}
row := r.Database.QueryRow(`
token, err := getUserToken(r.Database, user, tokenValue)
if err != nil {
return nil, err
}
if _, err := r.Database.Exec("DELETE FROM share_token WHERE token_id = ?", token.TokenID); err != nil {
return nil, errors.Wrapf(err, "Error occurred when trying to delete share token (%s) from database", tokenValue)
}
return token, nil
}
func (r *mutationResolver) ProtectShareToken(ctx context.Context, tokenValue string, password *string) (*models.ShareToken, error) {
user := auth.UserFromContext(ctx)
if user == nil {
return nil, auth.ErrUnauthorized
}
token, err := getUserToken(r.Database, user, tokenValue)
if err != nil {
return nil, err
}
hashed_password, 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 {
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)
}
func hashSharePassword(password *string) (*string, error) {
var hashed_password *string = nil
if password != nil {
hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
return nil, err
}
hashed_str := string(hashedPassBytes)
hashed_password = &hashed_str
}
return hashed_password, 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
@ -181,9 +274,5 @@ 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 {
return nil, err
}
return token, nil
}

View File

@ -39,6 +39,7 @@ type Query {
photo(id: Int!): Photo!
shareToken(token: String!, password: String): ShareToken!
shareTokenValidatePassword(token: String!, password: String): Boolean!
search(query: String!, limitPhotos: Int, limitAlbums: Int): SearchResult!
}
@ -71,6 +72,11 @@ type Mutation {
sharePhoto(photoId: Int!, 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 photo as being a favorite"
favoritePhoto(photoId: Int!, favorite: Boolean!): Photo
updateUser(
id: Int!
@ -132,6 +138,8 @@ type ShareToken {
owner: User!
"Optional expire date"
expire: Time
"Whether or not a password is needed to access the share"
hasPassword: Boolean!
"The album this token shares"
album: Album
@ -202,6 +210,7 @@ type Photo {
"The album that holds the photo"
album: Album!
exif: PhotoEXIF
favorite: Boolean!
shares: [ShareToken!]!
downloads: [PhotoDownload!]!

View File

@ -11,6 +11,7 @@ import (
"strconv"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
"github.com/viktorstrate/photoview/api/graphql/auth"
"github.com/viktorstrate/photoview/api/graphql/models"
@ -60,7 +61,7 @@ func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) {
return
}
} else {
// Check if photo is authorized with a share token
token := r.URL.Query().Get("token")
if token == "" {
w.WriteHeader(http.StatusForbidden)
@ -78,6 +79,23 @@ func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) {
return
}
// Validate share token password, if set
if shareToken.Password != nil {
tokenPassword := r.Header.Get("TokenPassword")
if err := bcrypt.CompareHashAndPassword([]byte(*shareToken.Password), []byte(tokenPassword)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("unauthorized"))
return
} else {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
return
}
}
}
if shareToken.AlbumID != nil && photo.AlbumId != *shareToken.AlbumID {
// Check child albums
row := db.QueryRow(`
@ -133,7 +151,7 @@ func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) {
return
}
err = scanner.ProcessPhoto(tx, photo)
_, err = scanner.ProcessPhoto(tx, photo)
if err != nil {
log.Printf("ERROR: processing image not found in cache: %s\n", err)
w.WriteHeader(http.StatusInternalServerError)

View File

@ -1,510 +0,0 @@
package scanner
import (
"container/list"
"database/sql"
"fmt"
"io/ioutil"
"log"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/viktorstrate/photoview/api/graphql/models"
"github.com/viktorstrate/photoview/api/graphql/notification"
"github.com/viktorstrate/photoview/api/utils"
)
type scanner_cache map[string]interface{}
func (cache *scanner_cache) insert_photo_type(path string, content_type ImageType) {
(*cache)["photo_type//"+path] = content_type
}
func (cache *scanner_cache) get_photo_type(path string) *string {
result, found := (*cache)["photo_type//"+path].(string)
if found {
// log.Printf("Image cache hit: %s\n", path)
return &result
}
return nil
}
// Insert single album directory in cache
func (cache *scanner_cache) insert_album_path(path string, contains_photo bool) {
(*cache)["album_path//"+path] = contains_photo
}
// Insert album path and all parent directories up to the given root directory in cache
func (cache *scanner_cache) insert_album_paths(end_path string, root string, contains_photo bool) {
curr_path := path.Clean(end_path)
root_path := path.Clean(root)
for curr_path != root_path || curr_path == "." {
cache.insert_album_path(curr_path, contains_photo)
curr_path = path.Dir(curr_path)
}
}
func (cache *scanner_cache) album_contains_photo(path string) *bool {
contains_photo, found := (*cache)["album_path//"+path].(bool)
if found {
// log.Printf("Album cache hit: %s\n", path)
return &contains_photo
}
return nil
}
func ScanAll(database *sql.DB) error {
rows, err := database.Query("SELECT * FROM user")
if err != nil {
log.Printf("Could not fetch all users from database: %s\n", err.Error())
return err
}
users, err := models.NewUsersFromRows(rows)
if err != nil {
log.Printf("Could not convert users: %s\n", err)
return err
}
for _, user := range users {
go scan(database, user)
}
return nil
}
func ScanUser(database *sql.DB, userId int) error {
row := database.QueryRow("SELECT * FROM user WHERE user_id = ?", userId)
user, err := models.NewUserFromRow(row)
if err != nil {
log.Printf("Could not find user to scan: %s\n", err.Error())
return err
}
log.Printf("Starting scan for user '%s'\n", user.Username)
go scan(database, user)
return nil
}
func scan(database *sql.DB, user *models.User) {
// Check if user directory exists on the file system
if _, err := os.Stat(user.RootPath); err != nil {
if os.IsNotExist(err) {
ScannerError("Photo directory for user '%s' does not exist '%s'\n", user.Username, user.RootPath)
} else {
ScannerError("Could not read photo directory for user '%s': %s\n", user.Username, user.RootPath)
}
return
}
notifyKey := utils.GenerateToken()
processKey := utils.GenerateToken()
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
timeout := 3000
notification.BroadcastNotification(&models.Notification{
Key: notifyKey,
Type: models.NotificationTypeMessage,
Header: "User scan started",
Content: fmt.Sprintf("Scanning has started for user '%s'", user.Username),
Timeout: &timeout,
})
// Start scanning
scanner_cache := make(scanner_cache)
album_paths_scanned := make([]interface{}, 0)
photo_paths_scanned := make([]interface{}, 0)
type scanInfo struct {
path string
parentId *int
}
scanQueue := list.New()
scanQueue.PushBack(scanInfo{
path: user.RootPath,
parentId: nil,
})
newPhotos := list.New()
for scanQueue.Front() != nil {
albumInfo := scanQueue.Front().Value.(scanInfo)
scanQueue.Remove(scanQueue.Front())
albumPath := albumInfo.path
albumParentId := albumInfo.parentId
album_paths_scanned = append(album_paths_scanned, albumPath)
// Read path
dirContent, err := ioutil.ReadDir(albumPath)
if err != nil {
ScannerError("Could not read directory: %s\n", err.Error())
continue
}
tx, err := database.Begin()
if err != nil {
ScannerError("Could not begin database transaction: %s\n", err)
continue
}
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 {
ScannerError("Could not insert album into database: %s\n", err)
tx.Rollback()
continue
}
row := tx.QueryRow("SELECT album_id FROM album WHERE path_hash = MD5(?)", albumPath)
var albumId int
if err := row.Scan(&albumId); err != nil {
ScannerError("Could not get id of album: %s\n", err)
tx.Rollback()
return
}
// Commit album transaction
if err := tx.Commit(); err != nil {
log.Printf("ERROR: Could not commit database transaction: %s\n", err)
return
}
// Scan for photos
for _, item := range dirContent {
photoPath := path.Join(albumPath, item.Name())
if !item.IsDir() && isPathImage(photoPath, &scanner_cache) {
tx, err := database.Begin()
if err != nil {
ScannerError("Could not begin database transaction for image %s: %s\n", photoPath, err)
continue
}
photo_paths_scanned = append(photo_paths_scanned, photoPath)
photo, isNewPhoto, err := ScanPhoto(tx, photoPath, albumId, processKey)
if err != nil {
ScannerError("Scanning image %s: %s", photoPath, err)
tx.Rollback()
continue
}
if isNewPhoto {
newPhotos.PushBack(photo)
notifyThrottle.Trigger(func() {
notification.BroadcastNotification(&models.Notification{
Key: processKey,
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Scanning photo for user '%s'", user.Username),
Content: fmt.Sprintf("Scanning image at %s", photoPath),
})
})
}
tx.Commit()
}
}
// Scan for sub-albums
for _, item := range dirContent {
subalbumPath := path.Join(albumPath, item.Name())
// Skip if directory is hidden
if path.Base(subalbumPath)[0:1] == "." {
continue
}
if item.IsDir() && directoryContainsPhotos(subalbumPath, &scanner_cache) {
scanQueue.PushBack(scanInfo{
path: subalbumPath,
parentId: &albumId,
})
}
}
}
completeMessage := "No new photos were found"
if newPhotos.Len() > 0 {
completeMessage = fmt.Sprintf("%d new photos were found", newPhotos.Len())
}
notification.BroadcastNotification(&models.Notification{
Key: notifyKey,
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Scan completed for user '%s'", user.Username),
Content: completeMessage,
Positive: true,
})
cleanupCache(database, album_paths_scanned, photo_paths_scanned, user)
err := processUnprocessedPhotos(database, user, notifyKey)
if err != nil {
log.Printf("ERROR: processing photos: %s\n", err)
}
log.Printf("Done scanning user '%s'\n", user.Username)
}
func directoryContainsPhotos(rootPath string, cache *scanner_cache) bool {
if contains_image := cache.album_contains_photo(rootPath); contains_image != nil {
return *contains_image
}
scanQueue := list.New()
scanQueue.PushBack(rootPath)
scanned_directories := make([]string, 0)
for scanQueue.Front() != nil {
dirPath := scanQueue.Front().Value.(string)
scanQueue.Remove(scanQueue.Front())
scanned_directories = append(scanned_directories, dirPath)
dirContent, err := ioutil.ReadDir(dirPath)
if err != nil {
ScannerError("Could not read directory: %s\n", err.Error())
return false
}
for _, fileInfo := range dirContent {
filePath := path.Join(dirPath, fileInfo.Name())
if fileInfo.IsDir() {
scanQueue.PushBack(filePath)
} else {
if isPathImage(filePath, cache) {
cache.insert_album_paths(dirPath, rootPath, true)
return true
}
}
}
}
for _, scanned_path := range scanned_directories {
cache.insert_album_path(scanned_path, false)
}
return false
}
func processUnprocessedPhotos(database *sql.DB, user *models.User, notifyKey string) error {
processKey := utils.GenerateToken()
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
rows, err := database.Query(`
SELECT photo.* FROM photo JOIN album ON photo.album_id = album.album_id
WHERE album.owner_id = ?
AND photo.photo_id NOT IN (
SELECT photo_id FROM photo_url WHERE photo_url.photo_id = photo.photo_id
)
`, user.UserID)
if err != nil {
ScannerError("Could not get photos to process from db")
return err
}
photosToProcess, err := models.NewPhotosFromRows(rows)
if err != nil {
if err == sql.ErrNoRows {
// No photos to process
return nil
}
ScannerError("Could not parse photos to process from db %s", err)
return err
}
// Proccess all photos
for count, photo := range photosToProcess {
tx, err := database.Begin()
if err != nil {
ScannerError("Could not start database transaction: %s", err)
continue
}
notifyThrottle.Trigger(func() {
var progress float64 = float64(count) / float64(len(photosToProcess)) * 100.0
notification.BroadcastNotification(&models.Notification{
Key: processKey,
Type: models.NotificationTypeProgress,
Header: fmt.Sprintf("Processing photos (%d of %d) for user '%s'", count, len(photosToProcess), user.Username),
Content: fmt.Sprintf("Processing photo at %s", photo.Path),
Progress: &progress,
})
})
err = ProcessPhoto(tx, photo)
if err != nil {
tx.Rollback()
ScannerError("Could not process photo (%s): %s", photo.Path, err)
continue
}
err = tx.Commit()
if err != nil {
ScannerError("Could not commit db transaction: %s", err)
continue
}
}
if len(photosToProcess) > 0 {
notification.BroadcastNotification(&models.Notification{
Key: notifyKey,
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Processing photos for user '%s' has completed", user.Username),
Content: fmt.Sprintf("%d photos have been processed", len(photosToProcess)),
Positive: true,
})
notification.BroadcastNotification(&models.Notification{
Key: processKey,
Type: models.NotificationTypeClose,
})
}
return nil
}
func cleanupCache(database *sql.DB, scanned_albums []interface{}, scanned_photos []interface{}, user *models.User) {
if len(scanned_albums) == 0 {
return
}
// Delete old albums
album_args := make([]interface{}, 0)
album_args = append(album_args, user.UserID)
album_args = append(album_args, scanned_albums...)
albums_questions := strings.Repeat("MD5(?),", len(scanned_albums))[:len(scanned_albums)*7-1]
rows, err := database.Query("SELECT album_id FROM album WHERE album.owner_id = ? AND path_hash NOT IN ("+albums_questions+")", album_args...)
if err != nil {
ScannerError("Could not get albums from database: %s\n", err)
return
}
defer rows.Close()
deleted_album_ids := make([]interface{}, 0)
for rows.Next() {
var album_id int
if err := rows.Scan(&album_id); err != nil {
ScannerError("Could not parse album to be removed (album_id %d): %s\n", album_id, err)
}
deleted_album_ids = append(deleted_album_ids, album_id)
cache_path := path.Join("./photo_cache", strconv.Itoa(album_id))
err := os.RemoveAll(cache_path)
if err != nil {
ScannerError("Could not delete unused cache folder: %s\n%s\n", cache_path, err)
}
}
if len(deleted_album_ids) > 0 {
albums_questions = strings.Repeat("?,", len(deleted_album_ids))[:len(deleted_album_ids)*2-1]
if _, err := database.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)
}
}
// Delete old photos
photo_args := make([]interface{}, 0)
photo_args = append(photo_args, user.UserID)
photo_args = append(photo_args, scanned_photos...)
photo_questions := strings.Repeat("MD5(?),", len(scanned_photos))[:len(scanned_photos)*7-1]
rows, err = database.Query(`
SELECT photo.photo_id as photo_id, album.album_id as album_id FROM photo JOIN album ON photo.album_id = album.album_id
WHERE album.owner_id = ? AND photo.path_hash NOT IN (`+photo_questions+`)
`, photo_args...)
if err != nil {
ScannerError("Could not get deleted photos from database: %s\n", err)
return
}
defer rows.Close()
deleted_photo_ids := make([]interface{}, 0)
for rows.Next() {
var photo_id int
var album_id int
if err := rows.Scan(&photo_id, &album_id); err != nil {
ScannerError("Could not parse photo to be removed (album_id %d, photo_id %d): %s\n", album_id, photo_id, err)
}
deleted_photo_ids = append(deleted_photo_ids, photo_id)
cache_path := path.Join("./photo_cache", strconv.Itoa(album_id), strconv.Itoa(photo_id))
err := os.RemoveAll(cache_path)
if err != nil {
ScannerError("Could not delete unused cache photo folder: %s\n%s\n", cache_path, err)
}
}
if len(deleted_photo_ids) > 0 {
photo_questions = strings.Repeat("?,", len(deleted_photo_ids))[:len(deleted_photo_ids)*2-1]
if _, err := database.Exec("DELETE FROM photo WHERE photo_id IN ("+photo_questions+")", deleted_photo_ids...); err != nil {
ScannerError("Could not delete old photos from database:\n%s\n", err)
}
}
if len(deleted_album_ids) > 0 || len(deleted_photo_ids) > 0 {
timeout := 3000
notification.BroadcastNotification(&models.Notification{
Key: utils.GenerateToken(),
Type: models.NotificationTypeMessage,
Header: "Deleted old photos",
Content: fmt.Sprintf("Deleted %d albums and %d photos, that was not found on disk", len(deleted_album_ids), len(deleted_photo_ids)),
Timeout: &timeout,
})
}
}
func ScannerError(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("ERROR: %s", message)
notification.BroadcastNotification(&models.Notification{
Key: utils.GenerateToken(),
Type: models.NotificationTypeMessage,
Header: "Scanner error",
Content: message,
Negative: true,
})
}
func PhotoCache() string {
photoCache := os.Getenv("PHOTO_CACHE")
if photoCache == "" {
photoCache = "./photo_cache"
}
return photoCache
}

75
api/scanner/cache.go Normal file
View File

@ -0,0 +1,75 @@
package scanner
import (
"path"
"sync"
)
type AlbumScannerCache struct {
path_contains_photos map[string]bool
photo_types map[string]ImageType
mutex sync.Mutex
}
func MakeAlbumCache() *AlbumScannerCache {
return &AlbumScannerCache{
path_contains_photos: make(map[string]bool),
photo_types: make(map[string]ImageType),
}
}
// Insert single album directory in cache
func (c *AlbumScannerCache) InsertAlbumPath(path string, contains_photo bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.path_contains_photos[path] = contains_photo
}
// Insert album path and all parent directories up to the given root directory in cache
func (c *AlbumScannerCache) InsertAlbumPaths(end_path string, root string, contains_photo bool) {
curr_path := path.Clean(end_path)
root_path := path.Clean(root)
c.mutex.Lock()
defer c.mutex.Unlock()
for curr_path != root_path || curr_path == "." {
c.path_contains_photos[curr_path] = contains_photo
curr_path = path.Dir(curr_path)
}
}
func (c *AlbumScannerCache) AlbumContainsPhotos(path string) *bool {
c.mutex.Lock()
defer c.mutex.Unlock()
contains_photo, found := c.path_contains_photos[path]
if found {
// log.Printf("Album cache hit: %s\n", path)
return &contains_photo
}
return nil
}
func (c *AlbumScannerCache) InsertPhotoType(path string, content_type ImageType) {
c.mutex.Lock()
defer c.mutex.Unlock()
(c.photo_types)[path] = content_type
}
func (c *AlbumScannerCache) GetPhotoType(path string) *ImageType {
c.mutex.Lock()
defer c.mutex.Unlock()
result, found := c.photo_types[path]
if found {
// log.Printf("Image cache hit: %s\n", path)
return &result
}
return nil
}

View File

@ -234,8 +234,8 @@ func getImageType(path string) (*ImageType, error) {
return nil, nil
}
func isPathImage(path string, cache *scanner_cache) bool {
if cache.get_photo_type(path) != nil {
func isPathImage(path string, cache *AlbumScannerCache) bool {
if cache.GetPhotoType(path) != nil {
return true
}
@ -252,7 +252,7 @@ func isPathImage(path string, cache *scanner_cache) bool {
return false
}
cache.insert_photo_type(path, *imageType)
cache.InsertPhotoType(path, *imageType)
return true
}

View File

@ -43,41 +43,43 @@ func makePhotoURLChecker(tx *sql.Tx, photoID int) (func(purpose models.PhotoPurp
}, nil
}
func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error {
func ProcessPhoto(tx *sql.Tx, photo *models.Photo) (bool, error) {
log.Printf("Processing photo: %s\n", photo.Path)
didProcess := false
imageData := EncodeImageData{
photo: photo,
}
photoUrlFromDB, err := makePhotoURLChecker(tx, photo.PhotoID)
if err != nil {
return err
return false, err
}
// original photo url
origURL, err := photoUrlFromDB(models.PhotoOriginal)
if err != nil {
return err
return false, err
}
// Thumbnail
thumbURL, err := photoUrlFromDB(models.PhotoThumbnail)
if err != nil {
return errors.Wrap(err, "error processing thumbnail")
return false, errors.Wrap(err, "error processing thumbnail")
}
// Highres
highResURL, err := photoUrlFromDB(models.PhotoHighRes)
if err != nil {
return errors.Wrap(err, "error processing highres")
return false, errors.Wrap(err, "error processing highres")
}
// Make sure photo cache directory exists
photoCachePath, err := makePhotoCacheDir(photo)
if err != nil {
return errors.Wrap(err, "cache directory error")
return false, errors.Wrap(err, "cache directory error")
}
// Generate high res jpeg
@ -88,10 +90,12 @@ func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error {
contentType, err := imageData.ContentType()
if err != nil {
return err
return false, err
}
if !contentType.isWebCompatible() {
didProcess = true
highres_name := fmt.Sprintf("highres_%s_%s", path.Base(photo.Path), utils.GenerateToken())
highres_name = strings.ReplaceAll(highres_name, ".", "_")
highres_name = strings.ReplaceAll(highres_name, " ", "_")
@ -101,19 +105,19 @@ func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error {
err = imageData.EncodeHighRes(tx, baseImagePath)
if err != nil {
return errors.Wrap(err, "creating high-res cached image")
return false, errors.Wrap(err, "creating high-res cached image")
}
photoDimensions, err = GetPhotoDimensions(baseImagePath)
if err != nil {
return err
return false, err
}
_, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)",
photo.PhotoID, highres_name, photoDimensions.Width, photoDimensions.Height, models.PhotoHighRes, "image/jpeg")
if err != nil {
log.Printf("Could not insert highres photo url: %d, %s\n", photo.PhotoID, path.Base(photo.Path))
return err
return false, err
}
}
} else {
@ -122,31 +126,36 @@ func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error {
if _, err := os.Stat(baseImagePath); os.IsNotExist(err) {
fmt.Printf("High-res photo found in database but not in cache, re-encoding photo to cache: %s\n", highResURL.PhotoName)
didProcess = true
err = imageData.EncodeHighRes(tx, baseImagePath)
if err != nil {
return errors.Wrap(err, "creating high-res cached image")
return false, errors.Wrap(err, "creating high-res cached image")
}
}
}
// Save original photo to database
if origURL == nil {
didProcess = true
// Make sure photo dimensions is set
if photoDimensions == nil {
photoDimensions, err = GetPhotoDimensions(baseImagePath)
if err != nil {
return err
return false, err
}
}
if err = saveOriginalPhotoToDB(tx, photo, imageData, photoDimensions); err != nil {
return errors.Wrap(err, "saving original photo to database")
return false, errors.Wrap(err, "saving original photo to database")
}
}
// Save thumbnail to cache
if thumbURL == nil {
didProcess = true
thumbnail_name := fmt.Sprintf("thumbnail_%s_%s", path.Base(photo.Path), utils.GenerateToken())
thumbnail_name = strings.ReplaceAll(thumbnail_name, ".", "_")
thumbnail_name = strings.ReplaceAll(thumbnail_name, " ", "_")
@ -161,28 +170,29 @@ func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error {
thumbSize, err := EncodeThumbnail(baseImagePath, thumbOutputPath)
if err != nil {
return errors.Wrap(err, "could not create thumbnail cached image")
return false, errors.Wrap(err, "could not create thumbnail cached image")
}
_, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)", photo.PhotoID, thumbnail_name, thumbSize.Width, thumbSize.Height, models.PhotoThumbnail, "image/jpeg")
if err != nil {
return err
return false, err
}
} else {
// Verify that thumbnail photo still exists in cache
thumbPath := path.Join(*photoCachePath, thumbURL.PhotoName)
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
didProcess = true
fmt.Printf("Thumbnail photo found in database but not in cache, re-encoding photo to cache: %s\n", thumbURL.PhotoName)
_, err := EncodeThumbnail(baseImagePath, thumbPath)
if err != nil {
return errors.Wrap(err, "could not create thumbnail cached image")
return false, errors.Wrap(err, "could not create thumbnail cached image")
}
}
}
return nil
return didProcess, nil
}
func makePhotoCacheDir(photo *models.Photo) (*string, error) {

186
api/scanner/queue.go Normal file
View File

@ -0,0 +1,186 @@
package scanner
import (
"database/sql"
"fmt"
"log"
"sync"
"time"
"github.com/pkg/errors"
"github.com/viktorstrate/photoview/api/graphql/models"
"github.com/viktorstrate/photoview/api/graphql/notification"
"github.com/viktorstrate/photoview/api/utils"
)
type ScannerJob struct {
album *models.Album
cache *AlbumScannerCache
}
func (job *ScannerJob) Run(db *sql.DB) {
scanAlbum(job.album, job.cache, db)
}
type ScannerQueueSettings struct {
max_concurrent_tasks int
}
type ScannerQueue struct {
mutex sync.Mutex
idle_chan chan bool
in_progress []ScannerJob
up_next []ScannerJob
db *sql.DB
settings ScannerQueueSettings
}
var global_scanner_queue ScannerQueue
func InitializeScannerQueue(db *sql.DB) {
global_scanner_queue = ScannerQueue{
idle_chan: make(chan bool, 1),
in_progress: make([]ScannerJob, 0),
up_next: make([]ScannerJob, 0),
db: db,
settings: ScannerQueueSettings{max_concurrent_tasks: 3},
}
go global_scanner_queue.startBackgroundWorker()
}
func (queue *ScannerQueue) startBackgroundWorker() {
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
for {
log.Println("Queue waiting")
<-queue.idle_chan
log.Println("Queue waiting for lock")
queue.mutex.Lock()
log.Println("Queue running")
for len(queue.in_progress) < queue.settings.max_concurrent_tasks && len(queue.up_next) > 0 {
log.Println("Queue starting job")
nextJob := queue.up_next[0]
queue.up_next = queue.up_next[1:]
queue.in_progress = append(queue.in_progress, nextJob)
go func() {
log.Println("Starting job")
nextJob.Run(queue.db)
log.Println("Job finished")
// Delete finished job from queue
queue.mutex.Lock()
for i, x := range queue.in_progress {
if x == nextJob {
queue.in_progress[i] = queue.in_progress[len(queue.in_progress)-1]
queue.in_progress = queue.in_progress[0 : len(queue.in_progress)-1]
break
}
}
queue.mutex.Unlock()
queue.notify()
}()
}
in_progress_length := len(global_scanner_queue.in_progress)
up_next_length := len(global_scanner_queue.up_next)
queue.mutex.Unlock()
if in_progress_length+up_next_length == 0 {
notification.BroadcastNotification(&models.Notification{
Key: "global-scanner-progress",
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Scanner complete"),
Content: fmt.Sprintf("All jobs have been scanned"),
Positive: true,
})
} else {
notifyThrottle.Trigger(func() {
notification.BroadcastNotification(&models.Notification{
Key: "global-scanner-progress",
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Scanning photos"),
Content: fmt.Sprintf("%d jobs in progress\n%d jobs waiting", in_progress_length, up_next_length),
})
})
}
}
}
// Notifies the queue that the jobs has changed
func (queue *ScannerQueue) notify() bool {
select {
case queue.idle_chan <- true:
return true
default:
return false
}
}
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")
}
for _, user := range users {
AddUserToQueue(user)
}
return nil
}
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)
}
global_scanner_queue.mutex.Lock()
for _, album := range albums {
global_scanner_queue.addJob(&ScannerJob{
album: album,
cache: album_cache,
})
}
global_scanner_queue.mutex.Unlock()
return nil
}
// Queue should be locked prior to calling this function
func (queue *ScannerQueue) addJob(job *ScannerJob) error {
if exists, err := queue.jobOnQueue(job); exists || err != nil {
return err
}
queue.up_next = append(queue.up_next, *job)
queue.notify()
return nil
}
// Queue should be locked prior to calling this function
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 {
return true, nil
}
}
return false, nil
}

95
api/scanner/queue_test.go Normal file
View File

@ -0,0 +1,95 @@
package scanner
import (
"testing"
"github.com/viktorstrate/photoview/api/graphql/models"
)
func TestScannerQueue_AddJob(t *testing.T) {
scannerJobs := []ScannerJob{
{album: &models.Album{AlbumID: 100}, cache: MakeAlbumCache()},
{album: &models.Album{AlbumID: 20}, cache: MakeAlbumCache()},
}
mockScannerQueue := ScannerQueue{
idle_chan: make(chan bool, 1),
in_progress: make([]ScannerJob, 0),
up_next: scannerJobs,
db: nil,
}
t.Run("add new job to scanner queue", func(t *testing.T) {
newJob := ScannerJob{album: &models.Album{AlbumID: 42}, cache: MakeAlbumCache()}
startingJobs := len(mockScannerQueue.up_next)
err := mockScannerQueue.addJob(&newJob)
if err != nil {
t.Errorf(".AddJob() returned an unexpected error: %s", err)
}
if len(mockScannerQueue.up_next) != startingJobs+1 {
t.Errorf("Expected scanner queue length to be %d but got %d", startingJobs+1, len(mockScannerQueue.up_next))
} else if mockScannerQueue.up_next[len(mockScannerQueue.up_next)-1] != newJob {
t.Errorf("Expected scanner queue to contain the job that was added: %+v", newJob)
}
})
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()})
if err != nil {
t.Errorf(".AddJob() returned an unexpected error: %s", err)
}
if len(mockScannerQueue.up_next) != startingJobs {
t.Errorf("Expected scanner queue length not to change: start length %d, new length %d", startingJobs, len(mockScannerQueue.up_next))
}
})
}
func TestScannerQueue_JobOnQueue(t *testing.T) {
scannerJobs := []ScannerJob{
{album: &models.Album{AlbumID: 100}, cache: MakeAlbumCache()},
{album: &models.Album{AlbumID: 20}, cache: MakeAlbumCache()},
}
mockScannerQueue := ScannerQueue{
idle_chan: make(chan bool, 1),
in_progress: make([]ScannerJob, 0),
up_next: scannerJobs,
db: nil,
}
onQueueTests := []struct {
string
bool
ScannerJob
}{
{"album which owner is already on the queue", true, ScannerJob{
album: &models.Album{AlbumID: 100}, cache: MakeAlbumCache(),
}},
{"album that is not on the queue", false, ScannerJob{
album: &models.Album{AlbumID: 321}, cache: MakeAlbumCache(),
}},
}
for _, test := range onQueueTests {
t.Run(test.string, func(t *testing.T) {
onQueue, err := mockScannerQueue.jobOnQueue(&test.ScannerJob)
if err != nil {
t.Error("Expected jobOnQueue not to return an error")
} else if onQueue != test.bool {
t.Fail()
}
})
}
}

View File

@ -0,0 +1,118 @@
package scanner
import (
"database/sql"
"fmt"
"io/ioutil"
"path"
"time"
"github.com/viktorstrate/photoview/api/graphql/models"
"github.com/viktorstrate/photoview/api/graphql/notification"
"github.com/viktorstrate/photoview/api/utils"
)
func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB) {
album_notify_key := utils.GenerateToken()
notifyThrottle := utils.NewThrottle(500 * time.Millisecond)
notifyThrottle.Trigger(nil)
// Scan for photos
albumPhotos, err := findPhotosForAlbum(album, cache, db, func(photo *models.Photo, newPhoto bool) {
if newPhoto {
notifyThrottle.Trigger(func() {
notification.BroadcastNotification(&models.Notification{
Key: album_notify_key,
Type: models.NotificationTypeMessage,
Header: fmt.Sprintf("Found new photos in album '%s'", album.Title),
Content: fmt.Sprintf("Found photo %s", photo.Path),
})
})
}
})
if err != nil {
ScannerError("Failed to find photos for album (%s): %s", album.Path, err)
}
album_has_changes := false
for count, photo := range albumPhotos {
tx, err := db.Begin()
if err != nil {
ScannerError("Failed to begin database transaction: %s", err)
}
processing_was_needed, err := ProcessPhoto(tx, photo)
if err != nil {
tx.Rollback()
ScannerError("Failed to process photo (%s): %s", photo.Path, err)
continue
}
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 photo for album '%s'", album.Title),
Content: fmt.Sprintf("Processed photo at %s", photo.Path),
Progress: &progress,
})
}
err = tx.Commit()
if err != nil {
ScannerError("Failed to commit database transaction: %s", err)
}
}
if album_has_changes {
timeoutDelay := 2000
notification.BroadcastNotification(&models.Notification{
Key: album_notify_key,
Type: models.NotificationTypeMessage,
Positive: true,
Header: fmt.Sprintf("Done processing photos for album '%s'", album.Title),
Content: fmt.Sprintf("All photos have been processed"),
Timeout: &timeoutDelay,
})
}
}
func findPhotosForAlbum(album *models.Album, cache *AlbumScannerCache, db *sql.DB, onScanPhoto func(photo *models.Photo, newPhoto bool)) ([]*models.Photo, error) {
albumPhotos := make([]*models.Photo, 0)
dirContent, err := ioutil.ReadDir(album.Path)
if err != nil {
return nil, err
}
for _, item := range dirContent {
photoPath := path.Join(album.Path, item.Name())
if !item.IsDir() && isPathImage(photoPath, cache) {
tx, err := db.Begin()
if err != nil {
ScannerError("Could not begin database transaction for image %s: %s\n", photoPath, err)
continue
}
photo, isNewPhoto, err := ScanPhoto(tx, photoPath, album.AlbumID)
if err != nil {
ScannerError("Scanning image error (%s): %s", photoPath, err)
tx.Rollback()
continue
}
onScanPhoto(photo, isNewPhoto)
albumPhotos = append(albumPhotos, photo)
tx.Commit()
}
}
return albumPhotos, nil
}

View File

@ -8,10 +8,7 @@ import (
"github.com/viktorstrate/photoview/api/graphql/models"
)
func ScanPhoto(tx *sql.Tx, photoPath string, albumId int, notificationKey string) (*models.Photo, bool, error) {
log.Printf("Scanning image: %s\n", photoPath)
func ScanPhoto(tx *sql.Tx, photoPath string, albumId int) (*models.Photo, bool, error) {
photoName := path.Base(photoPath)
// Check if image already exists
@ -28,6 +25,8 @@ func ScanPhoto(tx *sql.Tx, photoPath string, albumId int, notificationKey string
}
}
log.Printf("Scanning image: %s\n", photoPath)
result, err := tx.Exec("INSERT INTO photo (title, path, path_hash, album_id) VALUES (?, ?, MD5(path), ?)", photoName, photoPath, albumId)
if err != nil {
log.Printf("ERROR: Could not insert photo into database")

290
api/scanner/scanner_user.go Normal file
View File

@ -0,0 +1,290 @@
package scanner
import (
"container/list"
"database/sql"
"fmt"
"io/ioutil"
"log"
"os"
"path"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/viktorstrate/photoview/api/graphql/models"
"github.com/viktorstrate/photoview/api/graphql/notification"
"github.com/viktorstrate/photoview/api/utils"
)
func findAlbumsForUser(db *sql.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)}
}
}
type scanInfo struct {
path string
parentId *int
}
scanQueue := list.New()
scanQueue.PushBack(scanInfo{
path: user.RootPath,
parentId: 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
// Read path
dirContent, err := ioutil.ReadDir(albumPath)
if err != nil {
albumErrors = append(albumErrors, 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
}
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) VALUES (?, ?, ?, ?)", albumTitle, albumParentId, user.UserID, albumPath)
if err != nil {
albumErrors = append(albumErrors, errors.Wrap(err, "insert album into database"))
tx.Rollback()
continue
}
row := tx.QueryRow("SELECT * FROM album WHERE path = ?", 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)
// Commit album transaction
if err := tx.Commit(); err != nil {
albumErrors = append(albumErrors, errors.Wrap(err, "commit database transaction"))
continue
}
// Scan for sub-albums
for _, item := range dirContent {
subalbumPath := path.Join(albumPath, item.Name())
// Skip if directory is hidden
if path.Base(subalbumPath)[0:1] == "." {
continue
}
if item.IsDir() && directoryContainsPhotos(subalbumPath, album_cache) {
scanQueue.PushBack(scanInfo{
path: subalbumPath,
parentId: &album.AlbumID,
})
}
}
}
deleteErrors := deleteOldUserAlbums(db, userAlbums, user)
albumErrors = append(albumErrors, deleteErrors...)
return userAlbums, albumErrors
}
func directoryContainsPhotos(rootPath string, cache *AlbumScannerCache) bool {
if contains_image := cache.AlbumContainsPhotos(rootPath); contains_image != nil {
return *contains_image
}
scanQueue := list.New()
scanQueue.PushBack(rootPath)
scanned_directories := make([]string, 0)
for scanQueue.Front() != nil {
dirPath := scanQueue.Front().Value.(string)
scanQueue.Remove(scanQueue.Front())
scanned_directories = append(scanned_directories, dirPath)
dirContent, err := ioutil.ReadDir(dirPath)
if err != nil {
ScannerError("Could not read directory: %s\n", err.Error())
return false
}
for _, fileInfo := range dirContent {
filePath := path.Join(dirPath, fileInfo.Name())
if fileInfo.IsDir() {
scanQueue.PushBack(filePath)
} else {
if isPathImage(filePath, cache) {
cache.InsertAlbumPaths(dirPath, rootPath, true)
return true
}
}
}
}
for _, scanned_path := range scanned_directories {
cache.InsertAlbumPath(scanned_path, false)
}
return false
}
func deleteOldUserAlbums(db *sql.DB, scannedAlbums []*models.Album, user *models.User) []error {
if len(scannedAlbums) == 0 {
return nil
}
albumPaths := make([]interface{}, len(scannedAlbums))
for i, album := range scannedAlbums {
albumPaths[i] = album.Path
}
// Delete old albums
album_args := make([]interface{}, 0)
album_args = append(album_args, user.UserID)
album_args = append(album_args, albumPaths...)
albums_questions := strings.Repeat("?,", len(albumPaths))[:len(albumPaths)*2-1]
rows, err := db.Query("SELECT album_id FROM album WHERE album.owner_id = ? AND path NOT IN ("+albums_questions+")", album_args...)
if 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)
if err != nil {
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cache_path))
}
}
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"))
}
}
return deleteErrors
}
// func cleanupCache(database *sql.DB, cache *ScannerCache, user *models.User) {
// // Delete old photos
// photo_args := make([]interface{}, 0)
// photo_args = append(photo_args, user.UserID)
// photo_args = append(photo_args, cache.photo_paths_scanned...)
// photo_questions := strings.Repeat("?,", len(cache.photo_paths_scanned))[:len(cache.photo_paths_scanned)*2-1]
// rows, err = database.Query(`
// SELECT photo.photo_id as photo_id, album.album_id as album_id FROM photo JOIN album ON photo.album_id = album.album_id
// WHERE album.owner_id = ? AND photo.path NOT IN (`+photo_questions+`)
// `, photo_args...)
// if err != nil {
// ScannerError("Could not get deleted photos from database: %s\n", err)
// return
// }
// defer rows.Close()
// deleted_photo_ids := make([]interface{}, 0)
// for rows.Next() {
// var photo_id int
// var album_id int
// if err := rows.Scan(&photo_id, &album_id); err != nil {
// ScannerError("Could not parse photo to be removed (album_id %d, photo_id %d): %s\n", album_id, photo_id, err)
// }
// deleted_photo_ids = append(deleted_photo_ids, photo_id)
// cache_path := path.Join("./photo_cache", strconv.Itoa(album_id), strconv.Itoa(photo_id))
// err := os.RemoveAll(cache_path)
// if err != nil {
// ScannerError("Could not delete unused cache photo folder: %s\n%s\n", cache_path, err)
// }
// }
// if len(deleted_photo_ids) > 0 {
// photo_questions = strings.Repeat("?,", len(deleted_photo_ids))[:len(deleted_photo_ids)*2-1]
// if _, err := database.Exec("DELETE FROM photo WHERE photo_id IN ("+photo_questions+")", deleted_photo_ids...); err != nil {
// ScannerError("Could not delete old photos from database:\n%s\n", err)
// }
// }
// if len(deleted_album_ids) > 0 || len(deleted_photo_ids) > 0 {
// timeout := 3000
// notification.BroadcastNotification(&models.Notification{
// Key: utils.GenerateToken(),
// Type: models.NotificationTypeMessage,
// Header: "Deleted old photos",
// Content: fmt.Sprintf("Deleted %d albums and %d photos, that was not found on disk", len(deleted_album_ids), len(deleted_photo_ids)),
// Timeout: &timeout,
// })
// }
// }
func ScannerError(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
log.Printf("ERROR: %s", message)
notification.BroadcastNotification(&models.Notification{
Key: utils.GenerateToken(),
Type: models.NotificationTypeMessage,
Header: "Scanner error",
Content: message,
Negative: true,
})
}
func PhotoCache() string {
photoCache := os.Getenv("PHOTO_CACHE")
if photoCache == "" {
photoCache = "./photo_cache"
}
return photoCache
}

View File

@ -13,6 +13,7 @@ import (
"github.com/viktorstrate/photoview/api/database"
"github.com/viktorstrate/photoview/api/graphql/auth"
"github.com/viktorstrate/photoview/api/routes"
"github.com/viktorstrate/photoview/api/scanner"
"github.com/viktorstrate/photoview/api/server"
"github.com/viktorstrate/photoview/api/utils"
@ -40,6 +41,8 @@ func main() {
log.Panicf("Could not migrate database: %s\n", err)
}
scanner.InitializeScannerQueue(db)
rootRouter := mux.NewRouter()
rootRouter.Use(auth.Middleware(db))

View File

@ -14,7 +14,7 @@ func CORSMiddleware(devMode bool) mux.MiddlewareFunc {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
methods := []string{http.MethodGet, http.MethodPost, http.MethodOptions}
headers := []string{"authorization", "content-type", "content-length"}
headers := []string{"authorization", "content-type", "content-length", "TokenPassword"}
w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ","))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ","))

View File

@ -15,6 +15,9 @@ func NewThrottle(interval time.Duration) Throttle {
}
func (t *Throttle) Trigger(action func()) {
if action == nil {
return
}
if time.Now().After(t.lastAction.Add(t.interval)) {
t.lastAction = time.Now()
action()

4442
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,9 @@
"license": "GPL-3.0",
"description": "UI app for Photoview",
"dependencies": {
"@babel/preset-env": "^7.9.5",
"apollo-cache-inmemory": "^1.6.3",
"apollo-client": "^2.6.4",
"@babel/preset-env": "^7.10.2",
"apollo-cache-inmemory": "^1.6.6",
"apollo-client": "^2.6.10",
"apollo-link": "^1.2.14",
"apollo-link-context": "^1.0.20",
"apollo-link-error": "^1.1.13",
@ -15,20 +15,20 @@
"babel-plugin-styled-components": "^1.10.7",
"copy-to-clipboard": "^3.3.1",
"downloadjs": "^1.4.7",
"graphql": "^15.0.0",
"graphql": "^15.1.0",
"graphql-tag": "^2.10.3",
"parcel-bundler": "^1.12.4",
"prop-types": "^15.7.2",
"react": "^16.13.1",
"react-apollo": "^3.1.5",
"react-dom": "^16.13.1",
"react-helmet": "^6.0.0",
"react-lazyload": "^2.6.7",
"react-router-dom": "^5.1.2",
"react-helmet": "^6.1.0",
"react-lazyload": "^2.6.8",
"react-router-dom": "^5.2.0",
"react-spring": "^8.0.27",
"semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^0.88.0",
"styled-components": "^5.1.0",
"semantic-ui-react": "^0.88.2",
"styled-components": "^5.1.1",
"subscriptions-transport-ws": "^0.9.16",
"url-join": "^4.0.1"
},
@ -37,16 +37,16 @@
"build": "parcel build src/index.html --no-source-maps"
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/plugin-transform-runtime": "^7.9.0",
"@babel/core": "^7.10.2",
"@babel/plugin-transform-runtime": "^7.10.1",
"babel-eslint": "^10.1.0",
"babel-plugin-graphql-tag": "^2.5.0",
"babel-plugin-transform-semantic-ui-react-imports": "^1.4.1",
"eslint": "^6.8.0",
"eslint-plugin-react": "^7.19.0",
"eslint-plugin-react-hooks": "^3.0.0",
"eslint": "^7.2.0",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.4",
"husky": "^4.2.5",
"lint-staged": "^10.1.7",
"lint-staged": "^10.2.10",
"parcel-plugin-sw-cache": "^0.3.1",
"prettier": "^2.0.5",
"react-router-prop-types": "^1.0.4"

View File

@ -9,12 +9,18 @@ const GlobalStyle = createGlobalStyle`
height: 100%;
margin: 0;
}
/* Make dimmer lighter */
.ui.dimmer {
background-color: rgba(0, 0, 0, 0.5);
}
`
import 'semantic-ui-css/components/reset.css'
import 'semantic-ui-css/components/site.css'
import 'semantic-ui-css/components/transition.css'
import 'semantic-ui-css/components/menu.css'
import 'semantic-ui-css/components/dimmer.css'
class App extends Component {
render() {

View File

@ -28,6 +28,7 @@ const albumQuery = gql`
highRes {
url
}
favorite
}
}
}

View File

@ -23,6 +23,7 @@ const photoQuery = gql`
highRes {
url
}
favorite
}
}
}

View File

@ -1,14 +1,25 @@
import React from 'react'
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import RouterProps from 'react-router-prop-types'
import { Route, Switch } from 'react-router-dom'
import AlbumSharePage from './AlbumSharePage'
import PhotoSharePage from './PhotoSharePage'
import { Query } from 'react-apollo'
import { useQuery } from 'react-apollo'
import gql from 'graphql-tag'
import {
Container,
Header,
Form,
Button,
Input,
Icon,
Message,
} from 'semantic-ui-react'
const tokenQuery = gql`
query SharePageToken($token: String!) {
shareToken(token: $token) {
const shareTokenQuery = gql`
query SharePageToken($token: String!, $password: String) {
shareToken(token: $token, password: $password) {
token
album {
...AlbumProps
@ -70,32 +81,153 @@ const tokenQuery = gql`
}
`
const validateTokenPasswordQuery = gql`
query ShareTokenValidatePassword($token: String!, $password: String) {
shareTokenValidatePassword(token: $token, password: $password)
}
`
const AuthorizedTokenRoute = ({ match }) => {
const token = match.params.token
const { loading, error, data } = useQuery(shareTokenQuery, {
variables: {
token,
password: sessionStorage.getItem(`share-token-pw-${token}`),
},
})
if (error) return error.message
if (loading) return 'Loading...'
if (data.shareToken.album) {
return <AlbumSharePage album={data.shareToken.album} match={match} />
}
if (data.shareToken.photo) {
return <PhotoSharePage photo={data.shareToken.photo} />
}
return <h1>Share not found</h1>
}
AuthorizedTokenRoute.propTypes = {
match: PropTypes.object.isRequired,
}
const MessageContainer = styled.div`
max-width: 400px;
margin: 100px auto 0;
`
const ProtectedTokenEnterPassword = ({
match,
refetchWithPassword,
loading = false,
}) => {
const [passwordValue, setPasswordValue] = useState('')
const [invalidPassword, setInvalidPassword] = useState(false)
const onSubmit = () => {
refetchWithPassword(passwordValue)
setInvalidPassword(true)
}
let errorMessage = null
if (invalidPassword && !loading) {
errorMessage = (
<Message negative>
<Message.Content>Wrong password, please try again.</Message.Content>
</Message>
)
}
return (
<MessageContainer>
<Header as="h1" style={{ fontWeight: 400 }}>
Protected share
</Header>
<p>This share is protected with a password.</p>
<Form>
<Form.Field>
<label>Password</label>
<Input
loading={loading}
disabled={loading}
onKeyUp={event => event.key == 'Enter' && onSubmit()}
onChange={e => setPasswordValue(e.target.value)}
placeholder="Password"
type="password"
icon={<Icon onClick={onSubmit} link name="arrow right" />}
/>
</Form.Field>
{errorMessage}
</Form>
</MessageContainer>
)
}
ProtectedTokenEnterPassword.propTypes = {
match: PropTypes.object.isRequired,
refetchWithPassword: PropTypes.func.isRequired,
loading: PropTypes.bool,
}
const TokenRoute = ({ match }) => {
const token = match.params.token
const { loading, error, data, refetch } = useQuery(
validateTokenPasswordQuery,
{
variables: {
token: match.params.token,
password: sessionStorage.getItem(`share-token-pw-${token}`),
},
}
)
if (error) {
if (error.message == 'GraphQL error: share not found') {
return (
<MessageContainer>
<h1>Share not found</h1>
<p>Maybe the share has expired or has been deleted.</p>
</MessageContainer>
)
}
return error.message
}
if (data && data.shareTokenValidatePassword == false) {
return (
<ProtectedTokenEnterPassword
match={match}
refetchWithPassword={password => {
sessionStorage.setItem(`share-token-pw-${token}`, password)
refetch({ variables: { password: password } })
}}
loading={loading}
/>
)
}
if (loading) return 'Loading...'
return <AuthorizedTokenRoute match={match} />
}
TokenRoute.propTypes = {
match: PropTypes.object.isRequired,
}
const SharePage = ({ match }) => {
return (
<Switch>
<Route path={`${match.url}/:token`}>
{({ match }) => (
<Query query={tokenQuery} variables={{ token: match.params.token }}>
{({ loading, error, data }) => {
if (error) return error.message
if (loading) return 'Loading...'
if (data.shareToken.album) {
return (
<AlbumSharePage album={data.shareToken.album} match={match} />
)
}
if (data.shareToken.photo) {
return <PhotoSharePage photo={data.shareToken.photo} />
}
return <h1>Share not found</h1>
}}
</Query>
)}
{({ match }) => <TokenRoute match={match} />}
</Route>
<Route path="/">Share not found</Route>
<Route path="/">Route not found</Route>
</Switch>
)
}

View File

@ -1,10 +1,21 @@
import React, { useState } from 'react'
import gql from 'graphql-tag'
import { useMutation } from 'react-apollo'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import LazyLoad from 'react-lazyload'
import { Icon } from 'semantic-ui-react'
import ProtectedImage from './ProtectedImage'
const markFavoriteMutation = gql`
mutation markPhotoFavorite($photoId: Int!, $favorite: Boolean!) {
favoritePhoto(photoId: $photoId, favorite: $favorite) {
id
favorite
}
}
`
const PhotoContainer = styled.div`
flex-grow: 1;
flex-basis: 0;
@ -78,7 +89,7 @@ const PhotoOverlay = styled.div`
const HoverIcon = styled(Icon)`
font-size: 1.5em !important;
margin: 160px 0 0 10px !important;
margin: 160px 10px 0 10px !important;
color: white !important;
text-shadow: 0 0 4px black;
opacity: 0 !important;
@ -87,7 +98,7 @@ const HoverIcon = styled(Icon)`
border-radius: 50%;
width: 34px !important;
height: 34px !important;
padding-top: 8px;
padding-top: 7px;
${PhotoContainer}:hover & {
opacity: 1 !important;
@ -100,6 +111,11 @@ const HoverIcon = styled(Icon)`
transition: opacity 100ms, background-color 100ms;
`
const FavoriteIcon = styled(HoverIcon)`
float: right;
opacity: ${({ favorite }) => (favorite ? '0.8' : '0.2')} !important;
`
export const Photo = ({
photo,
onSelectImage,
@ -107,29 +123,59 @@ export const Photo = ({
index,
active,
setPresenting,
}) => (
<PhotoContainer
key={photo.id}
style={{
cursor: onSelectImage ? 'pointer' : null,
minWidth: `min(${minWidth}px, 100% - 8px)`,
}}
onClick={() => {
onSelectImage && onSelectImage(index)
}}
>
<LazyPhoto src={photo.thumbnail && photo.thumbnail.url} />
<PhotoOverlay active={active}>
<HoverIcon
name="expand"
onClick={() => {
setPresenting(true)
}) => {
const [markFavorite] = useMutation(markFavoriteMutation)
let heartIcon = null
if (typeof photo.favorite == 'boolean') {
heartIcon = (
<FavoriteIcon
favorite={photo.favorite}
name={photo.favorite ? 'heart' : 'heart outline'}
onClick={event => {
event.stopPropagation()
markFavorite({
variables: {
photoId: photo.id,
favorite: !photo.favorite,
},
optimisticResponse: {
favoritePhoto: {
id: photo.id,
favorite: !photo.favorite,
__typename: 'Photo',
},
},
})
}}
/>
<HoverIcon name="heart outline" />
</PhotoOverlay>
</PhotoContainer>
)
)
}
return (
<PhotoContainer
key={photo.id}
style={{
cursor: onSelectImage ? 'pointer' : null,
minWidth: `min(${minWidth}px, 100% - 8px)`,
}}
onClick={() => {
onSelectImage && onSelectImage(index)
}}
>
<LazyPhoto src={photo.thumbnail && photo.thumbnail.url} />
<PhotoOverlay active={active}>
<HoverIcon
name="expand"
onClick={() => {
setPresenting(true)
}}
/>
{heartIcon}
</PhotoOverlay>
</PhotoContainer>
)
}
Photo.propTypes = {
photo: PropTypes.object.isRequired,

View File

@ -3,13 +3,18 @@ import PropTypes from 'prop-types'
let imageCache = {}
export async function fetchProtectedImage(src, { signal } = { signal: null }) {
export async function fetchProtectedImage(
src,
{ signal, headers: customHeaders } = { signal: null, headers: null }
) {
if (src) {
if (imageCache[src]) {
return imageCache[src]
}
let headers = {}
let headers = {
...customHeaders,
}
if (localStorage.getItem('token')) {
headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
}
@ -44,16 +49,27 @@ const ProtectedImage = ({ src, ...props }) => {
setImgSrc('')
const imgUrl = new URL(src)
const fetchHeaders = {}
if (localStorage.getItem('token') == null) {
// Get share token if not authorized
const token = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
if (token) {
imgUrl.searchParams.set('token', token[1])
const tokenRegex = location.pathname.match(/^\/share\/([\d\w]+)(\/?.*)$/)
if (tokenRegex) {
const token = tokenRegex[1]
imgUrl.searchParams.set('token', token)
const tokenPassword = sessionStorage.getItem(`share-token-pw-${token}`)
if (tokenPassword) {
fetchHeaders['TokenPassword'] = tokenPassword
}
}
}
fetchProtectedImage(imgUrl.href, { signal: fetchController.signal })
fetchProtectedImage(imgUrl.href, {
signal: fetchController.signal,
headers: fetchHeaders,
})
.then(newSrc => {
if (!canceled) {
setImgSrc(newSrc)

View File

@ -1,8 +1,15 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useMutation, useQuery } from 'react-apollo'
import gql from 'graphql-tag'
import { Table, Button, Dropdown } from 'semantic-ui-react'
import {
Table,
Button,
Dropdown,
Checkbox,
Input,
Icon,
} from 'semantic-ui-react'
import copy from 'copy-to-clipboard'
const sharePhotoQuery = gql`
@ -11,6 +18,7 @@ const sharePhotoQuery = gql`
id
shares {
token
hasPassword
}
}
}
@ -22,6 +30,7 @@ const shareAlbumQuery = gql`
id
shares {
token
hasPassword
}
}
}
@ -43,6 +52,15 @@ const addAlbumShareMutation = gql`
}
`
const protectShareMutation = gql`
mutation sidebarProtectShare($token: String!, $password: String) {
protectShareToken(token: $token, password: $password) {
token
hasPassword
}
}
`
const deleteShareMutation = gql`
mutation sidebareDeleteShare($token: String!) {
deleteShareToken(token: $token) {
@ -51,6 +69,153 @@ const deleteShareMutation = gql`
}
`
const ShareItemMoreDropdown = ({ id, share, isPhoto }) => {
const query = isPhoto ? sharePhotoQuery : shareAlbumQuery
const [deleteShare, { loading: deleteShareLoading }] = useMutation(
deleteShareMutation,
{
refetchQueries: [{ query: query, variables: { id } }],
}
)
const [addingPassword, setAddingPassword] = useState(false)
const showPasswordInput = addingPassword || share.hasPassword
const [passwordInputValue, setPasswordInputValue] = useState(
share.hasPassword ? '**********' : ''
)
const [passwordHidden, setPasswordHidden] = useState(share.hasPassword)
const hidePassword = hide => {
setPasswordHidden(hide)
if (hide) {
setPasswordInputValue('**********')
}
}
const [setPassword, { loading: setPasswordLoading }] = useMutation(
protectShareMutation,
{
refetchQueries: [{ query: query, variables: { id } }],
onCompleted: data => {
console.log('data', data)
hidePassword(data.protectShareToken.hasPassword)
},
// refetchQueries: [{ query: query, variables: { id } }],
variables: {
token: share.token,
},
}
)
let addPasswordInput = null
if (showPasswordInput) {
const setPasswordEvent = event => {
if (!passwordHidden && passwordInputValue != '' && event.key == 'Enter') {
event.preventDefault()
setPassword({
variables: {
password: event.target.value,
},
})
}
}
addPasswordInput = (
<Input
disabled={setPasswordLoading}
loading={setPasswordLoading}
style={{ marginTop: 8, marginRight: 0, display: 'block' }}
onClick={e => e.stopPropagation()}
value={passwordInputValue}
type={passwordHidden ? 'password' : 'text'}
onKeyUp={setPasswordEvent}
onChange={event => {
hidePassword(false)
setPasswordInputValue(event.target.value)
}}
placeholder="Password"
icon={
<Icon
name={passwordHidden ? 'lock' : 'arrow right'}
link={!passwordHidden}
onClick={setPasswordEvent}
/>
}
/>
)
}
const checkboxClick = () => {
const enable = !showPasswordInput
setAddingPassword(enable)
if (!enable) {
setPassword({
variables: {
password: null,
},
})
setPasswordInputValue('')
}
}
// const [dropdownOpen, setDropdownOpen] = useState(false)
return (
<Dropdown
// onBlur={event => {
// console.log('Blur')
// }}
// onClick={() => setDropdownOpen(state => !state)}
// onClose={() => setDropdownOpen(false)}
// open={dropdownOpen}
button
text="More"
closeOnChange={false}
closeOnBlur={false}
>
<Dropdown.Menu>
<Dropdown.Item
onKeyDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
checkboxClick()
}}
>
<Checkbox
label="Password"
onClick={e => e.stopPropagation()}
checked={showPasswordInput}
onChange={() => {
checkboxClick()
}}
/>
{addPasswordInput}
</Dropdown.Item>
<Dropdown.Item
text="Delete"
icon="delete"
disabled={deleteShareLoading}
onClick={() => {
deleteShare({
variables: {
token: share.token,
},
})
}}
/>
</Dropdown.Menu>
</Dropdown>
)
}
ShareItemMoreDropdown.propTypes = {
id: PropTypes.number.isRequired,
isPhoto: PropTypes.bool.isRequired,
share: PropTypes.object.isRequired,
}
const SidebarShare = ({ photo, album }) => {
if ((!photo || !photo.id) && (!album || !album.id)) return null
if (!localStorage.getItem('token')) return null
@ -71,13 +236,6 @@ const SidebarShare = ({ photo, album }) => {
variables: { id },
})
const [deleteShare, { loading: deleteShareLoading }] = useMutation(
deleteShareMutation,
{
refetchQueries: [{ query: query, variables: { id } }],
}
)
const [sharePhoto, { loading: sharePhotoLoading }] = useMutation(
addShareMutation,
{
@ -112,22 +270,7 @@ const SidebarShare = ({ photo, album }) => {
copy(`${location.origin}/share/${share.token}`)
}}
/>
<Dropdown button text="More">
<Dropdown.Menu>
<Dropdown.Item
text="Delete"
icon="delete"
disabled={deleteShareLoading}
onClick={() => {
deleteShare({
variables: {
token: share.token,
},
})
}}
/>
</Dropdown.Menu>
</Dropdown>
<ShareItemMoreDropdown share={share} id={id} isPhoto={isPhoto} />
</Button.Group>
</Table.Cell>
</Table.Row>