From 61af46f136b5042f095b83512a99e54f101d6355 Mon Sep 17 00:00:00 2001 From: diced Date: Wed, 19 Nov 2025 00:22:51 -0800 Subject: [PATCH] feat: export and import v4 (wip) (needs testing) --- .../ImportExportButton/ExportButton.tsx | 143 +++++++++ .../ImportV3Button}/Export3Details.tsx | 0 .../ImportV3Button}/Export3ImportSettings.tsx | 0 .../ImportV3Button}/Export3UserChoose.tsx | 0 .../ImportV3Button}/index.tsx | 10 +- .../ImportV4Button/index.tsx | 107 +++++++ .../ImportExportButton/index.tsx | 29 ++ .../parts/SettingsServerUtil/index.tsx | 4 +- src/lib/import/version3/validateExport.ts | 2 +- src/lib/import/version4/validateExport.ts | 205 +++++++++++++ src/server/routes/api/server/export.ts | 285 ++++++++++++++++++ src/server/routes/api/server/import/v4.ts | 59 ++++ 12 files changed, 836 insertions(+), 8 deletions(-) create mode 100644 src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ExportButton.tsx rename src/components/pages/settings/parts/SettingsServerUtil/{ImportButton => ImportExportButton/ImportV3Button}/Export3Details.tsx (100%) rename src/components/pages/settings/parts/SettingsServerUtil/{ImportButton => ImportExportButton/ImportV3Button}/Export3ImportSettings.tsx (100%) rename src/components/pages/settings/parts/SettingsServerUtil/{ImportButton => ImportExportButton/ImportV3Button}/Export3UserChoose.tsx (100%) rename src/components/pages/settings/parts/SettingsServerUtil/{ImportButton => ImportExportButton/ImportV3Button}/index.tsx (97%) create mode 100644 src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx create mode 100644 src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/index.tsx create mode 100644 src/lib/import/version4/validateExport.ts create mode 100644 src/server/routes/api/server/export.ts create mode 100644 src/server/routes/api/server/import/v4.ts diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ExportButton.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ExportButton.tsx new file mode 100644 index 00000000..630bfa7a --- /dev/null +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ExportButton.tsx @@ -0,0 +1,143 @@ +import { Alert, Box, Button, List, Modal, Code, Group, Divider, Checkbox, Pill } from '@mantine/core'; +import { IconAlertCircle, IconDownload } from '@tabler/icons-react'; +import { useState } from 'react'; + +export default function ExportButton() { + const [open, setOpen] = useState(false); + + const [noMetrics, setNoMetrics] = useState(false); + + return ( + <> + setOpen(false)} size='lg' title='Are you sure?'> + +

The export provides a complete snapshot of Zipline’s data and environment. It includes:

+ + + + Users: Account information including usernames, optional passwords, avatars, roles, view + settings, and optional TOTP secrets. + + + + Passkeys: Registered WebAuthn passkeys with creation dates, last-used timestamps, and + credential registration data. + + + + User Quotas: Quota settings such as max bytes, max files, max URLs, and quota types. + + + + OAuth Providers: Linked OAuth accounts including provider type, tokens, and OAuth IDs. + + + + User Tags: Tags created by users, including names, colors, and associated file IDs. + + + + Files: Metadata about uploaded files including size, type, timestamps, expiration, views, + password protection, owner, and folder association. + (Actual file contents are not included.) + + + + Folders: Folder metadata including visibility settings, upload permissions, file lists, + and ownership. + + + + URLs: Metadata for shortened URLs including destinations, vanity codes, view counts, + passwords, and user assignments. + + + + Thumbnails: Thumbnail path and associated file ID. + (Image data is not included.) + + + + Invites: Invite codes, creation/expiration dates, and usage counts. + + + + Metrics: System and usage statistics stored internally by Zipline. + + + +

+ Additionally, the export includes system-specific information: +

+ + + + CPU Count: The number of available processor cores. + + + Hostname: The host system’s network identifier. + + + Architecture: The hardware architecture (e.g., x64, arm64). + + + Platform: The operating system platform (e.g., linux, darwin). + + + OS Release: The OS or kernel version. + + + Environment Variables: A full snapshot of environment variables at the time of export. + + + Versions: The Zipline version, Node version, and export format version. + + + + + + setNoMetrics((val) => !val)} + /> + + + + } title='Warning' my='md'> + This export contains a significant amount of sensitive data, including user accounts, + authentication credentials, environment variables, and system metadata. Handle this file securely + and do not share it with untrusted parties. + + + + + + +
+
+ + + + ); +} diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportButton/Export3Details.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/Export3Details.tsx similarity index 100% rename from src/components/pages/settings/parts/SettingsServerUtil/ImportButton/Export3Details.tsx rename to src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/Export3Details.tsx diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportButton/Export3ImportSettings.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/Export3ImportSettings.tsx similarity index 100% rename from src/components/pages/settings/parts/SettingsServerUtil/ImportButton/Export3ImportSettings.tsx rename to src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/Export3ImportSettings.tsx diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportButton/Export3UserChoose.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/Export3UserChoose.tsx similarity index 100% rename from src/components/pages/settings/parts/SettingsServerUtil/ImportButton/Export3UserChoose.tsx rename to src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/Export3UserChoose.tsx diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportButton/index.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/index.tsx similarity index 97% rename from src/components/pages/settings/parts/SettingsServerUtil/ImportButton/index.tsx rename to src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/index.tsx index 8c61f028..3613d59b 100644 --- a/src/components/pages/settings/parts/SettingsServerUtil/ImportButton/index.tsx +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV3Button/index.tsx @@ -6,7 +6,7 @@ import { V3_SETTINGS_TRANSFORM, validateExport, } from '@/lib/import/version3/validateExport'; -import { Alert, Button, Code, FileButton, Modal, Stack } from '@mantine/core'; +import { Alert, Button, Code, FileButton, Modal, Pill, Stack } from '@mantine/core'; import { modals } from '@mantine/modals'; import { showNotification, updateNotification } from '@mantine/notifications'; import { @@ -23,7 +23,7 @@ import Export3Details from './Export3Details'; import Export3ImportSettings from './Export3ImportSettings'; import Export3UserChoose from './Export3UserChoose'; -export default function ImportButton() { +export default function ImportV3Button() { const [open, setOpen] = useState(false); const [file, setFile] = useState(null); const [export3, setExport3] = useState(null); @@ -262,7 +262,7 @@ export default function ImportButton() { return ( <> - setOpen(false)} title='Import data' size='xl'> + setOpen(false)} title='Import V3 Data' size='xl'> {export3 ? ( ); diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx new file mode 100644 index 00000000..03abcc50 --- /dev/null +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/ImportV4Button/index.tsx @@ -0,0 +1,107 @@ +import { Export4, validateExport } from '@/lib/import/version4/validateExport'; +import { Button, FileButton, Modal, Pill } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; + +export default function ImportV4Button() { + const [open, setOpen] = useState(false); + const [file, setFile] = useState(null); + const [export4, setExport4] = useState(null); + + const onContent = (content: string) => { + if (!content) return console.error('no content'); + try { + const data = JSON.parse(content); + onJson(data); + } catch (error) { + console.error('failed to parse file content', error); + } + }; + + const onJson = (data: unknown) => { + const validated = validateExport(data); + if (!validated.success) { + console.error('Failed to validate import data', validated); + showNotification({ + title: 'There were errors with the import', + message: + "Zipline couldn't validate the import data. Are you sure it's a valid export from Zipline v4? For more details about the error, check the browser console.", + color: 'red', + icon: , + autoClose: 10000, + }); + setOpen(false); + setFile(null); + return; + } + setExport4(validated.data); + }; + + useEffect(() => { + if (!open) return; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result; + onContent(content as string); + }; + reader.readAsText(file); + }, [file]); + + return ( + <> + setOpen(false)} title='Import V4 Data' size='xl'> + {export4 ? ( + + ) : ( + + {(props) => ( + <> + + + )} + + )} + + {file && export4 && ( + <> +
{JSON.stringify(export4, null, 2)}
+ + )} + + {export4 && ( + + )} +
+ + + + ); +} diff --git a/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/index.tsx b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/index.tsx new file mode 100644 index 00000000..004dfd5d --- /dev/null +++ b/src/components/pages/settings/parts/SettingsServerUtil/ImportExportButton/index.tsx @@ -0,0 +1,29 @@ +import { Button, Divider, Group, Modal } from '@mantine/core'; +import { IconDatabaseExport } from '@tabler/icons-react'; +import { useState } from 'react'; +import ImportV3Button from './ImportV3Button'; +import ImportV4Button from './ImportV4Button'; +import ExportButton from './ExportButton'; + +export default function ImportExport() { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(false)} size='lg' title='Import / Export Data'> + + + + + + + + + + + + + ); +} diff --git a/src/components/pages/settings/parts/SettingsServerUtil/index.tsx b/src/components/pages/settings/parts/SettingsServerUtil/index.tsx index edcc125d..bf7ca9d5 100755 --- a/src/components/pages/settings/parts/SettingsServerUtil/index.tsx +++ b/src/components/pages/settings/parts/SettingsServerUtil/index.tsx @@ -3,7 +3,7 @@ import ClearTempButton from './ClearTempButton'; import ClearZerosButton from './ClearZerosButton'; import GenThumbsButton from './GenThumbsButton'; import RequerySizeButton from './RequerySizeButton'; -import ImportButton from './ImportButton'; +import ImportExportButton from './ImportExportButton'; export default function SettingsServerActions() { return ( @@ -18,7 +18,7 @@ export default function SettingsServerActions() { - + ); diff --git a/src/lib/import/version3/validateExport.ts b/src/lib/import/version3/validateExport.ts index 86b27ef3..280d9fcf 100644 --- a/src/lib/import/version3/validateExport.ts +++ b/src/lib/import/version3/validateExport.ts @@ -407,7 +407,7 @@ export const V3_SETTINGS_TRANSFORM: Record { const result = export3Schema.safeParse(data); if (!result.success) { - if (typeof window === 'object') console.error('Failed to validate export data', result.error); + if (typeof window === 'object') console.error('Failed to validate export3 data', result.error); } return result; diff --git a/src/lib/import/version4/validateExport.ts b/src/lib/import/version4/validateExport.ts new file mode 100644 index 00000000..744a31ef --- /dev/null +++ b/src/lib/import/version4/validateExport.ts @@ -0,0 +1,205 @@ +import { Zipline } from '@/prisma/client'; +import { OAuthProviderType, Role, UserFilesQuota } from '@/prisma/enums'; +import { z } from 'zod'; + +export type Export4 = z.infer; + +export const export4Schema = z.object({ + versions: z.object({ + zipline: z.string(), + node: z.string(), + export: z.literal('4'), + }), + request: z.object({ + user: z.custom<`${string}:${string}`>((data) => { + if (typeof data !== 'string') return false; + + const parts = data.split(':'); + if (parts.length !== 2) return false; + + const [username, id] = parts; + if (!username || !id) return false; + + return data; + }), + date: z.string(), + os: z.object({ + platform: z.union([ + z.literal('aix'), + z.literal('darwin'), + z.literal('freebsd'), + z.literal('linux'), + z.literal('openbsd'), + z.literal('sunos'), + z.literal('win32'), + z.literal('android'), + ]), + arch: z.union([ + z.literal('arm'), + z.literal('arm64'), + z.literal('ia32'), + z.literal('loong64'), + z.literal('mips'), + z.literal('mipsel'), + z.literal('ppc'), + z.literal('ppc64'), + z.literal('riscv64'), + z.literal('s390'), + z.literal('s390x'), + z.literal('x64'), + ]), + cpus: z.number(), + hostname: z.string(), + release: z.string(), + }), + env: z.record(z.string(), z.string()), + }), + data: z.object({ + settings: z.custom(), + users: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + username: z.string(), + password: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + role: z.enum(Role), + view: z.record(z.string(), z.unknown()), + totpSecret: z.string().nullable().optional(), + }), + ), + userPasskeys: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + lastUsed: z + .string() + .nullable() + .optional() + .refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'), + name: z.string(), + reg: z.record(z.string(), z.unknown()), + userId: z.string(), + }), + ), + userQuotas: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + filesQuota: z.enum(UserFilesQuota), + maxBytes: z.string().nullable().optional(), + maxFiles: z.number().nullable().optional(), + maxUrls: z.number().nullable().optional(), + userId: z.string().nullable().optional(), + }), + ), + userOauthProviders: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + provider: z.enum(OAuthProviderType), + username: z.string(), + accessToken: z.string(), + refreshToken: z.string().nullable().optional(), + oauthId: z.string().nullable().optional(), + userId: z.string(), + }), + ), + userTags: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + name: z.string(), + color: z.string().nullable().optional(), + files: z.array(z.string()), + userId: z.string(), + }), + ), + invites: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + expiresAt: z + .string() + .nullable() + .optional() + .refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'), + code: z.string(), + uses: z.number(), + maxUses: z.number().nullable().optional(), + inviterId: z.string(), + }), + ), + folders: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + name: z.string(), + public: z.boolean(), + allowUploads: z.boolean(), + files: z.array(z.string()), + userId: z.string(), + }), + ), + urls: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + code: z.string(), + vanity: z.string().nullable().optional(), + destination: z.string(), + views: z.number(), + maxViews: z.number().nullable().optional(), + password: z.string().nullable().optional(), + enabled: z.boolean(), + userId: z.string().nullable().optional(), + }), + ), + files: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + deletesAt: z + .string() + .nullable() + .optional() + .refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'), + name: z.string(), + originalName: z.string().nullable().optional(), + size: z.number(), + type: z.string(), + views: z.number(), + maxViews: z.number().nullable().optional(), + favorite: z.boolean(), + password: z.string().nullable().optional(), + userId: z.string().nullable(), + folderId: z.string().nullable().optional(), + }), + ), + thumbnails: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + path: z.string(), + fileId: z.string(), + }), + ), + metrics: z.array( + z.object({ + id: z.string(), + createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'), + data: z.record(z.string(), z.unknown()), + }), + ), + }), +}); + +export function validateExport(data: unknown): ReturnType { + const result = export4Schema.safeParse(data); + + if (!result.success) { + if (typeof window === 'object') console.error('Failed to validate export4 data', result.error.issues); + } + + return result; +} diff --git a/src/server/routes/api/server/export.ts b/src/server/routes/api/server/export.ts new file mode 100644 index 00000000..bc1750db --- /dev/null +++ b/src/server/routes/api/server/export.ts @@ -0,0 +1,285 @@ +import { Export4 } from '@/lib/import/version4/validateExport'; +import { log } from '@/lib/logger'; +import { administratorMiddleware } from '@/server/middleware/administrator'; +import { userMiddleware } from '@/server/middleware/user'; +import fastifyPlugin from 'fastify-plugin'; + +import { prisma } from '@/lib/db'; +import { cpus, hostname, platform, release } from 'os'; +import { version } from '../../../../../package.json'; + +async function getCounts() { + const users = await prisma.user.count(); + const files = await prisma.file.count(); + const urls = await prisma.url.count(); + const folders = await prisma.folder.count(); + const invites = await prisma.invite.count(); + const thumbnails = await prisma.thumbnail.count(); + const metrics = await prisma.metric.count(); + + return { + users, + files, + urls, + folders, + invites, + thumbnails, + metrics, + }; +} + +export type ApiServerExport = Export4; + +type Query = { + nometrics?: string; + counts?: string; +}; + +const logger = log('api').c('server').c('export'); + +export const PATH = '/api/server/export'; +export default fastifyPlugin( + (server, _, done) => { + server.get<{ Querystring: Query }>( + PATH, + { + preHandler: [userMiddleware, administratorMiddleware], + }, + async (req, res) => { + if (req.query.counts === 'true') { + const counts = await getCounts(); + + return res.send(counts); + } + + logger.debug('exporting server data', { format: '4', requester: req.user.username }); + + const settingsTable = await prisma.zipline.findFirst(); + if (!settingsTable) + return res.badRequest( + 'Invalid setup, no settings found. Run the setup process again before exporting data.', + ); + + const export4: Export4 = { + versions: { + export: '4', + node: process.version, + zipline: version, + }, + request: { + date: new Date().toISOString(), + env: process.env as Record, + user: `${req.user.id}:${req.user.username}`, + os: { + arch: process.arch, + cpus: cpus().length, + hostname: hostname(), + platform: platform() as Export4['request']['os']['platform'], + release: release(), + }, + }, + data: { + settings: settingsTable, + + users: [], + userPasskeys: [], + userQuotas: [], + userOauthProviders: [], + userTags: [], + + invites: [], + folders: [], + urls: [], + files: [], + thumbnails: [], + metrics: [], + }, + }; + + const users = await prisma.user.findMany({ + include: { + passkeys: true, + quota: true, + oauthProviders: true, + invites: true, + urls: true, + tags: { + include: { + files: { + select: { + id: true, + }, + }, + }, + }, + folders: { + include: { + files: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + for (const user of users) { + export4.data.users.push({ + createdAt: user.createdAt.toISOString(), + id: user.id, + username: user.username, + password: user.password, + avatar: user.avatar, + role: user.role, + view: user.view, + totpSecret: user.totpSecret, + }); + + for (const passkey of user.passkeys) { + export4.data.userPasskeys.push({ + createdAt: passkey.createdAt.toISOString(), + id: passkey.id, + lastUsed: passkey.lastUsed ? passkey.lastUsed.toISOString() : null, + name: passkey.name, + reg: passkey.reg as Record, + userId: passkey.userId, + }); + } + + for (const oauthProvider of user.oauthProviders) { + export4.data.userOauthProviders.push({ + createdAt: oauthProvider.createdAt.toISOString(), + id: oauthProvider.id, + provider: oauthProvider.provider, + username: oauthProvider.username, + accessToken: oauthProvider.accessToken, + refreshToken: oauthProvider.refreshToken, + oauthId: oauthProvider.oauthId, + userId: oauthProvider.userId, + }); + } + + for (const tag of user.tags) { + export4.data.userTags.push({ + createdAt: tag.createdAt.toISOString(), + id: tag.id, + name: tag.name, + color: tag.color, + files: tag.files.map((file) => file.id), + userId: user.id, + }); + } + + for (const invite of user.invites) { + export4.data.invites.push({ + createdAt: invite.createdAt.toISOString(), + id: invite.id, + code: invite.code, + uses: invite.uses, + maxUses: invite.maxUses, + expiresAt: invite.expiresAt ? invite.expiresAt.toISOString() : null, + inviterId: invite.inviterId, + }); + } + + for (const folder of user.folders) { + export4.data.folders.push({ + createdAt: folder.createdAt.toISOString(), + id: folder.id, + name: folder.name, + public: folder.public, + allowUploads: folder.allowUploads, + userId: folder.userId, + files: folder.files.map((file) => file.id), + }); + } + + for (const url of user.urls) { + export4.data.urls.push({ + createdAt: url.createdAt.toISOString(), + id: url.id, + code: url.code, + vanity: url.vanity, + destination: url.destination, + views: url.views, + maxViews: url.maxViews, + password: url.password, + enabled: url.enabled, + userId: url.userId, + }); + } + + if (user.quota) { + export4.data.userQuotas.push({ + createdAt: user.quota.createdAt.toISOString(), + id: user.quota.id, + filesQuota: user.quota.filesQuota, + maxBytes: user.quota.maxBytes, + maxFiles: user.quota.maxFiles, + maxUrls: user.quota.maxUrls, + userId: user.quota.userId, + }); + } + } + + const files = await prisma.file.findMany(); + + for (const file of files) { + if (!file.userId) + logger.warn('file has no user associated with it, still exporting...', { + fileId: file.id, + name: file.name, + }); + + export4.data.files.push({ + createdAt: file.createdAt.toISOString(), + deletesAt: file.deletesAt ? file.deletesAt.toISOString() : null, + id: file.id, + name: file.name, + size: file.size, + favorite: file.favorite, + originalName: file.originalName, + type: file.type, + views: file.views, + maxViews: file.maxViews, + password: file.password, + userId: file.userId, + folderId: file.folderId, + }); + } + + const thumbnails = await prisma.thumbnail.findMany(); + + for (const thumbnail of thumbnails) { + export4.data.thumbnails.push({ + createdAt: thumbnail.createdAt.toISOString(), + id: thumbnail.id, + + path: thumbnail.path, + + fileId: thumbnail.fileId, + }); + } + + if (req.query.nometrics === undefined) { + const metrics = await prisma.metric.findMany(); + + export4.data.metrics = metrics.map((metric) => ({ + createdAt: metric.createdAt.toISOString(), + id: metric.id, + data: metric.data as Record, + })); + } + + return res + .header('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`) + .type('application/json') + .send(export4); + }, + ); + + done(); + }, + { name: PATH }, +); diff --git a/src/server/routes/api/server/import/v4.ts b/src/server/routes/api/server/import/v4.ts new file mode 100644 index 00000000..e9125dab --- /dev/null +++ b/src/server/routes/api/server/import/v4.ts @@ -0,0 +1,59 @@ +import { Export4, validateExport } from '@/lib/import/version4/validateExport'; +import { log } from '@/lib/logger'; +import { secondlyRatelimit } from '@/lib/ratelimits'; +import { administratorMiddleware } from '@/server/middleware/administrator'; +import { userMiddleware } from '@/server/middleware/user'; +import fastifyPlugin from 'fastify-plugin'; + +export type ApiServerImportV4 = { + users: Record; + files: Record; + folders: Record; + urls: Record; + settings: string[]; +}; + +type Body = { + export4: Export4; + + importFromUser?: string; +}; + +const logger = log('api').c('server').c('import').c('v4'); + +export const PATH = '/api/server/import/v4'; +export default fastifyPlugin( + (server, _, done) => { + server.post<{ Body: Body }>( + PATH, + { + preHandler: [userMiddleware, administratorMiddleware], + // 24gb, just in case + bodyLimit: 24 * 1024 * 1024 * 1024, + ...secondlyRatelimit(5), + }, + async (req, res) => { + if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin'); + + const { export4 } = req.body; + if (!export4) return res.badRequest('missing export4 in request body'); + + const validated = validateExport(export4); + if (!validated.success) { + logger.error('Failed to validate import data', { error: validated.error }); + + return res.status(400).send({ + error: 'Failed to validate import data', + statusCode: 400, + details: validated.error.issues, + }); + } + + return res.send({ message: 'Import v4 is not yet implemented' }); + }, + ); + + done(); + }, + { name: PATH }, +);