1
Fork 0

Scanner work + image processing

This commit is contained in:
viktorstrate 2019-07-16 18:31:48 +02:00
parent 286cfa992b
commit f0bf1c2dc4
13 changed files with 420 additions and 86 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
# See https://help.github.com/ignore-files/ for more about ignoring files. # See https://help.github.com/ignore-files/ for more about ignoring files.
/api/src/cache/
# dependencies # dependencies
node_modules/ node_modules/

View File

@ -23,14 +23,21 @@
"apollo-server": "^2.6.2", "apollo-server": "^2.6.2",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"cookie-parser": "^1.4.4",
"core-js": "^3.1.4", "core-js": "^3.1.4",
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"exiftool-vendored": "^8.15.0",
"fs-extra": "^8.1.0",
"graphql-tag": "^2.10.1", "graphql-tag": "^2.10.1",
"image-size": "^0.7.4",
"img-type": "^0.1.13",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lodash": "^4.17.14",
"neo4j-driver": "^1.7.3", "neo4j-driver": "^1.7.3",
"neo4j-graphql-js": "^2.6.3", "neo4j-graphql-js": "^2.6.3",
"node-fetch": "^2.3.0", "node-fetch": "^2.3.0",
"regenerator-runtime": "^0.13.2", "regenerator-runtime": "^0.13.2",
"sharp": "^0.22.1",
"uuid": "^3.3.2" "uuid": "^3.3.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -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 { 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' export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
@ -7,6 +15,11 @@ class PhotoScanner {
this.driver = driver this.driver = driver
this.isRunning = false this.isRunning = false
this.pubsub = new PubSub() 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() { async scanAll() {
@ -23,55 +36,216 @@ class PhotoScanner {
let session = this.driver.session() let session = this.driver.session()
session session.run('MATCH (u:User) return u').subscribe({
.run( onNext: record => {
'MATCH (u:User) return u.id AS id, u.username AS username, u.rootPath as path' const user = record.toObject().u.properties
)
.subscribe({
onNext: function(record) {
const username = record.get('username')
const id = record.get('id')
const path = record.get('path')
if (!path) { console.log('USER', user)
console.log(`User ${username}, has no root path, skipping`)
return
}
this.scanUser(id) if (!user.rootPath) {
console.log(`User ${user.username}, has no root path, skipping`)
return
}
console.log(`Scanning ${username}...`) this.scanUser(user)
},
onCompleted: () => {
session.close()
this.isRunning = false
console.log('Done scanning')
this.pubsub.publish(EVENT_SCANNER_PROGRESS, { console.log(`Scanning ${user.username}...`)
scannerStatusUpdate: { },
progress: 100, onCompleted: () => {
finished: true, session.close()
error: false, this.isRunning = false
errorMessage: '', console.log('Done scanning')
},
})
},
onError: error => {
console.error(error)
this.pubsub.publish(EVENT_SCANNER_PROGRESS, { this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: { scannerStatusUpdate: {
progress: 0, progress: 100,
finished: false, finished: true,
error: true, error: false,
errorMessage: error.message, 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 export default PhotoScanner

5
api/src/config.js Normal file
View File

@ -0,0 +1,5 @@
import path from 'path'
export default {
cachePath: path.resolve(__dirname, 'cache'),
}

View File

@ -1,13 +1,16 @@
import fs from 'fs' import fs from 'fs-extra'
import path from 'path' import path from 'path'
import { ApolloServer } from 'apollo-server-express' import { ApolloServer } from 'apollo-server-express'
import express from 'express' import express from 'express'
import bodyParser from 'body-parser' import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import { v1 as neo4j } from 'neo4j-driver' import { v1 as neo4j } from 'neo4j-driver'
import { makeAugmentedSchema } from 'neo4j-graphql-js' import { makeAugmentedSchema } from 'neo4j-graphql-js'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import http from 'http' import http from 'http'
import PhotoScanner from './Scanner' import PhotoScanner from './Scanner'
import _ from 'lodash'
import config from './config'
import { getUserFromToken, getTokenFromBearer } from './token' import { getUserFromToken, getTokenFromBearer } from './token'
@ -16,6 +19,7 @@ dotenv.config()
const app = express() const app = express()
app.use(bodyParser.json()) app.use(bodyParser.json())
app.use(cookieParser())
/* /*
* Create an executable GraphQL schema object from GraphQL type definitions * Create an executable GraphQL schema object from GraphQL type definitions
@ -33,6 +37,9 @@ const typeDefs = fs
import usersResolver from './resolvers/users' import usersResolver from './resolvers/users'
import scannerResolver from './resolvers/scanner' import scannerResolver from './resolvers/scanner'
import photosResolver from './resolvers/photos'
const resolvers = [usersResolver, scannerResolver, photosResolver]
const schema = makeAugmentedSchema({ const schema = makeAugmentedSchema({
typeDefs, typeDefs,
@ -42,19 +49,12 @@ const schema = makeAugmentedSchema({
hasRole: true, hasRole: true,
}, },
mutation: false, mutation: false,
query: { query: false,
exclude: ['ScannerResult', 'AuthorizeResult', 'Subscription'], // query: {
}, // exclude: ['ScannerResult', 'AuthorizeResult', 'Subscription'],
}, // },
resolvers: {
Mutation: {
...usersResolver.mutation,
...scannerResolver.mutation,
},
Subscription: {
...scannerResolver.subscription,
},
}, },
resolvers: resolvers.reduce((prev, curr) => _.merge(prev, curr), {}),
}) })
/* /*
@ -72,6 +72,10 @@ const driver = neo4j.driver(
const scanner = new PhotoScanner(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 * Create a new ApolloServer instance, serving the GraphQL schema
* created using makeAugmentedSchema above and injecting the Neo4j driver * created using makeAugmentedSchema above and injecting the Neo4j driver
@ -81,13 +85,21 @@ const scanner = new PhotoScanner(driver)
const server = new ApolloServer({ const server = new ApolloServer({
context: async function({ req }) { context: async function({ req }) {
let user = null let user = null
let token = null
if (req && req.headers.authorization) { if (req && req.headers.authorization) {
const token = getTokenFromBearer(req.headers.authorization) token = getTokenFromBearer(req.headers.authorization)
user = await getUserFromToken(token, driver) user = await getUserFromToken(token, driver)
} }
return { ...req, driver, scanner, user } return {
...req,
driver,
scanner,
user,
token,
endpoint: `http://localhost:${port}`,
}
}, },
schema: schema, schema: schema,
introspection: true, 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 }) 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) const httpServer = http.createServer(app)
server.installSubscriptionHandlers(httpServer) server.installSubscriptionHandlers(httpServer)

View File

@ -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,
}

View File

@ -1,6 +1,6 @@
import { EVENT_SCANNER_PROGRESS } from '../scanner' import { EVENT_SCANNER_PROGRESS } from '../scanner'
const mutation = { const Mutation = {
async scanAll(root, args, ctx, info) { async scanAll(root, args, ctx, info) {
if (ctx.scanner.isRunning) { if (ctx.scanner.isRunning) {
return { return {
@ -20,7 +20,7 @@ const mutation = {
}, },
} }
const subscription = { const Subscription = {
scannerStatusUpdate: { scannerStatusUpdate: {
subscribe(root, args, ctx, info) { subscribe(root, args, ctx, info) {
return ctx.scanner.pubsub.asyncIterator([EVENT_SCANNER_PROGRESS]) return ctx.scanner.pubsub.asyncIterator([EVENT_SCANNER_PROGRESS])
@ -29,6 +29,6 @@ const subscription = {
} }
export default { export default {
mutation, Mutation,
subscription, Subscription,
} }

View File

@ -1,8 +1,9 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import uuid from 'uuid' import uuid from 'uuid'
const mutation = { const Mutation = {
async authorizeUser(root, args, ctx, info) { async authorizeUser(root, args, ctx, info) {
console.log('Authorize user')
let { username, password } = args let { username, password } = args
let session = ctx.driver.session() let session = ctx.driver.session()
@ -69,5 +70,5 @@ const mutation = {
} }
export default { export default {
mutation, Mutation,
} }

View File

@ -6,28 +6,42 @@ enum Role {
type User @isAuthenticated { type User @isAuthenticated {
id: ID! id: ID!
username: String! username: String!
#: password: String
albums: [Album] @relation(name: "OWNS", direction: "OUT") albums: [Album] @relation(name: "OWNS", direction: "OUT")
rootPath: String! @hasRole(roles: [Admin]) rootPath: String! @hasRole(roles: [Admin])
admin: Boolean admin: Boolean
} }
type Album @isAuthenticated { type Album {
id: ID! id: ID!
title: String title: String
photos: [Photo] @relation(name: "CONTAINS", direction: "OUT") photos: [Photo] @relation(name: "CONTAINS", direction: "OUT")
owner: User! @relation(name: "OWNS", direction: "IN") 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! id: ID!
title: String title: String
path: String! original: PhotoURL @neo4j_ignore
thumbnail: PhotoURL @neo4j_ignore
album: Album! @relation(name: "CONTAINS", direction: "IN") album: Album! @relation(name: "CONTAINS", direction: "IN")
} }
type SiteInfo { type SiteInfo {
signupEnabled: Boolean! signupEnabled: Boolean!
firstSignup: Boolean! initialSetup: Boolean!
} }
type AuthorizeResult { type AuthorizeResult {
@ -49,18 +63,15 @@ type Subscription {
type Mutation { type Mutation {
authorizeUser(username: String!, password: String!): AuthorizeResult! authorizeUser(username: String!, password: String!): AuthorizeResult!
@neo4j_ignore
registerUser(username: String!, password: String!): AuthorizeResult! registerUser(username: String!, password: String!): AuthorizeResult!
@neo4j_ignore
scanAll: ScannerResult! @isAuthenticated scanAll: ScannerResult! @isAuthenticated @neo4j_ignore
} }
type Query { type Query {
siteInfo: SiteInfo siteInfo: SiteInfo
}
# type Query { myAlbums: [Album] @isAuthenticated
# usersBySubstring(substring: String): [User] }
# @cypher(
# statement: "MATCH (u:User) WHERE u.name CONTAINS $substring RETURN u"
# )
# }

View File

@ -1 +0,0 @@
const neo4j = require('neo4j-graphql-js')

View File

@ -14,10 +14,10 @@ export const getUserFromToken = async function(token, driver) {
) )
if (userResult.records.length == 0) { 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() session.close()

View File

@ -11,6 +11,7 @@
"apollo-link-error": "^1.1.11", "apollo-link-error": "^1.1.11",
"apollo-link-http": "^1.5.15", "apollo-link-http": "^1.5.15",
"babel-plugin-styled-components": "^1.10.6", "babel-plugin-styled-components": "^1.10.6",
"cookie": "^0.4.0",
"graphql": "^14.2.1", "graphql": "^14.2.1",
"graphql-tag": "^2.10.1", "graphql-tag": "^2.10.1",
"parcel-bundler": "^1.12.3", "parcel-bundler": "^1.12.3",

View File

@ -1814,6 +1814,11 @@ convert-source-map@^1.1.0, convert-source-map@^1.5.1:
dependencies: dependencies:
safe-buffer "~5.1.1" 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: copy-descriptor@^0.1.0:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"