From f4013142afdb407e699eff9587921e23c971f1db Mon Sep 17 00:00:00 2001 From: Vojtech Simetka Date: Wed, 26 Jan 2022 18:29:09 +0100 Subject: [PATCH] feat: add metadata and preview (#292) * chore: upload flow uses metadata object and has preview * chore: remove SwarmFile * feat: upload metadata and file preview * feat: add metadata and preview on download * fix: package the meta and preview files * fix: upload websites that are inside a folder (#296) * fix: upload websites that are inside a folder * docs: few comments to clarify what is going on * refactor: decrease local variables and fix state order to detect websites properly Co-authored-by: Cafe137 --- src/constants.ts | 3 + src/pages/files/AssetPreview.tsx | 94 +++++++++----------------------- src/pages/files/AssetSummary.tsx | 8 +-- src/pages/files/Share.tsx | 51 +++++++++++------ src/pages/files/Upload.tsx | 60 ++++++++++++++++++-- src/pages/files/UploadArea.tsx | 7 +-- src/providers/File.tsx | 47 ++++++++++++++-- src/react-app-env.d.ts | 15 +++++ src/utils/SwarmFile.ts | 24 -------- src/utils/file.ts | 90 +++++++++++++++++------------- src/utils/image.ts | 89 ++++++++++++++++++++++++++++++ 11 files changed, 321 insertions(+), 167 deletions(-) create mode 100644 src/constants.ts delete mode 100644 src/utils/SwarmFile.ts create mode 100644 src/utils/image.ts diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..1051dd9 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +export const META_FILE_NAME = '.swarmgatewaymeta.json' +export const PREVIEW_FILE_NAME = '.swarmgatewaypreview.jpeg' +export const PREVIEW_DIMENSIONS = { maxWidth: 250, maxHeight: 175 } diff --git a/src/pages/files/AssetPreview.tsx b/src/pages/files/AssetPreview.tsx index 173304a..8fd3b64 100644 --- a/src/pages/files/AssetPreview.tsx +++ b/src/pages/files/AssetPreview.tsx @@ -1,99 +1,55 @@ import { Box, Grid, Typography } from '@material-ui/core' import { Web } from '@material-ui/icons' -import { ReactElement, useEffect, useState } from 'react' +import { ReactElement } from 'react' import { File, Folder } from 'react-feather' import { FitImage } from '../../components/FitImage' -import { detectIndexHtml, getAssetNameFromFiles, getHumanReadableFileSize } from '../../utils/file' -import { SwarmFile } from '../../utils/SwarmFile' +import { getHumanReadableFileSize } from '../../utils/file' import { AssetIcon } from './AssetIcon' +import { shortenHash } from '../../utils/hash' interface Props { - assetName?: string - files: SwarmFile[] + previewUri?: string + metadata?: Metadata } // TODO: add optional prop for indexDocument when it is already known (e.g. downloading a manifest) -export function AssetPreview({ assetName, files }: Props): ReactElement { - const [previewComponent, setPreviewComponent] = useState(undefined) - const [previewUri, setPreviewUri] = useState(undefined) +export function AssetPreview({ metadata, previewUri }: Props): ReactElement | null { + let previewComponent = + let type = metadata?.type - useEffect(() => { - if (files.length === 1) { - // single image - if (files[0].type.startsWith('image/')) { - files[0].arrayBuffer().then(value => { - const blob = new Blob([value]) - setPreviewUri(URL.createObjectURL(blob)) - }) - // single non-image - } else { - setPreviewUri(undefined) - setPreviewComponent(} />) - } - // collection - } else if (detectIndexHtml(files)) { - setPreviewUri(undefined) - setPreviewComponent(} />) - } else { - setPreviewUri(undefined) - setPreviewComponent(} />) - } - }, [files]) - - const getPrimaryText = () => { - const name = getAssetNameFromFiles(files) - - if (files.length === 1) { - return 'Filename: ' + (assetName || name) - } - - return 'Folder name: ' + (assetName || name) + if (metadata?.isWebsite) { + previewComponent = + type = 'Website' + } else if (metadata?.type === 'folder') { + previewComponent = + type = 'Folder' } - const getKind = () => { - if (files.length === 1) { - return files[0].type - } - - if (detectIndexHtml(files)) { - return 'Website' - } - - return 'Folder' - } - - const isFolder = () => ['Folder', 'Website'].includes(getKind()) - - const getSize = () => { - const bytes = files.reduce((total, item) => total + item.size, 0) - - return getHumanReadableFileSize(bytes) - } - - const size = getSize() - return ( - {previewComponent ? ( - previewComponent - ) : ( + {previewUri ? ( + ) : ( + )} - {getPrimaryText()} - Kind: {getKind()} - {size !== '0 bytes' && Size: {size}} + {metadata?.hash && Swarm Hash: {shortenHash(metadata.hash)}} + + {metadata?.type === 'folder' ? 'Folder Name' : 'Filename'}: {metadata?.name} + + Kind: {type} + {metadata?.size && Size: {getHumanReadableFileSize(metadata.size)}} - {isFolder() && ( + {metadata?.type === 'folder' && metadata.count && ( Folder content - {files.length} items + {metadata.count} items )} diff --git a/src/pages/files/AssetSummary.tsx b/src/pages/files/AssetSummary.tsx index f024bb6..c7affd3 100644 --- a/src/pages/files/AssetSummary.tsx +++ b/src/pages/files/AssetSummary.tsx @@ -4,21 +4,19 @@ import { ReactElement } from 'react' import { DocumentationText } from '../../components/DocumentationText' import ExpandableListItemKey from '../../components/ExpandableListItemKey' import ExpandableListItemLink from '../../components/ExpandableListItemLink' -import { detectIndexHtml } from '../../utils/file' -import { SwarmFile } from '../../utils/SwarmFile' interface Props { - files: SwarmFile[] + isWebsite?: boolean hash: string } -export function AssetSummary({ files, hash }: Props): ReactElement { +export function AssetSummary({ isWebsite, hash }: Props): ReactElement { return ( <> - {detectIndexHtml(files) && ( + {isWebsite && ( ([]) const [swarmEntries, setSwarmEntries] = useState>({}) const [indexDocument, setIndexDocument] = useState(null) const [notFound, setNotFound] = useState(false) + const [preview, setPreview] = useState(undefined) + const [metadata, setMetadata] = useState() async function prepare() { if (!beeApi || !status.all) { @@ -51,16 +51,37 @@ export function Share(): ReactElement { return } const entries = await manifestJs.getHashes(reference) - setSwarmEntries(entries) const indexDocument = await manifestJs.getIndexDocumentPath(reference) setIndexDocument(indexDocument) - if (Object.keys(entries).length === 1) { - const response = await beeApi.downloadFile(reference) - setFiles([new SwarmFile(convertBeeFileToBrowserFile(response) as File)]) - } else { - setFiles(convertManifestToFiles(entries)) + const previewFile = entries[PREVIEW_FILE_NAME] + + delete entries[META_FILE_NAME] + delete entries[PREVIEW_FILE_NAME] + setSwarmEntries(entries) + + const count = Object.keys(entries).length + + let metadata: Metadata | undefined = { + hash, + size: 0, + type: count > 1 ? 'folder' : 'unknown', + name: reference, + isWebsite: Boolean(indexDocument) && count > 1, + count, } + + try { + const mtdt = await beeApi.downloadFile(reference, META_FILE_NAME) + const remoteMetadata = mtdt.data.text() + metadata = { ...metadata, ...(JSON.parse(remoteMetadata) as Metadata) } + } catch (e) {} // eslint-disable-line no-empty + + if (previewFile) { + setPreview(`${config.BEE_API_HOST}/bzz/${reference}/${PREVIEW_FILE_NAME}`) + } + + setMetadata(metadata) } function onOpen() { @@ -109,8 +130,6 @@ export function Share(): ReactElement { setDownloading(false) } - const assetName = shortenHash(reference) - if (!status.all) return if (loading) { @@ -129,17 +148,17 @@ export function Share(): ReactElement { return ( <> - + - + 1)} + hasIndexDocument={Boolean(metadata?.isWebsite)} loading={downloading} /> diff --git a/src/pages/files/Upload.tsx b/src/pages/files/Upload.tsx index da9b77a..8bad5c7 100644 --- a/src/pages/files/Upload.tsx +++ b/src/pages/files/Upload.tsx @@ -12,7 +12,7 @@ import { Context as FileContext } from '../../providers/File' import { Context as SettingsContext } from '../../providers/Settings' import { Context as StampsContext, EnrichedPostageBatch } from '../../providers/Stamps' import { ROUTES } from '../../routes' -import { detectIndexHtml, getAssetNameFromFiles } from '../../utils/file' +import { detectIndexHtml, getAssetNameFromFiles, packageFile } from '../../utils/file' import { persistIdentity, updateFeed } from '../../utils/identity' import { HISTORY_KEYS, putHistory } from '../../utils/local-storage' import { FeedPasswordDialog } from '../feeds/FeedPasswordDialog' @@ -21,6 +21,7 @@ import { PostageStampSelector } from '../stamps/PostageStampSelector' import { AssetPreview } from './AssetPreview' import { StampPreview } from './StampPreview' import { UploadActionBar } from './UploadActionBar' +import { META_FILE_NAME, PREVIEW_FILE_NAME } from '../../constants' export function Upload(): ReactElement { const [step, setStep] = useState(0) @@ -31,7 +32,7 @@ export function Upload(): ReactElement { const { refresh } = useContext(StampsContext) const { beeApi } = useContext(SettingsContext) - const { files, setFiles, uploadOrigin } = useContext(FileContext) + const { files, setFiles, uploadOrigin, metadata, previewUri, previewBlob } = useContext(FileContext) const { identities, setIdentities } = useContext(IdentityContext) const { status } = useContext(BeeContext) @@ -66,16 +67,63 @@ export function Upload(): ReactElement { } const uploadFiles = (password?: string) => { - if (!beeApi || !files.length || !stamp) { + if (!beeApi || !files.length || !stamp || !metadata) { return } - const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(files) || undefined + let fls = files.map(packageFile) // Apart from packaging, this is needed to not modify the original files array as it can trigger effects + let indexDocument: string | undefined = undefined // This means we assume it's folder + + if (files.length === 1) indexDocument = files[0].name + else if (files.length > 1) { + const idx = detectIndexHtml(files) + + // This is a website + if (idx) { + // The website is in some directory, remove it + if (idx.commonPrefix) { + const substrStart = idx.commonPrefix.length + indexDocument = idx.indexPath.substr(substrStart) + fls = fls.map(f => { + const path = (f.path as string).substr(substrStart) + + return { ...f, path, webkitRelativePath: path, fullPath: path } + }) + } else { + // The website is not packed in a directory + indexDocument = idx.indexPath + } + } + } + const lastModified = files[0].lastModified + + // We want to store only some metadata + const mtd: SwarmMetadata = { + name: metadata.name, + size: metadata.size, + } + + // Type of the file only makes sense for a single file + if (files.length === 1) mtd.type = metadata.type + + const metafile = new File([JSON.stringify(mtd)], META_FILE_NAME, { + type: 'application/json', + lastModified, + }) + fls.push(packageFile(metafile)) + + if (previewBlob) { + const previewFile = new File([previewBlob], PREVIEW_FILE_NAME, { + type: 'image/jpeg', + lastModified, + }) + fls.push(packageFile(previewFile)) + } setUploading(true) beeApi - .uploadFiles(stamp.batchID, files as unknown as File[], { indexDocument }) + .uploadFiles(stamp.batchID, fls, { indexDocument }) .then(hash => { putHistory(HISTORY_KEYS.UPLOAD_HISTORY, hash.reference, getAssetNameFromFiles(files)) @@ -121,7 +169,7 @@ export function Upload(): ReactElement { - {(step === 0 || step === 2) && } + {(step === 0 || step === 2) && } {step === 1 && ( <> diff --git a/src/pages/files/UploadArea.tsx b/src/pages/files/UploadArea.tsx index ff24a24..ead585a 100644 --- a/src/pages/files/UploadArea.tsx +++ b/src/pages/files/UploadArea.tsx @@ -9,7 +9,6 @@ import { SwarmButton } from '../../components/SwarmButton' import { Context, UploadOrigin } from '../../providers/File' import { ROUTES } from '../../routes' import { detectIndexHtml } from '../../utils/file' -import { SwarmFile } from '../../utils/SwarmFile' interface Props { uploadOrigin: UploadOrigin @@ -99,8 +98,8 @@ export function UploadArea({ uploadOrigin, showHelp }: Props): ReactElement { const handleChange = (files?: File[]) => { if (files) { - const swarmFiles = files.map(x => new SwarmFile(x)) - const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(swarmFiles) || undefined + const FilePaths = files as FilePath[] + const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(FilePaths) || undefined if (files.length && strictWebsiteMode && !indexDocument) { enqueueSnackbar('To upload a website, there must be an index.html or index.htm in the root of the folder.', { @@ -111,7 +110,7 @@ export function UploadArea({ uploadOrigin, showHelp }: Props): ReactElement { return } - setFiles(swarmFiles) + setFiles(FilePaths) if (files.length) { setUploadOrigin(uploadOrigin) diff --git a/src/providers/File.tsx b/src/providers/File.tsx index 1827f02..54a0c67 100644 --- a/src/providers/File.tsx +++ b/src/providers/File.tsx @@ -1,17 +1,22 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { createContext, ReactChild, ReactElement, useState } from 'react' -import { SwarmFile } from '../utils/SwarmFile' +import { createContext, ReactChild, ReactElement, useState, useEffect } from 'react' +import { getMetadata } from '../utils/file' +import { resize } from '../utils/image' +import { PREVIEW_DIMENSIONS } from '../constants' export type UploadOrigin = { origin: 'UPLOAD' | 'FEED'; uuid?: string } export const defaultUploadOrigin: UploadOrigin = { origin: 'UPLOAD' } interface ContextInterface { - files: SwarmFile[] - setFiles: (files: SwarmFile[]) => void + files: FilePath[] + setFiles: (files: FilePath[]) => void uploadOrigin: UploadOrigin setUploadOrigin: (uploadOrigin: UploadOrigin) => void + metadata?: Metadata + previewUri?: string + previewBlob?: Blob } const initialValues: ContextInterface = { @@ -29,8 +34,38 @@ interface Props { } export function Provider({ children }: Props): ReactElement { - const [files, setFiles] = useState(initialValues.files) + const [files, setFiles] = useState(initialValues.files) const [uploadOrigin, setUploadOrigin] = useState(initialValues.uploadOrigin) + const [metadata, setMetadata] = useState(undefined) + const [previewUri, setPreviewUri] = useState(undefined) + const [previewBlob, setPreviewBlob] = useState(undefined) - return {children} + useEffect(() => { + setMetadata(getMetadata(files)) + + if (previewUri) { + URL.revokeObjectURL(previewUri) // Clear the preview from memory + setPreviewUri(undefined) + setPreviewBlob(undefined) + } + + if (files.length !== 1 || !files[0].type.startsWith('image')) return + + resize(files[0], PREVIEW_DIMENSIONS.maxWidth, PREVIEW_DIMENSIONS.maxHeight).then(blob => { + setPreviewUri(URL.createObjectURL(blob)) // NOTE: Until it is cleared with URL.revokeObjectURL, the file stays allocated in memory + setPreviewBlob(blob) + }) + + return () => { + if (previewUri) { + URL.revokeObjectURL(previewUri) + } + } + }, [files]) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ) } diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 246a950..924899c 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -21,3 +21,18 @@ interface StatusEthereumConnectionHook extends StatusHookCommon { interface StatusTopologyHook extends StatusHookCommon { topology: Topology | null } + +interface SwarmMetadata { + size: number + name: string + type?: string +} + +interface Metadata extends SwarmMetadata { + type: string + isWebsite: boolean + count?: number + hash?: string +} + +type FilePath = File & { path?: string; fullPath?: string } diff --git a/src/utils/SwarmFile.ts b/src/utils/SwarmFile.ts deleted file mode 100644 index 01357dc..0000000 --- a/src/utils/SwarmFile.ts +++ /dev/null @@ -1,24 +0,0 @@ -export class SwarmFile { - public name: string - public path: string - public type: string - public size: number - public webkitRelativePath: string - public arrayBuffer: () => Promise - private data: Promise - - constructor(file: File) { - const path = Reflect.get(file, 'path') || file.webkitRelativePath || file.name - this.path = path.startsWith('/') ? path.slice(1) : path - this.webkitRelativePath = this.path - this.name = file.name - this.type = file.type - this.size = file.size - this.data = file.arrayBuffer() - this.arrayBuffer = async () => { - const data = await this.data - - return data - } - } -} diff --git a/src/utils/file.ts b/src/utils/file.ts index a11d086..cb90a3f 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,28 +1,32 @@ -import { FileData } from '@ethersphere/bee-js' -import { SwarmFile } from './SwarmFile' - const indexHtmls = ['index.html', 'index.htm'] -export function detectIndexHtml(files: SwarmFile[]): string | false { - if (!files.length) { +interface DetectedIndex { + indexPath: string + commonPrefix?: string +} + +export function detectIndexHtml(files: FilePath[]): DetectedIndex | false { + const paths = files.map(getPath) + + if (!paths.length) { return false } - const exactMatch = files.find(x => indexHtmls.includes(x.path)) + const exactMatch = paths.find(x => indexHtmls.includes(x)) if (exactMatch) { - return exactMatch.name + return { indexPath: exactMatch } } - const prefix = files[0].path.split('/')[0] + '/' + const prefix = paths[0].split('/')[0] + '/' - const allStartWithSamePrefix = files.every(x => x.path.startsWith(prefix)) + const allStartWithSamePrefix = paths.every(x => x.startsWith(prefix)) if (allStartWithSamePrefix) { - const match = files.find(x => indexHtmls.map(y => prefix + y).includes(x.path)) + const match = paths.find(x => indexHtmls.map(y => prefix + y).includes(x)) if (match) { - return match.name + return { indexPath: match, commonPrefix: prefix } } } @@ -53,38 +57,50 @@ export function getHumanReadableFileSize(bytes: number): string { return bytes + ' bytes' } -export function convertBeeFileToBrowserFile(file: FileData): Partial { - return { - name: file.name, - size: file.data.byteLength, - type: file.contentType, - arrayBuffer: () => new Promise(resolve => resolve(file.data)), - } -} - -export function convertManifestToFiles(files: Record): SwarmFile[] { - return Object.entries(files).map( - x => - ({ - name: x[0], - path: x[0], - type: 'n/a', - size: 0, - webkitRelativePath: x[0], - arrayBuffer: () => new Promise(resolve => resolve(new ArrayBuffer(0))), - } as SwarmFile), - ) -} - -export function getAssetNameFromFiles(files: SwarmFile[]): string { +export function getAssetNameFromFiles(files: FilePath[]): string { if (files.length === 1) return files[0].name if (files.length > 0) { - const prefix = files[0].path.split('/')[0] + const prefix = getPath(files[0]).split('/')[0] // Only if all files have a common prefix we can use it as a folder name - if (files.every(f => f.path.split('/')[0] === prefix)) return prefix + if (files.every(f => getPath(f).split('/')[0] === prefix)) return prefix } return 'unknown' } + +export function getMetadata(files: FilePath[]): Metadata { + const size = files.reduce((total, item) => total + item.size, 0) + const isWebsite = Boolean(detectIndexHtml(files)) + const name = getAssetNameFromFiles(files) + const type = files.length === 1 ? files[0].type : 'folder' + const count = files.length + + return { size, name, type, isWebsite, count } +} + +export function getPath(file: FilePath): string { + return (file.path || file.webkitRelativePath || file.name).replace(/^\//g, '') // remove the starting slash +} + +/** + * Utility function that is needed to have correct directory structure as webkitRelativePath is read only + */ +export function packageFile(file: FilePath): FilePath { + const path = getPath(file) + + return { + path: path, + fullPath: path, + webkitRelativePath: path, + lastModified: file.lastModified, + name: file.name, + size: file.size, + type: file.type, + stream: file.stream, + slice: file.slice, + text: file.text, + arrayBuffer: async () => await file.arrayBuffer(), // This is needed for successful upload and can not simply be { arrayBuffer: file.arrayBuffer } + } +} diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..b058d98 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,89 @@ +interface Dimensions { + width: number + height: number +} + +/** + * Get the dimensions of the image after resize + * + * @param imgWidth Current image width + * @param imgHeight Current image height + * @param maxWidth Desired max width + * @param maxHeight Desired max height + * + * @returns Downscaled dimensions of the image to fit in the bounding box + */ +export function getDimensions(imgWidth: number, imgHeight: number, maxWidth?: number, maxHeight?: number): Dimensions { + const ratioWidth = maxWidth ? imgWidth / maxWidth : 1 + const ratioHeight = maxHeight ? imgHeight / maxHeight : 1 + + const ratio = Math.max(ratioWidth, ratioHeight) + + // No need to resize + if (ratio <= 1) return { width: imgWidth, height: imgHeight } + + return { width: imgWidth / ratio, height: imgHeight / ratio } +} + +/** + * Resize image passed to fit in the bounding box defined with maxWidth and maxHeight. + * Note that one or both of the bounding box dimensions may be omitted + * + * @param file Image file to be resized + * @param maxWidth Maximal image width + * @param maxHeight Maximal image height + * + * @returns Promise that resolves into the resized image blob + */ +export function resize(file: File, maxWidth?: number, maxHeight?: number): Promise { + return new Promise((resolve, reject) => { + const allowedTypes = [ + 'image/bmp', + 'image/gif', + 'image/vnd.microsoft.icon', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/tiff', + 'image/webp', + ] + + if (!file.size || !file.type || !allowedTypes.includes(file.type)) return reject('File not supported!') + + try { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = event => { + const src = event?.target?.result + + if (!src || typeof src !== 'string') throw new Error('Failed to load the image source') + + const img = new Image() + img.src = src + img.onload = () => { + const dimensions = getDimensions(img.width, img.height, maxWidth, maxHeight) + const elem = document.createElement('canvas') + elem.width = dimensions.width + elem.height = dimensions.height + const ctx = elem.getContext('2d') + + if (!ctx) throw new Error('Failed to create canvas context') + + ctx.drawImage(img, 0, 0, elem.width, elem.height) + ctx.canvas.toBlob( + blob => { + if (!blob) throw new Error('Failed to extract the blob from canvas') + + resolve(blob) + }, + 'image/jpeg', + 1, + ) + } + } + reader.onerror = error => reject(error) + } catch (error) { + reject(error) + } + }) +}