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,
|
V3_SETTINGS_TRANSFORM,
|
||||||
validateExport,
|
validateExport,
|
||||||
} from '@/lib/import/version3/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 { modals } from '@mantine/modals';
|
||||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +23,7 @@ import Export3Details from './Export3Details';
|
|||||||
import Export3ImportSettings from './Export3ImportSettings';
|
import Export3ImportSettings from './Export3ImportSettings';
|
||||||
import Export3UserChoose from './Export3UserChoose';
|
import Export3UserChoose from './Export3UserChoose';
|
||||||
|
|
||||||
export default function ImportButton() {
|
export default function ImportV3Button() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [export3, setExport3] = useState<Export3 | null>(null);
|
const [export3, setExport3] = useState<Export3 | null>(null);
|
||||||
@@ -262,7 +262,7 @@ export default function ImportButton() {
|
|||||||
|
|
||||||
return (
|
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 ? (
|
{export3 ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -315,8 +315,8 @@ export default function ImportButton() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Button size='sm' leftSection={<IconDatabaseImport size='1rem' />} onClick={() => setOpen(true)}>
|
<Button size='xl' rightSection={<Pill>V3</Pill>} onClick={() => setOpen(true)}>
|
||||||
Import Data
|
Import{' '}
|
||||||
</Button>
|
</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 ClearZerosButton from './ClearZerosButton';
|
||||||
import GenThumbsButton from './GenThumbsButton';
|
import GenThumbsButton from './GenThumbsButton';
|
||||||
import RequerySizeButton from './RequerySizeButton';
|
import RequerySizeButton from './RequerySizeButton';
|
||||||
import ImportButton from './ImportButton';
|
import ImportExportButton from './ImportExportButton';
|
||||||
|
|
||||||
export default function SettingsServerActions() {
|
export default function SettingsServerActions() {
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +18,7 @@ export default function SettingsServerActions() {
|
|||||||
<ClearTempButton />
|
<ClearTempButton />
|
||||||
<RequerySizeButton />
|
<RequerySizeButton />
|
||||||
<GenThumbsButton />
|
<GenThumbsButton />
|
||||||
<ImportButton />
|
<ImportExportButton />
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</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> {
|
export function validateExport(data: unknown): ReturnType<typeof export3Schema.safeParse> {
|
||||||
const result = export3Schema.safeParse(data);
|
const result = export3Schema.safeParse(data);
|
||||||
if (!result.success) {
|
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;
|
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