feat: exif metadata & remove gps
This commit is contained in:
parent
3175911105
commit
6457680065
18 changed files with 438 additions and 13 deletions
|
@ -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",
|
||||
|
|
|
@ -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 }) {
|
|||
</Stack>
|
||||
|
||||
<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={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
@ -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 }) {
|
|||
|
||||
<StatCards />
|
||||
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} />
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
|
||||
<Box my='sm'>
|
||||
<Title>Files</Title>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
@ -74,7 +75,7 @@ export default function Files({ disableMediaPreview }) {
|
|||
</Accordion>
|
||||
) : null}
|
||||
|
||||
<FilePagation disableMediaPreview={disableMediaPreview} />
|
||||
<FilePagation disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
128
src/components/pages/MetadataView.tsx
Normal file
128
src/components/pages/MetadataView.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<ServerSideProps> = async () => {
|
||||
export const getServerSideProps: GetServerSideProps<ServerSideProps> = 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<ServerSideProps> = 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<ServerSideProps> = 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;
|
||||
};
|
||||
|
|
86
src/lib/utils/exif.ts
Normal file
86
src/lib/utils/exif.ts
Normal 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
66
src/pages/api/exif.ts
Normal 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,
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -18,7 +18,7 @@ export default function FilesPage(props) {
|
|||
</Head>
|
||||
|
||||
<Layout props={props}>
|
||||
<Files disableMediaPreview={props.disable_media_preview} />
|
||||
<Files disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -16,7 +16,7 @@ export default function DashboardPage(props) {
|
|||
<title>{props.title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<Dashboard disableMediaPreview={props.disable_media_preview} />
|
||||
<Dashboard disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
|
23
src/pages/dashboard/metadata/[id].tsx
Normal file
23
src/pages/dashboard/metadata/[id].tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
72
yarn.lock
72
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
|
||||
|
|
Loading…
Reference in a new issue