Merge remote-tracking branch 'upstream/master' into use-hash-instead-of-full-path-to-avoid-key-length-maximum
This commit is contained in:
commit
e70da6bb26
|
@ -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 ./...
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- Add favorite attribute to photos
|
||||
ALTER TABLE photo DROP favorite
|
|
@ -0,0 +1,2 @@
|
|||
-- Add favorite attribute to photos
|
||||
ALTER TABLE photo ADD favorite BOOL DEFAULT false
|
25
api/go.mod
25
api/go.mod
|
@ -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
|
||||
)
|
||||
|
|
43
api/go.sum
43
api/go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
|
@ -143,6 +146,7 @@ type ComplexityRoot struct {
|
|||
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
|
||||
}
|
||||
|
@ -163,6 +167,7 @@ type ComplexityRoot struct {
|
|||
ShareToken struct {
|
||||
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
|
||||
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,15 +177,10 @@ 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)
|
||||
hashed_password, err := hashSharePassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashed_str := string(hashedPassBytes)
|
||||
hashed_password = &hashed_str
|
||||
}
|
||||
|
||||
token := utils.GenerateToken()
|
||||
res, err := r.Database.Exec("INSERT INTO share_token (value, owner_id, expire, password, photo_id) VALUES (?, ?, ?, ?, ?)", token, user.UserID, expire, hashed_password, photoID)
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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!]!
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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, ","))
|
||||
|
|
|
@ -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()
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -28,6 +28,7 @@ const albumQuery = gql`
|
|||
highRes {
|
||||
url
|
||||
}
|
||||
favorite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ const photoQuery = gql`
|
|||
highRes {
|
||||
url
|
||||
}
|
||||
favorite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,20 +81,27 @@ const tokenQuery = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const SharePage = ({ match }) => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${match.url}/:token`}>
|
||||
{({ match }) => (
|
||||
<Query query={tokenQuery} variables={{ token: match.params.token }}>
|
||||
{({ loading, error, data }) => {
|
||||
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} />
|
||||
)
|
||||
return <AlbumSharePage album={data.shareToken.album} match={match} />
|
||||
}
|
||||
|
||||
if (data.shareToken.photo) {
|
||||
|
@ -91,11 +109,125 @@ const SharePage = ({ match }) => {
|
|||
}
|
||||
|
||||
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 } })
|
||||
}}
|
||||
</Query>
|
||||
)}
|
||||
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 }) => <TokenRoute match={match} />}
|
||||
</Route>
|
||||
<Route path="/">Share not found</Route>
|
||||
<Route path="/">Route not found</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,7 +123,36 @@ export const Photo = ({
|
|||
index,
|
||||
active,
|
||||
setPresenting,
|
||||
}) => (
|
||||
}) => {
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PhotoContainer
|
||||
key={photo.id}
|
||||
style={{
|
||||
|
@ -126,10 +171,11 @@ export const Photo = ({
|
|||
setPresenting(true)
|
||||
}}
|
||||
/>
|
||||
<HoverIcon name="heart outline" />
|
||||
{heartIcon}
|
||||
</PhotoOverlay>
|
||||
</PhotoContainer>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Photo.propTypes = {
|
||||
photo: PropTypes.object.isRequired,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue