From f0bf1c2dc4cd717afb3b0d2527c6fa012de0b15a Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 16 Jul 2019 18:31:48 +0200 Subject: [PATCH] Scanner work + image processing --- .gitignore | 2 + api/package.json | 7 + api/src/Scanner.js | 258 +++++++++++++++++++++++++++++------ api/src/config.js | 5 + api/src/index.js | 101 ++++++++++---- api/src/resolvers/photos.js | 74 ++++++++++ api/src/resolvers/scanner.js | 8 +- api/src/resolvers/users.js | 5 +- api/src/schema.graphql | 35 +++-- api/src/test.js | 1 - api/src/token.js | 4 +- ui/package.json | 1 + ui/yarn.lock | 5 + 13 files changed, 420 insertions(+), 86 deletions(-) create mode 100644 api/src/config.js create mode 100644 api/src/resolvers/photos.js delete mode 100644 api/src/test.js diff --git a/.gitignore b/.gitignore index 9c0513dc..d209a838 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # See https://help.github.com/ignore-files/ for more about ignoring files. +/api/src/cache/ + # dependencies node_modules/ diff --git a/api/package.json b/api/package.json index 3d7bd571..84565879 100644 --- a/api/package.json +++ b/api/package.json @@ -23,14 +23,21 @@ "apollo-server": "^2.6.2", "babel-runtime": "^6.26.0", "body-parser": "^1.19.0", + "cookie-parser": "^1.4.4", "core-js": "^3.1.4", "dotenv": "^8.0.0", + "exiftool-vendored": "^8.15.0", + "fs-extra": "^8.1.0", "graphql-tag": "^2.10.1", + "image-size": "^0.7.4", + "img-type": "^0.1.13", "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.14", "neo4j-driver": "^1.7.3", "neo4j-graphql-js": "^2.6.3", "node-fetch": "^2.3.0", "regenerator-runtime": "^0.13.2", + "sharp": "^0.22.1", "uuid": "^3.3.2" }, "devDependencies": { diff --git a/api/src/Scanner.js b/api/src/Scanner.js index d4f06f66..baa97e78 100644 --- a/api/src/Scanner.js +++ b/api/src/Scanner.js @@ -1,4 +1,12 @@ +import fs from 'fs-extra' +import path from 'path' +import { resolve as pathResolve, basename as pathBasename } from 'path' import { PubSub } from 'apollo-server' +import imgType from 'img-type' +import uuid from 'uuid' +import { exiftool } from 'exiftool-vendored' +import sharp from 'sharp' +import config from './config' export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS' @@ -7,6 +15,11 @@ class PhotoScanner { this.driver = driver this.isRunning = false this.pubsub = new PubSub() + + this.scanAll = this.scanAll.bind(this) + this.scanAlbum = this.scanAlbum.bind(this) + this.scanUser = this.scanUser.bind(this) + this.processImage = this.processImage.bind(this) } async scanAll() { @@ -23,55 +36,216 @@ class PhotoScanner { let session = this.driver.session() - session - .run( - 'MATCH (u:User) return u.id AS id, u.username AS username, u.rootPath as path' - ) - .subscribe({ - onNext: function(record) { - const username = record.get('username') - const id = record.get('id') - const path = record.get('path') + session.run('MATCH (u:User) return u').subscribe({ + onNext: record => { + const user = record.toObject().u.properties - if (!path) { - console.log(`User ${username}, has no root path, skipping`) - return - } + console.log('USER', user) - this.scanUser(id) + if (!user.rootPath) { + console.log(`User ${user.username}, has no root path, skipping`) + return + } - console.log(`Scanning ${username}...`) - }, - onCompleted: () => { - session.close() - this.isRunning = false - console.log('Done scanning') + this.scanUser(user) - this.pubsub.publish(EVENT_SCANNER_PROGRESS, { - scannerStatusUpdate: { - progress: 100, - finished: true, - error: false, - errorMessage: '', - }, - }) - }, - onError: error => { - console.error(error) + console.log(`Scanning ${user.username}...`) + }, + onCompleted: () => { + session.close() + this.isRunning = false + console.log('Done scanning') - this.pubsub.publish(EVENT_SCANNER_PROGRESS, { - scannerStatusUpdate: { - progress: 0, - finished: false, - error: true, - errorMessage: error.message, - }, - }) - }, - }) + this.pubsub.publish(EVENT_SCANNER_PROGRESS, { + scannerStatusUpdate: { + progress: 100, + finished: true, + error: false, + errorMessage: '', + }, + }) + }, + onError: error => { + console.error(error) + + this.pubsub.publish(EVENT_SCANNER_PROGRESS, { + scannerStatusUpdate: { + progress: 0, + finished: false, + error: true, + errorMessage: error.message, + }, + }) + }, + }) } - async scanUser(id) {} + async scanUser(user) { + console.log('Scanning path', user.rootPath) + + const driver = this.driver + const scanAlbum = this.scanAlbum + + async function scanPath(path) { + const list = fs.readdirSync(path) + + let foundImage = false + + for (const item of list) { + const itemPath = pathResolve(path, item) + // console.log(`Scanning item ${itemPath}...`) + const stat = fs.statSync(itemPath) + + if (stat.isDirectory()) { + // console.log(`Entering directory ${itemPath}`) + const imagesInDirectory = await scanPath(itemPath) + + if (imagesInDirectory) { + console.log(`Found album at ${itemPath}`) + const session = driver.session() + + const findAlbumResult = await session.run( + 'MATCH (a:Album { path: {path} }) RETURN a', + { + path: itemPath, + } + ) + + console.log('FIND ALBUM RESULT', findAlbumResult.records) + + if (findAlbumResult.records.length != 0) { + console.log('Album already exists') + + const album = findAlbumResult.records[0].toObject().a.properties + scanAlbum(album) + + continue + } + + console.log('Adding album') + const albumId = uuid() + 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, + } + ) + + const album = albumResult.records[0].toObject().a.properties + scanAlbum(album) + + session.close() + } + + continue + } + + if (!foundImage && (await imgType.isImg(itemPath))) { + foundImage = true + } + } + + return foundImage + } + + await scanPath(user.rootPath) + console.log('User scan complete') + } + + async scanAlbum(album) { + const { title, path, id } = album + console.log('Scanning album', title) + + const list = fs.readdirSync(path) + + for (const item of list) { + const itemPath = pathResolve(path, item) + + if (await imgType.isImg(itemPath)) { + const session = this.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}`) + } else { + console.log(`Found new image at ${itemPath}`) + const imageId = uuid() + await session.run( + `MATCH (a:Album { id: {albumId} }) + CREATE (p:Photo {id: {id}, path: {path}, title: {title} }) + CREATE (a)-[:CONTAINS]->(p)`, + { + id: imageId, + path: itemPath, + title: item, + albumId: id, + } + ) + + this.processImage(imageId) + } + } + } + } + + async processImage(id) { + console.log('Processing image') + const session = this.driver.session() + + const result = await session.run('MATCH (p:Photo { id: {id} }) return p', { + id, + }) + const photo = result.records[0].get('p').properties + + console.log('PHOTO', photo.path) + + const imagePath = path.resolve(config.cachePath, 'images', id) + + await fs.remove(imagePath) + await fs.mkdirp(imagePath) + + const type = await imgType.getType(photo.path) + + let resizeBaseImg = photo.path + + const rawTypes = ['cr2', 'arw', 'crw', 'dng'] + + if (rawTypes.includes(type)) { + console.log('Processing RAW image') + + const extractedPath = path.resolve(imagePath, 'extracted.jpg') + await exiftool.extractPreview(photo.path, extractedPath) + + resizeBaseImg = extractedPath + } + + // Resize image + console.log('Resizing image', resizeBaseImg) + await sharp(resizeBaseImg) + .resize(1440, 1080, { fit: 'inside', withoutEnlargement: true }) + .toFile(path.resolve(imagePath, 'thumbnail.jpg')) + + await sharp(resizeBaseImg) + .jpeg({ quality: 85 }) + .toFile(path.resolve(imagePath, 'original.jpg')) + + session.close() + + console.log('Processing done') + } } export default PhotoScanner diff --git a/api/src/config.js b/api/src/config.js new file mode 100644 index 00000000..c6fbf599 --- /dev/null +++ b/api/src/config.js @@ -0,0 +1,5 @@ +import path from 'path' + +export default { + cachePath: path.resolve(__dirname, 'cache'), +} diff --git a/api/src/index.js b/api/src/index.js index 67b92b0a..b2c4b438 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -1,13 +1,16 @@ -import fs from 'fs' +import fs from 'fs-extra' import path from 'path' import { ApolloServer } from 'apollo-server-express' import express from 'express' import bodyParser from 'body-parser' +import cookieParser from 'cookie-parser' import { v1 as neo4j } from 'neo4j-driver' import { makeAugmentedSchema } from 'neo4j-graphql-js' import dotenv from 'dotenv' import http from 'http' import PhotoScanner from './Scanner' +import _ from 'lodash' +import config from './config' import { getUserFromToken, getTokenFromBearer } from './token' @@ -16,6 +19,7 @@ dotenv.config() const app = express() app.use(bodyParser.json()) +app.use(cookieParser()) /* * Create an executable GraphQL schema object from GraphQL type definitions @@ -33,6 +37,9 @@ const typeDefs = fs import usersResolver from './resolvers/users' import scannerResolver from './resolvers/scanner' +import photosResolver from './resolvers/photos' + +const resolvers = [usersResolver, scannerResolver, photosResolver] const schema = makeAugmentedSchema({ typeDefs, @@ -42,19 +49,12 @@ const schema = makeAugmentedSchema({ hasRole: true, }, mutation: false, - query: { - exclude: ['ScannerResult', 'AuthorizeResult', 'Subscription'], - }, - }, - resolvers: { - Mutation: { - ...usersResolver.mutation, - ...scannerResolver.mutation, - }, - Subscription: { - ...scannerResolver.subscription, - }, + query: false, + // query: { + // exclude: ['ScannerResult', 'AuthorizeResult', 'Subscription'], + // }, }, + resolvers: resolvers.reduce((prev, curr) => _.merge(prev, curr), {}), }) /* @@ -72,6 +72,10 @@ const driver = neo4j.driver( const scanner = new PhotoScanner(driver) +// Specify port and path for GraphQL endpoint +const port = process.env.GRAPHQL_LISTEN_PORT || 4001 +const graphPath = '/graphql' + /* * Create a new ApolloServer instance, serving the GraphQL schema * created using makeAugmentedSchema above and injecting the Neo4j driver @@ -81,13 +85,21 @@ const scanner = new PhotoScanner(driver) const server = new ApolloServer({ context: async function({ req }) { let user = null + let token = null if (req && req.headers.authorization) { - const token = getTokenFromBearer(req.headers.authorization) + token = getTokenFromBearer(req.headers.authorization) user = await getUserFromToken(token, driver) } - return { ...req, driver, scanner, user } + return { + ...req, + driver, + scanner, + user, + token, + endpoint: `http://localhost:${port}`, + } }, schema: schema, introspection: true, @@ -105,16 +117,59 @@ const server = new ApolloServer({ }, }) -// Specify port and path for GraphQL endpoint -const port = process.env.GRAPHQL_LISTEN_PORT || 4001 -const graphPath = '/graphql' - -/* - * Optionally, apply Express middleware for authentication, etc - * This also also allows us to specify a path for the GraphQL endpoint - */ server.applyMiddleware({ app, graphPath }) +app.use('/images/:id/:image', async function(req, res) { + const { id, image } = req.params + + console.log('image', image) + + if (image != 'original.jpg' && image != 'thumbnail.jpg') { + return res.status(404).send('Image not found') + } + + let user = null + + try { + const token = req.cookies.token + user = await getUserFromToken(token, driver) + } catch (err) { + return res.status(401).send(err.message) + } + + const session = driver.session() + + const result = await session.run( + 'MATCH (p:Photo { id: {id} }) MATCH (p)<-[:CONTAINS]-(:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId', + { + id, + } + ) + + if (result.records.length == 0) { + return res.status(404).send(`Image not found`) + } + + const userId = result.records[0].get('userId') + const photo = result.records[0].get('photo') + + if (userId != user.id) { + return res.status(401).send(`Image not owned by you`) + } + + session.close() + + const imagePath = path.resolve(config.cachePath, 'images', id, image) + const imageFound = await fs.exists(imagePath) + + if (!imageFound) { + console.log('Image not found', imagePath) + await scanner.processImage(id) + } + + res.sendFile(imagePath) +}) + const httpServer = http.createServer(app) server.installSubscriptionHandlers(httpServer) diff --git a/api/src/resolvers/photos.js b/api/src/resolvers/photos.js new file mode 100644 index 00000000..3266ac32 --- /dev/null +++ b/api/src/resolvers/photos.js @@ -0,0 +1,74 @@ +import { cypherQuery } from 'neo4j-graphql-js' +import { promisify } from 'util' +import fs from 'fs-extra' +import path from 'path' +import config from '../config' + +const imageSize = promisify(require('image-size')) + +function injectAt(query, index, injection) { + return query.substr(0, index) + injection + query.substr(index) +} + +const Query = { + myAlbums: async function(root, 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 + console.log(query) + + const addIDSplit = query[0].indexOf('album_photos {') + 14 + + console.log('ID SPLIT', query[0].substr(0, addIDSplit)) + query[0] = injectAt(query[0], addIDSplit, `.id,`) + + const session = ctx.driver.session() + + const result = await session.run(...query) + + session.close() + + return result.records.map(record => record.get('album')) + }, +} + +const Photo = { + async original(root, args, ctx, info) { + const imgPath = path.resolve( + config.cachePath, + 'images', + root.id, + 'original.jpg' + ) + + if (!(await fs.exists(imgPath))) { + await ctx.scanner.processImage(root.id) + } + + const { width, height } = await imageSize(imgPath) + return { + path: `${ctx.endpoint}/images/${root.id}/original.jpg`, + width, + height, + } + }, + async thumbnail(root, args, ctx, info) { + return { + path: `${ctx.endpoint}/images/${root.id}/thumbnail.jpg`, + width: 120, + height: 240, + } + }, +} + +export default { + Query, + Photo, +} diff --git a/api/src/resolvers/scanner.js b/api/src/resolvers/scanner.js index 55a9a3a2..08adda1c 100644 --- a/api/src/resolvers/scanner.js +++ b/api/src/resolvers/scanner.js @@ -1,6 +1,6 @@ import { EVENT_SCANNER_PROGRESS } from '../scanner' -const mutation = { +const Mutation = { async scanAll(root, args, ctx, info) { if (ctx.scanner.isRunning) { return { @@ -20,7 +20,7 @@ const mutation = { }, } -const subscription = { +const Subscription = { scannerStatusUpdate: { subscribe(root, args, ctx, info) { return ctx.scanner.pubsub.asyncIterator([EVENT_SCANNER_PROGRESS]) @@ -29,6 +29,6 @@ const subscription = { } export default { - mutation, - subscription, + Mutation, + Subscription, } diff --git a/api/src/resolvers/users.js b/api/src/resolvers/users.js index a9c39188..8bd84e3f 100644 --- a/api/src/resolvers/users.js +++ b/api/src/resolvers/users.js @@ -1,8 +1,9 @@ import jwt from 'jsonwebtoken' import uuid from 'uuid' -const mutation = { +const Mutation = { async authorizeUser(root, args, ctx, info) { + console.log('Authorize user') let { username, password } = args let session = ctx.driver.session() @@ -69,5 +70,5 @@ const mutation = { } export default { - mutation, + Mutation, } diff --git a/api/src/schema.graphql b/api/src/schema.graphql index e0ea072a..5e2c2169 100644 --- a/api/src/schema.graphql +++ b/api/src/schema.graphql @@ -6,28 +6,42 @@ enum Role { type User @isAuthenticated { id: ID! username: String! + #: password: String albums: [Album] @relation(name: "OWNS", direction: "OUT") rootPath: String! @hasRole(roles: [Admin]) admin: Boolean } -type Album @isAuthenticated { +type Album { id: ID! title: String photos: [Photo] @relation(name: "CONTAINS", direction: "OUT") owner: User! @relation(name: "OWNS", direction: "IN") + path: String } -type Photo @isAuthenticated { +type UnimportedAlbum { + id: ID! + relativePath: String +} + +type PhotoURL { + path: String + width: Int + height: Int +} + +type Photo { id: ID! title: String - path: String! + original: PhotoURL @neo4j_ignore + thumbnail: PhotoURL @neo4j_ignore album: Album! @relation(name: "CONTAINS", direction: "IN") } type SiteInfo { signupEnabled: Boolean! - firstSignup: Boolean! + initialSetup: Boolean! } type AuthorizeResult { @@ -49,18 +63,15 @@ type Subscription { type Mutation { authorizeUser(username: String!, password: String!): AuthorizeResult! + @neo4j_ignore registerUser(username: String!, password: String!): AuthorizeResult! + @neo4j_ignore - scanAll: ScannerResult! @isAuthenticated + scanAll: ScannerResult! @isAuthenticated @neo4j_ignore } type Query { siteInfo: SiteInfo -} -# type Query { -# usersBySubstring(substring: String): [User] -# @cypher( -# statement: "MATCH (u:User) WHERE u.name CONTAINS $substring RETURN u" -# ) -# } + myAlbums: [Album] @isAuthenticated +} diff --git a/api/src/test.js b/api/src/test.js deleted file mode 100644 index d43ae6d4..00000000 --- a/api/src/test.js +++ /dev/null @@ -1 +0,0 @@ -const neo4j = require('neo4j-graphql-js') diff --git a/api/src/token.js b/api/src/token.js index 1f4c6c27..f25703f2 100644 --- a/api/src/token.js +++ b/api/src/token.js @@ -14,10 +14,10 @@ export const getUserFromToken = async function(token, driver) { ) if (userResult.records.length == 0) { - throw new Error(`User doesn't exist anymore`) + throw new Error(`User was not found`) } - const user = userResult.records[0].toObject() + let user = userResult.records[0].toObject().u.properties session.close() diff --git a/ui/package.json b/ui/package.json index f2c365f4..eb0d97bc 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,6 +11,7 @@ "apollo-link-error": "^1.1.11", "apollo-link-http": "^1.5.15", "babel-plugin-styled-components": "^1.10.6", + "cookie": "^0.4.0", "graphql": "^14.2.1", "graphql-tag": "^2.10.1", "parcel-bundler": "^1.12.3", diff --git a/ui/yarn.lock b/ui/yarn.lock index c1b8ce47..39d01c1c 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1814,6 +1814,11 @@ convert-source-map@^1.1.0, convert-source-map@^1.5.1: dependencies: safe-buffer "~5.1.1" +cookie@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" + integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"