mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 07:40:45 -08:00
Compare commits
3 Commits
7572f7f3da
...
ca09b1319d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca09b1319d | ||
|
|
5d27c14b77 | ||
|
|
9da74054ff |
@@ -1,15 +1,17 @@
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
|
||||
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -20,63 +22,57 @@ const gitignorePatterns = gitignoreContent
|
||||
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||
.map((pattern) => pattern.trim());
|
||||
|
||||
export default tseslint.config(
|
||||
const reactRecommendedRules = reactPlugin.configs.recommended.rules;
|
||||
const reactHooksRecommendedRules = reactHooksPlugin.configs['recommended-latest'].rules;
|
||||
const reactRefreshRules = reactRefreshPlugin.configs.vite.rules;
|
||||
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
export default defineConfig(
|
||||
tseslint.configs.recommended,
|
||||
|
||||
jsxA11yPlugin.flatConfigs.recommended,
|
||||
reactPlugin.configs.flat.recommended,
|
||||
reactHooksPlugin.configs.flat.recommended,
|
||||
reactRefreshPlugin.configs.vite,
|
||||
|
||||
{ ignores: gitignorePatterns },
|
||||
|
||||
{
|
||||
extends: [
|
||||
tseslint.configs.recommended,
|
||||
reactHooksPlugin.configs['recommended-latest'],
|
||||
reactRefreshPlugin.configs.vite,
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
prettier: prettier,
|
||||
react: reactPlugin,
|
||||
'jsx-a11y': jsxA11yPlugin,
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
prettier,
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
|
||||
rules: {
|
||||
...prettierConfig.rules,
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{},
|
||||
{
|
||||
fileInfoOptions: {
|
||||
withNodeModules: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
'prettier/prettier': ['error', {}, { fileInfoOptions: { withNodeModules: false } }],
|
||||
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: [
|
||||
'error',
|
||||
'single',
|
||||
{
|
||||
avoidEscape: true,
|
||||
},
|
||||
],
|
||||
quotes: ['error', 'single', { avoidEscape: true }],
|
||||
semi: ['error', 'always'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
indent: 'off',
|
||||
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
|
||||
'react/jsx-uses-react': 'warn',
|
||||
'react/jsx-uses-vars': 'warn',
|
||||
'react/no-danger-with-children': 'warn',
|
||||
@@ -87,28 +83,29 @@ export default tseslint.config(
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'jsx-a11y/alt-text': 'off',
|
||||
'react/display-name': 'off',
|
||||
|
||||
'jsx-a11y/alt-text': 'off',
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
96
package.json
96
package.json
@@ -28,95 +28,95 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@mantine/charts": "^8.2.8",
|
||||
"@mantine/code-highlight": "^8.2.8",
|
||||
"@mantine/core": "^8.2.8",
|
||||
"@mantine/dates": "^8.2.8",
|
||||
"@mantine/dropzone": "^8.2.8",
|
||||
"@mantine/form": "^8.2.8",
|
||||
"@mantine/hooks": "^8.2.8",
|
||||
"@mantine/modals": "^8.2.8",
|
||||
"@mantine/notifications": "^8.2.8",
|
||||
"@mantine/charts": "^8.3.9",
|
||||
"@mantine/code-highlight": "^8.3.9",
|
||||
"@mantine/core": "^8.3.9",
|
||||
"@mantine/dates": "^8.3.9",
|
||||
"@mantine/dropzone": "^8.3.9",
|
||||
"@mantine/form": "^8.3.9",
|
||||
"@mantine/hooks": "^8.3.9",
|
||||
"@mantine/modals": "^8.3.9",
|
||||
"@mantine/notifications": "^8.3.9",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
"@prisma/internals": "6.13.0",
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.10.0",
|
||||
"asciinema-player": "^3.12.1",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cross-env": "^10.0.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"dotenv": "^17.2.2",
|
||||
"commander": "^14.0.2",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"katex": "^0.16.22",
|
||||
"mantine-datatable": "^8.2.0",
|
||||
"isomorphic-dompurify": "^2.33.0",
|
||||
"katex": "^0.16.27",
|
||||
"mantine-datatable": "^8.3.9",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.0.2",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"react-window": "1.8.11",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.3",
|
||||
"swr": "^2.3.6",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^7.1.4",
|
||||
"zod": "^4.1.5",
|
||||
"zustand": "^5.0.8"
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.3.7",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.7",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"eslint": "^9.34.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.92.0",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.94.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
3277
pnpm-lock.yaml
generated
3277
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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, 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,11 +1,15 @@
|
||||
import {
|
||||
CompleteMultipartUploadCommand,
|
||||
CopyObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
UploadPartCopyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { createReadStream } from 'fs';
|
||||
@@ -225,7 +229,7 @@ export class S3Datasource extends Datasource {
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
const command = new GetObjectCommand({
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: this.key(file),
|
||||
});
|
||||
@@ -323,6 +327,96 @@ export class S3Datasource extends Datasource {
|
||||
}
|
||||
|
||||
public async rename(from: string, to: string): Promise<void> {
|
||||
const size = await this.size(from);
|
||||
|
||||
if (size !== 0 && size > 5 * 1024 * 1024 * 1024) {
|
||||
this.logger.debug('object larger than 5GB, using multipart copy for rename', { from, to, size });
|
||||
|
||||
const createCommand = new CreateMultipartUploadCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: this.key(to),
|
||||
});
|
||||
|
||||
let uploadId: string;
|
||||
try {
|
||||
const createRes = await this.client.send(createCommand);
|
||||
if (!isOk(createRes.$metadata.httpStatusCode || 0)) {
|
||||
this.logger.error('there was an error while initiating multipart upload');
|
||||
this.logger.error('error metadata', createRes.$metadata as Record<string, unknown>);
|
||||
throw new Error('Failed to initiate multipart upload');
|
||||
}
|
||||
|
||||
if (!createRes.UploadId) {
|
||||
this.logger.error('no upload ID returned while initiating multipart upload');
|
||||
throw new Error('Failed to initiate multipart upload');
|
||||
}
|
||||
|
||||
uploadId = createRes.UploadId;
|
||||
} catch (e) {
|
||||
this.logger.error('there was an error while initiating multipart upload');
|
||||
this.logger.error('error metadata', e as Record<string, unknown>);
|
||||
|
||||
throw new Error('Failed to initiate multipart upload');
|
||||
}
|
||||
|
||||
const partSize = 5 * 1024 * 1024;
|
||||
const eTags = [];
|
||||
|
||||
for (let start = 0, part = 1; start < size; start += partSize, part++) {
|
||||
const end = Math.min(start + partSize - 1, size - 1);
|
||||
|
||||
const uploadPartCopyCommand = new UploadPartCopyCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: this.key(to),
|
||||
CopySource: this.options.bucket + '/' + this.key(from),
|
||||
CopySourceRange: `bytes=${start}-${end}`,
|
||||
PartNumber: part,
|
||||
UploadId: uploadId,
|
||||
});
|
||||
|
||||
try {
|
||||
const copyRes = await this.client.send(uploadPartCopyCommand);
|
||||
if (!isOk(copyRes.$metadata.httpStatusCode || 0)) {
|
||||
this.logger.error('there was an error while copying part of the object');
|
||||
this.logger.error('error metadata', copyRes.$metadata as Record<string, unknown>);
|
||||
throw new Error('Failed to copy part of the object');
|
||||
}
|
||||
|
||||
eTags.push({ ETag: copyRes.CopyPartResult?.ETag, PartNumber: part });
|
||||
} catch (e) {
|
||||
this.logger.error('there was an error while renaming object using multipart copy');
|
||||
this.logger.error('error metadata', e as Record<string, unknown>);
|
||||
|
||||
throw new Error('Failed to rename object using multipart copy');
|
||||
}
|
||||
}
|
||||
|
||||
const completeMultipartUploadCommand = new CompleteMultipartUploadCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: this.key(to),
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: eTags,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const completeRes = await this.client.send(completeMultipartUploadCommand);
|
||||
if (!isOk(completeRes.$metadata.httpStatusCode || 0)) {
|
||||
this.logger.error('there was an error while completing multipart upload');
|
||||
this.logger.error('error metadata', completeRes.$metadata as Record<string, unknown>);
|
||||
throw new Error('Failed to complete multipart upload');
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('there was an error while completing multipart upload');
|
||||
this.logger.error('error metadata', e as Record<string, unknown>);
|
||||
|
||||
throw new Error('Failed to complete multipart upload');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const copyCommand = new CopyObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: this.key(to),
|
||||
|
||||
@@ -209,7 +209,7 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
server.setErrorHandler((error, _, res) => {
|
||||
server.setErrorHandler((error: { statusCode: number; message: string }, _, res) => {
|
||||
if (error.statusCode) {
|
||||
res.status(error.statusCode);
|
||||
res.send({ error: error.message, statusCode: error.statusCode });
|
||||
|
||||
@@ -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