Delete folder `api-node-old`
Old api can be found on branch `old-nodejs-server`
This commit is contained in:
parent
6e72caf2f0
commit
2bbb1740ba
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"useBuiltIns": "usage",
|
|
||||||
"corejs": 3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"plugins": ["@babel/plugin-transform-spread"]
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
node_modules/
|
|
||||||
build/
|
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"env": {
|
|
||||||
"es6": true,
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"extends": "eslint:recommended",
|
|
||||||
"globals": {
|
|
||||||
"Atomics": "readonly",
|
|
||||||
"SharedArrayBuffer": "readonly"
|
|
||||||
},
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2018,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"require-atomic-updates": "off"
|
|
||||||
},
|
|
||||||
"parser": "babel-eslint"
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
FROM node:10
|
|
||||||
|
|
||||||
ENV PRODUCTION=1
|
|
||||||
|
|
||||||
RUN mkdir -p /app
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 4001
|
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
|
|
@ -1,54 +0,0 @@
|
||||||
# GRANDstack Starter - GraphQL API
|
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
Install dependencies:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the GraphQL service:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
This will start the GraphQL service (by default on localhost:4000) where you can issue GraphQL requests or access GraphQL Playground in the browser:
|
|
||||||
|
|
||||||
![GraphQL Playground](img/graphql-playground.png)
|
|
||||||
|
|
||||||
## Configure
|
|
||||||
|
|
||||||
Set your Neo4j connection string and credentials in `.env`. For example:
|
|
||||||
|
|
||||||
*.env*
|
|
||||||
|
|
||||||
```
|
|
||||||
NEO4J_URI=bolt://localhost:7687
|
|
||||||
NEO4J_USER=neo4j
|
|
||||||
NEO4J_PASSWORD=letmein
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that grand-stack-starter does not currently bundle a distribution of Neo4j. You can download [Neo4j Desktop](https://neo4j.com/download/) and run locally for development, spin up a [hosted Neo4j Sandbox instance](https://neo4j.com/download/), run Neo4j in one of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/), or [spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/). Just be sure to update the Neo4j connection string and credentials accordingly in `.env`.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
You can deploy to any service that hosts Node.js apps, but [Zeit Now](https://zeit.co/now) is a great easy to use service for hosting your app that has an easy to use free plan for small projects.
|
|
||||||
|
|
||||||
To deploy your GraphQL service on Zeit Now, first install [Now Desktop](https://zeit.co/download) - you'll need to provide an email address. Then run
|
|
||||||
|
|
||||||
```
|
|
||||||
now
|
|
||||||
```
|
|
||||||
|
|
||||||
to deploy your GraphQL service on Zeit Now. Once deployed you'll be given a fresh URL that represents the current state of your application where you can access your GraphQL endpoint and GraphQL Playgound. For example: https://grand-stack-starter-api-pqdeodpvok.now.sh/
|
|
||||||
|
|
||||||
## Seeding The Database
|
|
||||||
|
|
||||||
Optionally you can seed the GraphQL service by executing mutations that will write sample data to the database:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run seedDb
|
|
||||||
```
|
|
|
@ -1,11 +0,0 @@
|
||||||
# Copy this file to .env
|
|
||||||
|
|
||||||
NEO4J_URI=bolt://localhost:7687
|
|
||||||
NEO4J_USER=neo4j
|
|
||||||
NEO4J_PASSWORD=letmein
|
|
||||||
|
|
||||||
GRAPHQL_LISTEN_PORT=4001
|
|
||||||
API_ENDPOINT=http://localhost:4001/
|
|
||||||
|
|
||||||
JWT_SECRET=You-sh0uld_Change-Th1s
|
|
||||||
ENCRYPTION_SALT_ROUNDS=12
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,73 +0,0 @@
|
||||||
{
|
|
||||||
"name": "photoview-api",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "API app for GRANDstack",
|
|
||||||
"main": "src/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"start-dev": "./node_modules/.bin/nodemon --exec babel-node src/index.js",
|
|
||||||
"build": "babel src -d build; cp .env build; cp src/schema.graphql build",
|
|
||||||
"now-build": "babel src -d build; cp .env build; cp src/schema.graphql build",
|
|
||||||
"start": "npm run build && node build/index.js",
|
|
||||||
"seedDb": "./node_modules/.bin/babel-node src/seed/seed-db.js"
|
|
||||||
},
|
|
||||||
"author": "Viktor Strate",
|
|
||||||
"license": "GPL-3.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/node": "^7.8.3",
|
|
||||||
"@babel/plugin-transform-spread": "^7.8.3",
|
|
||||||
"apollo-boost": "^0.4.7",
|
|
||||||
"apollo-cache-inmemory": "^1.6.5",
|
|
||||||
"apollo-client": "^2.6.8",
|
|
||||||
"apollo-link-http": "^1.5.16",
|
|
||||||
"apollo-server": "^2.9.16",
|
|
||||||
"babel-runtime": "^6.26.0",
|
|
||||||
"bcrypt": "^3.0.7",
|
|
||||||
"body-parser": "^1.19.0",
|
|
||||||
"core-js": "^3.6.4",
|
|
||||||
"dotenv": "^8.2.0",
|
|
||||||
"exiftool-vendored": "^9.5.0",
|
|
||||||
"fs-extra": "^8.1.0",
|
|
||||||
"graphql-tag": "^2.10.1",
|
|
||||||
"image-size": "^0.8.3",
|
|
||||||
"image-type": "^4.1.0",
|
|
||||||
"is-image": "^3.0.0",
|
|
||||||
"jsonwebtoken": "^8.5.1",
|
|
||||||
"lodash": "^4.17.15",
|
|
||||||
"neo4j-driver": "^1.7.6",
|
|
||||||
"neo4j-graphql-js": "^2.11.5",
|
|
||||||
"node-fetch": "^2.3.0",
|
|
||||||
"read-chunk": "^3.2.0",
|
|
||||||
"regenerator-runtime": "^0.13.3",
|
|
||||||
"sharp": "^0.24.0",
|
|
||||||
"uuid": "^3.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/cli": "^7.8.3",
|
|
||||||
"@babel/core": "^7.8.3",
|
|
||||||
"@babel/preset-env": "^7.8.3",
|
|
||||||
"babel-eslint": "^10.0.3",
|
|
||||||
"eslint": "^6.8.0",
|
|
||||||
"husky": "^4.0.10",
|
|
||||||
"lint-staged": "^10.0.0",
|
|
||||||
"nodemon": "^2.0.2",
|
|
||||||
"prettier": "^1.19.1"
|
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true
|
|
||||||
},
|
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
|
||||||
"*.{js,json,css,md,graphql": [
|
|
||||||
"prettier --write",
|
|
||||||
"git add"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
// set environment variables from ../.env
|
|
||||||
require('dotenv').config()
|
|
||||||
|
|
||||||
let encryptionSaltRounds = 10
|
|
||||||
if (parseInt(process.env.ENCRYPTION_SALT_ROUNDS)) {
|
|
||||||
encryptionSaltRounds = parseInt(process.env.ENCRYPTION_SALT_ROUNDS)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
cachePath: process.env.PHOTO_CACHE || path.resolve(__dirname, 'cache'),
|
|
||||||
host: new URL(process.env.API_ENDPOINT || 'http://localhost:4001/'),
|
|
||||||
encryptionSaltRounds,
|
|
||||||
}
|
|
|
@ -1,58 +0,0 @@
|
||||||
import fs from 'fs-extra'
|
|
||||||
import path from 'path'
|
|
||||||
import { makeAugmentedSchema } from 'neo4j-graphql-js'
|
|
||||||
import _ from 'lodash'
|
|
||||||
|
|
||||||
import usersResolver from './resolvers/users'
|
|
||||||
import scannerResolver from './resolvers/scanner'
|
|
||||||
import photosResolver from './resolvers/photos'
|
|
||||||
import siteInfoResolver from './resolvers/siteInfo'
|
|
||||||
import sharingResolver from './resolvers/sharing'
|
|
||||||
|
|
||||||
const resolvers = [
|
|
||||||
usersResolver,
|
|
||||||
scannerResolver,
|
|
||||||
photosResolver,
|
|
||||||
siteInfoResolver,
|
|
||||||
sharingResolver,
|
|
||||||
]
|
|
||||||
|
|
||||||
const typeDefs = fs
|
|
||||||
.readFileSync(
|
|
||||||
process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')
|
|
||||||
)
|
|
||||||
.toString('utf-8')
|
|
||||||
|
|
||||||
let productionExcludes = []
|
|
||||||
|
|
||||||
// if (process.env.PRODUCTION == true) {
|
|
||||||
// productionExcludes = [
|
|
||||||
// 'ScannerResult',
|
|
||||||
// 'AuthorizeResult',
|
|
||||||
// 'PhotoURL',
|
|
||||||
// 'SiteInfo',
|
|
||||||
// 'User',
|
|
||||||
// 'Album',
|
|
||||||
// 'PhotoEXIF',
|
|
||||||
// 'Photo',
|
|
||||||
// 'ShareToken',
|
|
||||||
// 'Result',
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
|
|
||||||
const schema = makeAugmentedSchema({
|
|
||||||
typeDefs,
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
isAuthenticated: true,
|
|
||||||
hasRole: true,
|
|
||||||
},
|
|
||||||
mutation: false,
|
|
||||||
query: {
|
|
||||||
exclude: [...productionExcludes],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolvers: resolvers.reduce((prev, curr) => _.merge(prev, curr), {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default schema
|
|
|
@ -1,7 +0,0 @@
|
||||||
import uuid from 'uuid'
|
|
||||||
|
|
||||||
function generateID() {
|
|
||||||
return uuid().substr(-12)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default generateID
|
|
|
@ -1,151 +0,0 @@
|
||||||
import { ApolloServer } from 'apollo-server-express'
|
|
||||||
import express, { Router } from 'express'
|
|
||||||
import bodyParser from 'body-parser'
|
|
||||||
import cors from 'cors'
|
|
||||||
import { v1 as neo4j } from 'neo4j-driver'
|
|
||||||
import http from 'http'
|
|
||||||
import PhotoScanner from './scanner/Scanner'
|
|
||||||
import _ from 'lodash'
|
|
||||||
import config from './config'
|
|
||||||
import gql from 'graphql-tag'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
import { getUserFromToken, getTokenFromBearer } from './token'
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
app.use(bodyParser.json())
|
|
||||||
app.use(cors())
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Create a Neo4j driver instance to connect to the database
|
|
||||||
* using credentials specified as environment variables
|
|
||||||
* with fallback to defaults
|
|
||||||
*/
|
|
||||||
const driver = neo4j.driver(
|
|
||||||
process.env.NEO4J_URI || 'bolt://localhost:7687',
|
|
||||||
neo4j.auth.basic(
|
|
||||||
process.env.NEO4J_USER || 'neo4j',
|
|
||||||
process.env.NEO4J_PASSWORD || 'letmein'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const scanner = new PhotoScanner(driver)
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
req.driver = driver
|
|
||||||
req.scanner = scanner
|
|
||||||
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Every 4th hour
|
|
||||||
setInterval(scanner.scanAll, 1000 * 60 * 60 * 4)
|
|
||||||
|
|
||||||
// Specify port and path for GraphQL endpoint
|
|
||||||
const graphPath = new URL(path.join(config.host.toString(), '/graphql'))
|
|
||||||
.pathname
|
|
||||||
|
|
||||||
app.use(graphPath, (req, res, next) => {
|
|
||||||
if (req.body.query) {
|
|
||||||
const query = gql(req.body.query)
|
|
||||||
const defs = query.definitions.filter(x => x.kind == 'OperationDefinition')
|
|
||||||
|
|
||||||
const selections = defs.reduce((prev, curr) => {
|
|
||||||
return prev.concat(curr.selectionSet.selections)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const names = selections.map(x => x.name.value)
|
|
||||||
const illegalNames = names.filter(
|
|
||||||
name => name.substr(0, 1) == name.substr(0, 1).match(/[A-Z]/)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (illegalNames.length > 0) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.send({ error: `Illegal query, types not allowed: ${illegalNames}` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
|
|
||||||
const endpointUrl = new URL(config.host)
|
|
||||||
// endpointUrl.port = process.env.GRAPHQL_LISTEN_PORT || 4001
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Create a new ApolloServer instance, serving the GraphQL schema
|
|
||||||
* created using makeAugmentedSchema above and injecting the Neo4j driver
|
|
||||||
* instance into the context object so it is available in the
|
|
||||||
* generated resolvers to connect to the database.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import schema from './graphql-schema'
|
|
||||||
|
|
||||||
const server = new ApolloServer({
|
|
||||||
context: async function({ req }) {
|
|
||||||
let user = null
|
|
||||||
let token = null
|
|
||||||
|
|
||||||
if (req && req.headers.authorization) {
|
|
||||||
token = getTokenFromBearer(req.headers.authorization)
|
|
||||||
user = await getUserFromToken(token, driver)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...req,
|
|
||||||
driver,
|
|
||||||
scanner,
|
|
||||||
user,
|
|
||||||
token,
|
|
||||||
endpoint: endpointUrl.toString(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
schema,
|
|
||||||
introspection: true,
|
|
||||||
playground: !process.env.PRODUCTION,
|
|
||||||
subscriptions: {
|
|
||||||
path: graphPath,
|
|
||||||
onConnect: async (connectionParams, webSocket) => {
|
|
||||||
const token = getTokenFromBearer(connectionParams.Authorization)
|
|
||||||
const user = await getUserFromToken(token, driver)
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
server.applyMiddleware({ app, path: graphPath })
|
|
||||||
const router = new Router()
|
|
||||||
|
|
||||||
import loadImageRoutes from './routes/images'
|
|
||||||
import loadDownloadRoutes from './routes/downloads'
|
|
||||||
|
|
||||||
loadImageRoutes(router)
|
|
||||||
loadDownloadRoutes(router)
|
|
||||||
|
|
||||||
app.use(config.host.pathname, router)
|
|
||||||
|
|
||||||
const httpServer = http.createServer(app)
|
|
||||||
server.installSubscriptionHandlers(httpServer)
|
|
||||||
|
|
||||||
httpServer.listen(
|
|
||||||
{ port: process.env.GRAPHQL_LISTEN_PORT, path: graphPath },
|
|
||||||
() => {
|
|
||||||
console.log(
|
|
||||||
`🚀 GraphQL endpoint ready at ${new URL(server.graphqlPath, endpointUrl)}`
|
|
||||||
)
|
|
||||||
|
|
||||||
let subscriptionUrl = new URL(endpointUrl)
|
|
||||||
subscriptionUrl.protocol = 'ws'
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`🚀 Subscriptions ready at ${new URL(
|
|
||||||
server.subscriptionsPath,
|
|
||||||
endpointUrl
|
|
||||||
)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -1,12 +0,0 @@
|
||||||
import { cypherQuery } from 'neo4j-graphql-js'
|
|
||||||
|
|
||||||
// Helper functions, that makes it easier to manipulate neo4j-graphql-js translations
|
|
||||||
|
|
||||||
export function replaceMatch({ root, args, ctx, info }, match) {
|
|
||||||
let query = cypherQuery(args, ctx, info)[0]
|
|
||||||
|
|
||||||
query = query.substr(query.indexOf(')') + 1)
|
|
||||||
query = match + query
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
import { cypherQuery } from 'neo4j-graphql-js'
|
|
||||||
import path from 'path'
|
|
||||||
import config from '../config'
|
|
||||||
|
|
||||||
function injectAt(query, index, injection) {
|
|
||||||
return query.substr(0, index) + injection + query.substr(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
const myAlbumQuery = function(args, ctx, info) {
|
|
||||||
const query = cypherQuery(args, ctx, info)
|
|
||||||
|
|
||||||
const whereSplit = query[0].indexOf('RETURN')
|
|
||||||
|
|
||||||
query[0] = injectAt(
|
|
||||||
query[0],
|
|
||||||
whereSplit,
|
|
||||||
`MATCH (u:User { id: {userid} }) WHERE (u)-[:OWNS]->(album) `
|
|
||||||
)
|
|
||||||
query[1].userid = ctx.user.id
|
|
||||||
|
|
||||||
const addIDSplitIndex = query[0].indexOf('album_photos {')
|
|
||||||
|
|
||||||
if (addIDSplitIndex != -1) {
|
|
||||||
const addIDSplit = addIDSplitIndex + 14
|
|
||||||
|
|
||||||
console.log('ID SPLIT', query[0])
|
|
||||||
|
|
||||||
query[0] = injectAt(
|
|
||||||
query[0],
|
|
||||||
addIDSplit,
|
|
||||||
query[0].indexOf('album_photos {}') == -1 ? ` .id, ` : ` .id `
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
const myPhotoQuery = function(args, ctx, info) {
|
|
||||||
const query = cypherQuery(args, ctx, info)
|
|
||||||
|
|
||||||
const whereSplit = query[0].indexOf('RETURN')
|
|
||||||
|
|
||||||
query[0] = injectAt(
|
|
||||||
query[0],
|
|
||||||
whereSplit,
|
|
||||||
`MATCH (u:User { id: {userid} }) WHERE (u)-[:OWNS]->(:Album)-[:CONTAINS]->(photo) `
|
|
||||||
)
|
|
||||||
query[1].userid = ctx.user.id
|
|
||||||
|
|
||||||
query[0] = injectAt(
|
|
||||||
query[0],
|
|
||||||
query[0].indexOf('RETURN `photo` {') + 16,
|
|
||||||
query[0].indexOf('RETURN `photo` {}') == -1 ? ` .id, ` : ` .id `
|
|
||||||
)
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
const Query = {
|
|
||||||
async myAlbums(root, args, ctx, info) {
|
|
||||||
let query = myAlbumQuery(args, ctx, info)
|
|
||||||
console.log(query)
|
|
||||||
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const result = await session.run(...query)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
return result.records.map(record => record.get('album'))
|
|
||||||
},
|
|
||||||
async album(root, args, ctx, info) {
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
let query = myAlbumQuery(args, ctx, info)
|
|
||||||
|
|
||||||
const whereSplit = query[0].indexOf('RETURN')
|
|
||||||
|
|
||||||
query[0] = injectAt(query[0], whereSplit, ` AND album.id = {id} `)
|
|
||||||
query[1].id = args.id
|
|
||||||
console.log(query)
|
|
||||||
|
|
||||||
const result = await session.run(...query)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (result.records.length == 0) {
|
|
||||||
throw new Error('Album was not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.records[0].get('album')
|
|
||||||
},
|
|
||||||
async myPhotos(root, args, ctx, info) {
|
|
||||||
let query = myPhotoQuery(args, ctx, info)
|
|
||||||
console.log(query)
|
|
||||||
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const result = await session.run(...query)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
return result.records.map(record => record.get('photo'))
|
|
||||||
},
|
|
||||||
async photo(root, args, ctx, info) {
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
let query = myPhotoQuery(args, ctx, info)
|
|
||||||
|
|
||||||
const whereSplit = query[0].indexOf('RETURN')
|
|
||||||
|
|
||||||
query[0] = injectAt(query[0], whereSplit, ` AND photo.id = {id} `)
|
|
||||||
query[1].id = args.id
|
|
||||||
console.log(query)
|
|
||||||
|
|
||||||
const result = await session.run(...query)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (result.records.length == 0) {
|
|
||||||
throw new Error('Album was not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.records[0].get('photo')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlResolve = {
|
|
||||||
url(root, args, ctx, info) {
|
|
||||||
let url = new URL(path.join(config.host.href, root.url))
|
|
||||||
|
|
||||||
if (ctx.shareToken) url.search = `?token=${ctx.shareToken}`
|
|
||||||
return url.href
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Query,
|
|
||||||
PhotoURL: urlResolve,
|
|
||||||
PhotoDownload: urlResolve,
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
import { EVENT_SCANNER_PROGRESS } from '../scanner/Scanner'
|
|
||||||
|
|
||||||
const Mutation = {
|
|
||||||
async scanAll(root, args, ctx, info) {
|
|
||||||
ctx.scanner.scanAll()
|
|
||||||
|
|
||||||
return {
|
|
||||||
finished: false,
|
|
||||||
success: true,
|
|
||||||
progress: 0,
|
|
||||||
message: 'Starting scanner',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async scanUser(root, args, ctx, info) {
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const userResult = await session.run(
|
|
||||||
`MATCH (u:User { id: {userId} }) RETURN u`,
|
|
||||||
{
|
|
||||||
userId: args.userId,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (userResult.records.length == 0) {
|
|
||||||
return {
|
|
||||||
finished: false,
|
|
||||||
success: false,
|
|
||||||
progress: 0,
|
|
||||||
message: 'Could not scan user: User not found',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userResult.records[0].get('u').properties
|
|
||||||
|
|
||||||
ctx.scanner.scanUser(user)
|
|
||||||
|
|
||||||
return {
|
|
||||||
finished: false,
|
|
||||||
success: true,
|
|
||||||
progress: 0,
|
|
||||||
message: 'Starting scanner',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const Subscription = {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
subscribe(root, args, ctx, info) {
|
|
||||||
return ctx.scanner.pubsub.asyncIterator([EVENT_SCANNER_PROGRESS])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Mutation,
|
|
||||||
Subscription,
|
|
||||||
}
|
|
|
@ -1,179 +0,0 @@
|
||||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
|
||||||
import generateID from '../id-generator'
|
|
||||||
import { replaceMatch } from './neo4j-helpers'
|
|
||||||
|
|
||||||
const Mutation = {
|
|
||||||
async shareAlbum(root, args, ctx, info) {
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const ownsAlbumResult = await session.run(
|
|
||||||
`
|
|
||||||
MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album { id: {albumId} })
|
|
||||||
RETURN a
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
userId: ctx.user.id,
|
|
||||||
albumId: args.albumId,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ownsAlbumResult.records.length == 0) {
|
|
||||||
session.close()
|
|
||||||
throw new Error('User does not own that album')
|
|
||||||
}
|
|
||||||
|
|
||||||
const createResult = await session.run(
|
|
||||||
`
|
|
||||||
MATCH (u:User { id: {userId} })
|
|
||||||
MATCH (a:Album { id: {albumId} })
|
|
||||||
CREATE (share:ShareToken {shareToken} )
|
|
||||||
CREATE (u)-[:SHARE_TOKEN]->(share)-[:SHARES]->(a)
|
|
||||||
RETURN share
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
userId: ctx.user.id,
|
|
||||||
albumId: args.albumId,
|
|
||||||
shareToken: {
|
|
||||||
token: generateID(),
|
|
||||||
expire: args.expire,
|
|
||||||
password: args.password,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
return {
|
|
||||||
expire: null,
|
|
||||||
password: null,
|
|
||||||
...createResult.records[0].get('share').properties,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async sharePhoto(root, args, ctx, info) {
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const ownsPhotoResult = await session.run(
|
|
||||||
`
|
|
||||||
MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo { id: {photoId} })
|
|
||||||
RETURN a
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
userId: ctx.user.id,
|
|
||||||
photoId: args.photoId,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ownsPhotoResult.records.length == 0) {
|
|
||||||
session.close()
|
|
||||||
throw new Error('User does not own that photo')
|
|
||||||
}
|
|
||||||
|
|
||||||
const createResult = await session.run(
|
|
||||||
`
|
|
||||||
MATCH (u:User { id: {userId} })
|
|
||||||
MATCH (p:Photo { id: {photoId} })
|
|
||||||
CREATE (share:ShareToken {shareToken} )
|
|
||||||
CREATE (u)-[:SHARE_TOKEN]->(share)-[:SHARES]->(p)
|
|
||||||
RETURN share
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
userId: ctx.user.id,
|
|
||||||
photoId: args.photoId,
|
|
||||||
shareToken: {
|
|
||||||
token: generateID(),
|
|
||||||
expire: args.expire,
|
|
||||||
password: args.password,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
return {
|
|
||||||
expire: null,
|
|
||||||
password: null,
|
|
||||||
...createResult.records[0].get('share').properties,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async deleteShareToken(root, args, ctx, info) {
|
|
||||||
if (!ctx.user.admin) {
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
const result = await session.run(
|
|
||||||
`MATCH (u:User { id: {userId} })-[:SHARE_TOKEN]->(token:ShareToken { token: {token} })
|
|
||||||
RETURN token`,
|
|
||||||
{
|
|
||||||
userId: ctx.user.id,
|
|
||||||
token: args.token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (result.records.length == 0) {
|
|
||||||
throw new Error('User is not allowed to delete this share')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return neo4jgraphql(root, args, ctx, info)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const Query = {
|
|
||||||
async albumShares(root, args, ctx, info) {
|
|
||||||
const query = replaceMatch(
|
|
||||||
{ root, args, ctx, info },
|
|
||||||
`
|
|
||||||
MATCH (u:User { id: {userId} })
|
|
||||||
MATCH (u)-[:OWNS]->(a:Album { id: {albumId} })
|
|
||||||
MATCH (a)<-[:SHARES]-(shareToken:ShareToken)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const queryResult = await session.run(query, {
|
|
||||||
...args,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
albumId: args.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
const tokens = queryResult.records.map(token => token.get('shareToken'))
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
},
|
|
||||||
async photoShares(root, args, ctx, info) {
|
|
||||||
const query = replaceMatch(
|
|
||||||
{ root, args, ctx, info },
|
|
||||||
`
|
|
||||||
MATCH (u:User { id: {userId} })
|
|
||||||
MATCH (u)-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo {id: {photoId} })
|
|
||||||
MATCH (p)<-[:SHARES]-(shareToken:ShareToken)
|
|
||||||
`
|
|
||||||
)
|
|
||||||
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const queryResult = await session.run(query, {
|
|
||||||
...args,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
photoId: args.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
const tokens = queryResult.records.map(token => token.get('shareToken'))
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
},
|
|
||||||
shareToken(root, args, ctx, info) {
|
|
||||||
ctx.shareToken = args.token
|
|
||||||
return neo4jgraphql(root, args, ctx, info)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Mutation,
|
|
||||||
Query,
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import { registerUser, authorizeUser } from './users'
|
|
||||||
|
|
||||||
async function initialSetup(driver) {
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
await session.run(
|
|
||||||
`MERGE (info:SiteInfo) ON CREATE SET info = {initialSettings}`,
|
|
||||||
{
|
|
||||||
initialSettings: {
|
|
||||||
initialSetup: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const Query = {
|
|
||||||
async siteInfo(root, args, ctx, info) {
|
|
||||||
await initialSetup(ctx.driver)
|
|
||||||
|
|
||||||
return neo4jgraphql(root, args, ctx, info)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const Mutation = {
|
|
||||||
async initialSetupWizard(root, args, ctx, info) {
|
|
||||||
await initialSetup(ctx.driver)
|
|
||||||
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const result = await session.run(`MATCH (i:SiteInfo) RETURN i`)
|
|
||||||
|
|
||||||
const siteInfo = result.records[0].get('i').properties
|
|
||||||
|
|
||||||
if (siteInfo.initialSetup == false) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: 'Has already been setup',
|
|
||||||
token: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userResult = await registerUser(root, args, ctx, info)
|
|
||||||
|
|
||||||
if (!userResult.success) {
|
|
||||||
return userResult
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = jwt.decode(userResult.token).id
|
|
||||||
|
|
||||||
await session.run(`MATCH (u:User { id: {id} }) SET u.admin = true`, {
|
|
||||||
id: userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
await session.run(`MATCH (i:SiteInfo) SET i.initialSetup = false`)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
const token = (await authorizeUser(root, args, ctx, info)).token
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
status: 'Initial setup successful',
|
|
||||||
token,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Query,
|
|
||||||
Mutation,
|
|
||||||
}
|
|
|
@ -1,180 +0,0 @@
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import generateID from '../id-generator'
|
|
||||||
import fs from 'fs-extra'
|
|
||||||
import bcrypt from 'bcrypt'
|
|
||||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
|
||||||
import config from '../config'
|
|
||||||
|
|
||||||
const Mutation = {
|
|
||||||
async authorizeUser(root, args, ctx, info) {
|
|
||||||
console.log('Authorize user')
|
|
||||||
let { username } = args
|
|
||||||
|
|
||||||
let session = ctx.driver.session()
|
|
||||||
|
|
||||||
let result = await session.run(
|
|
||||||
'MATCH (user:User {username: {username}}) RETURN user',
|
|
||||||
{ username }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.records.length == 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: 'Username or password was invalid',
|
|
||||||
token: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = result.records[0]
|
|
||||||
|
|
||||||
const user = record.get('user').properties
|
|
||||||
|
|
||||||
if ((await bcrypt.compare(args.password, user.password)) == false) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: 'Username or password was invalid',
|
|
||||||
token: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let roles = []
|
|
||||||
|
|
||||||
if (user.admin) {
|
|
||||||
roles.push('admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwt.sign({ id: user.id, roles }, process.env.JWT_SECRET)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
status: 'Authorized',
|
|
||||||
token,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async registerUser(root, args, ctx, info) {
|
|
||||||
let { username, rootPath } = args
|
|
||||||
|
|
||||||
let session = ctx.driver.session()
|
|
||||||
let findResult = await session.run(
|
|
||||||
'MATCH (usr:User {username: {username} }) RETURN usr',
|
|
||||||
{ username }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (findResult.records.length > 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: 'Username is already taken',
|
|
||||||
token: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await fs.exists(rootPath))) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: 'Root path does not exist on the server',
|
|
||||||
token: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(
|
|
||||||
args.password,
|
|
||||||
config.encryptionSaltRounds
|
|
||||||
)
|
|
||||||
|
|
||||||
const registerResult = await session.run(
|
|
||||||
'CREATE (n:User { username: {username}, password: {password}, id: {id}, admin: false, rootPath: {rootPath} }) return n.id',
|
|
||||||
{ username, password: hashedPassword, id: generateID(), rootPath }
|
|
||||||
)
|
|
||||||
|
|
||||||
let id = registerResult.records[0].get('n.id')
|
|
||||||
|
|
||||||
const token = jwt.sign({ id, roles: [] }, process.env.JWT_SECRET)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
status: 'User created',
|
|
||||||
token: token,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async updateUser(root, args, ctx, info) {
|
|
||||||
if (args.rootPath) {
|
|
||||||
if (!(await fs.exists(args.rootPath))) {
|
|
||||||
throw Error('New root path not found in server filesystem')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.password)
|
|
||||||
args.password = await bcrypt.hash(
|
|
||||||
args.password,
|
|
||||||
config.encryptionSaltRounds
|
|
||||||
)
|
|
||||||
|
|
||||||
return neo4jgraphql(root, args, ctx, info)
|
|
||||||
},
|
|
||||||
async createUser(root, args, ctx, info) {
|
|
||||||
if (args.rootPath) {
|
|
||||||
if (!(await fs.exists(args.rootPath))) {
|
|
||||||
throw Error('Root path not found in server filesystem')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line require-atomic-updates
|
|
||||||
args.id = generateID()
|
|
||||||
|
|
||||||
if (args.password)
|
|
||||||
args.password = await bcrypt.hash(
|
|
||||||
args.password,
|
|
||||||
config.encryptionSaltRounds
|
|
||||||
)
|
|
||||||
|
|
||||||
return neo4jgraphql(root, args, ctx, info)
|
|
||||||
},
|
|
||||||
async changeUserPassword(root, args, ctx, info) {
|
|
||||||
const { newPassword, id } = args
|
|
||||||
|
|
||||||
const session = ctx.driver.session()
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(
|
|
||||||
newPassword,
|
|
||||||
config.encryptionSaltRounds
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.run(
|
|
||||||
`MATCH (u:User { id: {id} }) SET u.password = {password}`,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
password: hashedPassword,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
errorMessage: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const registerUser = Mutation.registerUser
|
|
||||||
export const authorizeUser = Mutation.authorizeUser
|
|
||||||
|
|
||||||
const Query = {
|
|
||||||
myUser(root, args, ctx, info) {
|
|
||||||
let customArgs = {
|
|
||||||
filter: {},
|
|
||||||
...args,
|
|
||||||
}
|
|
||||||
|
|
||||||
customArgs.filter.id = ctx.user.id
|
|
||||||
|
|
||||||
return neo4jgraphql(root, customArgs, ctx, info)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Mutation,
|
|
||||||
Query,
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { getImageFromRequest, RequestError } from './images'
|
|
||||||
import fs from 'fs-extra'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
async function sendDownload(req, res) {
|
|
||||||
let { photo, cachePath } = req
|
|
||||||
const cacheBasename = path.basename(cachePath)
|
|
||||||
const photoBasename = path.basename(photo.path)
|
|
||||||
|
|
||||||
if (cacheBasename == photoBasename) {
|
|
||||||
if (!(await fs.exists(photo.path))) {
|
|
||||||
throw new RequestError(500, 'Image missing from the server')
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.sendFile(photo.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RequestError(404, 'Image could not be found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadDownloadRoutes = router => {
|
|
||||||
router.use('/download/:id/:image', getImageFromRequest)
|
|
||||||
router.use('/download/:id/:image', sendDownload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default loadDownloadRoutes
|
|
|
@ -1,185 +0,0 @@
|
||||||
import fs from 'fs-extra'
|
|
||||||
import path from 'path'
|
|
||||||
import _ from 'lodash'
|
|
||||||
import config from '../config'
|
|
||||||
import { isRawImage, getImageCachePath } from '../scanner/utils'
|
|
||||||
import { getUserFromToken, getTokenFromBearer } from '../token'
|
|
||||||
|
|
||||||
export class RequestError extends Error {
|
|
||||||
constructor(httpCode, message) {
|
|
||||||
super(message)
|
|
||||||
this.httpCode = httpCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImageFromRequest(req, res, next) {
|
|
||||||
const { id, image } = req.params
|
|
||||||
const driver = req.driver
|
|
||||||
|
|
||||||
const shareToken = req.query.token
|
|
||||||
|
|
||||||
let photo, albumId
|
|
||||||
|
|
||||||
try {
|
|
||||||
let verify = null
|
|
||||||
|
|
||||||
if (shareToken) {
|
|
||||||
verify = await verifyShareToken({ shareToken, id, driver })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verify) {
|
|
||||||
verify = await verifyUser(req, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verify == null) throw RequestError(500, 'Unable to verify request')
|
|
||||||
|
|
||||||
photo = verify.photo
|
|
||||||
albumId = verify.albumId
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(error.status || 500).send(error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachePath = path.resolve(getImageCachePath(id, albumId), image)
|
|
||||||
req.cachePath = cachePath
|
|
||||||
req.photo = photo
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendImage(req, res) {
|
|
||||||
let { photo, cachePath } = req
|
|
||||||
const photoBasename = path.basename(cachePath)
|
|
||||||
|
|
||||||
if (!(await fs.exists(cachePath))) {
|
|
||||||
if (photoBasename == 'thumbnail.jpg') {
|
|
||||||
console.log('Thumbnail not found, generating', photo.path)
|
|
||||||
await req.scanner.processImage(photo.id)
|
|
||||||
|
|
||||||
if (!(await fs.exists(cachePath))) {
|
|
||||||
throw new Error('Thumbnail not found after image processing')
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.sendFile(cachePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
cachePath = photo.path
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await isRawImage(cachePath)) {
|
|
||||||
console.log('RAW preview image not found, generating', cachePath)
|
|
||||||
await req.scanner.processImage(photo.id)
|
|
||||||
|
|
||||||
cachePath = path.resolve(
|
|
||||||
config.cachePath,
|
|
||||||
'images',
|
|
||||||
photo.id,
|
|
||||||
photoBasename
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!(await fs.exists(cachePath))) {
|
|
||||||
throw new Error('RAW preview not found after image processing')
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.sendFile(cachePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
res.sendFile(cachePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyUser(req, id) {
|
|
||||||
let user = null
|
|
||||||
const { driver } = req
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = getTokenFromBearer(req.headers.authorization)
|
|
||||||
user = await getUserFromToken(token, driver)
|
|
||||||
} catch (err) {
|
|
||||||
throw new RequestError(401, err.message)
|
|
||||||
// return res.status(401).send(err.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const result = await session.run(
|
|
||||||
'MATCH (p:Photo { id: {id} })<-[:CONTAINS]-(a:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId, a.id as albumId',
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (result.records.length == 0) {
|
|
||||||
throw new RequestError(404, 'Image not found')
|
|
||||||
// return res.status(404).send(`Image not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = result.records[0].get('userId')
|
|
||||||
const albumId = result.records[0].get('albumId')
|
|
||||||
const photo = result.records[0].get('photo').properties
|
|
||||||
|
|
||||||
if (userId != user.id) {
|
|
||||||
throw new RequestError(401, 'Image not owned by you')
|
|
||||||
// return res.status(401).send(`Image not owned by you`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
albumId,
|
|
||||||
photo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyShareToken({ shareToken, id, driver }) {
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const shareTokenResult = await session.run(
|
|
||||||
`MATCH (share:ShareToken { token: {shareToken} })-[:SHARES]->(shared)
|
|
||||||
MATCH (photo:Photo { id: {id} })<-[:CONTAINS]-(album:Album)
|
|
||||||
RETURN share, photo, shared, album`,
|
|
||||||
{ shareToken, id }
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (shareTokenResult.records.length == 0) {
|
|
||||||
throw new RequestError(404, 'Image not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const share = shareTokenResult.records[0].get('share').properties
|
|
||||||
const album = shareTokenResult.records[0].get('album').properties
|
|
||||||
const photo = shareTokenResult.records[0].get('photo').properties
|
|
||||||
const sharedObject = shareTokenResult.records[0].get('shared')
|
|
||||||
|
|
||||||
if (sharedObject.labels[0] == 'Album') {
|
|
||||||
const session = driver.session()
|
|
||||||
const albumResult = await session.run(
|
|
||||||
`MATCH (album)-[:CONTAINS]->(photo:Photo { id: {id} })
|
|
||||||
RETURN album`,
|
|
||||||
{ id }
|
|
||||||
)
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
if (albumResult.records.length == 0) {
|
|
||||||
throw new RequestError(403, 'Invalid share token')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const sharedPhoto = sharedObject.properties
|
|
||||||
|
|
||||||
if (sharedPhoto.id != photo.id) {
|
|
||||||
throw new RequestError(403, 'Invalid share token')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
photo,
|
|
||||||
albumId: album.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImageRoutes(router) {
|
|
||||||
router.use('/images/:id/:image', getImageFromRequest)
|
|
||||||
router.use('/images/:id/:image', sendImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default loadImageRoutes
|
|
|
@ -1,146 +0,0 @@
|
||||||
import { PubSub } from 'apollo-server'
|
|
||||||
import _ from 'lodash'
|
|
||||||
import _scanUser from './scanUser'
|
|
||||||
import _scanAlbum from './scanAlbum'
|
|
||||||
import _processImage from './processImage'
|
|
||||||
import _scanAll from './scanAll'
|
|
||||||
|
|
||||||
export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
|
|
||||||
|
|
||||||
async function _execScan(scanner, scanFunction) {
|
|
||||||
try {
|
|
||||||
if (scanner.isRunning) throw new Error('Scanner already running')
|
|
||||||
scanner.isRunning = true
|
|
||||||
scanner.imageProgress = {}
|
|
||||||
|
|
||||||
const session = scanner.driver.session()
|
|
||||||
const photoResult = await session.run(
|
|
||||||
'MATCH (photo:Photo) RETURN photo.id as photoId'
|
|
||||||
)
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
photoResult.records
|
|
||||||
.map(x => x.get('photoId'))
|
|
||||||
.forEach(id => {
|
|
||||||
scanner.markImageToProgress(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
scanner.pubsub.publish(EVENT_SCANNER_PROGRESS, {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
progress: 0,
|
|
||||||
finished: false,
|
|
||||||
success: true,
|
|
||||||
message: 'Scan started',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Calling scan function')
|
|
||||||
await scanFunction()
|
|
||||||
console.log('Scan function ended')
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Done scanning ${Object.keys(scanner.imageProgress).length} photos`
|
|
||||||
)
|
|
||||||
|
|
||||||
scanner.pubsub.publish(EVENT_SCANNER_PROGRESS, {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
progress: 100,
|
|
||||||
finished: true,
|
|
||||||
success: true,
|
|
||||||
message: `Done scanning ${
|
|
||||||
Object.keys(scanner.imageProgress).length
|
|
||||||
} photos`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`SCANNER ERROR: ${e.message}\n${e.stack}`)
|
|
||||||
scanner.pubsub.publish(EVENT_SCANNER_PROGRESS, {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
progress: 0,
|
|
||||||
finished: true,
|
|
||||||
success: false,
|
|
||||||
message: `Scanner error: ${e.message}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
scanner.isRunning = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PhotoScanner {
|
|
||||||
constructor(driver) {
|
|
||||||
this.driver = driver
|
|
||||||
this.isRunning = false
|
|
||||||
this.pubsub = new PubSub()
|
|
||||||
|
|
||||||
this.processImage = this.processImage.bind(this)
|
|
||||||
this.scanAlbum = this.scanAlbum.bind(this)
|
|
||||||
this.scanUser = this.scanUser.bind(this)
|
|
||||||
this.scanAll = this.scanAll.bind(this)
|
|
||||||
|
|
||||||
this.imageProgress = {}
|
|
||||||
|
|
||||||
this.markImageToProgress = imageId => {
|
|
||||||
if (!this.imageProgress[imageId]) this.imageProgress[imageId] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markFinishedImage = imageId => {
|
|
||||||
this.imageProgress[imageId] = true
|
|
||||||
this.broadcastProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.finishedImages = () =>
|
|
||||||
Object.values(this.imageProgress).reduce((prev, x) => {
|
|
||||||
x ? prev++ : prev
|
|
||||||
return prev
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
this.broadcastProgress = _.throttle(() => {
|
|
||||||
if (!this.isRunning) return
|
|
||||||
if (Object.keys(this.imageProgress).length == 0) return
|
|
||||||
|
|
||||||
let progress =
|
|
||||||
(this.finishedImages() / Object.keys(this.imageProgress).length) * 100
|
|
||||||
|
|
||||||
console.log(`Progress: ${progress}`)
|
|
||||||
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
progress,
|
|
||||||
finished: false,
|
|
||||||
success: true,
|
|
||||||
message: `${this.finishedImages()} photos scanned`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, 250)
|
|
||||||
|
|
||||||
this.markImageToProgress = this.markImageToProgress.bind(this)
|
|
||||||
this.markFinishedImage = this.markFinishedImage.bind(this)
|
|
||||||
this.finishedImages = this.finishedImages.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
async scanUser(user) {
|
|
||||||
await _execScan(this, async () => {
|
|
||||||
await _scanUser(this, user)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async scanAlbum(album) {
|
|
||||||
await _execScan(this, async () => {
|
|
||||||
await _scanAlbum(this, album)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async processImage(id) {
|
|
||||||
await _execScan(this, async () => {
|
|
||||||
await _processImage(this, id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async scanAll() {
|
|
||||||
await _execScan(this, async () => {
|
|
||||||
await _scanAll(this)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PhotoScanner
|
|
|
@ -1,202 +0,0 @@
|
||||||
import fs from 'fs-extra'
|
|
||||||
import path from 'path'
|
|
||||||
import { exiftool } from 'exiftool-vendored'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
import { isRawImage, imageSize, getImageCachePath } from './utils'
|
|
||||||
import { DateTime as NeoDateTime } from 'neo4j-driver/lib/v1/temporal-types.js'
|
|
||||||
|
|
||||||
async function addExifTags({ session, photo }) {
|
|
||||||
const exifResult = await session.run(
|
|
||||||
'MATCH (p:Photo { id: {id} })-[:EXIF]->(exif:PhotoEXIF) RETURN exif',
|
|
||||||
{
|
|
||||||
id: photo.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (exifResult.records.length > 0) return
|
|
||||||
|
|
||||||
const rawTags = await exiftool.read(photo.path)
|
|
||||||
|
|
||||||
let iso = rawTags.ISO
|
|
||||||
if (typeof iso != 'number') {
|
|
||||||
try {
|
|
||||||
iso = parseInt(iso)
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Could not parse ISO as int', e, e.stack)
|
|
||||||
iso = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const photoExif = {
|
|
||||||
camera: rawTags.Model,
|
|
||||||
maker: rawTags.Make,
|
|
||||||
lens: rawTags.LensType,
|
|
||||||
dateShot:
|
|
||||||
rawTags.DateTimeOriginal &&
|
|
||||||
typeof rawTags.DateTimeOriginal.toDate == 'function' &&
|
|
||||||
NeoDateTime.fromStandardDate(rawTags.DateTimeOriginal.toDate()),
|
|
||||||
fileSize: rawTags.FileSize,
|
|
||||||
exposure: rawTags.ShutterSpeedValue,
|
|
||||||
aperture: rawTags.ApertureValue,
|
|
||||||
iso,
|
|
||||||
focalLength: rawTags.FocalLength,
|
|
||||||
flash: rawTags.Flash,
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await session.run(
|
|
||||||
`MATCH (p:Photo { id: {id} })
|
|
||||||
CREATE (p)-[:EXIF]->(exif:PhotoEXIF {exifProps})`,
|
|
||||||
{
|
|
||||||
id: photo.id,
|
|
||||||
exifProps: photoExif,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Added exif tags to photo', photo.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function processImage(scanner, id) {
|
|
||||||
const { driver, markFinishedImage } = scanner
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const result = await session.run(
|
|
||||||
`MATCH (p:Photo { id: {id} })<-[:CONTAINS]-(a:Album) RETURN p, a.id`,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const photo = result.records[0].get('p').properties
|
|
||||||
const albumId = result.records[0].get('a.id')
|
|
||||||
|
|
||||||
const imagePath = getImageCachePath(id, albumId)
|
|
||||||
|
|
||||||
// Verify that processing is needed
|
|
||||||
if (await fs.exists(path.resolve(imagePath, 'thumbnail.jpg'))) {
|
|
||||||
const urlResult = await session.run(
|
|
||||||
`MATCH (p:Photo { id: {id} })-->(urls:PhotoURL) RETURN urls`,
|
|
||||||
{ id }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (urlResult.records.length == 2) {
|
|
||||||
markFinishedImage(id)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
console.log('Skipping image', photo.path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Begin processing
|
|
||||||
await session.run(
|
|
||||||
`MATCH (p:Photo { id: {id} })-->(urls:PhotoURL) DETACH DELETE urls`,
|
|
||||||
{ id }
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.remove(imagePath)
|
|
||||||
await fs.mkdirp(imagePath)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Could not remove old image, and make directory', e, e.stack)
|
|
||||||
}
|
|
||||||
|
|
||||||
let originalPath = photo.path
|
|
||||||
|
|
||||||
if (await isRawImage(photo.path)) {
|
|
||||||
// console.log('Processing RAW image')
|
|
||||||
|
|
||||||
const extractedPath = path.resolve(imagePath, 'extracted.jpg')
|
|
||||||
await exiftool.extractPreview(photo.path, extractedPath)
|
|
||||||
|
|
||||||
const rawTags = await exiftool.read(photo.path)
|
|
||||||
// ISO, FNumber, Model, ExposureTime, FocalLength, LensType
|
|
||||||
// console.log(rawTags)
|
|
||||||
|
|
||||||
let rotateAngle = null
|
|
||||||
switch (rawTags.Orientation) {
|
|
||||||
case 8:
|
|
||||||
rotateAngle = -90
|
|
||||||
break
|
|
||||||
case 3:
|
|
||||||
rotateAngle = 180
|
|
||||||
break
|
|
||||||
case 6:
|
|
||||||
rotateAngle = 90
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace extension with .jpg
|
|
||||||
let processedBase = path.basename(photo.path).match(/(.*)(\..*)/)
|
|
||||||
processedBase =
|
|
||||||
processedBase == null ? path.basename(photo.path) : processedBase[1]
|
|
||||||
processedBase += '.jpg'
|
|
||||||
|
|
||||||
const processedPath = path.resolve(imagePath, processedBase)
|
|
||||||
await sharp(extractedPath)
|
|
||||||
.jpeg({ quality: 80 })
|
|
||||||
.rotate(rotateAngle)
|
|
||||||
.toFile(processedPath)
|
|
||||||
|
|
||||||
fs.remove(extractedPath)
|
|
||||||
|
|
||||||
originalPath = processedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize image
|
|
||||||
const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
|
|
||||||
await sharp(originalPath)
|
|
||||||
.jpeg({ quality: 70 })
|
|
||||||
.resize(720, 480, { fit: 'inside', withoutEnlargement: true })
|
|
||||||
.toFile(thumbnailPath)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { width: originalWidth, height: originalHeight } = await imageSize(
|
|
||||||
originalPath
|
|
||||||
)
|
|
||||||
const { width: thumbnailWidth, height: thumbnailHeight } = await imageSize(
|
|
||||||
thumbnailPath
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.run(
|
|
||||||
`MATCH (p:Photo { id: {id} })
|
|
||||||
CREATE (p)-[:THUMBNAIL_URL]->(thumbnail:PhotoURL { thumbnail })
|
|
||||||
CREATE (p)-[:ORIGINAL_URL]->(original:PhotoURL { original })
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
thumbnail: {
|
|
||||||
url: `/images/${id}/${path.basename(thumbnailPath)}`,
|
|
||||||
width: thumbnailWidth,
|
|
||||||
height: thumbnailHeight,
|
|
||||||
},
|
|
||||||
original: {
|
|
||||||
url: `/images/${id}/${path.basename(originalPath)}`,
|
|
||||||
width: originalWidth,
|
|
||||||
height: originalHeight,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.run(
|
|
||||||
`
|
|
||||||
MATCH (p:Photo { id: {id} })
|
|
||||||
CREATE (p)-[:DOWNLOAD]->(original:PhotoDownload {original})
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
original: {
|
|
||||||
title: 'Original',
|
|
||||||
url: `/download/${id}/${path.basename(photo.path)}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Create photo url failed', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
await addExifTags({ session, photo })
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
markFinishedImage(id)
|
|
||||||
}
|
|
|
@ -1,124 +0,0 @@
|
||||||
import fs from 'fs-extra'
|
|
||||||
import path from 'path'
|
|
||||||
import generateID from '../id-generator'
|
|
||||||
import { isImage, getImageCachePath } from './utils'
|
|
||||||
import _processImage from './processImage'
|
|
||||||
import { EVENT_SCANNER_PROGRESS } from './Scanner'
|
|
||||||
|
|
||||||
export default async function scanAlbum(scanner, album) {
|
|
||||||
const { driver, markImageToProgress } = scanner
|
|
||||||
const { title, path: albumPath, id } = album
|
|
||||||
|
|
||||||
console.log('Scanning album', title)
|
|
||||||
|
|
||||||
let processedImages = []
|
|
||||||
|
|
||||||
const list = await fs.readdir(albumPath)
|
|
||||||
let processingImagePromises = []
|
|
||||||
|
|
||||||
const addPhotoToProcess = photo => {
|
|
||||||
markImageToProgress(photo.id)
|
|
||||||
|
|
||||||
processingImagePromises.push(
|
|
||||||
_processImage(scanner, photo.id).catch(e => {
|
|
||||||
console.error(
|
|
||||||
`Error processing image (${JSON.stringify(photo)}): ${e.stack}`
|
|
||||||
)
|
|
||||||
scanner.pubsub.publish(EVENT_SCANNER_PROGRESS, {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
progress: 0,
|
|
||||||
finished: false,
|
|
||||||
success: false,
|
|
||||||
message: `Error processing image at ${photo.path}: ${e.message}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of list) {
|
|
||||||
const itemPath = path.resolve(albumPath, item)
|
|
||||||
processedImages.push(itemPath)
|
|
||||||
|
|
||||||
if (await isImage(itemPath)) {
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const photoResult = await session.run(
|
|
||||||
`MATCH (p:Photo {path: {imgPath} })<--(a:Album {id: {albumId}}) RETURN p`,
|
|
||||||
{
|
|
||||||
imgPath: itemPath,
|
|
||||||
albumId: id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (photoResult.records.length != 0) {
|
|
||||||
// console.log(`Photo already exists ${item}`)
|
|
||||||
|
|
||||||
const photo = photoResult.records[0].get('p').properties
|
|
||||||
|
|
||||||
addPhotoToProcess(photo)
|
|
||||||
} else {
|
|
||||||
console.log(`Found new image at ${itemPath}`)
|
|
||||||
|
|
||||||
const photo = {
|
|
||||||
id: generateID(),
|
|
||||||
path: itemPath,
|
|
||||||
title: item,
|
|
||||||
}
|
|
||||||
|
|
||||||
await session.run(
|
|
||||||
`MATCH (a:Album { id: {albumId} })
|
|
||||||
CREATE (p:Photo {photo})
|
|
||||||
CREATE (a)-[:CONTAINS]->(p)`,
|
|
||||||
{
|
|
||||||
photo,
|
|
||||||
albumId: id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
addPhotoToProcess(photo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const deletedImagesResult = await session.run(
|
|
||||||
`MATCH (a:Album { id: {albumId} })-[:CONTAINS]->(p:Photo)-->(trail)
|
|
||||||
WHERE NOT p.path IN {images}
|
|
||||||
WITH p, p.id AS imageId, trail
|
|
||||||
DETACH DELETE p, trail
|
|
||||||
RETURN DISTINCT imageId`,
|
|
||||||
{
|
|
||||||
albumId: id,
|
|
||||||
images: processedImages,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const deletedImages = deletedImagesResult.records.map(record =>
|
|
||||||
record.get('imageId')
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const imageId of deletedImages) {
|
|
||||||
await fs.remove(getImageCachePath(imageId, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Deleted ${deletedImages.length} images from album ${title}`)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
await Promise.all(processingImagePromises).catch(e => {
|
|
||||||
console.error(`Error processing image: ${e.stack}`)
|
|
||||||
scanner.pubsub.publish(EVENT_SCANNER_PROGRESS, {
|
|
||||||
scannerStatusUpdate: {
|
|
||||||
progress: 0,
|
|
||||||
finished: false,
|
|
||||||
success: false,
|
|
||||||
message: `Error processing image: ${e.message}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
console.log('Done processing album', album.title)
|
|
||||||
|
|
||||||
scanner.broadcastProgress()
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
import _scanUser from './scanUser'
|
|
||||||
|
|
||||||
export default function scanAll(scanner) {
|
|
||||||
const { driver } = scanner
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let session = driver.session()
|
|
||||||
|
|
||||||
let usersToScan = []
|
|
||||||
|
|
||||||
session.run('MATCH (u:User) return u').subscribe({
|
|
||||||
onNext: record => {
|
|
||||||
const user = record.toObject().u.properties
|
|
||||||
|
|
||||||
if (!user.rootPath) {
|
|
||||||
console.log(`User ${user.username}, has no root path, skipping`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usersToScan.push(user)
|
|
||||||
},
|
|
||||||
onCompleted: async () => {
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
for (let user of usersToScan) {
|
|
||||||
try {
|
|
||||||
await _scanUser(scanner, user)
|
|
||||||
} catch (reason) {
|
|
||||||
console.log(
|
|
||||||
`User scan exception for user ${user.username} ${reason}`
|
|
||||||
)
|
|
||||||
reject(reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
onError: error => {
|
|
||||||
session.close()
|
|
||||||
reject(error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,151 +0,0 @@
|
||||||
import fs from 'fs-extra'
|
|
||||||
import { resolve as pathResolve } from 'path'
|
|
||||||
import generateID from '../id-generator'
|
|
||||||
import { isImage, getAlbumCachePath } from './utils'
|
|
||||||
import _scanAlbum from './scanAlbum'
|
|
||||||
|
|
||||||
export default async function scanUser(scanner, user) {
|
|
||||||
const { driver } = scanner
|
|
||||||
|
|
||||||
console.log('Scanning user', user.username, 'at', user.rootPath)
|
|
||||||
|
|
||||||
let foundAlbumIds = []
|
|
||||||
|
|
||||||
async function scanPath(path) {
|
|
||||||
const list = fs.readdirSync(path)
|
|
||||||
|
|
||||||
let foundImageOrAlbum = false
|
|
||||||
let newAlbums = []
|
|
||||||
|
|
||||||
for (const item of list) {
|
|
||||||
const itemPath = pathResolve(path, item)
|
|
||||||
// console.log(`Scanning item ${itemPath}...`)
|
|
||||||
let stat = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
stat = await fs.stat(itemPath)
|
|
||||||
} catch {
|
|
||||||
console.log('ERROR reading file stat for item:', itemPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stat && stat.isDirectory()) {
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const findAlbumResult = await session.run(
|
|
||||||
'MATCH (a:Album { path: {path} }) RETURN a',
|
|
||||||
{
|
|
||||||
path: itemPath,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
const {
|
|
||||||
foundImage: imagesInDirectory,
|
|
||||||
newAlbums: childAlbums,
|
|
||||||
} = await scanPath(itemPath)
|
|
||||||
|
|
||||||
if (findAlbumResult.records.length > 0) {
|
|
||||||
const album = findAlbumResult.records[0].toObject().a.properties
|
|
||||||
console.log('Found existing album', album.title)
|
|
||||||
|
|
||||||
foundImageOrAlbum = true
|
|
||||||
foundAlbumIds.push(album.id)
|
|
||||||
await _scanAlbum(scanner, album)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imagesInDirectory) {
|
|
||||||
console.log(`Found new album at ${itemPath}`)
|
|
||||||
foundImageOrAlbum = true
|
|
||||||
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
console.log('Adding album')
|
|
||||||
const albumId = generateID()
|
|
||||||
const albumResult = await session.run(
|
|
||||||
`MATCH (u:User { id: {userId} })
|
|
||||||
CREATE (a:Album { id: {id}, title: {title}, path: {path} })
|
|
||||||
CREATE (u)-[own:OWNS]->(a)
|
|
||||||
RETURN a`,
|
|
||||||
{
|
|
||||||
id: albumId,
|
|
||||||
userId: user.id,
|
|
||||||
title: item,
|
|
||||||
path: itemPath,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
newAlbums.push(albumId)
|
|
||||||
const album = albumResult.records[0].toObject().a.properties
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Linking ${childAlbums.length} child albums for ${album.title}`
|
|
||||||
)
|
|
||||||
for (let childAlbum of childAlbums) {
|
|
||||||
await session.run(
|
|
||||||
`MATCH (parent:Album { id: {parentId} })
|
|
||||||
MATCH (child:Album { id: {childId} })
|
|
||||||
CREATE (parent)-[:SUBALBUM]->(child)`,
|
|
||||||
{
|
|
||||||
parentId: albumId,
|
|
||||||
childId: childAlbum,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
foundAlbumIds.push(album.id)
|
|
||||||
await _scanAlbum(scanner, album)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundImageOrAlbum && (await isImage(itemPath))) {
|
|
||||||
foundImageOrAlbum = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { foundImage: foundImageOrAlbum, newAlbums }
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdirp(user.rootPath)
|
|
||||||
|
|
||||||
await scanPath(user.rootPath)
|
|
||||||
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const userAlbumsResult = await session.run(
|
|
||||||
`MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album)
|
|
||||||
WHERE NOT a.id IN {foundAlbums}
|
|
||||||
OPTIONAL MATCH (a)-[:CONTAINS]->(p:Photo)-->(photoTail)
|
|
||||||
WITH a, p, photoTail, a.id AS albumId
|
|
||||||
DETACH DELETE a, p, photoTail
|
|
||||||
RETURN DISTINCT albumId`,
|
|
||||||
{ userId: user.id, foundAlbums: foundAlbumIds }
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('FOUND ALBUM IDS', foundAlbumIds)
|
|
||||||
const deletedAlbumIds = userAlbumsResult.records.map(record =>
|
|
||||||
record.get('albumId')
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const albumId of deletedAlbumIds) {
|
|
||||||
try {
|
|
||||||
await fs.remove(getAlbumCachePath(albumId))
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error while trying to delete album from cache', e, e.stack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Deleted ${userAlbumsResult.records.length} albums from ${user.username} that was not found locally`
|
|
||||||
)
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
console.log('User scan complete')
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
import fs from 'fs-extra'
|
|
||||||
import readChunk from 'read-chunk'
|
|
||||||
import imageType from 'image-type'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import path from 'path'
|
|
||||||
import config from '../config'
|
|
||||||
|
|
||||||
const rawTypes = ['cr2', 'arw', 'crw', 'dng']
|
|
||||||
const imageTypes = [...rawTypes, 'jpg', 'png', 'gif', 'bmp']
|
|
||||||
|
|
||||||
export const isImage = async path => {
|
|
||||||
if ((await fs.stat(path)).isDirectory()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = await readChunk(path, 0, 12)
|
|
||||||
const type = imageType(buffer)
|
|
||||||
|
|
||||||
return type && imageTypes.includes(type.ext)
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`isImage error at ${path}: ${JSON.stringify(e)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isRawImage = async path => {
|
|
||||||
try {
|
|
||||||
const buffer = await readChunk(path, 0, 12)
|
|
||||||
const { ext } = imageType(buffer)
|
|
||||||
|
|
||||||
return rawTypes.includes(ext)
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`isRawImage error at ${path}: ${JSON.stringify(e)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const imageSize = promisify(require('image-size'))
|
|
||||||
|
|
||||||
export const getAlbumCachePath = id =>
|
|
||||||
path.resolve(config.cachePath, 'images', id)
|
|
||||||
|
|
||||||
export const getImageCachePath = (imageId, albumId) =>
|
|
||||||
path.resolve(getAlbumCachePath(albumId), imageId)
|
|
|
@ -1,167 +0,0 @@
|
||||||
enum Role {
|
|
||||||
admin
|
|
||||||
user
|
|
||||||
}
|
|
||||||
|
|
||||||
type User {
|
|
||||||
id: ID!
|
|
||||||
username: String!
|
|
||||||
albums: [Album] @relation(name: "OWNS", direction: "OUT")
|
|
||||||
# Local filepath for the user's photos
|
|
||||||
rootPath: String! @hasRole(roles: [admin])
|
|
||||||
admin: Boolean
|
|
||||||
shareTokens: [ShareToken] @relation(name: "SHARE_TOKEN", direction: "OUT")
|
|
||||||
}
|
|
||||||
|
|
||||||
type Album {
|
|
||||||
id: ID!
|
|
||||||
title: String
|
|
||||||
photos: [Photo] @relation(name: "CONTAINS", direction: "OUT")
|
|
||||||
subAlbums: [Album] @relation(name: "SUBALBUM", direction: "OUT")
|
|
||||||
parentAlbum: Album @relation(name: "SUBALBUM", direction: "IN")
|
|
||||||
owner: User! @relation(name: "OWNS", direction: "IN")
|
|
||||||
path: String
|
|
||||||
|
|
||||||
shares: [ShareToken] @relation(name: "SHARES", direction: "IN")
|
|
||||||
}
|
|
||||||
|
|
||||||
type PhotoURL {
|
|
||||||
# URL for previewing the image
|
|
||||||
url: String
|
|
||||||
# Width of the image in pixels
|
|
||||||
width: Int
|
|
||||||
# Height of the image in pixels
|
|
||||||
height: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
type PhotoDownload {
|
|
||||||
title: String
|
|
||||||
url: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type PhotoEXIF {
|
|
||||||
photo: Photo @relation(name: "EXIF", direction: "IN")
|
|
||||||
camera: String
|
|
||||||
maker: String
|
|
||||||
lens: String
|
|
||||||
dateShot: DateTime
|
|
||||||
fileSize: String
|
|
||||||
exposure: String
|
|
||||||
aperture: Float
|
|
||||||
iso: Int
|
|
||||||
focalLength: String
|
|
||||||
flash: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Photo {
|
|
||||||
id: ID!
|
|
||||||
title: String
|
|
||||||
# Local filepath for the photo
|
|
||||||
path: String
|
|
||||||
# URL to display the photo in full resolution
|
|
||||||
original: PhotoURL @relation(name: "ORIGINAL_URL", direction: "OUT")
|
|
||||||
# URL to display the photo in a smaller resolution
|
|
||||||
thumbnail: PhotoURL @relation(name: "THUMBNAIL_URL", direction: "OUT")
|
|
||||||
# The album that holds the photo
|
|
||||||
album: Album! @relation(name: "CONTAINS", direction: "IN")
|
|
||||||
exif: PhotoEXIF @relation(name: "EXIF", direction: "OUT")
|
|
||||||
|
|
||||||
shares: [ShareToken] @relation(name: "SHARES", direction: "IN")
|
|
||||||
downloads: [PhotoDownload] @relation(name: "DOWNLOAD", direction: "OUT")
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShareToken {
|
|
||||||
token: ID!
|
|
||||||
owner: User @relation(name: "SHARE_TOKEN", direction: "IN")
|
|
||||||
# Optional expire date
|
|
||||||
expire: Date
|
|
||||||
# Optional password
|
|
||||||
# password: String
|
|
||||||
|
|
||||||
album: Album @relation(name: "SHARES", direction: "OUT")
|
|
||||||
photo: Photo @relation(name: "SHARES", direction: "OUT")
|
|
||||||
}
|
|
||||||
|
|
||||||
type SiteInfo {
|
|
||||||
initialSetup: Boolean!
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthorizeResult {
|
|
||||||
success: Boolean!
|
|
||||||
status: String
|
|
||||||
token: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScannerResult {
|
|
||||||
finished: Boolean!
|
|
||||||
success: Boolean!
|
|
||||||
progress: Float
|
|
||||||
message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result {
|
|
||||||
success: Boolean!
|
|
||||||
errorMessage: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Subscription {
|
|
||||||
scannerStatusUpdate: ScannerResult
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mutation {
|
|
||||||
authorizeUser(username: String!, password: String!): AuthorizeResult!
|
|
||||||
@neo4j_ignore
|
|
||||||
|
|
||||||
registerUser(
|
|
||||||
username: String!
|
|
||||||
password: String!
|
|
||||||
rootPath: String!
|
|
||||||
): AuthorizeResult! @hasRole(roles: [admin]) @neo4j_ignore
|
|
||||||
|
|
||||||
shareAlbum(albumId: ID!, expire: Date, password: String): ShareToken
|
|
||||||
@isAuthenticated
|
|
||||||
sharePhoto(photoId: ID!, expire: Date, password: String): ShareToken
|
|
||||||
@isAuthenticated
|
|
||||||
|
|
||||||
deleteShareToken(token: ID!): ShareToken @isAuthenticated
|
|
||||||
|
|
||||||
setAdmin(userId: ID!, admin: Boolean!): Result!
|
|
||||||
@hasRole(roles: [admin])
|
|
||||||
@neo4j_ignore
|
|
||||||
|
|
||||||
scanAll: ScannerResult! @isAuthenticated @neo4j_ignore
|
|
||||||
scanUser(userId: ID!): ScannerResult! @isAuthenticated @neo4j_ignore
|
|
||||||
|
|
||||||
initialSetupWizard(
|
|
||||||
username: String!
|
|
||||||
password: String!
|
|
||||||
rootPath: String!
|
|
||||||
): AuthorizeResult @neo4j_ignore
|
|
||||||
|
|
||||||
updateUser(id: ID!, username: String, rootPath: String, admin: Boolean): User
|
|
||||||
@hasRole(roles: [admin])
|
|
||||||
createUser(id: ID, username: String, rootPath: String, admin: Boolean): User
|
|
||||||
@hasRole(roles: [admin])
|
|
||||||
deleteUser(id: ID!): User @hasRole(roles: [admin])
|
|
||||||
|
|
||||||
changeUserPassword(id: ID!, newPassword: String!): Result
|
|
||||||
@hasRole(roles: [admin])
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query {
|
|
||||||
siteInfo: SiteInfo
|
|
||||||
|
|
||||||
myUser: User @isAuthenticated
|
|
||||||
user: [User] @hasRole(roles: [admin])
|
|
||||||
|
|
||||||
myAlbums: [Album] @isAuthenticated
|
|
||||||
album(id: ID): Album @isAuthenticated
|
|
||||||
|
|
||||||
myPhotos: [Photo] @isAuthenticated
|
|
||||||
photo(id: ID!): Photo @isAuthenticated
|
|
||||||
|
|
||||||
shareToken(token: ID!): ShareToken
|
|
||||||
|
|
||||||
albumShares(id: ID!, password: String): [ShareToken] @isAuthenticated
|
|
||||||
photoShares(id: ID!, password: String): [ShareToken] @isAuthenticated
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
|
|
||||||
export const getUserFromToken = async function(token, driver) {
|
|
||||||
const tokenContent = jwt.verify(token, process.env.JWT_SECRET)
|
|
||||||
const userId = tokenContent.id
|
|
||||||
|
|
||||||
const session = driver.session()
|
|
||||||
|
|
||||||
const userResult = await session.run(
|
|
||||||
'MATCH (u:User {id: {userId}}) RETURN u',
|
|
||||||
{
|
|
||||||
userId,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (userResult.records.length == 0) {
|
|
||||||
throw new Error(`User was not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = userResult.records[0].toObject().u.properties
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTokenFromBearer = bearer => {
|
|
||||||
let token = bearer
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('Missing auth token')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token.toLowerCase().startsWith('bearer ')) {
|
|
||||||
throw new Error('Invalid auth token')
|
|
||||||
}
|
|
||||||
|
|
||||||
token = token.substr(7)
|
|
||||||
|
|
||||||
return token
|
|
||||||
}
|
|
Loading…
Reference in New Issue