mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
Compare commits
32 Commits
8487e07006
...
trunk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc8b044bb | ||
|
|
61af46f136 | ||
|
|
771aa67673 | ||
|
|
b2db0c15a3 | ||
|
|
d49afe60c8 | ||
|
|
3370d4b663 | ||
|
|
1f1bcd3a47 | ||
|
|
d9df04bac5 | ||
|
|
2bf2809269 | ||
|
|
9bb9e7e399 | ||
|
|
89d6b2908d | ||
|
|
63c268cd1e | ||
|
|
6e2da52f77 | ||
|
|
04b27a2dee | ||
|
|
6f4c3271c1 | ||
|
|
b014f10240 | ||
|
|
d3a417aff0 | ||
|
|
63596d983e | ||
|
|
ffbad41994 | ||
|
|
2a6f1f418a | ||
|
|
2402c6f0ef | ||
|
|
317e97e3a6 | ||
|
|
f7753ccf2e | ||
|
|
2ad10e9a52 | ||
|
|
b4be96c7a8 | ||
|
|
69dfad201b | ||
|
|
ee1681497e | ||
|
|
2f19140085 | ||
|
|
c9d492f9d2 | ||
|
|
a7a23f3fd9 | ||
|
|
36ffb669b2 | ||
|
|
f0ee4cdab3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,4 +48,5 @@ yarn-error.log*
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
src/prisma
|
||||
src/prisma
|
||||
.memory.log*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
|
||||
14
package.json
14
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.3.1",
|
||||
"version": "4.3.2",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
@@ -21,8 +21,11 @@
|
||||
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
"@aws-sdk/lib-storage": "3.879.0",
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
"@aws-sdk/lib-storage": "3.726.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
@@ -46,6 +49,7 @@
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.10.0",
|
||||
"bytes": "^3.1.2",
|
||||
@@ -59,7 +63,6 @@
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.5.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
@@ -75,6 +78,7 @@
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"react-window": "1.8.11",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.3",
|
||||
"swr": "^2.3.6",
|
||||
@@ -84,6 +88,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/katex": "^0.16.7",
|
||||
@@ -93,6 +98,7 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
1838
pnpm-lock.yaml
generated
1838
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -20,6 +20,7 @@ model Zipline {
|
||||
coreReturnHttpsUrls Boolean @default(false)
|
||||
coreDefaultDomain String?
|
||||
coreTempDirectory String // default join(tmpdir(), 'zipline')
|
||||
coreTrustProxy Boolean @default(false)
|
||||
|
||||
chunksEnabled Boolean @default(true)
|
||||
chunksMax String @default("95mb")
|
||||
|
||||
@@ -157,7 +157,6 @@ export default function Login() {
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ willRedirect, config });
|
||||
if (willRedirect && config) {
|
||||
const provider = Object.keys(config.oauthEnabled).find(
|
||||
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
|
||||
|
||||
@@ -265,7 +265,7 @@ export async function render(
|
||||
: ''
|
||||
}
|
||||
|
||||
<title>${file.name}</title>
|
||||
<title>${file.originalName ?? file.name}</title>
|
||||
`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
Icon,
|
||||
IconBombFilled,
|
||||
IconClipboardTypography,
|
||||
IconCopy,
|
||||
IconDeviceSdCard,
|
||||
IconDownload,
|
||||
@@ -88,11 +89,13 @@ export default function FileModal({
|
||||
setOpen,
|
||||
file,
|
||||
reduce,
|
||||
user,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file?: File | null;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
@@ -226,7 +229,7 @@ export default function FileModal({
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{!reduce && (
|
||||
{!reduce && !user && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='md' my='xs'>
|
||||
<Box>
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
@@ -234,15 +237,15 @@ export default function FileModal({
|
||||
</Title>
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
withinPortal={false}
|
||||
store={tagsCombobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
pointer
|
||||
onClick={() => tagsCombobox.toggleDropdown()}
|
||||
onClick={() => tagsCombobox.openDropdown()}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? (
|
||||
@@ -254,9 +257,14 @@ export default function FileModal({
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type='hidden'
|
||||
onFocus={() => tagsCombobox.openDropdown()}
|
||||
onBlur={() => tagsCombobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
if (
|
||||
event.key === 'Backspace' &&
|
||||
value.length > 0 &&
|
||||
event.currentTarget.value === ''
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value[value.length - 1]);
|
||||
}
|
||||
@@ -285,9 +293,7 @@ export default function FileModal({
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<Combobox.Option value='no-tags' disabled>
|
||||
No tags found, create one outside of this menu.
|
||||
</Combobox.Option>
|
||||
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
@@ -310,8 +316,8 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
store={folderCombobox}
|
||||
withinPortal={false}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
@@ -398,6 +404,11 @@ export default function FileModal({
|
||||
tooltip='View file in a new tab'
|
||||
color='blue'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconClipboardTypography}
|
||||
onClick={() => copyFile(file, clipboard, true)}
|
||||
tooltip='Copy raw file link'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconCopy}
|
||||
onClick={() => copyFile(file, clipboard)}
|
||||
|
||||
@@ -27,10 +27,14 @@ export function downloadFile(file: File) {
|
||||
window.open(`/raw/${file.name}?download=true`, '_blank');
|
||||
}
|
||||
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
|
||||
const domain = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
|
||||
const url = raw
|
||||
? `${domain}/raw/${file.name}`
|
||||
: file.url
|
||||
? `${domain}${file.url}`
|
||||
: `${domain}/view/${file.name}`;
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
|
||||
99
src/components/pages/files/TableEditModal.tsx
Normal file
99
src/components/pages/files/TableEditModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original Name',
|
||||
tags: 'Tags',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
favorite: 'Favorite',
|
||||
views: 'Views',
|
||||
};
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.field,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Group gap='xs'>
|
||||
<IconGripVertical size='1rem' />
|
||||
|
||||
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
|
||||
|
||||
<Text>{NAMES[item.field]}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
|
||||
const [fields, setIndex, reset] = useFileTableSettingsStore(
|
||||
useShallow((state) => [state.fields, state.setIndex, state.reset]),
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const newIndex = fields.findIndex((item) => item.field === over?.id);
|
||||
|
||||
setIndex(active.id as FieldSettings['field'], newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
|
||||
<Text mb='md' size='sm' c='dimmed'>
|
||||
Select and drag fields below to make them appear/disappear/reorder in the file table view.
|
||||
</Text>
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
|
||||
{fields.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
|
||||
>
|
||||
<SortableTableField item={item} />
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -69,20 +69,23 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkFavorite(ids: string[]) {
|
||||
export async function bulkFavorite(ids: string[], favorite: boolean) {
|
||||
const text = favorite ? 'favorite' : 'unfavorite';
|
||||
const textcaps = favorite ? 'Favorite' : 'Unfavorite';
|
||||
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
|
||||
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
|
||||
title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
|
||||
children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
|
||||
labels: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Favorite',
|
||||
confirm: `${textcaps}`,
|
||||
},
|
||||
confirmProps: { color: 'yellow' },
|
||||
onConfirm: async () => {
|
||||
notifications.show({
|
||||
title: 'Favoriting files',
|
||||
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`,
|
||||
title: `${textcaps}ing files`,
|
||||
message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
|
||||
color: 'yellow',
|
||||
loading: true,
|
||||
id: 'bulk-favorite',
|
||||
@@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) {
|
||||
{
|
||||
files: ids,
|
||||
|
||||
favorite: true,
|
||||
favorite,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
notifications.update({
|
||||
title: 'Error while favoriting files',
|
||||
title: 'Error while modifying files',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconStarsOff size='1rem' />,
|
||||
@@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) {
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
title: 'Favorited files',
|
||||
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`,
|
||||
title: `${textcaps}d files`,
|
||||
message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
|
||||
color: 'yellow',
|
||||
icon: <IconStarsFilled size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
|
||||
@@ -6,12 +6,16 @@ import FileTable from './views/FileTable';
|
||||
import Files from './views/Files';
|
||||
import TagsButton from './tags/TagsButton';
|
||||
import PendingFilesButton from './PendingFilesButton';
|
||||
import { IconFileUpload } from '@tabler/icons-react';
|
||||
import { IconFileUpload, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
|
||||
const [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
@@ -28,6 +32,23 @@ export default function DashboardFiles() {
|
||||
<TagsButton />
|
||||
<PendingFilesButton />
|
||||
|
||||
<Tooltip label='Table Options'>
|
||||
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
|
||||
<IconTableOptions size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<GridTableSwitcher type='files' />
|
||||
</Group>
|
||||
|
||||
@@ -38,7 +59,16 @@ export default function DashboardFiles() {
|
||||
<Files />
|
||||
</>
|
||||
) : (
|
||||
<FileTable />
|
||||
<FileTable
|
||||
idSearch={{
|
||||
open: idSearchOpen,
|
||||
setOpen: setIdSearchOpen,
|
||||
}}
|
||||
tableEdit={{
|
||||
open: tableEditOpen,
|
||||
setOpen: setTableEditOpen,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { bytes } from '@/lib/bytes';
|
||||
import { type File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -32,7 +34,6 @@ import {
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFile,
|
||||
IconGridPatternFilled,
|
||||
IconStar,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -40,10 +41,10 @@ import { DataTable } from 'mantine-datatable';
|
||||
import { lazy, useEffect, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import TableEditModal, { NAMES } from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
|
||||
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
|
||||
|
||||
@@ -54,13 +55,6 @@ type ReducerQuery = {
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
|
||||
const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original name',
|
||||
type: 'Type',
|
||||
id: 'ID',
|
||||
};
|
||||
|
||||
function SearchFilter({
|
||||
setSearchField,
|
||||
searchQuery,
|
||||
@@ -88,8 +82,8 @@ function SearchFilter({
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={NAMES[field]}
|
||||
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
|
||||
label={NAMES[field as keyof typeof NAMES]}
|
||||
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
|
||||
value={searchQuery[field]}
|
||||
onChange={onChange}
|
||||
size='sm'
|
||||
@@ -179,10 +173,26 @@ function TagsFilter({
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileTable({ id }: { id?: string }) {
|
||||
export default function FileTable({
|
||||
id,
|
||||
tableEdit,
|
||||
idSearch,
|
||||
}: {
|
||||
id?: string;
|
||||
tableEdit: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
idSearch: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
|
||||
const fields = useFileTableSettingsStore((state) => state.fields);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
@@ -204,7 +214,6 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
||||
const [searchQuery, setSearchQuery] = useReducer(
|
||||
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
||||
@@ -218,13 +227,13 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (idSearchOpen) return;
|
||||
if (idSearch.open) return;
|
||||
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: '',
|
||||
});
|
||||
}, [idSearchOpen]);
|
||||
}, [idSearch.open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
@@ -264,6 +273,100 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}),
|
||||
});
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='name'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'originalName',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='originalName'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file: File) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='type'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
|
||||
},
|
||||
{ accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file: File) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
render: (file: File) => file.views,
|
||||
},
|
||||
{
|
||||
accessor: 'id',
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
|
||||
const columns = FIELDS.filter((f) => visibleFields.includes(f.accessor as any));
|
||||
columns.sort((a, b) => {
|
||||
const aIndex = fields.findIndex((f) => f.field === a.accessor);
|
||||
const bIndex = fields.findIndex((f) => f.field === b.accessor);
|
||||
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
@@ -285,6 +388,8 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}
|
||||
}, [searchField]);
|
||||
|
||||
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
@@ -293,22 +398,12 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
if (!open) setSelectedFile(null);
|
||||
}}
|
||||
file={selectedFile}
|
||||
user={id}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
// lol if it works it works :shrug:
|
||||
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} />
|
||||
|
||||
<Box>
|
||||
<Collapse in={selectedFiles.length > 0}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
@@ -335,48 +430,56 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
variant='outline'
|
||||
color='yellow'
|
||||
leftSection={<IconStar size='1rem' />}
|
||||
onClick={() => bulkFavorite(selectedFiles.map((x) => x.id))}
|
||||
onClick={() =>
|
||||
bulkFavorite(
|
||||
selectedFiles.map((x) => x.id),
|
||||
!unfavoriteAll,
|
||||
)
|
||||
}
|
||||
>
|
||||
Favorite {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
|
||||
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file
|
||||
{selectedFiles.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => handleAddFolder(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={folderSearch}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setFolderSearch(folderSearch || '');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
{!id && (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => handleAddFolder(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={folderSearch}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setFolderSearch(folderSearch || '');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
@@ -393,8 +496,8 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={idSearchOpen}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Collapse in={idSearch.open}>
|
||||
<Paper withBorder p='sm' mt='sm'>
|
||||
<TextInput
|
||||
placeholder='Search by ID'
|
||||
value={searchQuery.id}
|
||||
@@ -412,80 +515,13 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
|
||||
{/* @ts-ignore */}
|
||||
<DataTable
|
||||
mt='xs'
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={data?.page ?? []}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='name'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='type'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
|
||||
},
|
||||
{ accessor: 'size', sortable: true, render: (file) => bytes(file.size) },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
render: (file) => file.views,
|
||||
},
|
||||
{
|
||||
accessor: 'id',
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
...columns,
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlign: 'right',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconShare,
|
||||
IconShareOff,
|
||||
IconTrashFilled,
|
||||
IconZip,
|
||||
} from '@tabler/icons-react';
|
||||
import ViewFilesModal from '../ViewFilesModal';
|
||||
import EditFolderNameModal from '../EditFolderNameModal';
|
||||
@@ -169,6 +170,14 @@ export default function FolderTableView() {
|
||||
<IconPencil size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Export folder as ZIP'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
onClick={() => window.open(`/api/user/folders/${folder.id}/export`, '_blank')}
|
||||
>
|
||||
<IconZip size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete Folder'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
|
||||
@@ -99,8 +99,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
|
||||
const recent = data[0]; // it is sorted by desc so 0 is the first one.
|
||||
|
||||
if (recent.data.filesUsers.length === 0) return null;
|
||||
if (recent.data.urlsUsers.length === 0) return null;
|
||||
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -121,7 +120,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
.sort((a, b) => b.sum - a.sum)
|
||||
.map((count, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{count.username}</Table.Td>
|
||||
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
|
||||
<Table.Td>{count.sum}</Table.Td>
|
||||
<Table.Td>{bytes(count.storage)}</Table.Td>
|
||||
<Table.Td>{count.views}</Table.Td>
|
||||
@@ -147,7 +146,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
.sort((a, b) => b.sum - a.sum)
|
||||
.map((count, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{count.username}</Table.Td>
|
||||
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
|
||||
<Table.Td>{count.sum}</Table.Td>
|
||||
<Table.Td>{count.views}</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -32,7 +32,6 @@ export default function DashboardServerSettings() {
|
||||
|
||||
const scrollToSetting = useMemo(() => {
|
||||
return (setting: string) => {
|
||||
console.log('scrolling to setting:', setting);
|
||||
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
|
||||
if (input) {
|
||||
const observer = new IntersectionObserver(
|
||||
|
||||
@@ -17,11 +17,13 @@ export default function Core({
|
||||
coreReturnHttpsUrls: boolean;
|
||||
coreDefaultDomain: string | null | undefined;
|
||||
coreTempDirectory: string;
|
||||
coreTrustProxy: boolean;
|
||||
}>({
|
||||
initialValues: {
|
||||
coreReturnHttpsUrls: false,
|
||||
coreDefaultDomain: '',
|
||||
coreTempDirectory: '/tmp/zipline',
|
||||
coreTrustProxy: false,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -45,6 +47,7 @@ export default function Core({
|
||||
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
|
||||
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
|
||||
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
|
||||
coreTrustProxy: data.settings.coreTrustProxy ?? false,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -55,14 +58,20 @@ export default function Core({
|
||||
<Title order={2}>Core</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
description='Return URLs with HTTPS protocol.'
|
||||
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
description='Return URLs with HTTPS protocol.'
|
||||
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Trust Proxies'
|
||||
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
|
||||
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Domain'
|
||||
description='The domain to use when generating URLs. This value should not include the protocol.'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,3})$/gim;
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,30})$/gim;
|
||||
|
||||
export default function Domains({
|
||||
swr: { data, isLoading },
|
||||
|
||||
@@ -20,8 +20,6 @@ export default function DashboardSettings() {
|
||||
const config = useConfig();
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
console.log(config.oauthEnabled);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap='sm'>
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function GeneratorButton({
|
||||
{name === 'ShareX' && (
|
||||
<Switch
|
||||
label='Xshare Compatibility'
|
||||
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The genereated config will not work with ShareX.'
|
||||
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The generated config will not work with ShareX.'
|
||||
checked={options.sharex_xshareCompatibility ?? false}
|
||||
onChange={(event) => setOption({ sharex_xshareCompatibility: event.currentTarget.checked })}
|
||||
disabled={!onlyFile}
|
||||
|
||||
@@ -27,9 +27,9 @@ export default function TwoFAButton() {
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const {
|
||||
data: twoData,
|
||||
error: twoError,
|
||||
isLoading: twoLoading,
|
||||
data: mfaData,
|
||||
error: mfaError,
|
||||
isLoading: mfaLoading,
|
||||
} = useSWR<Extract<Response['/api/user/mfa/totp'], { secret: string; qrcode: string }>>(
|
||||
totpOpen && !user?.totpSecret ? '/api/user/mfa/totp' : null,
|
||||
null,
|
||||
@@ -51,7 +51,7 @@ export default function TwoFAButton() {
|
||||
'POST',
|
||||
{
|
||||
code: pin,
|
||||
secret: twoData!.secret,
|
||||
secret: mfaData!.secret,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -156,25 +156,20 @@ export default function TwoFAButton() {
|
||||
</Text>
|
||||
|
||||
<Box pos='relative'>
|
||||
{twoLoading && !twoError ? (
|
||||
{mfaLoading && !mfaError ? (
|
||||
<Box w={180} h={180}>
|
||||
<LoadingOverlay visible pos='relative' />
|
||||
</Box>
|
||||
) : (
|
||||
<Center>
|
||||
<Image
|
||||
width={180}
|
||||
height={180}
|
||||
src={twoData?.qrcode}
|
||||
alt={'qr code ' + twoData?.secret}
|
||||
/>
|
||||
<Image h={180} w={180} src={mfaData?.qrcode} alt={'qr code ' + mfaData?.secret} />
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
If you can't scan the QR code, you can manually enter the following code into your
|
||||
authenticator app: <Code>{twoData?.secret ?? ''}</Code>
|
||||
authenticator app: <Code>{mfaData?.secret ?? ''}</Code>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -189,6 +189,8 @@ export function uploadFiles(
|
||||
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
|
||||
options.imageCompressionPercent &&
|
||||
req.setRequestHeader('x-zipline-image-compression-percent', options.imageCompressionPercent.toString());
|
||||
options.imageCompressionFormat !== 'default' &&
|
||||
req.setRequestHeader('x-zipline-image-compression-type', options.imageCompressionFormat);
|
||||
options.maxViews && req.setRequestHeader('x-zipline-max-views', options.maxViews.toString());
|
||||
options.addOriginalName && req.setRequestHeader('x-zipline-original-name', 'true');
|
||||
options.overrides_returnDomain && req.setRequestHeader('x-zipline-domain', options.overrides_returnDomain);
|
||||
|
||||
@@ -250,6 +250,8 @@ export async function uploadPartialFiles(
|
||||
'x-zipline-image-compression-percent',
|
||||
options.imageCompressionPercent.toString(),
|
||||
);
|
||||
options.imageCompressionFormat !== 'default' &&
|
||||
req.setRequestHeader('x-zipline-image-compression-type', options.imageCompressionFormat);
|
||||
options.maxViews && req.setRequestHeader('x-zipline-max-views', options.maxViews.toString());
|
||||
options.addOriginalName && req.setRequestHeader('x-zipline-original-name', 'true');
|
||||
options.overrides_returnDomain &&
|
||||
|
||||
@@ -2,10 +2,11 @@ import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files';
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
|
||||
import { IconArrowBackUp } from '@tabler/icons-react';
|
||||
import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import FileTable from '../files/views/FileTable';
|
||||
import Files from '../files/views/Files';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ViewUserFiles() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
@@ -16,6 +17,9 @@ export default function ViewUserFiles() {
|
||||
|
||||
const view = useViewStore((state) => state.files);
|
||||
|
||||
const [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
@@ -26,10 +30,41 @@ export default function ViewUserFiles() {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Table Options'>
|
||||
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
|
||||
<IconTableOptions size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<GridTableSwitcher type='files' />
|
||||
</Group>
|
||||
|
||||
{view === 'grid' ? <Files id={user.id} /> : <FileTable id={user.id} />}
|
||||
{view === 'grid' ? (
|
||||
<Files id={user.id} />
|
||||
) : (
|
||||
<FileTable
|
||||
id={user.id}
|
||||
tableEdit={{
|
||||
open: tableEditOpen,
|
||||
setOpen: setTableEditOpen,
|
||||
}}
|
||||
idSearch={{
|
||||
open: idSearchOpen,
|
||||
setOpen: setIdSearchOpen,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
.theme {
|
||||
color: var(--_color);
|
||||
background: var(--_background);
|
||||
display: block;
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||
import { IconCheck, IconClipboardCopy, IconChevronDown, IconChevronUp } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconCheck, IconChevronDown, IconChevronUp, IconClipboardCopy } from '@tabler/icons-react';
|
||||
import type { HLJSApi } from 'highlight.js';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
import './HighlightCode.theme.scss';
|
||||
import { type HLJSApi } from 'highlight.js';
|
||||
|
||||
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
||||
const theme = useMantineTheme();
|
||||
@@ -14,15 +15,56 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
import('highlight.js').then((mod) => setHljs(mod.default || mod));
|
||||
}, []);
|
||||
|
||||
const lines = code.split('\n');
|
||||
const lineNumbers = lines.map((_, i) => i + 1);
|
||||
const displayLines = expanded ? lines : lines.slice(0, 50);
|
||||
const displayLineNumbers = expanded ? lineNumbers : lineNumbers.slice(0, 50);
|
||||
const lines = useMemo(() => code.split('\n'), [code]);
|
||||
const visible = expanded ? lines.length : Math.min(lines.length, 50);
|
||||
const expandable = lines.length > 50;
|
||||
|
||||
let lang = language;
|
||||
if (!hljs || !hljs.getLanguage(lang)) {
|
||||
lang = 'text';
|
||||
}
|
||||
const lang = useMemo(() => {
|
||||
if (!hljs) return 'plaintext';
|
||||
if (hljs.getLanguage(language)) return language;
|
||||
|
||||
return 'plaintext';
|
||||
}, [hljs, language]);
|
||||
|
||||
const hlLines = useMemo(() => {
|
||||
if (!hljs) return lines;
|
||||
|
||||
return lines.map(
|
||||
(line) =>
|
||||
hljs.highlight(line, {
|
||||
language: lang,
|
||||
}).value,
|
||||
);
|
||||
}, [lines, hljs, lang]);
|
||||
|
||||
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
whiteSpace: 'pre',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
component='span'
|
||||
c='dimmed'
|
||||
mr='md'
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
width: 40,
|
||||
textAlign: 'right',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</Text>
|
||||
|
||||
<code className='theme hljs' style={{ flex: 1 }} dangerouslySetInnerHTML={{ __html: hlLines[index] }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' my='md' pos='relative'>
|
||||
@@ -44,37 +86,17 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
)}
|
||||
</CopyButton>
|
||||
|
||||
<ScrollArea type='auto' dir='ltr' offsetScrollbars={false}>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre', overflowX: 'auto' }} className='theme'>
|
||||
<code className='theme'>
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i}>
|
||||
<Text
|
||||
component='span'
|
||||
size='sm'
|
||||
c='dimmed'
|
||||
mr='md'
|
||||
style={{ userSelect: 'none', fontFamily: 'monospace' }}
|
||||
>
|
||||
{displayLineNumbers[i]}
|
||||
</Text>
|
||||
<span
|
||||
className='line'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: lang === 'none' || !hljs ? line : hljs.highlight(line, { language: lang }).value,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
|
||||
<List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
|
||||
{Row}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
|
||||
{lines.length > 50 && (
|
||||
{expandable && (
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='light'
|
||||
size='compact-sm'
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />}
|
||||
style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extname } from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const COMPRESS_TYPES = ['jpg', 'jpeg', 'png', 'webp', 'jxl'] as const;
|
||||
@@ -22,7 +23,9 @@ export function checkOutput(type: CompressType): boolean {
|
||||
export async function compressFile(filePath: string, options: CompressOptions): Promise<CompressResult> {
|
||||
const { quality, type } = options;
|
||||
|
||||
const image = sharp(filePath).withMetadata();
|
||||
const animated = ['.gif', '.webp', '.avif', '.tiff'].includes(extname(filePath).toLowerCase());
|
||||
|
||||
const image = sharp(filePath, { animated }).withMetadata();
|
||||
|
||||
const result: CompressResult = {
|
||||
mimetype: '',
|
||||
@@ -56,7 +59,7 @@ export async function compressFile(filePath: string, options: CompressOptions):
|
||||
break;
|
||||
}
|
||||
|
||||
await sharp(buffer).toFile(filePath);
|
||||
await sharp(buffer, { animated }).toFile(filePath);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DATABASE_TO_PROP = {
|
||||
coreReturnHttpsUrls: 'core.returnHttpsUrls',
|
||||
coreDefaultDomain: 'core.defaultDomain',
|
||||
coreTempDirectory: 'core.tempDirectory',
|
||||
coreTrustProxy: 'core.trustProxy',
|
||||
|
||||
chunksMax: 'chunks.max',
|
||||
chunksSize: 'chunks.size',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { log } from '@/lib/logger';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parse } from './transform';
|
||||
|
||||
export type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json';
|
||||
export function env(property: string, env: string | string[], type: EnvType, isDb: boolean = false) {
|
||||
export function env(property: string, env: string, type: EnvType, isDb: boolean = false) {
|
||||
return {
|
||||
variable: env,
|
||||
property,
|
||||
@@ -15,7 +16,14 @@ export const ENVS = [
|
||||
env('core.port', 'CORE_PORT', 'number'),
|
||||
env('core.hostname', 'CORE_HOSTNAME', 'string'),
|
||||
env('core.secret', 'CORE_SECRET', 'string'),
|
||||
env('core.databaseUrl', ['DATABASE_URL', 'CORE_DATABASE_URL'], 'string'),
|
||||
|
||||
env('core.databaseUrl', 'DATABASE_URL', 'string'),
|
||||
// or
|
||||
env('core.database.username', 'DATABASE_USERNAME', 'string', true),
|
||||
env('core.database.password', 'DATABASE_PASSWORD', 'string', true),
|
||||
env('core.database.host', 'DATABASE_HOST', 'string', true),
|
||||
env('core.database.port', 'DATABASE_PORT', 'number', true),
|
||||
env('core.database.name', 'DATABASE_NAME', 'string', true),
|
||||
|
||||
env('datasource.type', 'DATASOURCE_TYPE', 'string'),
|
||||
env('datasource.s3.accessKeyId', 'DATASOURCE_S3_ACCESS_KEY_ID', 'string'),
|
||||
@@ -32,6 +40,7 @@ export const ENVS = [
|
||||
env('ssl.cert', 'SSL_CERT', 'string'),
|
||||
|
||||
// database stuff
|
||||
env('core.trustProxy', 'CORE_TRUST_PROXY', 'boolean', true),
|
||||
env('core.returnHttpsUrls', 'CORE_RETURN_HTTPS_URLS', 'boolean', true),
|
||||
env('core.defaultDomain', 'CORE_DEFAULT_DOMAIN', 'string', true),
|
||||
env('core.tempDirectory', 'CORE_TEMP_DIRECTORY', 'string', true),
|
||||
@@ -159,11 +168,62 @@ export const PROP_TO_ENV: Record<string, string | string[]> = Object.fromEntries
|
||||
ENVS.map((env) => [env.property, env.variable]),
|
||||
);
|
||||
|
||||
export const REQUIRED_DB_VARS = [
|
||||
'DATABASE_USERNAME',
|
||||
'DATABASE_PASSWORD',
|
||||
'DATABASE_HOST',
|
||||
'DATABASE_PORT',
|
||||
'DATABASE_NAME',
|
||||
];
|
||||
|
||||
type EnvResult = {
|
||||
env: Record<string, any>;
|
||||
dbEnv: Record<string, any>;
|
||||
};
|
||||
|
||||
export function checkDbVars(): boolean {
|
||||
if (process.env.DATABASE_URL) return true;
|
||||
|
||||
for (let i = 0; i !== REQUIRED_DB_VARS.length; ++i) {
|
||||
if (process.env[REQUIRED_DB_VARS[i]] === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readDbVars(): Record<string, string> {
|
||||
const logger = log('config').c('readDbVars');
|
||||
|
||||
if (process.env.DATABASE_URL) return { DATABASE_URL: process.env.DATABASE_URL };
|
||||
|
||||
const dbVars: Record<string, string> = {};
|
||||
for (let i = 0; i !== REQUIRED_DB_VARS.length; ++i) {
|
||||
const value = process.env[REQUIRED_DB_VARS[i]];
|
||||
const valueFileName = process.env[`${REQUIRED_DB_VARS[i]}_FILE`];
|
||||
if (valueFileName) {
|
||||
try {
|
||||
dbVars[REQUIRED_DB_VARS[i]] = readFileSync(valueFileName, 'utf-8').trim();
|
||||
} catch {
|
||||
logger.error(`Failed to read database env value from file for ${REQUIRED_DB_VARS[i]}. Exiting...`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (value) {
|
||||
dbVars[REQUIRED_DB_VARS[i]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(dbVars).length || Object.keys(dbVars).length !== REQUIRED_DB_VARS.length) {
|
||||
logger.error(
|
||||
`No database environment variables found (DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}]), exiting...`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return dbVars;
|
||||
}
|
||||
|
||||
export function readEnv(): EnvResult {
|
||||
const logger = log('config').c('readEnv');
|
||||
const envResult: EnvResult = {
|
||||
@@ -173,11 +233,18 @@ export function readEnv(): EnvResult {
|
||||
|
||||
for (let i = 0; i !== ENVS.length; ++i) {
|
||||
const env = ENVS[i];
|
||||
if (Array.isArray(env.variable)) {
|
||||
env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL';
|
||||
}
|
||||
|
||||
const value = process.env[env.variable];
|
||||
let value = process.env[env.variable];
|
||||
const valueFileName = process.env[`${env.variable}_FILE`];
|
||||
if (valueFileName) {
|
||||
try {
|
||||
value = readFileSync(valueFileName, 'utf-8').trim();
|
||||
logger.debug('Using env value from file', { variable: env.variable, file: valueFileName });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to read env value from file for ${env.variable}. Skipping...`).error(e as Error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ export const rawConfig: any = {
|
||||
databaseUrl: undefined,
|
||||
returnHttpsUrls: undefined,
|
||||
tempDirectory: undefined,
|
||||
trustProxy: undefined,
|
||||
database: {
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
name: undefined,
|
||||
},
|
||||
},
|
||||
chunks: {
|
||||
max: undefined,
|
||||
|
||||
@@ -67,13 +67,36 @@ export const schema = z.object({
|
||||
});
|
||||
}
|
||||
}),
|
||||
databaseUrl: z.url(),
|
||||
returnHttpsUrls: z.boolean().default(false),
|
||||
defaultDomain: z.string().nullable().default(null),
|
||||
tempDirectory: z
|
||||
.string()
|
||||
.transform((s) => resolve(s))
|
||||
.default(join(tmpdir(), 'zipline')),
|
||||
trustProxy: z.boolean().default(false),
|
||||
|
||||
databaseUrl: z.url(),
|
||||
|
||||
database: z
|
||||
.object({
|
||||
username: z.string().nullable().default(null),
|
||||
password: z.string().nullable().default(null),
|
||||
host: z.string().nullable().default(null),
|
||||
port: z.number().nullable().default(null),
|
||||
name: z.string().nullable().default(null),
|
||||
})
|
||||
.superRefine((val, c) => {
|
||||
const values = Object.values(val);
|
||||
const someSet = values.some((v) => v !== null);
|
||||
const allSet = values.every((v) => v !== null);
|
||||
|
||||
if (someSet && !allSet) {
|
||||
c.addIssue({
|
||||
code: 'custom',
|
||||
message: 'If one database field is set, all fields must be set',
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
chunks: z.object({
|
||||
max: z.string().default('95mb'),
|
||||
|
||||
@@ -64,7 +64,7 @@ export class S3Datasource extends Datasource {
|
||||
this.ensureReadWriteAccess();
|
||||
}
|
||||
|
||||
private key(path: string): string {
|
||||
public key(path: string): string {
|
||||
if (this.options.subdirectory) {
|
||||
return this.options.subdirectory.endsWith('/')
|
||||
? this.options.subdirectory + path
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Prisma, PrismaClient } from '@/prisma/client';
|
||||
import { metadataSchema } from './models/incompleteFile';
|
||||
import { metricDataSchema } from './models/metric';
|
||||
import { userViewSchema } from './models/user';
|
||||
import { readDbVars, REQUIRED_DB_VARS } from '../config/read/env';
|
||||
|
||||
const building = !!process.env.ZIPLINE_BUILD;
|
||||
|
||||
@@ -31,12 +32,27 @@ function parseDbLog(env: string): Prisma.LogLevel[] {
|
||||
.filter((v) => v) as unknown as Prisma.LogLevel[];
|
||||
}
|
||||
|
||||
function pgConnectionString() {
|
||||
const vars = readDbVars();
|
||||
if (vars.DATABASE_URL) return vars.DATABASE_URL;
|
||||
|
||||
return `postgresql://${vars.DATABASE_USERNAME}:${vars.DATABASE_PASSWORD}@${vars.DATABASE_HOST}:${vars.DATABASE_PORT}/${vars.DATABASE_NAME}`;
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
const logger = log('db');
|
||||
|
||||
logger.info('connecting to database ' + process.env.DATABASE_URL);
|
||||
const connectionString = pgConnectionString();
|
||||
if (!connectionString) {
|
||||
logger.error(`either DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}] not set, exiting...`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
process.env.DATABASE_URL = connectionString;
|
||||
|
||||
logger.info('connecting to database', { url: connectionString });
|
||||
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
const client = new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const metricDataSchema = z.object({
|
||||
|
||||
filesUsers: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
username: z.string().nullable(),
|
||||
sum: z.number(),
|
||||
storage: z.number(),
|
||||
views: z.number(),
|
||||
@@ -27,7 +27,7 @@ export const metricDataSchema = z.object({
|
||||
),
|
||||
urlsUsers: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
username: z.string().nullable(),
|
||||
sum: z.number(),
|
||||
views: z.number(),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// heavily modified from @xoi/gps-metadata-remover to fit the needs of zipline
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import {
|
||||
PNG_TAG,
|
||||
PNG_IEND,
|
||||
@@ -136,5 +136,9 @@ export function removeGps(input: Buffer | string): boolean {
|
||||
removed = stripGpsFromTiff(buffer, tiffIfdOffset, littleEndian);
|
||||
}
|
||||
|
||||
if (removed && typeof input === 'string') {
|
||||
writeFileSync(input, buffer);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -39,9 +39,12 @@ export async function queryStats(): Promise<MetricData> {
|
||||
});
|
||||
|
||||
for (let i = 0; i !== filesByUser.length; ++i) {
|
||||
const id = filesByUser[i].userId;
|
||||
if (!id) continue;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: filesByUser[i].userId!,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,9 +52,12 @@ export async function queryStats(): Promise<MetricData> {
|
||||
}
|
||||
|
||||
for (let i = 0; i !== urlsByUser.length; ++i) {
|
||||
const id = urlsByUser[i].userId;
|
||||
if (!id) continue;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: urlsByUser[i].userId!,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
58
src/lib/store/fileTableSettings.ts
Executable file
58
src/lib/store/fileTableSettings.ts
Executable file
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const FIELDS = ['name', 'originalName', 'tags', 'type', 'size', 'createdAt', 'favorite', 'views'] as const;
|
||||
|
||||
export const defaultFields: FieldSettings[] = [
|
||||
{ field: 'name', visible: true },
|
||||
{ field: 'originalName', visible: false },
|
||||
{ field: 'tags', visible: true },
|
||||
{ field: 'type', visible: true },
|
||||
{ field: 'size', visible: true },
|
||||
{ field: 'createdAt', visible: true },
|
||||
{ field: 'favorite', visible: true },
|
||||
{ field: 'views', visible: true },
|
||||
];
|
||||
|
||||
export type FieldSettings = {
|
||||
field: (typeof FIELDS)[number];
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type FileTableSettings = {
|
||||
fields: FieldSettings[];
|
||||
|
||||
setVisible: (field: FieldSettings['field'], visible: boolean) => void;
|
||||
setIndex: (field: FieldSettings['field'], index: number) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const useFileTableSettingsStore = create<FileTableSettings>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
fields: defaultFields,
|
||||
|
||||
setVisible: (field, visible) =>
|
||||
set((state) => ({
|
||||
fields: state.fields.map((f) => (f.field === field ? { ...f, visible } : f)),
|
||||
})),
|
||||
|
||||
setIndex: (field, index) =>
|
||||
set((state) => {
|
||||
const currentIndex = state.fields.findIndex((f) => f.field === field);
|
||||
if (currentIndex === -1 || index < 0 || index >= state.fields.length) return state;
|
||||
|
||||
const newFields = [...state.fields];
|
||||
const [movedField] = newFields.splice(currentIndex, 1);
|
||||
newFields.splice(index, 0, movedField);
|
||||
|
||||
return { fields: newFields };
|
||||
}),
|
||||
|
||||
reset: () => set({ fields: defaultFields }),
|
||||
}),
|
||||
{
|
||||
name: 'zipline-file-table-settings',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -185,6 +185,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
|
||||
const imageCompressionPercent = headers['x-zipline-image-compression-percent'];
|
||||
const imageCompressionType = headers['x-zipline-image-compression-type'];
|
||||
|
||||
if (imageCompressionType) {
|
||||
if (!imageCompressionPercent)
|
||||
return headerError(
|
||||
|
||||
@@ -152,7 +152,7 @@ async function main() {
|
||||
client: s3datasource.client,
|
||||
params: {
|
||||
Bucket: s3datasource.options.bucket,
|
||||
Key: file.filename,
|
||||
Key: s3datasource.key(file.filename),
|
||||
Body: bodyStream,
|
||||
},
|
||||
partSize: bytes(config.chunks.size),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import { checkDbVars, REQUIRED_DB_VARS } from '@/lib/config/read/env';
|
||||
import { getDatasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { runMigrations } from '@/lib/db/migration';
|
||||
@@ -19,7 +20,7 @@ import { fastifyRateLimit } from '@fastify/rate-limit';
|
||||
import { fastifySensible } from '@fastify/sensible';
|
||||
import { fastifyStatic } from '@fastify/static';
|
||||
import fastify from 'fastify';
|
||||
import { mkdir, readFile } from 'fs/promises';
|
||||
import { appendFile, mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { version } from '../../package.json';
|
||||
import { checkRateLimit } from './plugins/checkRateLimit';
|
||||
@@ -46,8 +47,8 @@ async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
logger.info('starting zipline', { mode: MODE, version: version, argv });
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
logger.error('DATABASE_URL not set, exiting...');
|
||||
if (!checkDbVars()) {
|
||||
logger.error(`either DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}] not set, exiting...`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -65,6 +66,13 @@ async function main() {
|
||||
|
||||
await mkdir(config.core.tempDirectory, { recursive: true });
|
||||
|
||||
logger.debug('creating server', {
|
||||
port: config.core.port,
|
||||
hostname: config.core.hostname,
|
||||
ssl: notNull(config.ssl.key, config.ssl.cert),
|
||||
trustProxy: config.core.trustProxy,
|
||||
});
|
||||
|
||||
const server = fastify({
|
||||
https: notNull(config.ssl.key, config.ssl.cert)
|
||||
? {
|
||||
@@ -72,6 +80,7 @@ async function main() {
|
||||
cert: await readFile(config.ssl.cert!, 'utf8'),
|
||||
}
|
||||
: null,
|
||||
trustProxy: config.core.trustProxy,
|
||||
});
|
||||
|
||||
await server.register(fastifyCookie, {
|
||||
@@ -275,6 +284,18 @@ async function main() {
|
||||
}
|
||||
|
||||
tasks.start();
|
||||
|
||||
if (process.env.DEBUG_MONITOR_MEMORY === 'true') {
|
||||
await writeFile('.memory.log', '', 'utf8');
|
||||
setInterval(async () => {
|
||||
const mu = process.memoryUsage();
|
||||
const cpu = process.cpuUsage();
|
||||
|
||||
const entry = `${Math.floor(Date.now() / 1000)},${mu.rss},${mu.heapUsed},${mu.heapTotal},${mu.external},${mu.arrayBuffers},${cpu.system},${cpu.user}\n`;
|
||||
|
||||
await appendFile('.memory.log', entry, 'utf8');
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -45,7 +45,9 @@ async function vitePlugin(fastify: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
reply.hijack();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
vite!.middlewares(req.raw, reply.raw, (err: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
|
||||
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 },
|
||||
);
|
||||
@@ -118,6 +118,7 @@ export default fastifyPlugin(
|
||||
.nullable()
|
||||
.refine((value) => !value || /^[a-z0-9-.]+$/.test(value), 'Invalid domain format'),
|
||||
coreReturnHttpsUrls: z.boolean(),
|
||||
coreTrustProxy: z.boolean(),
|
||||
|
||||
chunksEnabled: z.boolean(),
|
||||
chunksMax: zBytes,
|
||||
@@ -321,7 +322,7 @@ export default fastifyPlugin(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,3})$/gi,
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,30})$/gi,
|
||||
'Invalid Domain',
|
||||
),
|
||||
),
|
||||
|
||||
@@ -41,6 +41,7 @@ export const getExtension = (filename: string, override?: string): string => {
|
||||
export type ApiUploadResponse = {
|
||||
files: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
pending?: boolean;
|
||||
@@ -212,6 +213,7 @@ export default fastifyPlugin(
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
name: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
url: encodeURI(responseUrl),
|
||||
removedGps: removedGps || undefined,
|
||||
|
||||
@@ -7,13 +7,13 @@ import { guess } from '@/lib/mimes';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { formatFileName } from '@/lib/uploader/formatFileName';
|
||||
import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parseHeaders';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { readdir, rename, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { ApiUploadResponse, getExtension } from '.';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
|
||||
const logger = log('api').c('upload').c('partial');
|
||||
|
||||
@@ -256,6 +256,7 @@ export default fastifyPlugin(
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
name: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
url: responseUrl,
|
||||
pending: true,
|
||||
|
||||
@@ -5,11 +5,12 @@ import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
import archiver from 'archiver';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { rm, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Export } from '@/prisma/client';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
|
||||
export type ApiUserExportResponse = {
|
||||
running?: boolean;
|
||||
@@ -92,70 +93,29 @@ export default fastifyPlugin(
|
||||
size: '0',
|
||||
},
|
||||
});
|
||||
|
||||
const writeStream = createWriteStream(exportPath);
|
||||
const zip = new Zip();
|
||||
|
||||
const onBackpressure = (stream: any, outputStream: any, cb: any) => {
|
||||
const runCb = () => {
|
||||
cb(applyOutputBackpressure || backpressureBytes > backpressureThreshold);
|
||||
};
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
const backpressureThreshold = 65536;
|
||||
const backpressure: number[] = [];
|
||||
let backpressureBytes = 0;
|
||||
const push = stream.push;
|
||||
stream.push = (data: string | any[], final: any) => {
|
||||
backpressure.push(data.length);
|
||||
backpressureBytes += data.length;
|
||||
runCb();
|
||||
push.call(stream, data, final);
|
||||
};
|
||||
let ondata = stream.ondata;
|
||||
const ondataPatched = (err: any, data: any, final: any) => {
|
||||
ondata.call(stream, err, data, final);
|
||||
backpressureBytes -= backpressure.shift()!;
|
||||
runCb();
|
||||
};
|
||||
if (ondata) {
|
||||
stream.ondata = ondataPatched;
|
||||
} else {
|
||||
Object.defineProperty(stream, 'ondata', {
|
||||
get: () => ondataPatched,
|
||||
set: (cb) => (ondata = cb),
|
||||
});
|
||||
zip.pipe(writeStream);
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn(`failed to get file ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let applyOutputBackpressure = false;
|
||||
const write = outputStream.write;
|
||||
outputStream.write = (data: any) => {
|
||||
const outputNotFull = write.call(outputStream, data);
|
||||
applyOutputBackpressure = !outputNotFull;
|
||||
runCb();
|
||||
};
|
||||
outputStream.on('drain', () => {
|
||||
applyOutputBackpressure = false;
|
||||
runCb();
|
||||
});
|
||||
};
|
||||
zip.append(stream, { name: file.name });
|
||||
totalSize += file.size;
|
||||
logger.debug('file added to zip', { name: file.name, size: file.size });
|
||||
}
|
||||
|
||||
zip.ondata = async (err, data, final) => {
|
||||
if (err) {
|
||||
writeStream.close();
|
||||
logger.debug('error while writing to zip', { err });
|
||||
logger.error(`export for ${req.user.id} failed`);
|
||||
|
||||
await prisma.export.delete({ where: { id: exportDb.id } });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
writeStream.write(data);
|
||||
|
||||
if (!final) return;
|
||||
|
||||
writeStream.end();
|
||||
logger.debug('exported', { path: exportPath, bytes: data.length });
|
||||
writeStream.on('close', async () => {
|
||||
logger.debug('exported', { path: exportPath, bytes: zip.pointer() });
|
||||
logger.info(`export for ${req.user.id} finished at ${exportPath}`);
|
||||
|
||||
await prisma.export.update({
|
||||
@@ -165,37 +125,15 @@ export default fastifyPlugin(
|
||||
size: (await stat(exportPath)).size.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
zip.on('error', (err) => {
|
||||
logger.error('export zip error', { err, exportId: exportDb.id });
|
||||
});
|
||||
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn(`failed to get file ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
zip.finalize();
|
||||
|
||||
const passThrough = new ZipPassThrough(file.name);
|
||||
zip.add(passThrough);
|
||||
|
||||
onBackpressure(passThrough, stream, (applyBackpressure: boolean) => {
|
||||
if (applyBackpressure) {
|
||||
stream.pause();
|
||||
} else if (stream.isPaused()) {
|
||||
stream.resume();
|
||||
}
|
||||
});
|
||||
stream.on('data', (c) => passThrough.push(c));
|
||||
stream.on('end', () => {
|
||||
passThrough.push(new Uint8Array(0), true);
|
||||
logger.debug(`file ${i + 1}/${files.length} added to zip`, { name: file.name });
|
||||
});
|
||||
}
|
||||
|
||||
zip.end();
|
||||
|
||||
logger.info(`export for ${req.user.id} started`);
|
||||
logger.info(`export for ${req.user.id} started`, { totalSize: bytes(totalSize) });
|
||||
|
||||
return res.send({ running: true });
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { canInteract } from '@/lib/role';
|
||||
|
||||
export type ApiUserFilesIdResponse = File;
|
||||
|
||||
@@ -33,12 +34,14 @@ export default fastifyPlugin(
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
return res.send(file);
|
||||
});
|
||||
|
||||
@@ -49,12 +52,14 @@ export default fastifyPlugin(
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
const data: Prisma.FileUpdateInput = {};
|
||||
|
||||
if (req.body.favorite !== undefined) data.favorite = req.body.favorite;
|
||||
@@ -126,6 +131,7 @@ export default fastifyPlugin(
|
||||
logger.info(`${req.user.username} updated file ${newFile.name}`, {
|
||||
updated: Object.keys(req.body),
|
||||
id: newFile.id,
|
||||
owner: file.User?.id,
|
||||
});
|
||||
|
||||
return res.send(newFile);
|
||||
@@ -135,11 +141,16 @@ export default fastifyPlugin(
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
userId: req.user.id,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
const deletedFile = await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
@@ -151,6 +162,7 @@ export default fastifyPlugin(
|
||||
|
||||
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
|
||||
size: bytes(deletedFile.size),
|
||||
owner: file.User?.id,
|
||||
});
|
||||
|
||||
return res.send(deletedFile);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { Role } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
@@ -22,6 +24,23 @@ type Body = {
|
||||
|
||||
const logger = log('api').c('user').c('files').c('transaction');
|
||||
|
||||
function checkInteraction(
|
||||
current: { id: string; role: Role },
|
||||
roles: { id: string; role: Role }[],
|
||||
): number[] {
|
||||
const indices: number[] = [];
|
||||
|
||||
for (let i = 0; i !== roles.length; ++i) {
|
||||
if (roles[i].id === current.id) continue;
|
||||
|
||||
if (!canInteract(current.role, roles[i].role)) {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
|
||||
export const PATH = '/api/user/files/transaction';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
@@ -34,14 +53,28 @@ export default fastifyPlugin(
|
||||
if (!files || !files.length) return res.badRequest('Cannot process transaction without files');
|
||||
|
||||
if (typeof favorite === 'boolean') {
|
||||
const toFavoriteFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: { in: files },
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
const invalids = checkInteraction(
|
||||
{ id: req.user.id, role: req.user.role },
|
||||
toFavoriteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
|
||||
);
|
||||
if (invalids.length > 0)
|
||||
return res.forbidden(`You don't have the permission to modify files[${invalids.join(', ')}]`);
|
||||
|
||||
const resp = await prisma.file.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
|
||||
data: {
|
||||
favorite: favorite,
|
||||
},
|
||||
@@ -51,6 +84,7 @@ export default fastifyPlugin(
|
||||
|
||||
logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, {
|
||||
user: req.user.id,
|
||||
owners: toFavoriteFiles.map((f) => f.userId),
|
||||
});
|
||||
|
||||
return res.send(resp);
|
||||
@@ -108,21 +142,28 @@ export default fastifyPlugin(
|
||||
files: files.length,
|
||||
});
|
||||
|
||||
if (delete_datasourceFiles) {
|
||||
const dFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
const toDeleteFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: { in: files },
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== dFiles.length; ++i) {
|
||||
await datasource.delete(dFiles[i].name);
|
||||
const invalids = checkInteraction(
|
||||
{ id: req.user.id, role: req.user.role },
|
||||
toDeleteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
|
||||
);
|
||||
if (invalids.length > 0)
|
||||
return res.forbidden(`You don't have the permission to delete files[${invalids.join(', ')}]`);
|
||||
|
||||
if (delete_datasourceFiles) {
|
||||
for (let i = 0; i !== toDeleteFiles.length; ++i) {
|
||||
await datasource.delete(toDeleteFiles[i].name);
|
||||
}
|
||||
|
||||
logger.info(`${req.user.username} deleted ${dFiles.length} files from datasource`, {
|
||||
logger.info(`${req.user.username} deleted ${toDeleteFiles.length} files from datasource`, {
|
||||
user: req.user.id,
|
||||
});
|
||||
}
|
||||
@@ -132,7 +173,6 @@ export default fastifyPlugin(
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -140,6 +180,7 @@ export default fastifyPlugin(
|
||||
|
||||
logger.info(`${req.user.username} deleted ${resp.count} files`, {
|
||||
user: req.user.id,
|
||||
owners: toDeleteFiles.map((f) => f.userId),
|
||||
});
|
||||
|
||||
return res.send(resp);
|
||||
|
||||
68
src/server/routes/api/user/folders/[id]/export.ts
Normal file
68
src/server/routes/api/user/folders/[id]/export.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import archiver from 'archiver';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
export type ApiUserFoldersIdExportResponse = null;
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('user').c('folders').c('[id]').c('export');
|
||||
|
||||
export const PATH = '/api/user/folders/:id/export';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
});
|
||||
if (!folder) return res.notFound('Folder not found');
|
||||
if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder');
|
||||
|
||||
if (!folder.files.length) return res.badRequest("Can't export an empty folder.");
|
||||
|
||||
logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id });
|
||||
|
||||
res.hijack();
|
||||
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
zip.pipe(res.raw);
|
||||
|
||||
for (const file of folder.files) {
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn('failed to get file stream for folder export', { file: file.id, folder: folder.id });
|
||||
continue;
|
||||
}
|
||||
|
||||
zip.append(stream, { name: file.name });
|
||||
}
|
||||
|
||||
zip.on('error', (err) => {
|
||||
logger.error('error during folder export zip creation', { folder: folder.id }).error(err as Error);
|
||||
});
|
||||
|
||||
zip.on('finish', () => {
|
||||
logger.info(`folder export completed: ${folder.name}`, { user: req.user.id, folder: folder.id });
|
||||
});
|
||||
|
||||
await zip.finalize();
|
||||
});
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
@@ -28,6 +28,7 @@ export async function filesRoute(
|
||||
|
||||
if (file.User?.view.enabled) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
if (file.type.startsWith('text/')) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
if (file.password) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
|
||||
return rawFileHandler(req, res);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
// not safe in production, but fine in dev
|
||||
allowedHosts: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user