mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
feat: export and import v4 (wip) (needs testing)
This commit is contained in:
@@ -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 Zipline’s 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 system’s 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
205
src/lib/import/version4/validateExport.ts
Normal file
205
src/lib/import/version4/validateExport.ts
Normal 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;
|
||||
}
|
||||
285
src/server/routes/api/server/export.ts
Normal file
285
src/server/routes/api/server/export.ts
Normal 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 },
|
||||
);
|
||||
59
src/server/routes/api/server/import/v4.ts
Normal file
59
src/server/routes/api/server/import/v4.ts
Normal 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 },
|
||||
);
|
||||
Reference in New Issue
Block a user