mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-08-03 20:49:02 +00:00
Work on sharing + some security hardening
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
FROM node:10
|
||||
|
||||
ENV PRODUCTION=1
|
||||
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
2
api/package-lock.json
generated
2
api/package-lock.json
generated
@@ -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
58
api/src/graphql-schema.js
Normal 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
7
api/src/id-generator.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import uuid from 'uuid'
|
||||
|
||||
function generateID() {
|
||||
return uuid().substr(-12)
|
||||
}
|
||||
|
||||
export default generateID
|
||||
@@ -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)
|
||||
|
||||
12
api/src/resolvers/neo4j-helpers.js
Normal file
12
api/src/resolvers/neo4j-helpers.js
Normal 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
|
||||
}
|
||||
153
api/src/resolvers/sharing.js
Normal file
153
api/src/resolvers/sharing.js
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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} })
|
||||
|
||||
@@ -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} })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
|
||||
78
ui/src/components/sidebar/Sharing.js
Normal file
78
ui/src/components/sidebar/Sharing.js
Normal 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
|
||||
Reference in New Issue
Block a user