From 6457680065af974232b10dee8e6135a32ef9ff97 Mon Sep 17 00:00:00 2001 From: diced Date: Sat, 19 Nov 2022 13:58:14 -0800 Subject: [PATCH] feat: exif metadata & remove gps --- package.json | 1 + src/components/File.tsx | 9 +- .../pages/Dashboard/RecentFiles.tsx | 3 +- src/components/pages/Dashboard/index.tsx | 4 +- src/components/pages/Files/FilePagation.tsx | 3 +- src/components/pages/Files/index.tsx | 5 +- src/components/pages/MetadataView.tsx | 128 ++++++++++++++++++ src/lib/config/Config.ts | 6 + src/lib/config/readConfig.ts | 3 + src/lib/config/validateConfig.ts | 9 ++ src/lib/middleware/getServerSideProps.ts | 16 ++- src/lib/utils/exif.ts | 86 ++++++++++++ src/pages/api/exif.ts | 66 +++++++++ src/pages/api/upload.ts | 13 +- src/pages/dashboard/files.tsx | 2 +- src/pages/dashboard/index.tsx | 2 +- src/pages/dashboard/metadata/[id].tsx | 23 ++++ yarn.lock | 72 ++++++++++ 18 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 src/components/pages/MetadataView.tsx create mode 100644 src/lib/utils/exif.ts create mode 100644 src/pages/api/exif.ts create mode 100644 src/pages/dashboard/metadata/[id].tsx diff --git a/package.json b/package.json index ec56b9e..394718b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dayjs": "^1.11.6", "dotenv": "^16.0.3", "dotenv-expand": "^9.0.0", + "exiftool-vendored": "^18.6.0", "fflate": "^0.7.4", "find-my-way": "^7.3.1", "minio": "^7.0.32", diff --git a/src/components/File.tsx b/src/components/File.tsx index 949372d..7cef4b4 100644 --- a/src/components/File.tsx +++ b/src/components/File.tsx @@ -43,12 +43,14 @@ export function FileMeta({ Icon, title, subtitle, ...other }) { ); } -export default function File({ image, updateImages, disableMediaPreview }) { +export default function File({ image, updateImages, disableMediaPreview, exifEnabled }) { const [open, setOpen] = useState(false); const deleteFile = useFileDelete(); const favoriteFile = useFileFavorite(); const clipboard = useClipboard(); + console.log(exifEnabled); + const loading = deleteFile.isLoading || favoriteFile.isLoading; const handleDelete = async () => { @@ -156,6 +158,11 @@ export default function File({ image, updateImages, disableMediaPreview }) { + {exifEnabled && ( + + + + )} diff --git a/src/components/pages/Dashboard/RecentFiles.tsx b/src/components/pages/Dashboard/RecentFiles.tsx index 757dcf1..052ec21 100644 --- a/src/components/pages/Dashboard/RecentFiles.tsx +++ b/src/components/pages/Dashboard/RecentFiles.tsx @@ -5,7 +5,7 @@ import MutedText from 'components/MutedText'; import { invalidateFiles, useRecent } from 'lib/queries/files'; import { UploadCloud } from 'react-feather'; -export default function RecentFiles({ disableMediaPreview }) { +export default function RecentFiles({ disableMediaPreview, exifEnabled }) { const recent = useRecent('media'); return ( @@ -24,6 +24,7 @@ export default function RecentFiles({ disableMediaPreview }) { image={image} updateImages={invalidateFiles} disableMediaPreview={disableMediaPreview} + exifEnabled={exifEnabled} /> )) ) : ( diff --git a/src/components/pages/Dashboard/index.tsx b/src/components/pages/Dashboard/index.tsx index edea39f..b182086 100644 --- a/src/components/pages/Dashboard/index.tsx +++ b/src/components/pages/Dashboard/index.tsx @@ -13,7 +13,7 @@ import { useRecoilValue } from 'recoil'; import RecentFiles from './RecentFiles'; import { StatCards } from './StatCards'; -export default function Dashboard({ disableMediaPreview }) { +export default function Dashboard({ disableMediaPreview, exifEnabled }) { const user = useRecoilValue(userSelector); const theme = useMantineTheme(); @@ -72,7 +72,7 @@ export default function Dashboard({ disableMediaPreview }) { - + Files diff --git a/src/components/pages/Files/FilePagation.tsx b/src/components/pages/Files/FilePagation.tsx index 662abb0..eff51f0 100644 --- a/src/components/pages/Files/FilePagation.tsx +++ b/src/components/pages/Files/FilePagation.tsx @@ -5,7 +5,7 @@ import MutedText from 'components/MutedText'; import { usePaginatedFiles } from 'lib/queries/files'; import { useState } from 'react'; -export default function FilePagation({ disableMediaPreview }) { +export default function FilePagation({ disableMediaPreview, exifEnabled }) { const [checked, setChecked] = useState(false); const pages = usePaginatedFiles(!checked ? { filter: 'media' } : {}); @@ -38,6 +38,7 @@ export default function FilePagation({ disableMediaPreview }) { image={image} updateImages={() => pages.refetch()} disableMediaPreview={disableMediaPreview} + exifEnabled={exifEnabled} /> )) diff --git a/src/components/pages/Files/index.tsx b/src/components/pages/Files/index.tsx index 593d84e..902da45 100644 --- a/src/components/pages/Files/index.tsx +++ b/src/components/pages/Files/index.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import { useState } from 'react'; import FilePagation from './FilePagation'; -export default function Files({ disableMediaPreview }) { +export default function Files({ disableMediaPreview, exifEnabled }) { const pages = usePaginatedFiles({ filter: 'media' }); const favoritePages = usePaginatedFiles({ favorite: 'media' }); const [favoritePage, setFavoritePage] = useState(1); @@ -49,6 +49,7 @@ export default function Files({ disableMediaPreview }) { image={image} updateImages={() => updatePages(true)} disableMediaPreview={disableMediaPreview} + exifEnabled={exifEnabled} /> )) @@ -74,7 +75,7 @@ export default function Files({ disableMediaPreview }) { ) : null} - + ); } diff --git a/src/components/pages/MetadataView.tsx b/src/components/pages/MetadataView.tsx new file mode 100644 index 0000000..9d83a46 --- /dev/null +++ b/src/components/pages/MetadataView.tsx @@ -0,0 +1,128 @@ +import { Button, Center, Group, Skeleton, Stack, Table, TextInput, Title } from '@mantine/core'; +import { useClipboard } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; +import { CopyIcon } from 'components/icons'; +import useFetch from 'hooks/useFetch'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +export default function MetadataView({ fileId }) { + const router = useRouter(); + const clipboard = useClipboard(); + + const [metadata, setMetadata] = useState([]); + const [search, setSearch] = useState(''); + const [filtered, setFiltered] = useState([]); + + const getMetadata = async () => { + const data = await useFetch(`/api/exif?id=${fileId}`); + if (!data.error) { + const arr = []; + for (const key in data) { + arr.push({ name: key, value: data[key] }); + } + setMetadata(arr); + } else { + setMetadata([]); + } + }; + + const copy = (value) => { + clipboard.copy(value); + showNotification({ + title: 'Copied to clipboard', + message: value, + icon: , + }); + }; + + const searchValue = (value) => { + setSearch(value); + + const filtered = metadata.filter((item) => { + return ( + item.name.toLowerCase().includes(value.toLowerCase()) || + item.value.toString().toLowerCase().includes(value.toLowerCase()) + ); + }); + + if (filtered.length > 0) { + setFiltered(filtered); + } else { + setFiltered(null); + } + }; + + const clearSearch = () => { + setSearch(''); + setFiltered([]); + }; + + useEffect(() => { + getMetadata(); + }, []); + + const rows = (filtered?.length ? filtered : metadata).map((element) => ( + + {element.name} + {element.value} + + + + + + + + )); + + return ( + <> + + Metadata for {fileId} + + + {metadata ? ( + <> + searchValue(e.currentTarget.value)} + /> + + {filtered === null ? ( +
+ + No results found + + +
+ ) : ( + + + + + + + + + {rows} +
NameValue
+ )} + + ) : ( + + )} + + ); +} diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts index f3c631b..e0ee915 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -128,6 +128,11 @@ export interface ConfigMfa { totp_issuer: string; } +export interface ConfigExif { + enabled: boolean; + remove_gps: boolean; +} + export interface Config { core: ConfigCore; uploader: ConfigUploader; @@ -140,4 +145,5 @@ export interface Config { features: ConfigFeatures; chunks: ConfigChunks; mfa: ConfigMfa; + exif: ConfigExif; } diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts index 116e3f9..a86a520 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -146,6 +146,9 @@ export default function readConfig() { map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'), map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'), + + map('EXIF_ENABLED', 'boolean', 'exif.enabled'), + map('EXIF_REMOVE_GPS', 'boolean', 'exif.remove_gps'), ]; const config = {}; diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts index c3e7907..85ac1b8 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -196,6 +196,15 @@ const validator = s.object({ totp_issuer: 'Zipline', totp_enabled: false, }), + exif: s + .object({ + enabled: s.boolean.default(false), + remove_gps: s.boolean.default(false), + }) + .default({ + enabled: false, + remove_gps: false, + }), }); export default function validate(config): Config { diff --git a/src/lib/middleware/getServerSideProps.ts b/src/lib/middleware/getServerSideProps.ts index 2ca1f6d..77eb8b0 100644 --- a/src/lib/middleware/getServerSideProps.ts +++ b/src/lib/middleware/getServerSideProps.ts @@ -19,9 +19,11 @@ export type ServerSideProps = { chunks_size: number; max_size: number; totp_enabled: boolean; + exif_enabled: boolean; + fileId?: string; }; -export const getServerSideProps: GetServerSideProps = async () => { +export const getServerSideProps: GetServerSideProps = async (ctx) => { const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret); const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret); const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret); @@ -48,7 +50,7 @@ export const getServerSideProps: GetServerSideProps = async () link_url: '/api/auth/oauth/google?state=link', }); - return { + const obj = { props: { title: config.website.title, external_links: JSON.stringify(config.website.external_links), @@ -60,6 +62,14 @@ export const getServerSideProps: GetServerSideProps = async () chunks_size: config.chunks.chunks_size, max_size: config.chunks.max_size, totp_enabled: config.mfa.totp_enabled, - }, + exif_enabled: config.exif.enabled, + } as ServerSideProps, }; + + if (ctx.resolvedUrl.startsWith('/dashboard/metadata')) { + if (!config.exif.enabled) return { notFound: true }; + obj.props.fileId = ctx.query.id as string; + } + + return obj; }; diff --git a/src/lib/utils/exif.ts b/src/lib/utils/exif.ts new file mode 100644 index 0000000..3b4ee37 --- /dev/null +++ b/src/lib/utils/exif.ts @@ -0,0 +1,86 @@ +import { Image } from '@prisma/client'; +import { createWriteStream } from 'fs'; +import { exiftool, Tags } from 'exiftool-vendored'; +import datasource from 'lib/datasource'; +import Logger from 'lib/logger'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { readFile, unlink } from 'fs/promises'; + +const logger = Logger.get('exif'); + +export async function readMetadata(filePath: string): Promise { + const exif = await exiftool.read(filePath); + logger.debug(`exif(${filePath}) -> ${JSON.stringify(exif)}`); + + for (const key in exif) { + if (exif[key]?.rawValue) { + exif[key] = exif[key].rawValue; + } + } + + delete exif.Directory; + delete exif.Source; + delete exif.SourceFile; + delete exif.errors; + delete exif.Warning; + + return exif; +} + +export async function removeGPSData(image: Image): Promise { + const file = join(tmpdir(), `zipline-exif-remove-${Date.now()}-${image.file}`); + logger.debug(`writing temp file to remove GPS data: ${file}`); + + const stream = await datasource.get(image.file); + const writeStream = createWriteStream(file); + stream.pipe(writeStream); + + await new Promise((resolve) => writeStream.on('finish', resolve)); + + logger.debug(`removing GPS data from ${file}`); + await exiftool.write(file, { + GPSVersionID: null, + GPSAltitude: null, + GPSAltitudeRef: null, + GPSAreaInformation: null, + GPSDateStamp: null, + GPSDateTime: null, + GPSDestBearing: null, + GPSDestBearingRef: null, + GPSDestDistance: null, + GPSDestLatitude: null, + GPSDestLatitudeRef: null, + GPSDestLongitude: null, + GPSDestLongitudeRef: null, + GPSDifferential: null, + GPSDOP: null, + GPSHPositioningError: null, + GPSImgDirection: null, + GPSImgDirectionRef: null, + GPSLatitude: null, + GPSLatitudeRef: null, + GPSLongitude: null, + GPSLongitudeRef: null, + GPSMapDatum: null, + GPSMeasureMode: null, + GPSPosition: null, + GPSProcessingMethod: null, + GPSSatellites: null, + GPSSpeed: null, + GPSSpeedRef: null, + GPSStatus: null, + GPSTimeStamp: null, + GPSTrack: null, + GPSTrackRef: null, + }); + + logger.debug(`reading file to upload to datasource: ${file} -> ${image.file}`); + const buffer = await readFile(file); + await datasource.save(image.file, buffer); + + logger.debug(`removing temp file: ${file}`); + await unlink(file); + + return; +} diff --git a/src/pages/api/exif.ts b/src/pages/api/exif.ts new file mode 100644 index 0000000..86af889 --- /dev/null +++ b/src/pages/api/exif.ts @@ -0,0 +1,66 @@ +import { createWriteStream, existsSync } from 'fs'; +import { unlink } from 'fs/promises'; +import config from 'lib/config'; +import datasource from 'lib/datasource'; +import Logger from 'lib/logger'; +import prisma from 'lib/prisma'; +import { readMetadata } from 'lib/utils/exif'; +import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const logger = Logger.get('exif'); + +async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { + if (!config.exif.enabled) return res.forbidden('exif disabled'); + + const { id } = req.query as { id: string }; + if (!id) return res.badRequest('no id'); + + const image = await prisma.image.findFirst({ + where: { + id: Number(id), + userId: user.id, + }, + }); + + if (!image) return res.notFound('image not found'); + + logger.info( + `${user.username} (${user.id}) requested to read exif metadata for image ${image.file} (${image.id})` + ); + + if (config.datasource.type === 'local') { + const filePath = join(config.datasource.local.directory, image.file); + logger.debug(`attemping to read exif metadata from ${filePath}`); + + if (!existsSync(filePath)) return res.notFound('image not found on fs'); + + const data = await readMetadata(filePath); + logger.debug(`exif(${filePath}) -> ${JSON.stringify(data)}`); + + return res.json(data); + } else { + const file = join(tmpdir(), `zipline-exif-read-${Date.now()}-${image.file}`); + logger.debug(`writing temp file to view metadata: ${file}`); + + const stream = await datasource.get(image.file); + const writeStream = createWriteStream(file); + stream.pipe(writeStream); + + await new Promise((resolve) => writeStream.on('finish', resolve)); + + const data = await readMetadata(file); + logger.debug(`exif(${file}) -> ${JSON.stringify(data)}`); + + await unlink(file); + logger.debug(`removing temp file: ${file}`); + + return res.json(data); + } +} + +export default withZipline(handler, { + methods: ['GET'], + user: true, +}); diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts index eff7c69..eeb2867 100644 --- a/src/pages/api/upload.ts +++ b/src/pages/api/upload.ts @@ -10,6 +10,7 @@ import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline' import prisma from 'lib/prisma'; import { createInvisImage, hashPassword, randomChars } from 'lib/util'; import { parseExpiry } from 'lib/utils/client'; +import { removeGPSData } from 'lib/utils/exif'; import multer from 'multer'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -36,7 +37,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { }); }); - const response: { files: string[]; expires_at?: Date } = { files: [] }; + const response: { files: string[]; expires_at?: Date; removed_gps?: boolean } = { files: [] }; const expires_at = req.headers['expires-at'] as string; let expiry: Date; @@ -189,6 +190,11 @@ async function handler(req: NextApiReq, res: NextApiRes) { ); } + if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) { + await removeGPSData(file); + response.removed_gps = true; + } + return res.json(response); } @@ -315,6 +321,11 @@ async function handler(req: NextApiReq, res: NextApiRes) { `${zconfig.core.https ? 'https' : 'http'}://${req.headers.host}/r/${invis ? invis.invis : image.file}` ); } + + if (zconfig.exif.enabled && zconfig.exif.remove_gps && image.mimetype.startsWith('image/')) { + await removeGPSData(image); + response.removed_gps = true; + } } if (user.administrator && zconfig.ratelimit.admin > 0) { diff --git a/src/pages/dashboard/files.tsx b/src/pages/dashboard/files.tsx index 745a855..199f4a9 100644 --- a/src/pages/dashboard/files.tsx +++ b/src/pages/dashboard/files.tsx @@ -18,7 +18,7 @@ export default function FilesPage(props) { - + ); diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 47e43cb..c495a7b 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -16,7 +16,7 @@ export default function DashboardPage(props) { {props.title} - + ); diff --git a/src/pages/dashboard/metadata/[id].tsx b/src/pages/dashboard/metadata/[id].tsx new file mode 100644 index 0000000..edd4eca --- /dev/null +++ b/src/pages/dashboard/metadata/[id].tsx @@ -0,0 +1,23 @@ +import { LoadingOverlay } from '@mantine/core'; +import Layout from 'components/Layout'; +import MetadataView from 'components/pages/MetadataView'; +import useLogin from 'hooks/useLogin'; +import Head from 'next/head'; +export { getServerSideProps } from 'middleware/getServerSideProps'; + +export default function MetadataPage(props) { + const { loading } = useLogin(); + + if (loading) return ; + + return ( + <> + + {props.title} + + + + + + ); +} diff --git a/yarn.lock b/yarn.lock index e8a6159..c97d26a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1145,6 +1145,13 @@ __metadata: languageName: node linkType: hard +"@photostructure/tz-lookup@npm:^7.0.0": + version: 7.0.0 + resolution: "@photostructure/tz-lookup@npm:7.0.0" + checksum: 859ddaa6b138b00f5fa799ba5ea34885b45cedbf30995d21c2bd51ab847d2fd9e7ccda6a6ef5e38f47785fdf2eb95157444a81f16e8d68dd23416add44cac22a + languageName: node + linkType: hard + "@prisma/client@npm:^4.5.0": version: 4.5.0 resolution: "@prisma/client@npm:4.5.0" @@ -1668,6 +1675,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:^3.1.0": + version: 3.1.0 + resolution: "@types/luxon@npm:3.1.0" + checksum: 04768029342ad76fc2a9339436c143ea64797b35cf9b03ddded787c13eae30f0ca1246e51c2c5365ed912f98068e13a967a3931b137eb4585248a0ad7ec3fa86 + languageName: node + linkType: hard + "@types/mime@npm:*": version: 3.0.1 resolution: "@types/mime@npm:3.0.1" @@ -2250,6 +2264,13 @@ __metadata: languageName: node linkType: hard +"batch-cluster@npm:^11.0.0": + version: 11.0.0 + resolution: "batch-cluster@npm:11.0.0" + checksum: 3322972c4f419e2f22332517f9fbe8def4f7cf03f00c701f30b6a394fff98ecf5d14152740ecc71e93e8baef871d308c684e33542e211f6c2ccd32df5e527fa8 + languageName: node + linkType: hard + "bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -4026,6 +4047,40 @@ __metadata: languageName: node linkType: hard +"exiftool-vendored.exe@npm:12.50.0": + version: 12.50.0 + resolution: "exiftool-vendored.exe@npm:12.50.0" + conditions: os=win32 + languageName: node + linkType: hard + +"exiftool-vendored.pl@npm:12.50.0": + version: 12.50.0 + resolution: "exiftool-vendored.pl@npm:12.50.0" + conditions: "!os=win32" + languageName: node + linkType: hard + +"exiftool-vendored@npm:^18.6.0": + version: 18.6.0 + resolution: "exiftool-vendored@npm:18.6.0" + dependencies: + "@photostructure/tz-lookup": ^7.0.0 + "@types/luxon": ^3.1.0 + batch-cluster: ^11.0.0 + exiftool-vendored.exe: 12.50.0 + exiftool-vendored.pl: 12.50.0 + he: ^1.2.0 + luxon: ^3.1.0 + dependenciesMeta: + exiftool-vendored.exe: + optional: true + exiftool-vendored.pl: + optional: true + checksum: d5a80d2124c39f58ffe71adf80188ed8497d49be931067139f8db6b86d3c6c020fb078bf17b65124685b34dea2a4ac927dd5fa0b6b822a3007b84660c0c96cf8 + languageName: node + linkType: hard + "expand-template@npm:^2.0.3": version: 2.0.3 resolution: "expand-template@npm:2.0.3" @@ -4632,6 +4687,15 @@ __metadata: languageName: node linkType: hard +"he@npm:^1.2.0": + version: 1.2.0 + resolution: "he@npm:1.2.0" + bin: + he: bin/he + checksum: 3d4d6babccccd79c5c5a3f929a68af33360d6445587d628087f39a965079d84f18ce9c3d3f917ee1e3978916fc833bb8b29377c3b403f919426f91bc6965e7a7 + languageName: node + linkType: hard + "hmac-drbg@npm:^1.0.1": version: 1.0.1 resolution: "hmac-drbg@npm:1.0.1" @@ -5566,6 +5630,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.1.0": + version: 3.1.0 + resolution: "luxon@npm:3.1.0" + checksum: f8a850b759ba7a2e009d904c522ed7bc264bf4add57578f8948e52a0ed96b627b025b5aad8032295b570ae19fac41f0ffab91bdb128715fb0cc020798a7ba886 + languageName: node + linkType: hard + "make-dir@npm:3.1.0, make-dir@npm:^3.0.0, make-dir@npm:^3.0.2, make-dir@npm:^3.1.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -8852,6 +8923,7 @@ __metadata: eslint-config-next: ^13.0.0 eslint-config-prettier: ^8.5.0 eslint-plugin-prettier: ^4.2.1 + exiftool-vendored: ^18.6.0 fflate: ^0.7.4 find-my-way: ^7.3.1 minio: ^7.0.32