mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 07:40:45 -08:00
feat: import v4 jsons (settings wip)
This commit is contained in:
@@ -1,10 +1,31 @@
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { ContextModalProps, ModalsProvider } from '@mantine/modals';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
import ThemeProvider from '@/components/ThemeProvider';
|
||||
import { type ZiplineTheme } from '@/lib/theme';
|
||||
import { type Config } from '@/lib/config/validate';
|
||||
import { Button, Text } from '@mantine/core';
|
||||
|
||||
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
|
||||
<>
|
||||
<Text size='sm'>{innerProps.modalBody}</Text>
|
||||
|
||||
<Button fullWidth mt='md' onClick={() => context.closeModal(id)}>
|
||||
OK
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const contextModals = {
|
||||
alert: AlertModal,
|
||||
};
|
||||
|
||||
declare module '@mantine/modals' {
|
||||
export interface MantineModalsOverride {
|
||||
modals: typeof contextModals;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Root({
|
||||
themes,
|
||||
@@ -37,6 +58,7 @@ export default function Root({
|
||||
},
|
||||
centered: true,
|
||||
}}
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications zIndex={10000000} />
|
||||
<Outlet />
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
IconFolder,
|
||||
IconGraphFilled,
|
||||
IconLink,
|
||||
IconSettings,
|
||||
IconTag,
|
||||
IconTagPlus,
|
||||
IconTarget,
|
||||
@@ -66,18 +65,9 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
const settingsRows = Object.entries(export4.data.settings)
|
||||
.filter(([key]) => !['id', 'createdAt', 'updatedAt'].includes(key))
|
||||
.map(([key, value]) => (
|
||||
<Table.Tr key={key}>
|
||||
<Table.Td ff='monospace'>{key}</Table.Td>
|
||||
<Table.Td ff='monospace'>{String(value)}</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
const userRows = export4.data.users.map((user, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{user.avatar ? <Avatar src={user.avatar} size={24} /> : ''}</Table.Td>
|
||||
<Table.Td>{user.avatar ? <Avatar src={user.avatar} size={24} radius='sm' /> : ''}</Table.Td>
|
||||
<Table.Td>{user.id}</Table.Td>
|
||||
<Table.Td>{user.username}</Table.Td>
|
||||
<Table.Td>{user.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
|
||||
@@ -467,23 +457,6 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value='settings'>
|
||||
<Accordion.Control icon={<IconSettings size='1rem' />}>Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Key</Table.Th>
|
||||
<Table.Th>Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{settingsRows}</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { Box, Button, Checkbox, Code, Collapse, Group, Paper, Table, Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
export default function Export4ImportSettings({
|
||||
export4,
|
||||
setImportSettings,
|
||||
importSettings,
|
||||
}: {
|
||||
export4: Export4;
|
||||
setImportSettings: (importSettings: boolean) => void;
|
||||
importSettings: boolean;
|
||||
}) {
|
||||
const [showSettings, { toggle: toggleSettings }] = useDisclosure(false);
|
||||
|
||||
const filteredSettings = Object.fromEntries(
|
||||
Object.entries(export4.data.settings).filter(
|
||||
([key, _value]) => !['createdAt', 'updatedAt', 'id'].includes(key),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box my='lg'>
|
||||
<Text size='md'>Import settings?</Text>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Import all settings from your previous instance into this v4 instance.
|
||||
<br />
|
||||
After importing, it is recommended to restart Zipline for all settings to take full effect.
|
||||
</Text>
|
||||
|
||||
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
|
||||
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
|
||||
</Button>
|
||||
|
||||
<Collapse in={showSettings}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={300}>Key</Table.Th>
|
||||
<Table.Th>Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{Object.entries(filteredSettings).map(([key, value]) => (
|
||||
<Table.Tr key={key}>
|
||||
<Table.Td ff='monospace'>{key}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text c='dimmed' fz='xs' ff='monospace'>
|
||||
{JSON.stringify(value)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
|
||||
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
|
||||
</Button>
|
||||
</Collapse>
|
||||
|
||||
<Checkbox.Card
|
||||
checked={importSettings}
|
||||
onClick={() => setImportSettings(!importSettings)}
|
||||
radius='md'
|
||||
my='sm'
|
||||
>
|
||||
<Group wrap='nowrap' align='flex-start'>
|
||||
<Checkbox.Indicator m='md' />
|
||||
<Text my='sm'>Import {Object.keys(filteredSettings).length} settings</Text>
|
||||
</Group>
|
||||
</Checkbox.Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { Avatar, Box, Group, Radio, Stack, Text } from '@mantine/core';
|
||||
|
||||
export default function Export4UserChoose({
|
||||
export4,
|
||||
setImportFrom,
|
||||
importFrom,
|
||||
}: {
|
||||
export4: Export4;
|
||||
setImportFrom: (importFrom: string) => void;
|
||||
importFrom: string;
|
||||
}) {
|
||||
return (
|
||||
<Box my='lg'>
|
||||
<Text size='md'>Select a user to import data from into the current user.</Text>
|
||||
<Text size='sm' c='dimmed'>
|
||||
This option allows you to import data from a user in your export into the currently logged-in user,
|
||||
even if both have the same username. Normally, the system skips importing users with usernames that
|
||||
already exist in the system. <br /> <br /> <b>However</b>, if you've just set up your instance
|
||||
and reused the same username as your old instance, this option enables you to merge data from that
|
||||
user into your logged-in account without needing to delete or replace it.{' '}
|
||||
<b>It is recommended to select a user with super-administrator permissions for this operation.</b>
|
||||
</Text>
|
||||
|
||||
<Radio.Group value={importFrom} onChange={(value) => setImportFrom(value)} name='importFrom'>
|
||||
{export4.data.users.map((user, i) => (
|
||||
<Radio.Card key={i} value={user.id} my='sm'>
|
||||
<Group wrap='nowrap' align='flex-start'>
|
||||
<Radio.Indicator m='md' />
|
||||
{user.avatar && <Avatar my='md' src={user.avatar} alt={user.username} radius='sm' />}
|
||||
<Stack gap={0}>
|
||||
<Text my='sm'>
|
||||
{user.username} ({user.id})
|
||||
</Text>{' '}
|
||||
{user.role === 'SUPERADMIN' && (
|
||||
<Text c='red' size='xs' mb='xs'>
|
||||
Super Administrator
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
))}
|
||||
|
||||
<Radio.Card value='' my='sm'>
|
||||
<Group wrap='nowrap' align='flex-start'>
|
||||
<Radio.Indicator m='md' />
|
||||
<Stack gap={0}>
|
||||
<Text my='sm'>Do not merge data</Text>{' '}
|
||||
<Text c='dimmed' size='xs' mb='xs'>
|
||||
Select this option if you do not want to merge data from any user into the current user.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Radio.Card>
|
||||
</Radio.Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { Box, Checkbox, Group, Text } from '@mantine/core';
|
||||
|
||||
export function detectSameInstance(export4?: Export4 | null, currentUserId?: string) {
|
||||
if (!export4) return false;
|
||||
if (!currentUserId) return false;
|
||||
|
||||
const idInExport = export4.data.users.find((user) => user.id === currentUserId);
|
||||
return !!idInExport;
|
||||
}
|
||||
|
||||
export default function Export4WarningSameInstance({
|
||||
export4,
|
||||
sameInstanceAgree,
|
||||
setSameInstanceAgree,
|
||||
}: {
|
||||
export4: Export4;
|
||||
sameInstanceAgree: boolean;
|
||||
setSameInstanceAgree: (sameInstanceAgree: boolean) => void;
|
||||
}) {
|
||||
const currentUserId = useUserStore((state) => state.user?.id);
|
||||
const isSameInstance = detectSameInstance(export4, currentUserId);
|
||||
|
||||
if (!isSameInstance) return null;
|
||||
|
||||
return (
|
||||
<Box my='lg'>
|
||||
<Text size='md' c='red'>
|
||||
Same Instance Detected
|
||||
</Text>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Detected that you are importing data from the same instance as the current running one. Proceeding
|
||||
with this import may lead to data conflicts or overwriting existing data. Please ensure that you
|
||||
understand the implications before continuing.
|
||||
</Text>
|
||||
|
||||
<Checkbox.Card
|
||||
checked={sameInstanceAgree}
|
||||
onClick={() => setSameInstanceAgree(!sameInstanceAgree)}
|
||||
radius='md'
|
||||
my='sm'
|
||||
>
|
||||
<Group wrap='nowrap' align='flex-start'>
|
||||
<Checkbox.Indicator m='md' />
|
||||
<Text my='sm'>I agree, and understand the implications.</Text>
|
||||
</Group>
|
||||
</Checkbox.Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
|
||||
import { Button, FileButton, Modal, Pill } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { Button, FileButton, Modal, Pill, Text } from '@mantine/core';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Export4Details from './Export4Details';
|
||||
import Export4ImportSettings from './Export4ImportSettings';
|
||||
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
|
||||
import Export4UserChoose from './Export4UserChoose';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Response } from '@/lib/api/response';
|
||||
|
||||
export default function ImportV4Button() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [export4, setExport4] = useState<Export4 | null>(null);
|
||||
const [importSettings, setImportSettings] = useState(true);
|
||||
const [sameInstanceAgree, setSameInstanceAgree] = useState(false);
|
||||
const [importFrom, setImportFrom] = useState('');
|
||||
|
||||
const currentUserId = useUserStore((state) => state.user?.id);
|
||||
const isSameInstance = detectSameInstance(export4, currentUserId);
|
||||
|
||||
const onContent = (content: string) => {
|
||||
if (!content) return console.error('no content');
|
||||
@@ -39,6 +52,118 @@ export default function ImportV4Button() {
|
||||
setExport4(validated.data);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!export4) return;
|
||||
|
||||
if (isSameInstance && !sameInstanceAgree) {
|
||||
modals.openContextModal({
|
||||
modal: 'alert',
|
||||
title: 'Same Instance Detected',
|
||||
innerProps: {
|
||||
modalBody:
|
||||
'Detected that you are importing data from the same instance as the current running one. You must agree to the warning before proceeding with the import.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you sure?',
|
||||
children:
|
||||
'This process will NOT overwrite existing data but will append to it. In case of conflicts, the imported data will be skipped and logged.',
|
||||
labels: {
|
||||
confirm: 'Yes, import data.',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
onConfirm: async () => {
|
||||
showNotification({
|
||||
title: 'Importing data...',
|
||||
message:
|
||||
'The export file will be uploaded. This amy take a few moments. The import is running in the background and is logged, so you can close this browser tab if you want.',
|
||||
color: 'blue',
|
||||
autoClose: 5000,
|
||||
id: 'importing-data',
|
||||
loading: true,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
const { error, data } = await fetchApi<Response['/api/server/import/v4']>(
|
||||
'/api/server/import/v4',
|
||||
'POST',
|
||||
{
|
||||
export4,
|
||||
config: {
|
||||
settings: importSettings,
|
||||
mergeCurrentUser: importFrom === '' ? undefined : importFrom,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
updateNotification({
|
||||
title: 'Failed to import data...',
|
||||
message:
|
||||
error.error ?? 'An error occurred while importing data. Check the logs for more details.',
|
||||
color: 'red',
|
||||
icon: <IconDatabaseOff size='1rem' />,
|
||||
id: 'importing-data',
|
||||
autoClose: 10000,
|
||||
});
|
||||
} else {
|
||||
if (!data) return;
|
||||
|
||||
modals.open({
|
||||
title: 'Import Completed.',
|
||||
children: (
|
||||
<Text size='md'>
|
||||
The import has been completed. To make sure files are properly viewable, make sure that you
|
||||
have configured the datasource correctly to match your previous instance. For example, if you
|
||||
were using local storage before, make sure to set it to the same directory (or same backed up
|
||||
directory) as before. If you are using S3, make sure you are using the same bucket. <br />{' '}
|
||||
<br />
|
||||
Additionally, it is recommended to restart Zipline to ensure all settings take full effect.
|
||||
<br /> <br />
|
||||
<b>Users: </b>
|
||||
{data.imported.users} imported.
|
||||
<br />
|
||||
<b>OAuth Providers: </b>
|
||||
{data.imported.oauthProviders} imported.
|
||||
<br />
|
||||
<b>Quotas: </b>
|
||||
{data.imported.quotas} imported.
|
||||
<br />
|
||||
<b>Passkeys: </b>
|
||||
{data.imported.passkeys} imported.
|
||||
<br />
|
||||
<b>Folders: </b>
|
||||
{data.imported.folders} imported.
|
||||
<br />
|
||||
<b>Files: </b>
|
||||
{data.imported.files} imported.
|
||||
<br />
|
||||
<b>Tags: </b>
|
||||
{data.imported.tags} imported.
|
||||
<br />
|
||||
<b>URLs: </b>
|
||||
{data.imported.urls} imported.
|
||||
<br />
|
||||
<b>Invites: </b>
|
||||
{data.imported.invites} imported.
|
||||
<br />
|
||||
<b>Metrics: </b>
|
||||
{data.imported.metrics} imported.
|
||||
</Text>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setFile(null);
|
||||
setExport4(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!file) return;
|
||||
@@ -90,11 +215,22 @@ export default function ImportV4Button() {
|
||||
{file && export4 && (
|
||||
<>
|
||||
<Export4Details export4={export4} />
|
||||
<Export4ImportSettings
|
||||
export4={export4}
|
||||
importSettings={importSettings}
|
||||
setImportSettings={setImportSettings}
|
||||
/>
|
||||
<Export4UserChoose export4={export4} importFrom={importFrom} setImportFrom={setImportFrom} />
|
||||
<Export4WarningSameInstance
|
||||
export4={export4}
|
||||
sameInstanceAgree={sameInstanceAgree}
|
||||
setSameInstanceAgree={setSameInstanceAgree}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{export4 && (
|
||||
<Button fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
|
||||
<Button onClick={handleImport} fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
|
||||
Import Data
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_tem
|
||||
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
|
||||
import { ApiServerFolderResponse } from '@/server/routes/api/server/folder';
|
||||
import { ApiServerImportV3 } from '@/server/routes/api/server/import/v3';
|
||||
import { ApiServerImportV4 } from '@/server/routes/api/server/import/v4';
|
||||
import { ApiServerPublicResponse } from '@/server/routes/api/server/public';
|
||||
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
|
||||
import { ApiServerSettingsResponse, ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
|
||||
@@ -83,6 +84,7 @@ export type Response = {
|
||||
'/api/server/themes': ApiServerThemesResponse;
|
||||
'/api/server/thumbnails': ApiServerThumbnailsResponse;
|
||||
'/api/server/import/v3': ApiServerImportV3;
|
||||
'/api/server/import/v4': ApiServerImportV4;
|
||||
'/api/healthcheck': ApiHealthcheckResponse;
|
||||
'/api/setup': ApiSetupResponse;
|
||||
'/api/upload': ApiUploadResponse;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
@@ -6,17 +8,27 @@ import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
export type ApiServerImportV4 = {
|
||||
users: Record<string, string>;
|
||||
files: Record<string, string>;
|
||||
folders: Record<string, string>;
|
||||
urls: Record<string, string>;
|
||||
settings: string[];
|
||||
imported: {
|
||||
users: number;
|
||||
oauthProviders: number;
|
||||
quotas: number;
|
||||
passkeys: number;
|
||||
folders: number;
|
||||
files: number;
|
||||
tags: number;
|
||||
urls: number;
|
||||
invites: number;
|
||||
metrics: number;
|
||||
};
|
||||
};
|
||||
|
||||
type Body = {
|
||||
export4: Export4;
|
||||
|
||||
importFromUser?: string;
|
||||
config: {
|
||||
settings: boolean;
|
||||
mergeCurrentUser: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('import').c('v4');
|
||||
@@ -35,7 +47,7 @@ export default fastifyPlugin(
|
||||
async (req, res) => {
|
||||
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
|
||||
|
||||
const { export4 } = req.body;
|
||||
const { export4, config: importConfig } = req.body;
|
||||
if (!export4) return res.badRequest('missing export4 in request body');
|
||||
|
||||
const validated = validateExport(export4);
|
||||
@@ -49,7 +61,466 @@ export default fastifyPlugin(
|
||||
});
|
||||
}
|
||||
|
||||
return res.send({ message: 'Import v4 is not yet implemented' });
|
||||
// users
|
||||
const importedUsers: Record<string, string> = {};
|
||||
|
||||
for (const user of export4.data.users) {
|
||||
let mergeCurrent = false;
|
||||
if (importConfig.mergeCurrentUser && user.id === importConfig.mergeCurrentUser) {
|
||||
logger.info('importing to current user', {
|
||||
from: user.id,
|
||||
to: req.user.id,
|
||||
});
|
||||
|
||||
mergeCurrent = true;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: user.username }, { id: user.id }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!mergeCurrent && existing) {
|
||||
logger.warn('user already exists with a username or id, skipping importing', {
|
||||
id: user.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mergeCurrent) {
|
||||
const updated = await prisma.user.update({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
data: {
|
||||
avatar: user.avatar ?? null,
|
||||
totpSecret: user.totpSecret ?? null,
|
||||
view: user.view as any,
|
||||
},
|
||||
});
|
||||
|
||||
importedUsers[user.id] = updated.id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username: user.username,
|
||||
password: user.password ?? null,
|
||||
avatar: user.avatar ?? null,
|
||||
role: user.role,
|
||||
view: user.view as any,
|
||||
totpSecret: user.totpSecret ?? null,
|
||||
token: createToken(),
|
||||
createdAt: new Date(user.createdAt),
|
||||
},
|
||||
});
|
||||
|
||||
importedUsers[user.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported users', { users: importedUsers });
|
||||
|
||||
// oauth providers from users
|
||||
const importedOauthProviders: Record<string, string> = {};
|
||||
|
||||
for (const oauthProvider of export4.data.userOauthProviders) {
|
||||
const userId = importedUsers[oauthProvider.userId];
|
||||
if (!userId) {
|
||||
logger.warn('failed to find user for oauth provider, skipping', {
|
||||
provider: oauthProvider.id,
|
||||
user: oauthProvider.userId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.oAuthProvider.findFirst({
|
||||
where: {
|
||||
provider: oauthProvider.provider,
|
||||
oauthId: oauthProvider.oauthId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('oauth provider already exists, skipping importing', {
|
||||
id: oauthProvider.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.oAuthProvider.create({
|
||||
data: {
|
||||
provider: oauthProvider.provider,
|
||||
oauthId: oauthProvider.oauthId,
|
||||
username: oauthProvider.username,
|
||||
accessToken: oauthProvider.accessToken,
|
||||
refreshToken: oauthProvider.refreshToken ?? null,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
importedOauthProviders[oauthProvider.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported oauth providers', { oauthProviders: importedOauthProviders });
|
||||
|
||||
// quotas from users
|
||||
const importedQuotas: Record<string, string> = {};
|
||||
|
||||
for (const quota of export4.data.userQuotas) {
|
||||
const userId = importedUsers[quota.userId ?? ''];
|
||||
if (!userId) {
|
||||
logger.warn('failed to find user for quota, skipping', {
|
||||
quota: quota.id,
|
||||
user: quota.userId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.userQuota.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('quota already exists for user, skipping importing', {
|
||||
id: quota.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.userQuota.create({
|
||||
data: {
|
||||
filesQuota: quota.filesQuota,
|
||||
maxBytes: quota.maxBytes ?? null,
|
||||
maxFiles: quota.maxFiles ?? null,
|
||||
maxUrls: quota.maxUrls ?? null,
|
||||
userId,
|
||||
createdAt: new Date(quota.createdAt),
|
||||
},
|
||||
});
|
||||
|
||||
importedQuotas[quota.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported quotas', { quotas: importedQuotas });
|
||||
|
||||
const importedPasskeys: Record<string, string> = {};
|
||||
|
||||
for (const passkey of export4.data.userPasskeys) {
|
||||
const userId = importedUsers[passkey.userId];
|
||||
if (!userId) {
|
||||
logger.warn('failed to find user for passkey, skipping', {
|
||||
passkey: passkey.id,
|
||||
user: passkey.userId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.userPasskey.findFirst({
|
||||
where: {
|
||||
name: passkey.name,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('passkey already exists for user, skipping importing', {
|
||||
id: passkey.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.userPasskey.create({
|
||||
data: {
|
||||
name: passkey.name,
|
||||
reg: passkey.reg as any,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
importedPasskeys[passkey.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported passkeys', { passkeys: importedPasskeys });
|
||||
|
||||
// folders
|
||||
const importedFolders: Record<string, string> = {};
|
||||
|
||||
for (const folder of export4.data.folders) {
|
||||
const userId = importedUsers[folder.userId ?? ''];
|
||||
if (!userId) {
|
||||
logger.warn('failed to find user for folder, skipping', {
|
||||
folder: folder.id,
|
||||
user: folder.userId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.folder.findFirst({
|
||||
where: {
|
||||
name: folder.name,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('folder already exists, skipping importing', {
|
||||
id: folder.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.folder.create({
|
||||
data: {
|
||||
userId,
|
||||
name: folder.name,
|
||||
allowUploads: folder.allowUploads,
|
||||
public: folder.public,
|
||||
createdAt: new Date(folder.createdAt),
|
||||
},
|
||||
});
|
||||
|
||||
importedFolders[folder.id] = created.id;
|
||||
}
|
||||
|
||||
// files
|
||||
const importedFiles: Record<string, string> = {};
|
||||
|
||||
for (const file of export4.data.files) {
|
||||
const userId = importedUsers[file.userId ?? ''];
|
||||
if (!userId) {
|
||||
logger.warn('failed to find user for file, skipping', {
|
||||
file: file.id,
|
||||
user: file.userId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: file.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('file already exists, skipping importing', {
|
||||
id: file.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderId = file.folderId ? importedFolders[file.folderId] : null;
|
||||
|
||||
const created = await prisma.file.create({
|
||||
data: {
|
||||
userId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
folderId,
|
||||
originalName: file.originalName ?? null,
|
||||
maxViews: file.maxViews ?? null,
|
||||
views: file.views ?? 0,
|
||||
deletesAt: file.deletesAt ? new Date(file.deletesAt) : null,
|
||||
createdAt: new Date(file.createdAt),
|
||||
favorite: file.favorite ?? false,
|
||||
password: file.password ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
importedFiles[file.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported files', { files: importedFiles });
|
||||
|
||||
// tags, mapped to files and users
|
||||
const importedTags: Record<string, string> = {};
|
||||
|
||||
for (const tag of export4.data.userTags) {
|
||||
const userId = tag.userId ? importedUsers[tag.userId] : null;
|
||||
|
||||
const existing = await prisma.tag.findFirst({
|
||||
where: {
|
||||
name: tag.name,
|
||||
userId: userId ?? null,
|
||||
createdAt: new Date(tag.createdAt),
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('tag already exists, skipping importing', {
|
||||
id: tag.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('tag has no user, skipping', { id: tag.id });
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.tag.create({
|
||||
data: {
|
||||
name: tag.name,
|
||||
color: tag.color ?? '#000000',
|
||||
files: {
|
||||
connect: tag.files.map((fileId) => ({ id: importedFiles[fileId] })),
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
importedTags[tag.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported tags', { tags: importedTags });
|
||||
|
||||
// urls
|
||||
const importedUrls: Record<string, string> = {};
|
||||
|
||||
for (const url of export4.data.urls) {
|
||||
const userId = url.userId ? importedUsers[url.userId] : null;
|
||||
|
||||
if (!userId) {
|
||||
logger.warn('failed to find user for url, skipping', {
|
||||
url: url.id,
|
||||
user: url.userId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.url.findFirst({
|
||||
where: {
|
||||
code: url.code,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('url already exists, skipping importing', {
|
||||
id: url.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.url.create({
|
||||
data: {
|
||||
userId,
|
||||
destination: url.destination,
|
||||
vanity: url.vanity ?? null,
|
||||
code: url.code,
|
||||
maxViews: url.maxViews ?? null,
|
||||
views: url.views,
|
||||
enabled: url.enabled,
|
||||
createdAt: new Date(url.createdAt),
|
||||
password: url.password ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
importedUrls[url.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported urls', { urls: importedUrls });
|
||||
|
||||
// invites
|
||||
const importedInvites: Record<string, string> = {};
|
||||
|
||||
for (const invite of export4.data.invites) {
|
||||
const inviterId = importedUsers[invite.inviterId];
|
||||
if (!inviterId) {
|
||||
logger.warn('failed to find inviter for invite, skipping', {
|
||||
invite: invite.id,
|
||||
inviter: invite.inviterId,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.invite.findFirst({
|
||||
where: {
|
||||
code: invite.code,
|
||||
inviterId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.warn('invite already exists, skipping importing', {
|
||||
id: invite.id,
|
||||
conflict: existing.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await prisma.invite.create({
|
||||
data: {
|
||||
code: invite.code,
|
||||
uses: invite.uses,
|
||||
maxUses: invite.maxUses ?? null,
|
||||
inviterId,
|
||||
createdAt: new Date(invite.createdAt),
|
||||
expiresAt: invite.expiresAt ? new Date(invite.expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
importedInvites[invite.id] = created.id;
|
||||
}
|
||||
|
||||
logger.debug('imported invites', { invites: importedInvites });
|
||||
|
||||
const metricRes = await prisma.metric.createMany({
|
||||
data: export4.data.metrics.map((metric) => ({
|
||||
createdAt: new Date(metric.createdAt),
|
||||
data: metric.data as any,
|
||||
})),
|
||||
});
|
||||
|
||||
// metrics, through batch
|
||||
logger.debug('imported metrics', { count: metricRes.count });
|
||||
|
||||
const response = {
|
||||
imported: {
|
||||
users: Object.keys(importedUsers).length,
|
||||
oauthProviders: Object.keys(importedOauthProviders).length,
|
||||
quotas: Object.keys(importedQuotas).length,
|
||||
passkeys: Object.keys(importedPasskeys).length,
|
||||
folders: Object.keys(importedFolders).length,
|
||||
files: Object.keys(importedFiles).length,
|
||||
tags: Object.keys(importedTags).length,
|
||||
urls: Object.keys(importedUrls).length,
|
||||
invites: Object.keys(importedInvites).length,
|
||||
metrics: metricRes.count,
|
||||
},
|
||||
};
|
||||
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user