diff --git a/src/client/Root.tsx b/src/client/Root.tsx index beb7fdee..9378e487 100644 --- a/src/client/Root.tsx +++ b/src/client/Root.tsx @@ -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 }>) => ( + <> + {innerProps.modalBody} + + + +); + +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} > diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4Details.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4Details.tsx index 59405462..0e0a2248 100644 --- a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4Details.tsx +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4Details.tsx @@ -21,7 +21,6 @@ import { IconFolder, IconGraphFilled, IconLink, - IconSettings, IconTag, IconTagPlus, IconTarget, @@ -66,18 +65,9 @@ export default function Export3Details({ export4 }: { export4: Export4 }) { )); - const settingsRows = Object.entries(export4.data.settings) - .filter(([key]) => !['id', 'createdAt', 'updatedAt'].includes(key)) - .map(([key, value]) => ( - - {key} - {String(value)} - - )); - const userRows = export4.data.users.map((user, i) => ( - {user.avatar ? : ''} + {user.avatar ? : ''} {user.id} {user.username} {user.password ? : } @@ -467,23 +457,6 @@ export default function Export3Details({ export4 }: { export4: Export4 }) { - - - }>Settings - - - - - - Key - Value - - - {settingsRows} -
-
-
-
); diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4ImportSettings.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4ImportSettings.tsx new file mode 100644 index 00000000..16bbc8de --- /dev/null +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4ImportSettings.tsx @@ -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 ( + + Import settings? + + Import all settings from your previous instance into this v4 instance. +
+ After importing, it is recommended to restart Zipline for all settings to take full effect. +
+ + + + + + + + + Key + Value + + + + {Object.entries(filteredSettings).map(([key, value]) => ( + + {key} + + + {JSON.stringify(value)} + + + + ))} + +
+
+ + +
+ + setImportSettings(!importSettings)} + radius='md' + my='sm' + > + + + Import {Object.keys(filteredSettings).length} settings + + +
+ ); +} diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4UserChoose.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4UserChoose.tsx new file mode 100644 index 00000000..3f37cd05 --- /dev/null +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4UserChoose.tsx @@ -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 ( + + Select a user to import data from into the current user. + + 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.

However, 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.{' '} + It is recommended to select a user with super-administrator permissions for this operation. +
+ + setImportFrom(value)} name='importFrom'> + {export4.data.users.map((user, i) => ( + + + + {user.avatar && } + + + {user.username} ({user.id}) + {' '} + {user.role === 'SUPERADMIN' && ( + + Super Administrator + + )} + + + + ))} + + + + + + Do not merge data{' '} + + Select this option if you do not want to merge data from any user into the current user. + + + + + +
+ ); +} diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4WarningSameInstance.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4WarningSameInstance.tsx new file mode 100644 index 00000000..b690f540 --- /dev/null +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/Export4WarningSameInstance.tsx @@ -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 ( + + + Same Instance Detected + + + 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. + + + setSameInstanceAgree(!sameInstanceAgree)} + radius='md' + my='sm' + > + + + I agree, and understand the implications. + + + + ); +} diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx index bfe0b903..f6f7c758 100644 --- a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx @@ -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(null); const [export4, setExport4] = useState(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( + '/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: , + id: 'importing-data', + autoClose: 10000, + }); + } else { + if (!data) return; + + modals.open({ + title: 'Import Completed.', + children: ( + + 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.
{' '} +
+ Additionally, it is recommended to restart Zipline to ensure all settings take full effect. +

+ Users: + {data.imported.users} imported. +
+ OAuth Providers: + {data.imported.oauthProviders} imported. +
+ Quotas: + {data.imported.quotas} imported. +
+ Passkeys: + {data.imported.passkeys} imported. +
+ Folders: + {data.imported.folders} imported. +
+ Files: + {data.imported.files} imported. +
+ Tags: + {data.imported.tags} imported. +
+ URLs: + {data.imported.urls} imported. +
+ Invites: + {data.imported.invites} imported. +
+ Metrics: + {data.imported.metrics} imported. +
+ ), + }); + } + }, + }); + + setFile(null); + setExport4(null); + }; + useEffect(() => { if (!open) return; if (!file) return; @@ -90,11 +215,22 @@ export default function ImportV4Button() { {file && export4 && ( <> + + + )} {export4 && ( - )} diff --git a/src/lib/api/response.ts b/src/lib/api/response.ts index 57bfb70d..8fe97ff0 100755 --- a/src/lib/api/response.ts +++ b/src/lib/api/response.ts @@ -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; diff --git a/src/server/routes/api/server/import/v4.ts b/src/server/routes/api/server/import/v4.ts index e9125dab..3ba6a3cf 100644 --- a/src/server/routes/api/server/import/v4.ts +++ b/src/server/routes/api/server/import/v4.ts @@ -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; - files: Record; - folders: Record; - urls: Record; - 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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 = {}; + + 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); }, );