Add download option to sidemenu for photos
This commit is contained in:
parent
a5513e3343
commit
108f6c2d0c
|
@ -29,6 +29,12 @@ const driver = neo4j.driver(
|
||||||
|
|
||||||
const scanner = new PhotoScanner(driver)
|
const scanner = new PhotoScanner(driver)
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.driver = driver
|
||||||
|
req.scanner = scanner
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
// Every 4th hour
|
// Every 4th hour
|
||||||
setInterval(scanner.scanAll, 1000 * 60 * 60 * 4)
|
setInterval(scanner.scanAll, 1000 * 60 * 60 * 4)
|
||||||
|
|
||||||
|
@ -85,8 +91,10 @@ const server = new ApolloServer({
|
||||||
server.applyMiddleware({ app, path: graphPath })
|
server.applyMiddleware({ app, path: graphPath })
|
||||||
|
|
||||||
import loadImageRoutes from './routes/images'
|
import loadImageRoutes from './routes/images'
|
||||||
|
import loadDownloadRoutes from './routes/downloads'
|
||||||
|
|
||||||
loadImageRoutes({ app, driver, scanner })
|
loadImageRoutes(app)
|
||||||
|
loadDownloadRoutes(app)
|
||||||
|
|
||||||
const httpServer = http.createServer(app)
|
const httpServer = http.createServer(app)
|
||||||
server.installSubscriptionHandlers(httpServer)
|
server.installSubscriptionHandlers(httpServer)
|
||||||
|
|
|
@ -124,7 +124,7 @@ const Query = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const PhotoURL = {
|
const urlResolve = {
|
||||||
url(root, args, ctx, info) {
|
url(root, args, ctx, info) {
|
||||||
let url = new URL(root.url, config.host)
|
let url = new URL(root.url, config.host)
|
||||||
if (ctx.shareToken) url.search = `?token=${ctx.shareToken}`
|
if (ctx.shareToken) url.search = `?token=${ctx.shareToken}`
|
||||||
|
@ -134,5 +134,6 @@ const PhotoURL = {
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query,
|
Query,
|
||||||
PhotoURL,
|
PhotoURL: urlResolve,
|
||||||
|
PhotoDownload: urlResolve,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -5,49 +5,90 @@ import config from '../config'
|
||||||
import { isRawImage, getImageCachePath } from '../scanner/utils'
|
import { isRawImage, getImageCachePath } from '../scanner/utils'
|
||||||
import { getUserFromToken, getTokenFromBearer } from '../token'
|
import { getUserFromToken, getTokenFromBearer } from '../token'
|
||||||
|
|
||||||
class RequestError extends Error {
|
export class RequestError extends Error {
|
||||||
constructor(httpCode, message) {
|
constructor(httpCode, message) {
|
||||||
super(message)
|
super(message)
|
||||||
this.httpCode = httpCode
|
this.httpCode = httpCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendImage({ photo, res, id, albumId, image, scanner }) {
|
export async function getImageFromRequest(req, res, next) {
|
||||||
let imagePath = path.resolve(getImageCachePath(id, albumId), image)
|
const { id, image } = req.params
|
||||||
|
const driver = req.driver
|
||||||
|
|
||||||
if (!(await fs.exists(imagePath))) {
|
const shareToken = req.query.token
|
||||||
if (image == 'thumbnail.jpg') {
|
|
||||||
|
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)
|
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')
|
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)) {
|
if (await isRawImage(cachePath)) {
|
||||||
console.log('RAW preview image not found, generating', imagePath)
|
console.log('RAW preview image not found, generating', cachePath)
|
||||||
await scanner.processImage(id)
|
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')
|
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
|
let user = null
|
||||||
|
const { driver } = req
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = getTokenFromBearer(req.headers.authorization)
|
const token = getTokenFromBearer(req.headers.authorization)
|
||||||
|
@ -136,39 +177,9 @@ async function verifyShareToken({ shareToken, id, driver }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImageRoutes({ app, driver, scanner }) {
|
function loadImageRoutes(app) {
|
||||||
app.use('/images/:id/:image', async (req, res) => {
|
app.use('/images/:id/:image', getImageFromRequest)
|
||||||
const { id, image } = req.params
|
app.use('/images/:id/:image', sendImage)
|
||||||
|
|
||||||
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 })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default loadImageRoutes
|
export default loadImageRoutes
|
||||||
|
|
|
@ -157,19 +157,35 @@ export default async function processImage({ driver, markFinishedImage }, id) {
|
||||||
|
|
||||||
await session.run(
|
await session.run(
|
||||||
`MATCH (p:Photo { id: {id} })
|
`MATCH (p:Photo { id: {id} })
|
||||||
CREATE (thumbnail:PhotoURL { url: {thumbnailUrl}, width: {thumbnailWidth}, height: {thumbnailHeight} })
|
CREATE (p)-[:THUMBNAIL_URL]->(thumbnail:PhotoURL { thumbnail })
|
||||||
CREATE (original:PhotoURL { url: {originalUrl}, width: {originalWidth}, height: {originalHeight} })
|
CREATE (p)-[:ORIGINAL_URL]->(original:PhotoURL { original })
|
||||||
CREATE (p)-[:THUMBNAIL_URL]->(thumbnail)
|
|
||||||
CREATE (p)-[:ORIGINAL_URL]->(original)
|
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
thumbnailUrl: `/images/${id}/${path.basename(thumbnailPath)}`,
|
thumbnail: {
|
||||||
thumbnailWidth,
|
url: `/images/${id}/${path.basename(thumbnailPath)}`,
|
||||||
thumbnailHeight,
|
width: thumbnailWidth,
|
||||||
originalUrl: `/images/${id}/${path.basename(originalPath)}`,
|
height: thumbnailHeight,
|
||||||
originalWidth,
|
},
|
||||||
originalHeight,
|
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) {
|
} catch (e) {
|
||||||
|
|
|
@ -26,7 +26,7 @@ type Album {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PhotoURL {
|
type PhotoURL {
|
||||||
# URL for the image
|
# URL for previewing the image
|
||||||
url: String
|
url: String
|
||||||
# Width of the image in pixels
|
# Width of the image in pixels
|
||||||
width: Int
|
width: Int
|
||||||
|
@ -34,6 +34,11 @@ type PhotoURL {
|
||||||
height: Int
|
height: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PhotoDownload {
|
||||||
|
title: String
|
||||||
|
url: String
|
||||||
|
}
|
||||||
|
|
||||||
type PhotoEXIF {
|
type PhotoEXIF {
|
||||||
photo: Photo @relation(name: "EXIF", direction: "IN")
|
photo: Photo @relation(name: "EXIF", direction: "IN")
|
||||||
camera: String
|
camera: String
|
||||||
|
@ -62,6 +67,7 @@ type Photo {
|
||||||
exif: PhotoEXIF @relation(name: "EXIF", direction: "OUT")
|
exif: PhotoEXIF @relation(name: "EXIF", direction: "OUT")
|
||||||
|
|
||||||
shares: [ShareToken] @relation(name: "SHARES", direction: "IN")
|
shares: [ShareToken] @relation(name: "SHARES", direction: "IN")
|
||||||
|
downloads: [PhotoDownload] @relation(name: "DOWNLOAD", direction: "OUT")
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShareToken {
|
type ShareToken {
|
||||||
|
|
|
@ -4,10 +4,9 @@ import styled from 'styled-components'
|
||||||
import { Query } from 'react-apollo'
|
import { Query } from 'react-apollo'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
import { Loader } from 'semantic-ui-react'
|
|
||||||
import ProtectedImage from '../photoGallery/ProtectedImage'
|
import ProtectedImage from '../photoGallery/ProtectedImage'
|
||||||
import SidebarShare from './Sharing'
|
import SidebarShare from './Sharing'
|
||||||
import { SidebarConsumer } from './Sidebar'
|
import SidebarDownload from './SidebarDownload'
|
||||||
|
|
||||||
const photoQuery = gql`
|
const photoQuery = gql`
|
||||||
query sidebarPhoto($id: ID!) {
|
query sidebarPhoto($id: ID!) {
|
||||||
|
@ -96,6 +95,7 @@ const SidebarContent = ({ photo, hidePreview }) => {
|
||||||
{!hidePreview && <PreviewImage src={previewUrl} />}
|
{!hidePreview && <PreviewImage src={previewUrl} />}
|
||||||
<Name>{photo && photo.title}</Name>
|
<Name>{photo && photo.title}</Name>
|
||||||
<div>{exifItems}</div>
|
<div>{exifItems}</div>
|
||||||
|
<SidebarDownload photoId={photo.id} />
|
||||||
<SidebarShare photo={photo} />
|
<SidebarShare photo={photo} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Download</h2>
|
||||||
|
<Query query={downloadQuery} variables={{ photoId }}>
|
||||||
|
{({ loading, error, data }) => {
|
||||||
|
if (error) return <div>Error {error.message}</div>
|
||||||
|
if (!data || !data.photo) return null
|
||||||
|
|
||||||
|
let buttons = data.photo.downloads.map(x => (
|
||||||
|
<Button key={x.url} onClick={() => downloadPhoto(x.url)}>
|
||||||
|
{x.title}
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
return <Button.Group>{buttons}</Button.Group>
|
||||||
|
}}
|
||||||
|
</Query>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SidebarDownload.propTypes = {
|
||||||
|
photoId: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SidebarDownload
|
Loading…
Reference in New Issue