feat: export and import v4 (wip) (needs testing)

This commit is contained in:
diced
2025-11-19 00:22:51 -08:00
parent 771aa67673
commit 61af46f136
12 changed files with 836 additions and 8 deletions

View File

@@ -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 (
<>
<Modal opened={open} onClose={() => setOpen(false)} size='lg' title='Are you sure?'>
<Box px='sm'>
<p>The export provides a complete snapshot of Ziplines data and environment. It includes:</p>
<List>
<List.Item>
<b>Users:</b> Account information including usernames, optional passwords, avatars, roles, view
settings, and optional TOTP secrets.
</List.Item>
<List.Item>
<b>Passkeys:</b> Registered WebAuthn passkeys with creation dates, last-used timestamps, and
credential registration data.
</List.Item>
<List.Item>
<b>User Quotas:</b> Quota settings such as max bytes, max files, max URLs, and quota types.
</List.Item>
<List.Item>
<b>OAuth Providers:</b> Linked OAuth accounts including provider type, tokens, and OAuth IDs.
</List.Item>
<List.Item>
<b>User Tags:</b> Tags created by users, including names, colors, and associated file IDs.
</List.Item>
<List.Item>
<b>Files:</b> Metadata about uploaded files including size, type, timestamps, expiration, views,
password protection, owner, and folder association.
<i> (Actual file contents are not included.)</i>
</List.Item>
<List.Item>
<b>Folders:</b> Folder metadata including visibility settings, upload permissions, file lists,
and ownership.
</List.Item>
<List.Item>
<b>URLs:</b> Metadata for shortened URLs including destinations, vanity codes, view counts,
passwords, and user assignments.
</List.Item>
<List.Item>
<b>Thumbnails:</b> Thumbnail path and associated file ID.
<i> (Image data is not included.)</i>
</List.Item>
<List.Item>
<b>Invites:</b> Invite codes, creation/expiration dates, and usage counts.
</List.Item>
<List.Item>
<b>Metrics:</b> System and usage statistics stored internally by Zipline.
</List.Item>
</List>
<p>
Additionally, the export includes <b>system-specific information</b>:
</p>
<List>
<List.Item>
<b>CPU Count:</b> The number of available processor cores.
</List.Item>
<List.Item>
<b>Hostname:</b> The host systems network identifier.
</List.Item>
<List.Item>
<b>Architecture:</b> The hardware architecture (e.g., <Code>x64</Code>, <Code>arm64</Code>).
</List.Item>
<List.Item>
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>).
</List.Item>
<List.Item>
<b>OS Release:</b> The OS or kernel version.
</List.Item>
<List.Item>
<b>Environment Variables:</b> A full snapshot of environment variables at the time of export.
</List.Item>
<List.Item>
<b>Versions:</b> The Zipline version, Node version, and export format version.
</List.Item>
</List>
<Divider my='md' />
<Checkbox
label='Exclude Metrics Data'
description='Exclude system and usage metrics from the export. This can reduce the export file size.'
checked={noMetrics}
onChange={() => setNoMetrics((val) => !val)}
/>
<Divider my='md' />
<Alert color='red' icon={<IconAlertCircle size='1rem' />} 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.
</Alert>
<Group grow my='md'>
<Button onClick={() => setOpen(false)} color='red'>
Cancel
</Button>
<Button
component='a'
href={`/api/server/export${noMetrics ? '?nometrics=true' : ''}`}
target='_blank'
rel='noreferrer'
leftSection={<IconDownload size='1rem' />}
onClick={() => setOpen(false)}
>
Download Export
</Button>
</Group>
</Box>
</Modal>
<Button
size='xl'
fullWidth
onClick={() => setOpen(true)}
leftSection={<IconDownload size='1rem' />}
rightSection={<Pill>V4</Pill>}
>
Export Data
</Button>
</>
);
}

View File

@@ -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<File | null>(null);
const [export3, setExport3] = useState<Export3 | null>(null);
@@ -262,7 +262,7 @@ export default function ImportButton() {
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Import data' size='xl'>
<Modal opened={open} onClose={() => setOpen(false)} title='Import V3 Data' size='xl'>
{export3 ? (
<Button
onClick={() => {
@@ -315,8 +315,8 @@ export default function ImportButton() {
)}
</Modal>
<Button size='sm' leftSection={<IconDatabaseImport size='1rem' />} onClick={() => setOpen(true)}>
Import Data
<Button size='xl' rightSection={<Pill>V3</Pill>} onClick={() => setOpen(true)}>
Import{' '}
</Button>
</>
);

View File

@@ -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<File | null>(null);
const [export4, setExport4] = useState<Export4 | null>(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: <IconDatabaseOff size='1rem' />,
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 (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Import V4 Data' size='xl'>
{export4 ? (
<Button
onClick={() => {
setFile(null);
setExport4(null);
}}
color='red'
variant='filled'
aria-label='Clear'
mb='xs'
leftSection={<IconX size='1rem' />}
fullWidth
>
Clear Import
</Button>
) : (
<FileButton onChange={setFile} accept='application/json'>
{(props) => (
<>
<Button
{...props}
disabled={!!file}
mb='xs'
leftSection={<IconUpload size='1rem' />}
fullWidth
>
Upload Export (JSON)
</Button>
</>
)}
</FileButton>
)}
{file && export4 && (
<>
<pre>{JSON.stringify(export4, null, 2)}</pre>
</>
)}
{export4 && (
<Button fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
Import Data
</Button>
)}
</Modal>
<Button size='xl' rightSection={<Pill>V4</Pill>} onClick={() => setOpen(true)}>
Import
</Button>
</>
);
}

View File

@@ -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 (
<>
<Modal opened={open} onClose={() => setOpen(false)} size='lg' title='Import / Export Data'>
<Group gap='sm' grow>
<ImportV3Button />
<ImportV4Button />
</Group>
<Divider my='md' />
<ExportButton />
</Modal>
<Button size='sm' leftSection={<IconDatabaseExport size='1rem' />} onClick={() => setOpen(true)}>
Import / Export Data
</Button>
</>
);
}

View File

@@ -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() {
<ClearTempButton />
<RequerySizeButton />
<GenThumbsButton />
<ImportButton />
<ImportExportButton />
</Group>
</Paper>
);

View File

@@ -407,7 +407,7 @@ export const V3_SETTINGS_TRANSFORM: Record<keyof typeof V3_COMPATIBLE_SETTINGS,
export function validateExport(data: unknown): ReturnType<typeof export3Schema.safeParse> {
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;

View File

@@ -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<typeof export4Schema>;
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<Zipline>(),
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<typeof export4Schema.safeParse> {
const result = export4Schema.safeParse(data);
if (!result.success) {
if (typeof window === 'object') console.error('Failed to validate export4 data', result.error.issues);
}
return result;
}

View File

@@ -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<string, string>,
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<string, unknown>,
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<string, unknown>,
}));
}
return res
.header('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`)
.type('application/json')
.send(export4);
},
);
done();
},
{ name: PATH },
);

View File

@@ -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<string, string>;
files: Record<string, string>;
folders: Record<string, string>;
urls: Record<string, string>;
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 },
);