Work on sharing + some security hardening

This commit is contained in:
viktorstrate
2019-08-14 23:57:07 +02:00
parent ddf06e3073
commit 54f5750ab5
15 changed files with 360 additions and 65 deletions

View File

@@ -1,5 +1,7 @@
FROM node:10
ENV PRODUCTION=1
RUN mkdir -p /app
WORKDIR /app

2
api/package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "grand-stack-starter-api",
"name": "photoview-api",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,

58
api/src/graphql-schema.js Normal file
View File

@@ -0,0 +1,58 @@
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: ['Subscription', ...productionExcludes],
},
},
resolvers: resolvers.reduce((prev, curr) => _.merge(prev, curr), {}),
})
export default schema

7
api/src/id-generator.js Normal file
View File

@@ -0,0 +1,7 @@
import uuid from 'uuid'
function generateID() {
return uuid().substr(-12)
}
export default generateID

View File

@@ -1,11 +1,8 @@
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 cors from 'cors'
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/Scanner'
@@ -21,53 +18,6 @@ const app = express()
app.use(bodyParser.json())
app.use(cors())
/*
* Create an executable GraphQL schema object from GraphQL type definitions
* including autogenerated queries and mutations.
* Optionally a config object can be included to specify which types to include
* in generated queries and/or mutations. Read more in the docs:
* https://grandstack.io/docs/neo4j-graphql-js-api.html#makeaugmentedschemaoptions-graphqlschema
*/
const typeDefs = fs
.readFileSync(
process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')
)
.toString('utf-8')
import usersResolver from './resolvers/users'
import scannerResolver from './resolvers/scanner'
import photosResolver from './resolvers/photos'
import siteInfoResolver from './resolvers/siteInfo'
const resolvers = [
usersResolver,
scannerResolver,
photosResolver,
siteInfoResolver,
]
const schema = makeAugmentedSchema({
typeDefs,
config: {
auth: {
isAuthenticated: true,
hasRole: true,
},
mutation: false,
query: {
exclude: [
'ScannerResult',
'AuthorizeResult',
'Subscription',
'PhotoURL',
'SiteInfo',
],
},
},
resolvers: resolvers.reduce((prev, curr) => _.merge(prev, curr), {}),
})
/*
* Create a Neo4j driver instance to connect to the database
* using credentials specified as environment variables
@@ -83,6 +33,7 @@ const driver = neo4j.driver(
const scanner = new PhotoScanner(driver)
// Every 4th hour
setInterval(scanner.scanAll, 1000 * 60 * 60 * 4)
// Specify port and path for GraphQL endpoint
@@ -97,6 +48,9 @@ const endpointUrl = new URL(config.host)
* 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
@@ -116,9 +70,9 @@ const server = new ApolloServer({
endpoint: endpointUrl.toString(),
}
},
schema: schema,
schema,
introspection: true,
playground: true,
playground: !process.env.PRODUCTION,
subscriptions: {
onConnect: async (connectionParams, webSocket) => {
const token = getTokenFromBearer(connectionParams.Authorization)

View File

@@ -0,0 +1,12 @@
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
}

View File

@@ -0,0 +1,153 @@
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,
}
},
}
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
},
}
export default {
Mutation,
Query,
}

View File

@@ -1,5 +1,5 @@
import jwt from 'jsonwebtoken'
import uuid from 'uuid'
import generateID from '../id-generator'
import fs from 'fs-extra'
import { neo4jgraphql } from 'neo4j-graphql-js'
@@ -69,7 +69,7 @@ const Mutation = {
const registerResult = await session.run(
'CREATE (n:User { username: {username}, password: {password}, id: {id}, admin: false, rootPath: {rootPath} }) return n.id',
{ username, password, id: uuid(), rootPath }
{ username, password, id: generateID(), rootPath }
)
let id = registerResult.records[0].get('n.id')
@@ -100,7 +100,7 @@ const Mutation = {
}
}
args.id = uuid()
args.id = generateID()
return neo4jgraphql(root, args, ctx, info)
},

View File

@@ -1,6 +1,6 @@
import fs from 'fs-extra'
import path from 'path'
import uuid from 'uuid'
import generateID from '../id-generator'
import { isImage, getImageCachePath } from './utils'
export default async function scanAlbum(
@@ -45,7 +45,7 @@ export default async function scanAlbum(
processingImagePromises.push(processImage(photoId))
} else {
console.log(`Found new image at ${itemPath}`)
const imageId = uuid()
const imageId = generateID()
await session.run(
`MATCH (a:Album { id: {albumId} })
CREATE (p:Photo {id: {id}, path: {path}, title: {title} })

View File

@@ -1,6 +1,6 @@
import fs from 'fs-extra'
import { resolve as pathResolve } from 'path'
import uuid from 'uuid'
import generateID from '../id-generator'
import { isImage, getAlbumCachePath } from './utils'
export default async function scanUser({ driver, scanAlbum }, user) {
@@ -9,7 +9,6 @@ export default async function scanUser({ driver, scanAlbum }, user) {
let foundAlbumIds = []
async function scanPath(path, parentAlbum) {
console.log('SCAN PATH', path)
const list = fs.readdirSync(path)
let foundImageOrAlbum = false
@@ -63,7 +62,7 @@ export default async function scanUser({ driver, scanAlbum }, user) {
const session = driver.session()
console.log('Adding album')
const albumId = uuid()
const albumId = generateID()
const albumResult = await session.run(
`MATCH (u:User { id: {userId} })
CREATE (a:Album { id: {id}, title: {title}, path: {path} })

View File

@@ -10,6 +10,7 @@ type User {
# Local filepath for the user's photos
rootPath: String! @hasRole(roles: [admin])
admin: Boolean
shareTokens: [ShareToken] @relation(name: "SHARE_TOKEN", direction: "OUT")
}
type Album {
@@ -20,6 +21,8 @@ type Album {
parentAlbum: Album @relation(name: "SUBALBUM", direction: "IN")
owner: User! @relation(name: "OWNS", direction: "IN")
path: String
shares: [ShareToken] @relation(name: "SHARES", direction: "OUT")
}
type PhotoURL {
@@ -57,6 +60,20 @@ type Photo {
# 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: "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: "IN")
photo: Photo @relation(name: "SHARES", direction: "IN")
}
type SiteInfo {
@@ -95,6 +112,11 @@ type Mutation {
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
setAdmin(userId: ID!, admin: Boolean!): Result!
@hasRole(roles: [admin])
@neo4j_ignore
@@ -126,5 +148,8 @@ type Query {
album(id: ID): Album @isAuthenticated
myPhotos: [Photo] @isAuthenticated
photo(id: ID): Photo @isAuthenticated
photo(id: ID!): Photo @isAuthenticated
albumShares(id: ID!, password: String): [ShareToken] @isAuthenticated
photoShares(id: ID!, password: String): [ShareToken] @isAuthenticated
}

View File

@@ -97,7 +97,9 @@ class PhotosPage extends Component {
galleryGroups = data.myAlbums.map((album, index) => (
<div key={album.id}>
<Link to={`/album/${album.id}`}>
<h1>{album.title}</h1>
<h1 style={{ color: 'black', margin: '24px 0 12px 0' }}>
{album.title}
</h1>
</Link>
<PhotoGallery
onSelectImage={photoIndex => {

View File

@@ -45,7 +45,9 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.map(({ message, locations, path }) =>
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
`[GraphQL error]: Message: ${message}, Location: ${JSON.stringify(
locations
)} Path: ${path}`
)
)
if (networkError) {

View File

@@ -5,10 +5,12 @@ import gql from 'graphql-tag'
import { SidebarItem } from './SidebarItem'
import { Loader } from 'semantic-ui-react'
import ProtectedImage from '../photoGallery/ProtectedImage'
import SidebarShare from './Sharing'
const photoQuery = gql`
query sidebarPhoto($id: ID) {
query sidebarPhoto($id: ID!) {
photo(id: $id) {
id
title
original {
url
@@ -122,6 +124,7 @@ class AlbumSidebar extends Component {
/>
<Name>{photo && photo.title}</Name>
<div>{exifItems}</div>
<SidebarShare photo={photo} />
</div>
)
}}

View File

@@ -0,0 +1,78 @@
import React from 'react'
import { Query } from 'react-apollo'
import gql from 'graphql-tag'
import { Table, Button, Icon, Dropdown } from 'semantic-ui-react'
const shareQuery = gql`
query sidbarGetShares($photoId: ID!) {
photoShares(id: $photoId) {
token
}
}
`
const SidebarShare = ({ photo }) => {
if (!photo || !photo.id) return null
return (
<div>
<h2>Sharing options</h2>
<Query query={shareQuery} variables={{ photoId: photo.id }}>
{({ loading, error, data }) => {
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
const rows = data.photoShares.map(share => (
<Table.Row key={share.token}>
<Table.Cell>
<b>Public Link</b> {share.token}
</Table.Cell>
<Table.Cell>
<Button.Group>
<Button icon="chain" content="Copy" />
<Dropdown button text="More">
<Dropdown.Menu>
<Dropdown.Item text="Delete" icon="delete" />
</Dropdown.Menu>
</Dropdown>
</Button.Group>
</Table.Cell>
</Table.Row>
))
if (rows.length == 0) {
rows.push(
<Table.Row>
<Table.Cell colSpan="2">No shares found</Table.Cell>
</Table.Row>
)
}
return (
<div>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell colSpan="2">
Public Shares
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{rows}</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan="2">
<Button content="New" floated="right" positive />
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</div>
)
}}
</Query>
</div>
)
}
export default SidebarShare