feat: exif metadata & remove gps

This commit is contained in:
diced 2022-11-19 13:58:14 -08:00
parent 3175911105
commit 6457680065
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
18 changed files with 438 additions and 13 deletions

View file

@ -49,6 +49,7 @@
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"dotenv-expand": "^9.0.0", "dotenv-expand": "^9.0.0",
"exiftool-vendored": "^18.6.0",
"fflate": "^0.7.4", "fflate": "^0.7.4",
"find-my-way": "^7.3.1", "find-my-way": "^7.3.1",
"minio": "^7.0.32", "minio": "^7.0.32",

View file

@ -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 [open, setOpen] = useState(false);
const deleteFile = useFileDelete(); const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite(); const favoriteFile = useFileFavorite();
const clipboard = useClipboard(); const clipboard = useClipboard();
console.log(exifEnabled);
const loading = deleteFile.isLoading || favoriteFile.isLoading; const loading = deleteFile.isLoading || favoriteFile.isLoading;
const handleDelete = async () => { const handleDelete = async () => {
@ -156,6 +158,11 @@ export default function File({ image, updateImages, disableMediaPreview }) {
</Stack> </Stack>
<Group position='right' mt='md'> <Group position='right' mt='md'>
{exifEnabled && (
<Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
<Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
</Link>
)}
<Button onClick={handleCopy}>Copy URL</Button> <Button onClick={handleCopy}>Copy URL</Button>
<Button onClick={handleDelete}>Delete</Button> <Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button> <Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>

View file

@ -5,7 +5,7 @@ import MutedText from 'components/MutedText';
import { invalidateFiles, useRecent } from 'lib/queries/files'; import { invalidateFiles, useRecent } from 'lib/queries/files';
import { UploadCloud } from 'react-feather'; import { UploadCloud } from 'react-feather';
export default function RecentFiles({ disableMediaPreview }) { export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
const recent = useRecent('media'); const recent = useRecent('media');
return ( return (
@ -24,6 +24,7 @@ export default function RecentFiles({ disableMediaPreview }) {
image={image} image={image}
updateImages={invalidateFiles} updateImages={invalidateFiles}
disableMediaPreview={disableMediaPreview} disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
/> />
)) ))
) : ( ) : (

View file

@ -13,7 +13,7 @@ import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles'; import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards'; import { StatCards } from './StatCards';
export default function Dashboard({ disableMediaPreview }) { export default function Dashboard({ disableMediaPreview, exifEnabled }) {
const user = useRecoilValue(userSelector); const user = useRecoilValue(userSelector);
const theme = useMantineTheme(); const theme = useMantineTheme();
@ -72,7 +72,7 @@ export default function Dashboard({ disableMediaPreview }) {
<StatCards /> <StatCards />
<RecentFiles disableMediaPreview={disableMediaPreview} /> <RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
<Box my='sm'> <Box my='sm'>
<Title>Files</Title> <Title>Files</Title>

View file

@ -5,7 +5,7 @@ import MutedText from 'components/MutedText';
import { usePaginatedFiles } from 'lib/queries/files'; import { usePaginatedFiles } from 'lib/queries/files';
import { useState } from 'react'; import { useState } from 'react';
export default function FilePagation({ disableMediaPreview }) { export default function FilePagation({ disableMediaPreview, exifEnabled }) {
const [checked, setChecked] = useState(false); const [checked, setChecked] = useState(false);
const pages = usePaginatedFiles(!checked ? { filter: 'media' } : {}); const pages = usePaginatedFiles(!checked ? { filter: 'media' } : {});
@ -38,6 +38,7 @@ export default function FilePagation({ disableMediaPreview }) {
image={image} image={image}
updateImages={() => pages.refetch()} updateImages={() => pages.refetch()}
disableMediaPreview={disableMediaPreview} disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
/> />
</div> </div>
)) ))

View file

@ -6,7 +6,7 @@ import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import FilePagation from './FilePagation'; import FilePagation from './FilePagation';
export default function Files({ disableMediaPreview }) { export default function Files({ disableMediaPreview, exifEnabled }) {
const pages = usePaginatedFiles({ filter: 'media' }); const pages = usePaginatedFiles({ filter: 'media' });
const favoritePages = usePaginatedFiles({ favorite: 'media' }); const favoritePages = usePaginatedFiles({ favorite: 'media' });
const [favoritePage, setFavoritePage] = useState(1); const [favoritePage, setFavoritePage] = useState(1);
@ -49,6 +49,7 @@ export default function Files({ disableMediaPreview }) {
image={image} image={image}
updateImages={() => updatePages(true)} updateImages={() => updatePages(true)}
disableMediaPreview={disableMediaPreview} disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
/> />
</div> </div>
)) ))
@ -74,7 +75,7 @@ export default function Files({ disableMediaPreview }) {
</Accordion> </Accordion>
) : null} ) : null}
<FilePagation disableMediaPreview={disableMediaPreview} /> <FilePagation disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
</> </>
); );
} }

View file

@ -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: <CopyIcon />,
});
};
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) => (
<tr key={element.name}>
<td>{element.name}</td>
<td>{element.value}</td>
<td>
<Button.Group>
<Button variant='light' onClick={() => copy(element.value)}>
Copy Value
</Button>
<Button variant='light' onClick={() => copy(element.name)}>
Copy Name
</Button>
</Button.Group>
</td>
</tr>
));
return (
<>
<Group mb='md'>
<Title>Metadata for {fileId}</Title>
</Group>
{metadata ? (
<>
<TextInput
my='md'
label='Search'
labelProps={{
size: 'xl',
}}
placeholder='Search for a metadata value'
value={search}
onChange={(e) => searchValue(e.currentTarget.value)}
/>
{filtered === null ? (
<Center>
<Group spacing='md'>
<Title>No results found</Title>
<Button variant='outline' color='red' onClick={clearSearch}>
Clear search
</Button>
</Group>
</Center>
) : (
<Table highlightOnHover>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
)}
</>
) : (
<Skeleton height={300} />
)}
</>
);
}

View file

@ -128,6 +128,11 @@ export interface ConfigMfa {
totp_issuer: string; totp_issuer: string;
} }
export interface ConfigExif {
enabled: boolean;
remove_gps: boolean;
}
export interface Config { export interface Config {
core: ConfigCore; core: ConfigCore;
uploader: ConfigUploader; uploader: ConfigUploader;
@ -140,4 +145,5 @@ export interface Config {
features: ConfigFeatures; features: ConfigFeatures;
chunks: ConfigChunks; chunks: ConfigChunks;
mfa: ConfigMfa; mfa: ConfigMfa;
exif: ConfigExif;
} }

View file

@ -146,6 +146,9 @@ export default function readConfig() {
map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'), map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'),
map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'), map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'),
map('EXIF_ENABLED', 'boolean', 'exif.enabled'),
map('EXIF_REMOVE_GPS', 'boolean', 'exif.remove_gps'),
]; ];
const config = {}; const config = {};

View file

@ -196,6 +196,15 @@ const validator = s.object({
totp_issuer: 'Zipline', totp_issuer: 'Zipline',
totp_enabled: false, 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 { export default function validate(config): Config {

View file

@ -19,9 +19,11 @@ export type ServerSideProps = {
chunks_size: number; chunks_size: number;
max_size: number; max_size: number;
totp_enabled: boolean; totp_enabled: boolean;
exif_enabled: boolean;
fileId?: string;
}; };
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async () => { export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret); 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 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); const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret);
@ -48,7 +50,7 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async ()
link_url: '/api/auth/oauth/google?state=link', link_url: '/api/auth/oauth/google?state=link',
}); });
return { const obj = {
props: { props: {
title: config.website.title, title: config.website.title,
external_links: JSON.stringify(config.website.external_links), external_links: JSON.stringify(config.website.external_links),
@ -60,6 +62,14 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async ()
chunks_size: config.chunks.chunks_size, chunks_size: config.chunks.chunks_size,
max_size: config.chunks.max_size, max_size: config.chunks.max_size,
totp_enabled: config.mfa.totp_enabled, 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;
}; };

86
src/lib/utils/exif.ts Normal file
View file

@ -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<Tags> {
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<void> {
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;
}

66
src/pages/api/exif.ts Normal file
View file

@ -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,
});

View file

@ -10,6 +10,7 @@ import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline'
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { createInvisImage, hashPassword, randomChars } from 'lib/util'; import { createInvisImage, hashPassword, randomChars } from 'lib/util';
import { parseExpiry } from 'lib/utils/client'; import { parseExpiry } from 'lib/utils/client';
import { removeGPSData } from 'lib/utils/exif';
import multer from 'multer'; import multer from 'multer';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; 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; const expires_at = req.headers['expires-at'] as string;
let expiry: Date; 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); 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}` `${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) { if (user.administrator && zconfig.ratelimit.admin > 0) {

View file

@ -18,7 +18,7 @@ export default function FilesPage(props) {
</Head> </Head>
<Layout props={props}> <Layout props={props}>
<Files disableMediaPreview={props.disable_media_preview} /> <Files disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
</Layout> </Layout>
</> </>
); );

View file

@ -16,7 +16,7 @@ export default function DashboardPage(props) {
<title>{props.title}</title> <title>{props.title}</title>
</Head> </Head>
<Layout props={props}> <Layout props={props}>
<Dashboard disableMediaPreview={props.disable_media_preview} /> <Dashboard disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
</Layout> </Layout>
</> </>
); );

View file

@ -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 <LoadingOverlay visible={loading} />;
return (
<>
<Head>
<title>{props.title}</title>
</Head>
<Layout props={props}>
<MetadataView fileId={props.fileId} />
</Layout>
</>
);
}

View file

@ -1145,6 +1145,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@prisma/client@npm:^4.5.0":
version: 4.5.0 version: 4.5.0
resolution: "@prisma/client@npm:4.5.0" resolution: "@prisma/client@npm:4.5.0"
@ -1668,6 +1675,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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:*": "@types/mime@npm:*":
version: 3.0.1 version: 3.0.1
resolution: "@types/mime@npm:3.0.1" resolution: "@types/mime@npm:3.0.1"
@ -2250,6 +2264,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "bl@npm:^4.0.3, bl@npm:^4.1.0":
version: 4.1.0 version: 4.1.0
resolution: "bl@npm:4.1.0" resolution: "bl@npm:4.1.0"
@ -4026,6 +4047,40 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "expand-template@npm:^2.0.3":
version: 2.0.3 version: 2.0.3
resolution: "expand-template@npm:2.0.3" resolution: "expand-template@npm:2.0.3"
@ -4632,6 +4687,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "hmac-drbg@npm:^1.0.1":
version: 1.0.1 version: 1.0.1
resolution: "hmac-drbg@npm:1.0.1" resolution: "hmac-drbg@npm:1.0.1"
@ -5566,6 +5630,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "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 version: 3.1.0
resolution: "make-dir@npm:3.1.0" resolution: "make-dir@npm:3.1.0"
@ -8852,6 +8923,7 @@ __metadata:
eslint-config-next: ^13.0.0 eslint-config-next: ^13.0.0
eslint-config-prettier: ^8.5.0 eslint-config-prettier: ^8.5.0
eslint-plugin-prettier: ^4.2.1 eslint-plugin-prettier: ^4.2.1
exiftool-vendored: ^18.6.0
fflate: ^0.7.4 fflate: ^0.7.4
find-my-way: ^7.3.1 find-my-way: ^7.3.1
minio: ^7.0.32 minio: ^7.0.32