diff --git a/api/Dockerfile b/api/Dockerfile index 51e36b52..cd07071b 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,7 @@ FROM node:10 +ENV PRODUCTION=1 + RUN mkdir -p /app WORKDIR /app diff --git a/api/package-lock.json b/api/package-lock.json index 507f9ccb..2a8604b5 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,5 +1,5 @@ { - "name": "grand-stack-starter-api", + "name": "photoview-api", "version": "0.0.1", "lockfileVersion": 1, "requires": true, diff --git a/api/src/graphql-schema.js b/api/src/graphql-schema.js new file mode 100644 index 00000000..28bbcdb7 --- /dev/null +++ b/api/src/graphql-schema.js @@ -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 diff --git a/api/src/id-generator.js b/api/src/id-generator.js new file mode 100644 index 00000000..c9353545 --- /dev/null +++ b/api/src/id-generator.js @@ -0,0 +1,7 @@ +import uuid from 'uuid' + +function generateID() { + return uuid().substr(-12) +} + +export default generateID diff --git a/api/src/index.js b/api/src/index.js index 30f863af..28449018 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -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) diff --git a/api/src/resolvers/neo4j-helpers.js b/api/src/resolvers/neo4j-helpers.js new file mode 100644 index 00000000..99b35b6e --- /dev/null +++ b/api/src/resolvers/neo4j-helpers.js @@ -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 +} diff --git a/api/src/resolvers/sharing.js b/api/src/resolvers/sharing.js new file mode 100644 index 00000000..fb4b3e85 --- /dev/null +++ b/api/src/resolvers/sharing.js @@ -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, +} diff --git a/api/src/resolvers/users.js b/api/src/resolvers/users.js index be3bd271..9cf6fa29 100644 --- a/api/src/resolvers/users.js +++ b/api/src/resolvers/users.js @@ -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) }, diff --git a/api/src/scanner/scanAlbum.js b/api/src/scanner/scanAlbum.js index e0da59a2..0ec9c327 100644 --- a/api/src/scanner/scanAlbum.js +++ b/api/src/scanner/scanAlbum.js @@ -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} }) diff --git a/api/src/scanner/scanUser.js b/api/src/scanner/scanUser.js index 753b653e..7d031a69 100644 --- a/api/src/scanner/scanUser.js +++ b/api/src/scanner/scanUser.js @@ -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} }) diff --git a/api/src/schema.graphql b/api/src/schema.graphql index dcc0e20a..c3d3b452 100644 --- a/api/src/schema.graphql +++ b/api/src/schema.graphql @@ -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 } diff --git a/ui/src/Pages/PhotosPage/PhotosPage.js b/ui/src/Pages/PhotosPage/PhotosPage.js index d02d3417..16963736 100644 --- a/ui/src/Pages/PhotosPage/PhotosPage.js +++ b/ui/src/Pages/PhotosPage/PhotosPage.js @@ -97,7 +97,9 @@ class PhotosPage extends Component { galleryGroups = data.myAlbums.map((album, index) => (
-

{album.title}

+

+ {album.title} +

{ diff --git a/ui/src/apolloClient.js b/ui/src/apolloClient.js index ea970a91..e81a7028 100644 --- a/ui/src/apolloClient.js +++ b/ui/src/apolloClient.js @@ -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) { diff --git a/ui/src/components/sidebar/PhotoSidebar.js b/ui/src/components/sidebar/PhotoSidebar.js index 24ca5282..59a0c4e5 100644 --- a/ui/src/components/sidebar/PhotoSidebar.js +++ b/ui/src/components/sidebar/PhotoSidebar.js @@ -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 { /> {photo && photo.title}
{exifItems}
+
) }} diff --git a/ui/src/components/sidebar/Sharing.js b/ui/src/components/sidebar/Sharing.js new file mode 100644 index 00000000..43acede4 --- /dev/null +++ b/ui/src/components/sidebar/Sharing.js @@ -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 ( +
+

Sharing options

+ + {({ loading, error, data }) => { + if (loading) return
Loading...
+ if (error) return
Error: {error}
+ + const rows = data.photoShares.map(share => ( + + + Public Link {share.token} + + + +
+ ) +} + +export default SidebarShare