diff --git a/api/src/index.js b/api/src/index.js index 6fe506ae..e2627658 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -29,6 +29,12 @@ const driver = neo4j.driver( const scanner = new PhotoScanner(driver) +app.use((req, res, next) => { + req.driver = driver + req.scanner = scanner + next() +}) + // Every 4th hour setInterval(scanner.scanAll, 1000 * 60 * 60 * 4) @@ -85,8 +91,10 @@ const server = new ApolloServer({ server.applyMiddleware({ app, path: graphPath }) import loadImageRoutes from './routes/images' +import loadDownloadRoutes from './routes/downloads' -loadImageRoutes({ app, driver, scanner }) +loadImageRoutes(app) +loadDownloadRoutes(app) const httpServer = http.createServer(app) server.installSubscriptionHandlers(httpServer) diff --git a/api/src/resolvers/photos.js b/api/src/resolvers/photos.js index 327232fc..381072b5 100644 --- a/api/src/resolvers/photos.js +++ b/api/src/resolvers/photos.js @@ -124,7 +124,7 @@ const Query = { }, } -const PhotoURL = { +const urlResolve = { url(root, args, ctx, info) { let url = new URL(root.url, config.host) if (ctx.shareToken) url.search = `?token=${ctx.shareToken}` @@ -134,5 +134,6 @@ const PhotoURL = { export default { Query, - PhotoURL, + PhotoURL: urlResolve, + PhotoDownload: urlResolve, } diff --git a/api/src/routes/downloads.js b/api/src/routes/downloads.js new file mode 100644 index 00000000..e18f16be --- /dev/null +++ b/api/src/routes/downloads.js @@ -0,0 +1,26 @@ +import { getImageFromRequest, RequestError } from './images' +import fs from 'fs-extra' +import path from 'path' + +async function sendDownload(req, res) { + let { photo, cachePath } = req + const cacheBasename = path.basename(cachePath) + const photoBasename = path.basename(photo.path) + + if (cacheBasename == photoBasename) { + if (!(await fs.exists(photo.path))) { + throw new RequestError(500, 'Image missing from the server') + } + + return res.sendFile(photo.path) + } + + throw new RequestError(404, 'Image could not be found') +} + +const loadDownloadRoutes = app => { + app.use('/download/:id/:image', getImageFromRequest) + app.use('/download/:id/:image', sendDownload) +} + +export default loadDownloadRoutes diff --git a/api/src/routes/images.js b/api/src/routes/images.js index ca7a322d..49896998 100644 --- a/api/src/routes/images.js +++ b/api/src/routes/images.js @@ -5,49 +5,90 @@ import config from '../config' import { isRawImage, getImageCachePath } from '../scanner/utils' import { getUserFromToken, getTokenFromBearer } from '../token' -class RequestError extends Error { +export 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) +export async function getImageFromRequest(req, res, next) { + const { id, image } = req.params + const driver = req.driver - if (!(await fs.exists(imagePath))) { - if (image == 'thumbnail.jpg') { + const shareToken = req.query.token + + let photo, albumId + + try { + let verify = null + + if (shareToken) { + verify = await verifyShareToken({ shareToken, id, driver }) + } + + if (!verify) { + verify = await verifyUser(req, 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) + } + + let cachePath = path.resolve(getImageCachePath(id, albumId), image) + req.cachePath = cachePath + req.photo = photo + + next() +} + +async function sendImage(req, res) { + let { photo, cachePath } = req + const photoBasename = path.basename(cachePath) + + if (!(await fs.exists(cachePath))) { + if (photoBasename == 'thumbnail.jpg') { console.log('Thumbnail not found, generating', photo.path) - await scanner.processImage(photo.id) + await req.scanner.processImage(photo.id) - if (!(await fs.exists(imagePath))) { + if (!(await fs.exists(cachePath))) { throw new Error('Thumbnail not found after image processing') } - return res.sendFile(imagePath) + return res.sendFile(cachePath) } - imagePath = photo.path + cachePath = photo.path } - if (await isRawImage(imagePath)) { - console.log('RAW preview image not found, generating', imagePath) - await scanner.processImage(id) + if (await isRawImage(cachePath)) { + console.log('RAW preview image not found, generating', cachePath) + await req.scanner.processImage(photo.id) - imagePath = path.resolve(config.cachePath, 'images', id, image) + cachePath = path.resolve( + config.cachePath, + 'images', + photo.id, + photoBasename + ) - if (!(await fs.exists(imagePath))) { + if (!(await fs.exists(cachePath))) { throw new Error('RAW preview not found after image processing') } - return res.sendFile(imagePath) + return res.sendFile(cachePath) } - res.sendFile(imagePath) + res.sendFile(cachePath) } -async function verifyUser({ req, driver, id }) { +async function verifyUser(req, id) { let user = null + const { driver } = req try { const token = getTokenFromBearer(req.headers.authorization) @@ -136,39 +177,9 @@ async function verifyShareToken({ shareToken, id, driver }) { } } -function loadImageRoutes({ app, driver, scanner }) { - app.use('/images/:id/:image', async (req, res) => { - const { id, image } = req.params - - const shareToken = req.query.token - - let photo, albumId - - try { - let verify = null - - if (shareToken) { - verify = await verifyShareToken({ shareToken, id, driver }) - } - - 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) - } - - sendImage({ photo, res, id, albumId, image, scanner }) - }) +function loadImageRoutes(app) { + app.use('/images/:id/:image', getImageFromRequest) + app.use('/images/:id/:image', sendImage) } export default loadImageRoutes diff --git a/api/src/scanner/processImage.js b/api/src/scanner/processImage.js index 63648465..78357f6d 100644 --- a/api/src/scanner/processImage.js +++ b/api/src/scanner/processImage.js @@ -157,19 +157,35 @@ export default async function processImage({ driver, markFinishedImage }, id) { await session.run( `MATCH (p:Photo { id: {id} }) - CREATE (thumbnail:PhotoURL { url: {thumbnailUrl}, width: {thumbnailWidth}, height: {thumbnailHeight} }) - CREATE (original:PhotoURL { url: {originalUrl}, width: {originalWidth}, height: {originalHeight} }) - CREATE (p)-[:THUMBNAIL_URL]->(thumbnail) - CREATE (p)-[:ORIGINAL_URL]->(original) + CREATE (p)-[:THUMBNAIL_URL]->(thumbnail:PhotoURL { thumbnail }) + CREATE (p)-[:ORIGINAL_URL]->(original:PhotoURL { original }) `, { id, - thumbnailUrl: `/images/${id}/${path.basename(thumbnailPath)}`, - thumbnailWidth, - thumbnailHeight, - originalUrl: `/images/${id}/${path.basename(originalPath)}`, - originalWidth, - originalHeight, + thumbnail: { + url: `/images/${id}/${path.basename(thumbnailPath)}`, + width: thumbnailWidth, + height: thumbnailHeight, + }, + original: { + url: `/images/${id}/${path.basename(originalPath)}`, + width: originalWidth, + height: originalHeight, + }, + } + ) + + await session.run( + ` + MATCH (p:Photo { id: {id} }) + CREATE (p)-[:DOWNLOAD]->(original:PhotoDownload {original}) + `, + { + id, + original: { + title: 'Original', + url: `/download/${id}/${path.basename(photo.path)}`, + }, } ) } catch (e) { diff --git a/api/src/schema.graphql b/api/src/schema.graphql index fb8fb70b..27bd9327 100644 --- a/api/src/schema.graphql +++ b/api/src/schema.graphql @@ -26,7 +26,7 @@ type Album { } type PhotoURL { - # URL for the image + # URL for previewing the image url: String # Width of the image in pixels width: Int @@ -34,6 +34,11 @@ type PhotoURL { height: Int } +type PhotoDownload { + title: String + url: String +} + type PhotoEXIF { photo: Photo @relation(name: "EXIF", direction: "IN") camera: String @@ -62,6 +67,7 @@ type Photo { exif: PhotoEXIF @relation(name: "EXIF", direction: "OUT") shares: [ShareToken] @relation(name: "SHARES", direction: "IN") + downloads: [PhotoDownload] @relation(name: "DOWNLOAD", direction: "OUT") } type ShareToken { diff --git a/ui/src/components/sidebar/PhotoSidebar.js b/ui/src/components/sidebar/PhotoSidebar.js index 2e3b2456..6ecb9d11 100644 --- a/ui/src/components/sidebar/PhotoSidebar.js +++ b/ui/src/components/sidebar/PhotoSidebar.js @@ -4,10 +4,9 @@ import styled from 'styled-components' import { Query } from 'react-apollo' import gql from 'graphql-tag' import SidebarItem from './SidebarItem' -import { Loader } from 'semantic-ui-react' import ProtectedImage from '../photoGallery/ProtectedImage' import SidebarShare from './Sharing' -import { SidebarConsumer } from './Sidebar' +import SidebarDownload from './SidebarDownload' const photoQuery = gql` query sidebarPhoto($id: ID!) { @@ -96,6 +95,7 @@ const SidebarContent = ({ photo, hidePreview }) => { {!hidePreview && } {photo && photo.title}
{exifItems}
+ ) diff --git a/ui/src/components/sidebar/SidebarDownload.js b/ui/src/components/sidebar/SidebarDownload.js new file mode 100644 index 00000000..6d9e780d --- /dev/null +++ b/ui/src/components/sidebar/SidebarDownload.js @@ -0,0 +1,62 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Menu, Dropdown, Button } from 'semantic-ui-react' +import { Query } from 'react-apollo' +import gql from 'graphql-tag' + +const downloadQuery = gql` + query sidebarDownloadQuery($photoId: ID!) { + photo(id: $photoId) { + id + downloads { + title + url + } + } + } +` + +const downloadPhoto = async url => { + const request = await fetch(url, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }) + + const content = await request.blob() + const contentUrl = URL.createObjectURL(content) + + var downloadAnchor = document.createElement('a', contentUrl) + downloadAnchor.setAttribute('href', contentUrl) + downloadAnchor.setAttribute('download', url.match(/[^/]*$/)[0]) + downloadAnchor.click() +} + +const SidebarDownload = ({ photoId }) => { + if (!photoId) return null + + return ( +
+

Download

+ + {({ loading, error, data }) => { + if (error) return
Error {error.message}
+ if (!data || !data.photo) return null + + let buttons = data.photo.downloads.map(x => ( + + )) + return {buttons} + }} +
+
+ ) +} + +SidebarDownload.propTypes = { + photoId: PropTypes.string, +} + +export default SidebarDownload