diff --git a/api/src/resolvers/photos.js b/api/src/resolvers/photos.js index bee03eaa..327232fc 100644 --- a/api/src/resolvers/photos.js +++ b/api/src/resolvers/photos.js @@ -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 }, } diff --git a/api/src/resolvers/sharing.js b/api/src/resolvers/sharing.js index 0cb97e89..29c63c62 100644 --- a/api/src/resolvers/sharing.js +++ b/api/src/resolvers/sharing.js @@ -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 { diff --git a/api/src/routes/images.js b/api/src/routes/images.js index a2b07b8d..ca7a322d 100644 --- a/api/src/routes/images.js +++ b/api/src/routes/images.js @@ -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 diff --git a/api/src/schema.graphql b/api/src/schema.graphql index dbed8c4c..fb8fb70b 100644 --- a/api/src/schema.graphql +++ b/api/src/schema.graphql @@ -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 } diff --git a/ui/package-lock.json b/ui/package-lock.json index f7f1eda7..544159d5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index e2ba2d78..bbf85ae2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/AuthorizedRoute.js b/ui/src/AuthorizedRoute.js index 618953f9..967c7c1e 100644 --- a/ui/src/AuthorizedRoute.js +++ b/ui/src/AuthorizedRoute.js @@ -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') diff --git a/ui/src/Layout.js b/ui/src/Layout.js index ce95e26b..3120e169 100644 --- a/ui/src/Layout.js +++ b/ui/src/Layout.js @@ -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 ( - - - - Photos - - - - Albums - - - {({ loading, error, data }) => { - if (data && data.myUser && data.myUser.admin) { - return ( - - - Settings - - ) - } + + + + + Photos + + + + Albums + + + {({ loading, error, data }) => { + if (data && data.myUser && data.myUser.admin) { + return ( + + + Settings + + ) + } - return null - }} - - + return null + }} + + + {this.props.children} @@ -129,7 +132,7 @@ class Layout extends Component { } Layout.propTypes = { - children: PropTypes.element.isRequired, + children: PropTypes.any.isRequired, } export default Layout diff --git a/ui/src/Pages/SharePage/AlbumSharePage.js b/ui/src/Pages/SharePage/AlbumSharePage.js new file mode 100644 index 00000000..8bd86c60 --- /dev/null +++ b/ui/src/Pages/SharePage/AlbumSharePage.js @@ -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 ( + +

{album.title}

+ { + setActiveIndex(index) + }} + setPresenting={setPresenting} + nextImage={() => { + setActiveIndex((activeIndex + 1) % album.photos.length) + }} + previousImage={() => { + setActiveIndex( + activeIndex < 1 ? album.photos.length - 1 : activeIndex - 1 + ) + }} + /> +
+ ) +} + +AlbumSharePage.propTypes = { + album: PropTypes.object.isRequired, +} + +export default AlbumSharePage diff --git a/ui/src/Pages/SharePage/PhotoSharePage.js b/ui/src/Pages/SharePage/PhotoSharePage.js new file mode 100644 index 00000000..20186a5e --- /dev/null +++ b/ui/src/Pages/SharePage/PhotoSharePage.js @@ -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 ( + + + {({ updateSidebar }) => ( + <> +

{photo.title}

+ { + updateSidebar() + }} + /> + + )} +
+
+ ) +} + +AlbumSharePage.propTypes = { + photo: PropTypes.object, +} + +export default AlbumSharePage diff --git a/ui/src/Pages/SharePage/SharePage.js b/ui/src/Pages/SharePage/SharePage.js new file mode 100644 index 00000000..3d585f89 --- /dev/null +++ b/ui/src/Pages/SharePage/SharePage.js @@ -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 ( + + + {({ match }) => ( + + {({ loading, error, data }) => { + if (error) return error.message + if (loading) return 'Loading...' + + if (data.shareToken.album) { + return + } + + if (data.shareToken.photo) { + return + } + + return

Share not found

+ }} +
+ )} +
+ Share not found +
+ ) +} + +SharePage.propTypes = { + ...RouterProps, +} + +export default SharePage diff --git a/ui/src/Routes.js b/ui/src/Routes.js index a9e5ccb0..85044ef5 100644 --- a/ui/src/Routes.js +++ b/ui/src/Routes.js @@ -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 { + diff --git a/ui/src/components/photoGallery/PhotoGallery.js b/ui/src/components/photoGallery/PhotoGallery.js index 0fface49..31c7c192 100644 --- a/ui/src/components/photoGallery/PhotoGallery.js +++ b/ui/src/components/photoGallery/PhotoGallery.js @@ -88,7 +88,7 @@ const PhotoGallery = ({ key={photo.id} photo={photo} onSelectImage={index => { - updateSidebar() + updateSidebar() onSelectImage(index) }} setPresenting={setPresenting} @@ -142,10 +142,10 @@ const PhotoGallery = ({ ) : ( - {item => props => ( + {photo => props => ( )} diff --git a/ui/src/components/photoGallery/PresentView.js b/ui/src/components/photoGallery/PresentView.js index 35389133..fc9fd0a5 100644 --- a/ui/src/components/photoGallery/PresentView.js +++ b/ui/src/components/photoGallery/PresentView.js @@ -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 = ( - - {({ loading, error, data }) => { - if (error) { - alert(error) + let originalPhoto = null + + if (photo && photo.original && photo.original.url) { + originalPhoto = ( + { + e.target.style.display = 'initial' + imageLoaded && imageLoaded() + }} + /> + ) + } else { + originalPhoto = ( + + {({ loading, error, data }) => { + if (error) { + alert(error) + return null + } + + if (data && data.photo) { + const photo = data.photo + + return ( + { + e.target.style.display = 'initial' + imageLoaded && imageLoaded() + }} + /> + ) + } + return null - } - - if (data && data.photo) { - const photo = data.photo - - return ( - { - e.target.style.display = 'initial' - imageLoaded && imageLoaded() - }} - /> - ) - } - - return null - }} - - ) + }} + + ) + } setOriginalPhoto(originalPhoto) } @@ -119,5 +133,4 @@ PresentPhoto.propTypes = { photo: PropTypes.object, thumbnail: PropTypes.string, imageLoaded: PropTypes.func, - photoId: PropTypes.string, } diff --git a/ui/src/components/sidebar/PhotoSidebar.js b/ui/src/components/sidebar/PhotoSidebar.js index 9a451b54..2e3b2456 100644 --- a/ui/src/components/sidebar/PhotoSidebar.js +++ b/ui/src/components/sidebar/PhotoSidebar.js @@ -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 => ( + + )) + } + + let previewUrl = null + if (photo) { + if (photo.original) previewUrl = photo.original.url + else if (photo.thumbnail) previewUrl = photo.thumbnail.url + } + + return ( +
+ {!hidePreview && } + {photo && photo.title} +
{exifItems}
+ +
+ ) +} + +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 } return (
- + {({ 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 => ( - - )) + if (loading) { + return } return ( -
- - - {photo && photo.title} -
{exifItems}
- -
+ ) }}
@@ -121,7 +137,8 @@ class PhotoSidebar extends Component { } PhotoSidebar.propTypes = { - imageId: PropTypes.string.isRequired, + photo: PropTypes.object.isRequired, + hidePreview: PropTypes.bool, } export default PhotoSidebar diff --git a/ui/src/components/sidebar/Sharing.js b/ui/src/components/sidebar/Sharing.js index 1e6b7737..c49d33e9 100644 --- a/ui/src/components/sidebar/Sharing.js +++ b/ui/src/components/sidebar/Sharing.js @@ -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 }) => { -