Work on sharing

This commit is contained in:
viktorstrate
2019-08-17 22:46:18 +02:00
parent afcd6cf0ed
commit bafb33cf43
16 changed files with 462 additions and 136 deletions

View File

@@ -126,7 +126,9 @@ const Query = {
const PhotoURL = {
url(root, args, ctx, info) {
return new URL(root.url, config.host).href
let url = new URL(root.url, config.host)
if (ctx.shareToken) url.search = `?token=${ctx.shareToken}`
return url.href
},
}

View File

@@ -27,7 +27,7 @@ const Mutation = {
MATCH (u:User { id: {userId} })
MATCH (a:Album { id: {albumId} })
CREATE (share:ShareToken {shareToken} )
CREATE (u)-[:SHARE_TOKEN]->(share)<-[:SHARES]-(a)
CREATE (u)-[:SHARE_TOKEN]->(share)-[:SHARES]->(a)
RETURN share
`,
{
@@ -73,7 +73,7 @@ const Mutation = {
MATCH (u:User { id: {userId} })
MATCH (p:Photo { id: {photoId} })
CREATE (share:ShareToken {shareToken} )
CREATE (u)-[:SHARE_TOKEN]->(share)<-[:SHARES]-(p)
CREATE (u)-[:SHARE_TOKEN]->(share)-[:SHARES]->(p)
RETURN share
`,
{
@@ -125,7 +125,7 @@ const Query = {
`
MATCH (u:User { id: {userId} })
MATCH (u)-[:OWNS]->(a:Album { id: {albumId} })
MATCH (a)-[:SHARES]->(shareToken:ShareToken)
MATCH (a)<-[:SHARES]-(shareToken:ShareToken)
`
)
@@ -149,7 +149,7 @@ const Query = {
`
MATCH (u:User { id: {userId} })
MATCH (u)-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo {id: {photoId} })
MATCH (p)-[:SHARES]->(shareToken:ShareToken)
MATCH (p)<-[:SHARES]-(shareToken:ShareToken)
`
)
@@ -167,6 +167,10 @@ const Query = {
return tokens
},
shareToken(root, args, ctx, info) {
ctx.shareToken = args.token
return neo4jgraphql(root, args, ctx, info)
},
}
export default {

View File

@@ -5,6 +5,13 @@ import config from '../config'
import { isRawImage, getImageCachePath } from '../scanner/utils'
import { getUserFromToken, getTokenFromBearer } from '../token'
class RequestError extends Error {
constructor(httpCode, message) {
super(message)
this.httpCode = httpCode
}
}
async function sendImage({ photo, res, id, albumId, image, scanner }) {
let imagePath = path.resolve(getImageCachePath(id, albumId), image)
@@ -39,48 +46,129 @@ async function sendImage({ photo, res, id, albumId, image, scanner }) {
res.sendFile(imagePath)
}
async function verifyUser({ req, driver, id }) {
let user = null
try {
const token = getTokenFromBearer(req.headers.authorization)
user = await getUserFromToken(token, driver)
} catch (err) {
throw new RequestError(401, err.message)
// return res.status(401).send(err.message)
}
const session = driver.session()
const result = await session.run(
'MATCH (p:Photo { id: {id} })<-[:CONTAINS]-(a:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId, a.id as albumId',
{
id,
}
)
session.close()
if (result.records.length == 0) {
throw new RequestError(404, 'Image not found')
// return res.status(404).send(`Image not found`)
}
const userId = result.records[0].get('userId')
const albumId = result.records[0].get('albumId')
const photo = result.records[0].get('photo').properties
if (userId != user.id) {
throw new RequestError(401, 'Image not owned by you')
// return res.status(401).send(`Image not owned by you`)
}
return {
user,
albumId,
photo,
}
}
async function verifyShareToken({ shareToken, id, driver }) {
const session = driver.session()
const shareTokenResult = await session.run(
`MATCH (share:ShareToken { token: {shareToken} })-[:SHARES]->(shared)
MATCH (photo:Photo { id: {id} })<-[:CONTAINS]-(album:Album)
RETURN share, photo, shared, album`,
{ shareToken, id }
)
session.close()
if (shareTokenResult.records.length == 0) {
throw new RequestError(404, 'Image not found')
}
const share = shareTokenResult.records[0].get('share').properties
const album = shareTokenResult.records[0].get('album').properties
const photo = shareTokenResult.records[0].get('photo').properties
const sharedObject = shareTokenResult.records[0].get('shared')
if (sharedObject.labels[0] == 'Album') {
const session = driver.session()
const albumResult = await session.run(
`MATCH (album)-[:CONTAINS]->(photo:Photo { id: {id} })
RETURN album`,
{ id }
)
session.close()
if (albumResult.records.length == 0) {
throw new RequestError(403, 'Invalid share token')
}
} else {
const sharedPhoto = sharedObject.properties
if (sharedPhoto.id != photo.id) {
throw new RequestError(403, 'Invalid share token')
}
}
return {
photo,
albumId: album.id,
}
}
function loadImageRoutes({ app, driver, scanner }) {
app.use('/images/:id/:image', async (req, res) => {
const { id, image } = req.params
let user = null
const shareToken = req.query.token
let photo, albumId
try {
const token = getTokenFromBearer(req.headers.authorization)
user = await getUserFromToken(token, driver)
} catch (err) {
return res.status(401).send(err.message)
}
let verify = null
const session = driver.session()
const result = await session.run(
'MATCH (p:Photo { id: {id} })<-[:CONTAINS]-(a:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId, a.id as albumId',
{
id,
if (shareToken) {
verify = await verifyShareToken({ shareToken, id, driver })
}
)
if (result.records.length == 0) {
return res.status(404).send(`Image not found`)
if (!verify) {
verify = await verifyUser({
req,
driver,
id,
})
}
if (verify == null) throw RequestError(500, 'Unable to verify request')
photo = verify.photo
albumId = verify.albumId
} catch (error) {
return res.status(error.status || 500).send(error.message)
}
const userId = result.records[0].get('userId')
const albumId = result.records[0].get('albumId')
const photo = result.records[0].get('photo').properties
if (userId != user.id) {
return res.status(401).send(`Image not owned by you`)
}
session.close()
sendImage({ photo, res, id, albumId, image, scanner })
})
// app.use('/share/:token/:image', async (req, res) => {
// const { token } = req.params
// })
}
export default loadImageRoutes

View File

@@ -22,7 +22,7 @@ type Album {
owner: User! @relation(name: "OWNS", direction: "IN")
path: String
shares: [ShareToken] @relation(name: "SHARES", direction: "OUT")
shares: [ShareToken] @relation(name: "SHARES", direction: "IN")
}
type PhotoURL {
@@ -61,7 +61,7 @@ type Photo {
album: Album! @relation(name: "CONTAINS", direction: "IN")
exif: PhotoEXIF @relation(name: "EXIF", direction: "OUT")
shares: [ShareToken] @relation(name: "SHARES", direction: "OUT")
shares: [ShareToken] @relation(name: "SHARES", direction: "IN")
}
type ShareToken {
@@ -70,10 +70,10 @@ type ShareToken {
# Optional expire date
expire: Date
# Optional password
password: String
# password: String
album: Album @relation(name: "SHARES", direction: "IN")
photo: Photo @relation(name: "SHARES", direction: "IN")
album: Album @relation(name: "SHARES", direction: "OUT")
photo: Photo @relation(name: "SHARES", direction: "OUT")
}
type SiteInfo {
@@ -152,6 +152,8 @@ type Query {
myPhotos: [Photo] @isAuthenticated
photo(id: ID!): Photo @isAuthenticated
shareToken(token: ID!): ShareToken
albumShares(id: ID!, password: String): [ShareToken] @isAuthenticated
photoShares(id: ID!, password: String): [ShareToken] @isAuthenticated
}

13
ui/package-lock.json generated
View File

@@ -2196,6 +2196,14 @@
"resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
},
"copy-to-clipboard": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz",
"integrity": "sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w==",
"requires": {
"toggle-selection": "^1.0.6"
}
},
"core-js": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz",
@@ -8565,6 +8573,11 @@
"repeat-string": "^1.6.1"
}
},
"toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI="
},
"toidentifier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",

View File

@@ -13,6 +13,7 @@
"apollo-link-http": "^1.5.15",
"apollo-link-ws": "^1.0.18",
"babel-plugin-styled-components": "^1.10.6",
"copy-to-clipboard": "^3.2.0",
"graphql": "^14.2.1",
"graphql-tag": "^2.10.1",
"parcel-bundler": "^1.12.3",

View File

@@ -12,6 +12,12 @@ const adminQuery = gql`
}
`
export const Authorized = ({ children }) => {
const token = localStorage.getItem('token')
return token ? children : null
}
const AuthorizedRoute = ({ component: Component, admin = false, ...props }) => {
const token = localStorage.getItem('token')

View File

@@ -6,6 +6,7 @@ import { Icon } from 'semantic-ui-react'
import Sidebar from './components/sidebar/Sidebar'
import { Query } from 'react-apollo'
import gql from 'graphql-tag'
import { Authorized } from './AuthorizedRoute'
const adminQuery = gql`
query adminQuery {
@@ -93,30 +94,32 @@ class Layout extends Component {
render() {
return (
<Container>
<SideMenu>
<SideButton to="/photos" exact>
<Icon name="image outline" />
<SideButtonLabel>Photos</SideButtonLabel>
</SideButton>
<SideButton to="/albums" exact>
<Icon name="images outline" />
<SideButtonLabel>Albums</SideButtonLabel>
</SideButton>
<Query query={adminQuery}>
{({ loading, error, data }) => {
if (data && data.myUser && data.myUser.admin) {
return (
<SideButton to="/settings" exact>
<Icon name="settings" />
<SideButtonLabel>Settings</SideButtonLabel>
</SideButton>
)
}
<Authorized>
<SideMenu>
<SideButton to="/photos" exact>
<Icon name="image outline" />
<SideButtonLabel>Photos</SideButtonLabel>
</SideButton>
<SideButton to="/albums" exact>
<Icon name="images outline" />
<SideButtonLabel>Albums</SideButtonLabel>
</SideButton>
<Query query={adminQuery}>
{({ loading, error, data }) => {
if (data && data.myUser && data.myUser.admin) {
return (
<SideButton to="/settings" exact>
<Icon name="settings" />
<SideButtonLabel>Settings</SideButtonLabel>
</SideButton>
)
}
return null
}}
</Query>
</SideMenu>
return null
}}
</Query>
</SideMenu>
</Authorized>
<Sidebar>
<Content>{this.props.children}</Content>
</Sidebar>
@@ -129,7 +132,7 @@ class Layout extends Component {
}
Layout.propTypes = {
children: PropTypes.element.isRequired,
children: PropTypes.any.isRequired,
}
export default Layout

View File

@@ -0,0 +1,42 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import RouterPropTypes from 'react-router-prop-types'
import { Query } from 'react-apollo'
import gql from 'graphql-tag'
import Layout from '../../Layout'
import PhotoGallery from '../../components/photoGallery/PhotoGallery'
const AlbumSharePage = ({ album }) => {
const [activeIndex, setActiveIndex] = useState(-1)
const [presenting, setPresenting] = useState(false)
return (
<Layout>
<h1>{album.title}</h1>
<PhotoGallery
photos={album.photos}
loading={false}
activeIndex={activeIndex}
presenting={presenting}
onSelectImage={index => {
setActiveIndex(index)
}}
setPresenting={setPresenting}
nextImage={() => {
setActiveIndex((activeIndex + 1) % album.photos.length)
}}
previousImage={() => {
setActiveIndex(
activeIndex < 1 ? album.photos.length - 1 : activeIndex - 1
)
}}
/>
</Layout>
)
}
AlbumSharePage.propTypes = {
album: PropTypes.object.isRequired,
}
export default AlbumSharePage

View File

@@ -0,0 +1,39 @@
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import Layout from '../../Layout'
import ProtectedImage from '../../components/photoGallery/ProtectedImage'
import { SidebarConsumer } from '../../components/sidebar/Sidebar'
import PhotoSidebar from '../../components/sidebar/PhotoSidebar'
const DisplayPhoto = styled(ProtectedImage)`
width: 100%;
max-height: calc(80vh);
object-fit: contain;
`
const AlbumSharePage = ({ photo }) => {
return (
<Layout>
<SidebarConsumer>
{({ updateSidebar }) => (
<>
<h1>{photo.title}</h1>
<DisplayPhoto
src={photo.original.url}
onLoad={() => {
updateSidebar(<PhotoSidebar photo={photo} hidePreview />)
}}
/>
</>
)}
</SidebarConsumer>
</Layout>
)
}
AlbumSharePage.propTypes = {
photo: PropTypes.object,
}
export default AlbumSharePage

View File

@@ -0,0 +1,86 @@
import React from 'react'
import RouterProps from 'react-router-prop-types'
import { Route, Switch } from 'react-router-dom'
import AlbumSharePage from './AlbumSharePage'
import PhotoSharePage from './PhotoSharePage'
import { Query } from 'react-apollo'
import gql from 'graphql-tag'
const tokenQuery = gql`
query SharePageToken($token: ID!) {
shareToken(token: $token) {
token
album {
id
title
photos(orderBy: title_desc) {
...PhotoProps
}
}
photo {
...PhotoProps
}
}
}
fragment PhotoProps on Photo {
id
title
thumbnail {
url
width
height
}
original {
url
}
exif {
camera
maker
lens
dateShot {
formatted
}
fileSize
exposure
aperture
iso
focalLength
flash
}
}
`
const SharePage = ({ match }) => {
return (
<Switch>
<Route path={`${match.url}/:token`}>
{({ match }) => (
<Query query={tokenQuery} variables={{ token: match.params.token }}>
{({ loading, error, data }) => {
if (error) return error.message
if (loading) return 'Loading...'
if (data.shareToken.album) {
return <AlbumSharePage album={data.shareToken.album} />
}
if (data.shareToken.photo) {
return <PhotoSharePage photo={data.shareToken.photo} />
}
return <h1>Share not found</h1>
}}
</Query>
)}
</Route>
<Route path="/">Share not found</Route>
</Switch>
)
}
SharePage.propTypes = {
...RouterProps,
}
export default SharePage

View File

@@ -8,6 +8,7 @@ const AlbumsPage = React.lazy(() => import('./Pages/AllAlbumsPage/AlbumsPage'))
const AlbumPage = React.lazy(() => import('./Pages/AlbumPage/AlbumPage'))
const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute'))
const PhotosPage = React.lazy(() => import('./Pages/PhotosPage/PhotosPage'))
const SharePage = React.lazy(() => import('./Pages/SharePage/SharePage'))
const LoginPage = React.lazy(() => import('./Pages/LoginPage/LoginPage'))
const InitialSetupPage = React.lazy(() =>
@@ -31,6 +32,7 @@ class Routes extends React.Component {
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/initialSetup" component={InitialSetupPage} />
<Route path="/share" component={SharePage} />
<AuthorizedRoute exact path="/albums" component={AlbumsPage} />
<AuthorizedRoute path="/album/:id" component={AlbumPage} />
<AuthorizedRoute path="/photos" component={PhotosPage} />

View File

@@ -88,7 +88,7 @@ const PhotoGallery = ({
key={photo.id}
photo={photo}
onSelectImage={index => {
updateSidebar(<PhotoSidebar imageId={photo.id} />)
updateSidebar(<PhotoSidebar photo={photo} />)
onSelectImage(index)
}}
setPresenting={setPresenting}
@@ -142,10 +142,10 @@ const PhotoGallery = ({
) : (
<PresentContainer>
<Transition {...presentViewTransitionConfig}>
{item => props => (
{photo => props => (
<PresentPhoto
thumbnail={item && item.thumbnail.url}
photoId={item && item.id}
thumbnail={photo && photo.thumbnail.url}
photo={photo}
style={props}
/>
)}

View File

@@ -61,41 +61,55 @@ export const PresentPhoto = ({
photo,
thumbnail,
imageLoaded,
photoId,
...otherProps
}) => {
let [originalPhoto, setOriginalPhoto] = useState(null)
useEffect(() => {
if (!photoId) return
if (!(photo && photo.id)) return
function loadOriginalPhoto() {
const originalPhoto = (
<Query query={imageQuery} variables={{ id: photoId }}>
{({ loading, error, data }) => {
if (error) {
alert(error)
let originalPhoto = null
if (photo && photo.original && photo.original.url) {
originalPhoto = (
<StyledPhoto
style={{ display: 'none' }}
src={photo.original.url}
onLoad={e => {
e.target.style.display = 'initial'
imageLoaded && imageLoaded()
}}
/>
)
} else {
originalPhoto = (
<Query query={imageQuery} variables={{ id: photo.id }}>
{({ loading, error, data }) => {
if (error) {
alert(error)
return null
}
if (data && data.photo) {
const photo = data.photo
return (
<StyledPhoto
style={{ display: 'none' }}
src={photo.original.url}
onLoad={e => {
e.target.style.display = 'initial'
imageLoaded && imageLoaded()
}}
/>
)
}
return null
}
if (data && data.photo) {
const photo = data.photo
return (
<StyledPhoto
style={{ display: 'none' }}
src={photo.original.url}
onLoad={e => {
e.target.style.display = 'initial'
imageLoaded && imageLoaded()
}}
/>
)
}
return null
}}
</Query>
)
}}
</Query>
)
}
setOriginalPhoto(originalPhoto)
}
@@ -119,5 +133,4 @@ PresentPhoto.propTypes = {
photo: PropTypes.object,
thumbnail: PropTypes.string,
imageLoaded: PropTypes.func,
photoId: PropTypes.string,
}

View File

@@ -7,6 +7,7 @@ import SidebarItem from './SidebarItem'
import { Loader } from 'semantic-ui-react'
import ProtectedImage from '../photoGallery/ProtectedImage'
import SidebarShare from './Sharing'
import { SidebarConsumer } from './Sidebar'
const photoQuery = gql`
query sidebarPhoto($id: ID!) {
@@ -61,57 +62,72 @@ const exifNameLookup = {
flash: 'Flash',
}
const SidebarContent = ({ photo, hidePreview }) => {
let exifItems = []
if (photo && photo.exif) {
let exifKeys = Object.keys(photo.exif).filter(
x => !!photo.exif[x] && x != '__typename'
)
let exif = exifKeys.reduce(
(prev, curr) => ({
...prev,
[curr]: photo.exif[curr],
}),
{}
)
exif.dateShot = new Date(exif.dateShot.formatted).toLocaleString()
exifItems = exifKeys.map(key => (
<SidebarItem key={key} name={exifNameLookup[key]} value={exif[key]} />
))
}
let previewUrl = null
if (photo) {
if (photo.original) previewUrl = photo.original.url
else if (photo.thumbnail) previewUrl = photo.thumbnail.url
}
return (
<div>
{!hidePreview && <PreviewImage src={previewUrl} />}
<Name>{photo && photo.title}</Name>
<div>{exifItems}</div>
<SidebarShare photo={photo} />
</div>
)
}
SidebarContent.propTypes = {
photo: PropTypes.object,
hidePreview: PropTypes.bool,
}
class PhotoSidebar extends Component {
render() {
const { imageId } = this.props
const { photo, hidePreview } = this.props
if (!imageId) {
return null
if (!photo) return null
if (!localStorage.getItem('token')) {
return <SidebarContent photo={photo} hidePreview={hidePreview} />
}
return (
<div>
<Query query={photoQuery} variables={{ id: imageId }}>
<Query query={photoQuery} variables={{ id: photo.id }}>
{({ loading, error, data }) => {
if (error) return error
const { photo } = data
let exifItems = []
if (photo && photo.exif) {
let exifKeys = Object.keys(photo.exif).filter(
x => !!photo.exif[x] && x != '__typename'
)
let exif = exifKeys.reduce(
(prev, curr) => ({
...prev,
[curr]: photo.exif[curr],
}),
{}
)
exif.dateShot = new Date(exif.dateShot.formatted).toLocaleString()
exifItems = exifKeys.map(key => (
<SidebarItem
key={key}
name={exifNameLookup[key]}
value={exif[key]}
/>
))
if (loading) {
return <SidebarContent photo={photo} hidePreview={hidePreview} />
}
return (
<div>
<Loader active={loading} />
<PreviewImage
src={photo && photo.original && photo.original.url}
/>
<Name>{photo && photo.title}</Name>
<div>{exifItems}</div>
<SidebarShare photo={photo} />
</div>
<SidebarContent photo={data.photo} hidePreview={hidePreview} />
)
}}
</Query>
@@ -121,7 +137,8 @@ class PhotoSidebar extends Component {
}
PhotoSidebar.propTypes = {
imageId: PropTypes.string.isRequired,
photo: PropTypes.object.isRequired,
hidePreview: PropTypes.bool,
}
export default PhotoSidebar

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
import { Query, Mutation } from 'react-apollo'
import gql from 'graphql-tag'
import { Table, Button, Dropdown } from 'semantic-ui-react'
import copy from 'copy-to-clipboard'
const sharePhotoQuery = gql`
query sidbarGetPhotoShares($id: ID!) {
@@ -54,6 +55,7 @@ const deleteShareMutation = gql`
const SidebarShare = ({ photo, album }) => {
if ((!photo || !photo.id) && (!album || !album.id)) return null
if (!localStorage.getItem('token')) return null
const isPhoto = !!photo
const id = isPhoto ? photo.id : album.id
@@ -80,7 +82,13 @@ const SidebarShare = ({ photo, album }) => {
</Table.Cell>
<Table.Cell>
<Button.Group>
<Button icon="chain" content="Copy" />
<Button
icon="chain"
content="Copy link"
onClick={() => {
copy(`${location.origin}/share/${share.token}`)
}}
/>
<Dropdown button text="More">
<Dropdown.Menu>
<Mutation