Compare commits

...

32 Commits

Author SHA1 Message Date
diced
3fc8b044bb fix: #926 animated compression removes animation 2025-12-05 19:56:05 -08:00
diced
61af46f136 feat: export and import v4 (wip) (needs testing) 2025-11-19 00:22:51 -08:00
diced
771aa67673 fix: editing files that are owned by the current user again 2025-11-18 20:37:51 -08:00
diced
b2db0c15a3 fix: editing files that are owned by current user 2025-11-15 23:20:11 -08:00
diced
d49afe60c8 fix: #924 2025-11-14 23:52:10 -08:00
diced
3370d4b663 fix: remove random logs 2025-11-14 23:50:35 -08:00
diced
1f1bcd3a47 feat: export folder as zip file 2025-11-14 23:48:50 -08:00
diced
d9df04bac5 fix: transactions not working for current user 2025-11-14 23:36:03 -08:00
diced
2bf2809269 fix: metrics erroring with null usernames 2025-11-14 23:18:01 -08:00
diced
9bb9e7e399 feat: add copy raw file link button to file modal 2025-11-14 23:08:05 -08:00
diced
89d6b2908d fix: change memory monitor to csv-like 2025-11-11 22:17:46 -08:00
diced
63c268cd1e fix: actually write new buffer to file (gps removal) 2025-11-07 22:06:29 -08:00
diced
6e2da52f77 feat: actions when viewing other user files (#918) 2025-11-03 16:37:12 -08:00
diced
04b27a2dee fix: build error 2025-11-03 15:40:15 -08:00
diced
6f4c3271c1 fix: #914 2025-11-03 15:36:09 -08:00
diced
b014f10240 fix: #916 2025-11-03 15:36:03 -08:00
diced
d3a417aff0 fix: #921 2025-11-03 15:24:14 -08:00
diced
63596d983e fix: #919 2025-10-28 12:10:06 -07:00
diced
ffbad41994 fix: export issues (#915) 2025-10-27 15:05:01 -07:00
diced
2a6f1f418a feat: log memory usage with DEBUG_MEMORY_LOG 2025-10-27 15:01:19 -07:00
diced
2402c6f0ef fix: performance issues with code renderer (#911) 2025-10-23 21:51:37 -07:00
diced
317e97e3a6 fix: show original name in view route #908 2025-10-19 21:27:06 -07:00
Venipa
f7753ccf2e fix: partial s3 upload ignoring subdirectory (#910, #909) 2025-10-18 20:56:59 -07:00
diced
2ad10e9a52 feat(v4.3.2): version 2025-10-16 21:12:40 -07:00
diced
b4be96c7a8 feat: support separate db vars + file version 2025-10-16 21:02:17 -07:00
diced
69dfad201b feat: reorder/disable/enable table fields in file table 2025-10-12 21:43:50 -07:00
diced
ee1681497e feat: allow any env to be read from a file 2025-10-12 21:43:34 -07:00
diced
2f19140085 feat: add file name in upload response 2025-10-03 21:01:18 -07:00
diced
c9d492f9d2 feat: trust proxies option (#879) 2025-10-03 20:55:35 -07:00
diced
a7a23f3fd9 chore: downgrade aws sdks (#888)
newer AWS sdks introduce dumb AWS specific stuff that break
interoperability with other services.
2025-09-19 20:26:20 -07:00
diced
36ffb669b2 fix: accidental force push lmaoo (#886)
PR: #886
2025-09-18 12:41:22 -07:00
diced
f0ee4cdab3 fix: allow any host on dev 2025-09-18 12:31:59 -07:00
64 changed files with 2897 additions and 1056 deletions

3
.gitignore vendored
View File

@@ -48,4 +48,5 @@ yarn-error.log*
uploads*/
*.crt
*.key
src/prisma
src/prisma
.memory.log*

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:15
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=postgres

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -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")

View File

@@ -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,

View File

@@ -265,7 +265,7 @@ export async function render(
: ''
}
<title>${file.name}</title>
<title>${file.originalName ?? file.name}</title>
`;
return {

View File

@@ -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)}

View File

@@ -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);

View 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>
);
}

View File

@@ -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',

View File

@@ -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,
}}
/>
)}
</>
);

View File

@@ -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',

View File

@@ -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'

View File

@@ -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>

View File

@@ -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(

View File

@@ -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.'

View File

@@ -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 },

View File

@@ -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'>

View File

@@ -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}

View File

@@ -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&apos;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'>

View File

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

View File

@@ -6,7 +6,7 @@ import {
V3_SETTINGS_TRANSFORM,
validateExport,
} from '@/lib/import/version3/validateExport';
import { Alert, Button, Code, FileButton, Modal, Stack } from '@mantine/core';
import { Alert, Button, Code, FileButton, Modal, Pill, Stack } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
@@ -23,7 +23,7 @@ import Export3Details from './Export3Details';
import Export3ImportSettings from './Export3ImportSettings';
import Export3UserChoose from './Export3UserChoose';
export default function ImportButton() {
export default function ImportV3Button() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [export3, setExport3] = useState<Export3 | null>(null);
@@ -262,7 +262,7 @@ export default function ImportButton() {
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Import data' size='xl'>
<Modal opened={open} onClose={() => setOpen(false)} title='Import V3 Data' size='xl'>
{export3 ? (
<Button
onClick={() => {
@@ -315,8 +315,8 @@ export default function ImportButton() {
)}
</Modal>
<Button size='sm' leftSection={<IconDatabaseImport size='1rem' />} onClick={() => setOpen(true)}>
Import Data
<Button size='xl' rightSection={<Pill>V3</Pill>} onClick={() => setOpen(true)}>
Import{' '}
</Button>
</>
);

View File

@@ -0,0 +1,107 @@
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { Button, FileButton, Modal, Pill } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
export default function ImportV4Button() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [export4, setExport4] = useState<Export4 | null>(null);
const onContent = (content: string) => {
if (!content) return console.error('no content');
try {
const data = JSON.parse(content);
onJson(data);
} catch (error) {
console.error('failed to parse file content', error);
}
};
const onJson = (data: unknown) => {
const validated = validateExport(data);
if (!validated.success) {
console.error('Failed to validate import data', validated);
showNotification({
title: 'There were errors with the import',
message:
"Zipline couldn't validate the import data. Are you sure it's a valid export from Zipline v4? For more details about the error, check the browser console.",
color: 'red',
icon: <IconDatabaseOff size='1rem' />,
autoClose: 10000,
});
setOpen(false);
setFile(null);
return;
}
setExport4(validated.data);
};
useEffect(() => {
if (!open) return;
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result;
onContent(content as string);
};
reader.readAsText(file);
}, [file]);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Import V4 Data' size='xl'>
{export4 ? (
<Button
onClick={() => {
setFile(null);
setExport4(null);
}}
color='red'
variant='filled'
aria-label='Clear'
mb='xs'
leftSection={<IconX size='1rem' />}
fullWidth
>
Clear Import
</Button>
) : (
<FileButton onChange={setFile} accept='application/json'>
{(props) => (
<>
<Button
{...props}
disabled={!!file}
mb='xs'
leftSection={<IconUpload size='1rem' />}
fullWidth
>
Upload Export (JSON)
</Button>
</>
)}
</FileButton>
)}
{file && export4 && (
<>
<pre>{JSON.stringify(export4, null, 2)}</pre>
</>
)}
{export4 && (
<Button fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
Import Data
</Button>
)}
</Modal>
<Button size='xl' rightSection={<Pill>V4</Pill>} onClick={() => setOpen(true)}>
Import
</Button>
</>
);
}

View File

@@ -0,0 +1,29 @@
import { Button, Divider, Group, Modal } from '@mantine/core';
import { IconDatabaseExport } from '@tabler/icons-react';
import { useState } from 'react';
import ImportV3Button from './ImportV3Button';
import ImportV4Button from './ImportV4Button';
import ExportButton from './ExportButton';
export default function ImportExport() {
const [open, setOpen] = useState(false);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} size='lg' title='Import / Export Data'>
<Group gap='sm' grow>
<ImportV3Button />
<ImportV4Button />
</Group>
<Divider my='md' />
<ExportButton />
</Modal>
<Button size='sm' leftSection={<IconDatabaseExport size='1rem' />} onClick={() => setOpen(true)}>
Import / Export Data
</Button>
</>
);
}

View File

@@ -3,7 +3,7 @@ import ClearTempButton from './ClearTempButton';
import ClearZerosButton from './ClearZerosButton';
import GenThumbsButton from './GenThumbsButton';
import RequerySizeButton from './RequerySizeButton';
import ImportButton from './ImportButton';
import ImportExportButton from './ImportExportButton';
export default function SettingsServerActions() {
return (
@@ -18,7 +18,7 @@ export default function SettingsServerActions() {
<ClearTempButton />
<RequerySizeButton />
<GenThumbsButton />
<ImportButton />
<ImportExportButton />
</Group>
</Paper>
);

View File

@@ -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);

View File

@@ -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 &&

View File

@@ -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,
}}
/>
)}
</>
);
}

View File

@@ -27,6 +27,7 @@
.theme {
color: var(--_color);
background: var(--_background);
display: block;
.hljs-comment,
.hljs-quote {

View File

@@ -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' }}
>

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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

View File

@@ -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,

View File

@@ -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(),
}),

View File

@@ -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;
}

View File

@@ -407,7 +407,7 @@ export const V3_SETTINGS_TRANSFORM: Record<keyof typeof V3_COMPATIBLE_SETTINGS,
export function validateExport(data: unknown): ReturnType<typeof export3Schema.safeParse> {
const result = export3Schema.safeParse(data);
if (!result.success) {
if (typeof window === 'object') console.error('Failed to validate export data', result.error);
if (typeof window === 'object') console.error('Failed to validate export3 data', result.error);
}
return result;

View File

@@ -0,0 +1,205 @@
import { Zipline } from '@/prisma/client';
import { OAuthProviderType, Role, UserFilesQuota } from '@/prisma/enums';
import { z } from 'zod';
export type Export4 = z.infer<typeof export4Schema>;
export const export4Schema = z.object({
versions: z.object({
zipline: z.string(),
node: z.string(),
export: z.literal('4'),
}),
request: z.object({
user: z.custom<`${string}:${string}`>((data) => {
if (typeof data !== 'string') return false;
const parts = data.split(':');
if (parts.length !== 2) return false;
const [username, id] = parts;
if (!username || !id) return false;
return data;
}),
date: z.string(),
os: z.object({
platform: z.union([
z.literal('aix'),
z.literal('darwin'),
z.literal('freebsd'),
z.literal('linux'),
z.literal('openbsd'),
z.literal('sunos'),
z.literal('win32'),
z.literal('android'),
]),
arch: z.union([
z.literal('arm'),
z.literal('arm64'),
z.literal('ia32'),
z.literal('loong64'),
z.literal('mips'),
z.literal('mipsel'),
z.literal('ppc'),
z.literal('ppc64'),
z.literal('riscv64'),
z.literal('s390'),
z.literal('s390x'),
z.literal('x64'),
]),
cpus: z.number(),
hostname: z.string(),
release: z.string(),
}),
env: z.record(z.string(), z.string()),
}),
data: z.object({
settings: z.custom<Zipline>(),
users: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
username: z.string(),
password: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
role: z.enum(Role),
view: z.record(z.string(), z.unknown()),
totpSecret: z.string().nullable().optional(),
}),
),
userPasskeys: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
lastUsed: z
.string()
.nullable()
.optional()
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
name: z.string(),
reg: z.record(z.string(), z.unknown()),
userId: z.string(),
}),
),
userQuotas: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
filesQuota: z.enum(UserFilesQuota),
maxBytes: z.string().nullable().optional(),
maxFiles: z.number().nullable().optional(),
maxUrls: z.number().nullable().optional(),
userId: z.string().nullable().optional(),
}),
),
userOauthProviders: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
provider: z.enum(OAuthProviderType),
username: z.string(),
accessToken: z.string(),
refreshToken: z.string().nullable().optional(),
oauthId: z.string().nullable().optional(),
userId: z.string(),
}),
),
userTags: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
name: z.string(),
color: z.string().nullable().optional(),
files: z.array(z.string()),
userId: z.string(),
}),
),
invites: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
expiresAt: z
.string()
.nullable()
.optional()
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
code: z.string(),
uses: z.number(),
maxUses: z.number().nullable().optional(),
inviterId: z.string(),
}),
),
folders: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
name: z.string(),
public: z.boolean(),
allowUploads: z.boolean(),
files: z.array(z.string()),
userId: z.string(),
}),
),
urls: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
code: z.string(),
vanity: z.string().nullable().optional(),
destination: z.string(),
views: z.number(),
maxViews: z.number().nullable().optional(),
password: z.string().nullable().optional(),
enabled: z.boolean(),
userId: z.string().nullable().optional(),
}),
),
files: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
deletesAt: z
.string()
.nullable()
.optional()
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
name: z.string(),
originalName: z.string().nullable().optional(),
size: z.number(),
type: z.string(),
views: z.number(),
maxViews: z.number().nullable().optional(),
favorite: z.boolean(),
password: z.string().nullable().optional(),
userId: z.string().nullable(),
folderId: z.string().nullable().optional(),
}),
),
thumbnails: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
path: z.string(),
fileId: z.string(),
}),
),
metrics: z.array(
z.object({
id: z.string(),
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
data: z.record(z.string(), z.unknown()),
}),
),
}),
});
export function validateExport(data: unknown): ReturnType<typeof export4Schema.safeParse> {
const result = export4Schema.safeParse(data);
if (!result.success) {
if (typeof window === 'object') console.error('Failed to validate export4 data', result.error.issues);
}
return result;
}

View File

@@ -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,
},
});

View 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',
},
),
);

View File

@@ -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(

View File

@@ -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),

View File

@@ -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();

View File

@@ -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();

View File

@@ -0,0 +1,285 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { log } from '@/lib/logger';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
import { prisma } from '@/lib/db';
import { cpus, hostname, platform, release } from 'os';
import { version } from '../../../../../package.json';
async function getCounts() {
const users = await prisma.user.count();
const files = await prisma.file.count();
const urls = await prisma.url.count();
const folders = await prisma.folder.count();
const invites = await prisma.invite.count();
const thumbnails = await prisma.thumbnail.count();
const metrics = await prisma.metric.count();
return {
users,
files,
urls,
folders,
invites,
thumbnails,
metrics,
};
}
export type ApiServerExport = Export4;
type Query = {
nometrics?: string;
counts?: string;
};
const logger = log('api').c('server').c('export');
export const PATH = '/api/server/export';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Querystring: Query }>(
PATH,
{
preHandler: [userMiddleware, administratorMiddleware],
},
async (req, res) => {
if (req.query.counts === 'true') {
const counts = await getCounts();
return res.send(counts);
}
logger.debug('exporting server data', { format: '4', requester: req.user.username });
const settingsTable = await prisma.zipline.findFirst();
if (!settingsTable)
return res.badRequest(
'Invalid setup, no settings found. Run the setup process again before exporting data.',
);
const export4: Export4 = {
versions: {
export: '4',
node: process.version,
zipline: version,
},
request: {
date: new Date().toISOString(),
env: process.env as Record<string, string>,
user: `${req.user.id}:${req.user.username}`,
os: {
arch: process.arch,
cpus: cpus().length,
hostname: hostname(),
platform: platform() as Export4['request']['os']['platform'],
release: release(),
},
},
data: {
settings: settingsTable,
users: [],
userPasskeys: [],
userQuotas: [],
userOauthProviders: [],
userTags: [],
invites: [],
folders: [],
urls: [],
files: [],
thumbnails: [],
metrics: [],
},
};
const users = await prisma.user.findMany({
include: {
passkeys: true,
quota: true,
oauthProviders: true,
invites: true,
urls: true,
tags: {
include: {
files: {
select: {
id: true,
},
},
},
},
folders: {
include: {
files: {
select: {
id: true,
},
},
},
},
},
});
for (const user of users) {
export4.data.users.push({
createdAt: user.createdAt.toISOString(),
id: user.id,
username: user.username,
password: user.password,
avatar: user.avatar,
role: user.role,
view: user.view,
totpSecret: user.totpSecret,
});
for (const passkey of user.passkeys) {
export4.data.userPasskeys.push({
createdAt: passkey.createdAt.toISOString(),
id: passkey.id,
lastUsed: passkey.lastUsed ? passkey.lastUsed.toISOString() : null,
name: passkey.name,
reg: passkey.reg as Record<string, unknown>,
userId: passkey.userId,
});
}
for (const oauthProvider of user.oauthProviders) {
export4.data.userOauthProviders.push({
createdAt: oauthProvider.createdAt.toISOString(),
id: oauthProvider.id,
provider: oauthProvider.provider,
username: oauthProvider.username,
accessToken: oauthProvider.accessToken,
refreshToken: oauthProvider.refreshToken,
oauthId: oauthProvider.oauthId,
userId: oauthProvider.userId,
});
}
for (const tag of user.tags) {
export4.data.userTags.push({
createdAt: tag.createdAt.toISOString(),
id: tag.id,
name: tag.name,
color: tag.color,
files: tag.files.map((file) => file.id),
userId: user.id,
});
}
for (const invite of user.invites) {
export4.data.invites.push({
createdAt: invite.createdAt.toISOString(),
id: invite.id,
code: invite.code,
uses: invite.uses,
maxUses: invite.maxUses,
expiresAt: invite.expiresAt ? invite.expiresAt.toISOString() : null,
inviterId: invite.inviterId,
});
}
for (const folder of user.folders) {
export4.data.folders.push({
createdAt: folder.createdAt.toISOString(),
id: folder.id,
name: folder.name,
public: folder.public,
allowUploads: folder.allowUploads,
userId: folder.userId,
files: folder.files.map((file) => file.id),
});
}
for (const url of user.urls) {
export4.data.urls.push({
createdAt: url.createdAt.toISOString(),
id: url.id,
code: url.code,
vanity: url.vanity,
destination: url.destination,
views: url.views,
maxViews: url.maxViews,
password: url.password,
enabled: url.enabled,
userId: url.userId,
});
}
if (user.quota) {
export4.data.userQuotas.push({
createdAt: user.quota.createdAt.toISOString(),
id: user.quota.id,
filesQuota: user.quota.filesQuota,
maxBytes: user.quota.maxBytes,
maxFiles: user.quota.maxFiles,
maxUrls: user.quota.maxUrls,
userId: user.quota.userId,
});
}
}
const files = await prisma.file.findMany();
for (const file of files) {
if (!file.userId)
logger.warn('file has no user associated with it, still exporting...', {
fileId: file.id,
name: file.name,
});
export4.data.files.push({
createdAt: file.createdAt.toISOString(),
deletesAt: file.deletesAt ? file.deletesAt.toISOString() : null,
id: file.id,
name: file.name,
size: file.size,
favorite: file.favorite,
originalName: file.originalName,
type: file.type,
views: file.views,
maxViews: file.maxViews,
password: file.password,
userId: file.userId,
folderId: file.folderId,
});
}
const thumbnails = await prisma.thumbnail.findMany();
for (const thumbnail of thumbnails) {
export4.data.thumbnails.push({
createdAt: thumbnail.createdAt.toISOString(),
id: thumbnail.id,
path: thumbnail.path,
fileId: thumbnail.fileId,
});
}
if (req.query.nometrics === undefined) {
const metrics = await prisma.metric.findMany();
export4.data.metrics = metrics.map((metric) => ({
createdAt: metric.createdAt.toISOString(),
id: metric.id,
data: metric.data as Record<string, unknown>,
}));
}
return res
.header('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`)
.type('application/json')
.send(export4);
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,59 @@
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { log } from '@/lib/logger';
import { secondlyRatelimit } from '@/lib/ratelimits';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiServerImportV4 = {
users: Record<string, string>;
files: Record<string, string>;
folders: Record<string, string>;
urls: Record<string, string>;
settings: string[];
};
type Body = {
export4: Export4;
importFromUser?: string;
};
const logger = log('api').c('server').c('import').c('v4');
export const PATH = '/api/server/import/v4';
export default fastifyPlugin(
(server, _, done) => {
server.post<{ Body: Body }>(
PATH,
{
preHandler: [userMiddleware, administratorMiddleware],
// 24gb, just in case
bodyLimit: 24 * 1024 * 1024 * 1024,
...secondlyRatelimit(5),
},
async (req, res) => {
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
const { export4 } = req.body;
if (!export4) return res.badRequest('missing export4 in request body');
const validated = validateExport(export4);
if (!validated.success) {
logger.error('Failed to validate import data', { error: validated.error });
return res.status(400).send({
error: 'Failed to validate import data',
statusCode: 400,
details: validated.error.issues,
});
}
return res.send({ message: 'Import v4 is not yet implemented' });
},
);
done();
},
{ name: PATH },
);

View File

@@ -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',
),
),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 });
});

View File

@@ -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);

View File

@@ -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);

View 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 },
);

View File

@@ -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);
}

View File

@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
},
server: {
middlewareMode: true,
// not safe in production, but fine in dev
allowedHosts: true,
},
resolve: {
alias: {