Compare commits

...

54 Commits

Author SHA1 Message Date
diced
1585287b63 feat(v4.4.0): version 2025-12-13 14:37:48 -08:00
dicedtomato
1d4c3f26b4 Merge commit from fork 2025-12-13 14:31:55 -08:00
diced
589f06b460 feat: new actions page + finish impl v4 export 2025-12-08 23:30:04 -08:00
diced
ca09b1319d chore: update packages + eslint + lint 2025-12-08 01:29:12 -08:00
diced
5d27c14b77 feat: import v4 jsons (settings wip) 2025-12-08 01:07:15 -08:00
diced
9da74054ff fix: #931 2025-12-07 21:58:56 -08:00
diced
7572f7f3da fix: #935 2025-12-07 20:36:12 -08:00
diced
ef979d8853 feat: import v4 details (wip still) 2025-12-06 21:56:32 -08:00
diced
d090ed2cc1 fix: #926 for good 2025-12-06 20:37:55 -08:00
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
diced
ac41dab2b2 fix: title not updating on first-load 2025-09-09 16:19:54 -07:00
diced
26661f7a83 fix: encode id for view route 2025-09-09 16:06:27 -07:00
diced
01a73df7f3 fix: say "try again" for invites when ratelimited 2025-09-08 23:08:29 -07:00
diced
6b1304f37b fix: #885 2025-09-08 23:06:27 -07:00
diced
19fc87818c feat(v4.3.1): version 2025-09-08 15:23:54 -07:00
diced
f168fa676d fix: better dev scripts runner 2025-09-08 11:53:45 -07:00
diced
44cb10acf2 fix: scripts 2025-09-08 11:50:45 -07:00
diced
2c21101e9e fix: remove log 2025-09-08 11:04:54 -07:00
diced
ecb83d96e3 fix: add /r/:id redirect (#882) 2025-09-08 10:35:21 -07:00
diced
bfae105e5f fix: invites not working 2025-09-06 16:29:24 -07:00
diced
3240e19710 fix: bypass local login #878 2025-09-06 12:51:46 -07:00
diced
40c12ca3f0 fix: 🖕prisma (rollback to working stuff) 2025-09-06 12:37:32 -07:00
diced
4907f4e450 fix: #876 2025-09-05 20:59:22 -07:00
97 changed files with 6825 additions and 3002 deletions

3
.gitignore vendored
View File

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

View File

@@ -46,6 +46,8 @@ COPY --from=builder /zipline/build ./build
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/code.json ./code.json
RUN pnpm prisma generate
# clean
RUN rm -rf /tmp/* /root/*

View File

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

View File

@@ -1,15 +1,17 @@
import unusedImports from 'eslint-plugin-unused-imports';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import unusedImports from 'eslint-plugin-unused-imports';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -20,63 +22,57 @@ const gitignorePatterns = gitignoreContent
.filter((line) => line.trim() && !line.startsWith('#'))
.map((pattern) => pattern.trim());
export default tseslint.config(
const reactRecommendedRules = reactPlugin.configs.recommended.rules;
const reactHooksRecommendedRules = reactHooksPlugin.configs['recommended-latest'].rules;
const reactRefreshRules = reactRefreshPlugin.configs.vite.rules;
import { defineConfig } from 'eslint/config';
export default defineConfig(
tseslint.configs.recommended,
jsxA11yPlugin.flatConfigs.recommended,
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat.recommended,
reactRefreshPlugin.configs.vite,
{ ignores: gitignorePatterns },
{
extends: [
tseslint.configs.recommended,
reactHooksPlugin.configs['recommended-latest'],
reactRefreshPlugin.configs.vite,
],
},
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaFeatures: { jsx: true },
},
},
plugins: {
'unused-imports': unusedImports,
prettier: prettier,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
},
rules: {
...reactPlugin.configs.recommended.rules,
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin,
prettier,
'unused-imports': unusedImports,
},
rules: {
...prettierConfig.rules,
'prettier/prettier': [
'error',
{},
{
fileInfoOptions: {
withNodeModules: false,
},
},
],
'prettier/prettier': ['error', {}, { fileInfoOptions: { withNodeModules: false } }],
'linebreak-style': ['error', 'unix'],
quotes: [
'error',
'single',
{
avoidEscape: true,
},
],
quotes: ['error', 'single', { avoidEscape: true }],
semi: ['error', 'always'],
'jsx-quotes': ['error', 'prefer-single'],
indent: 'off',
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/set-state-in-effect': 'warn',
'react-refresh/only-export-components': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
@@ -87,28 +83,29 @@ export default tseslint.config(
'react/react-in-jsx-scope': 'off',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'jsx-a11y/alt-text': 'off',
'react/display-name': 'off',
'jsx-a11y/alt-text': 'off',
'jsx-a11y/no-autofocus': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
},
settings: {
react: {
version: 'detect',
},
react: { version: 'detect' },
},
},
);

View File

@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.3.0",
"version": "4.4.0",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
@@ -21,93 +21,102 @@
"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",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^8.3.0",
"@github/webauthn-json": "^2.1.1",
"@mantine/charts": "^8.2.8",
"@mantine/code-highlight": "^8.2.8",
"@mantine/core": "^8.2.8",
"@mantine/dates": "^8.2.8",
"@mantine/dropzone": "^8.2.8",
"@mantine/form": "^8.2.8",
"@mantine/hooks": "^8.2.8",
"@mantine/modals": "^8.2.8",
"@mantine/notifications": "^8.2.8",
"@prisma/adapter-pg": "^6.15.0",
"@prisma/client": "^6.15.0",
"@mantine/charts": "^8.3.9",
"@mantine/code-highlight": "^8.3.9",
"@mantine/core": "^8.3.9",
"@mantine/dates": "^8.3.9",
"@mantine/dropzone": "^8.3.9",
"@mantine/form": "^8.3.9",
"@mantine/hooks": "^8.3.9",
"@mantine/modals": "^8.3.9",
"@mantine/notifications": "^8.3.9",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
"@prisma/internals": "6.13.0",
"@prisma/migrate": "6.13.0",
"@smithy/node-http-handler": "^4.1.1",
"@tabler/icons-react": "^3.34.1",
"@tabler/icons-react": "^3.35.0",
"archiver": "^7.0.1",
"argon2": "^0.44.0",
"asciinema-player": "^3.10.0",
"asciinema-player": "^3.12.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.0",
"cookie": "^1.0.2",
"cross-env": "^10.0.0",
"dayjs": "^1.11.18",
"dotenv": "^17.2.2",
"commander": "^14.0.2",
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"fast-glob": "^3.3.3",
"fastify": "^5.5.0",
"fastify-plugin": "^5.0.1",
"fflate": "^0.8.2",
"fastify": "^5.6.2",
"fastify-plugin": "^5.1.0",
"fluent-ffmpeg": "^2.1.3",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.26.0",
"katex": "^0.16.22",
"mantine-datatable": "^8.2.0",
"isomorphic-dompurify": "^2.33.0",
"katex": "^0.16.27",
"mantine-datatable": "^8.3.9",
"ms": "^2.1.3",
"multer": "2.0.2",
"otplib": "^12.0.1",
"prisma": "^6.15.0",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.8.2",
"react-router-dom": "^7.10.1",
"react-window": "1.8.11",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.3",
"swr": "^2.3.6",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.4",
"zod": "^4.1.5",
"zustand": "^5.0.8"
"sharp": "^0.34.5",
"swr": "^2.3.7",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.34.0",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"sass": "^1.92.0",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22"

5546
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

@@ -15,6 +15,18 @@ function log(message: string) {
export async function run(name: string, ...steps: Step[]) {
const { execSync } = await import('child_process');
const runOne = process.argv[2];
if (runOne) {
const match = steps.find((s) => `${name}/${s.name}` === runOne);
if (!match) {
console.error(`x No step found with name "${runOne}"`);
process.exit(1);
}
steps = [match];
}
const start = process.hrtime();
for (const step of steps) {
if (!step.condition()) {
log(`- Skipping step "${name}/${step.name}"...`);
@@ -29,4 +41,9 @@ export async function run(name: string, ...steps: Step[]) {
process.exit(1);
}
}
const diff = process.hrtime(start);
const time = diff[0] * 1e9 + diff[1];
const timeStr = time > 1e9 ? `${(time / 1e9).toFixed(2)}s` : `${(time / 1e6).toFixed(2)}ms`;
log(`✓ Steps in "${name}" completed in ${timeStr}.`);
}

View File

@@ -1,10 +1,31 @@
import { ModalsProvider } from '@mantine/modals';
import { ContextModalProps, ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import { Outlet } from 'react-router-dom';
import { SWRConfig } from 'swr';
import ThemeProvider from '@/components/ThemeProvider';
import { type ZiplineTheme } from '@/lib/theme';
import { type Config } from '@/lib/config/validate';
import { Button, Text } from '@mantine/core';
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
<>
<Text size='sm'>{innerProps.modalBody}</Text>
<Button fullWidth mt='md' onClick={() => context.closeModal(id)}>
OK
</Button>
</>
);
const contextModals = {
alert: AlertModal,
};
declare module '@mantine/modals' {
export interface MantineModalsOverride {
modals: typeof contextModals;
}
}
export default function Root({
themes,
@@ -37,6 +58,7 @@ export default function Root({
},
centered: true,
}}
modals={contextModals}
>
<Notifications zIndex={10000000} />
<Outlet />

View File

@@ -32,7 +32,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
@@ -163,7 +163,7 @@ export default function Login() {
);
if (provider) {
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
window.location.href = `/api/auth/oauth/${provider.toLowerCase()}`;
}
}
}, [willRedirect, config]);

View File

@@ -1,5 +1,6 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
@@ -15,13 +16,12 @@ import {
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Register');
@@ -30,7 +30,6 @@ export function Component() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [invite, setInvite] = useState<any>(null);
const {
data: config,
@@ -44,6 +43,19 @@ export function Component() {
});
const code = new URLSearchParams(location.search).get('code') ?? undefined;
const {
data: invite,
error: inviteError,
isLoading: inviteLoading,
} = useSWR<Response['/api/auth/invites/web']>(
location.search.includes('code') ? `/api/auth/invites/web${location.search}` : null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
},
);
const form = useForm({
initialValues: {
@@ -61,7 +73,7 @@ export function Component() {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
redirect('/dashboard');
navigate('/dashboard');
} else {
setLoading(false);
}
@@ -69,21 +81,9 @@ export function Component() {
}, []);
useEffect(() => {
(async () => {
if (!code) return;
if (!config) return;
const res = await fetch(`/api/auth/invite/web?code=${code}`);
if (res.ok) {
const json = await res.json();
setInvite(json.invite);
} else {
redirect('/auth/login');
}
})();
}, [code]);
useEffect(() => {
if (!config?.features.userRegistration) {
if (!config?.features.userRegistration && !code) {
navigate('/auth/login');
}
}, [code, config]);
@@ -122,7 +122,7 @@ export function Component() {
});
mutate('/api/user');
redirect('/dashboard');
navigate('/dashboard');
}
};
@@ -138,6 +138,22 @@ export function Component() {
);
}
if (code && inviteError) {
if (inviteError) {
showNotification({
id: 'invalid-invite',
message: 'Invalid or expired invite. Please try again later.',
color: 'red',
});
navigate('/auth/login');
return null;
}
if (inviteLoading) return <LoadingOverlay visible />;
}
return (
<Center h='100vh'>
{config.website.loginBackground && (
@@ -183,8 +199,13 @@ export function Component() {
{invite && (
<Text ta='center' size='sm' c='dimmed'>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b> by{' '}
<b>{invite.inviter?.username}</b>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b>
{invite.inviter && (
<>
{' '}
by <b>{invite.inviter.username}</b>
</>
)}
</Text>
)}

View File

@@ -0,0 +1,10 @@
import DashboardServerActions from '@/components/pages/serverActions';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Actions');
return <DashboardServerActions />;
}
Component.displayName = 'Dashboard/Admin/Actions';

View File

@@ -82,6 +82,7 @@ export const router = createBrowserRouter([
children: [
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
{
path: 'users',
children: [

View File

@@ -25,7 +25,7 @@ import { createRoutes } from './routes';
export const getFile = async (id: string) =>
prisma.file.findFirst({
where: { name: id as string },
where: { name: decodeURIComponent(id) },
select: {
...fileSelect,
password: true,
@@ -265,7 +265,7 @@ export async function render(
: ''
}
<title>${file.name}</title>
<title>${file.originalName ?? file.name}</title>
`;
return {

View File

@@ -41,6 +41,7 @@ import {
IconRefreshDot,
IconSettingsFilled,
IconShieldLockFilled,
IconStopwatch,
IconTags,
IconUpload,
IconUsersGroup,
@@ -126,6 +127,12 @@ const navLinks: NavLinks[] = [
if: (user) => user?.role === 'SUPERADMIN',
href: '/dashboard/admin/settings',
},
{
label: 'Actions',
icon: <IconStopwatch size='1rem' />,
active: (path: string) => path === '/dashboard/admin/actions',
href: '/dashboard/admin/actions',
},
{
label: 'Users',
icon: <IconUsersGroup size='1rem' />,

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,27 @@ export default function DashboardFiles() {
<TagsButton />
<PendingFilesButton />
{view === 'table' && (
<>
<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 +63,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

@@ -0,0 +1,19 @@
import { ActionIcon } from '@mantine/core';
import { IconPlayerPlayFilled } from '@tabler/icons-react';
const ICON_SIZE = '1.75rem';
export default function ActionButton({ onClick, Icon }: { onClick: () => void; Icon?: React.FC<any> }) {
return (
<ActionIcon
onClick={onClick}
variant='filled'
color='blue'
radius='md'
size='xl'
className='zip-click-action-button'
>
{Icon ? <Icon size={ICON_SIZE} /> : <IconPlayerPlayFilled size={ICON_SIZE} />}
</ActionIcon>
);
}

View File

@@ -1,9 +1,9 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import ActionButton from '../ActionButton';
export default function ClearTempButton() {
const openModal = () =>
@@ -30,11 +30,5 @@ export default function ClearTempButton() {
},
});
return (
<>
<Button size='sm' leftSection={<IconTrashFilled size='1rem' />} onClick={openModal}>
Clear Temp Files
</Button>
</>
);
return <ActionButton onClick={openModal} Icon={IconTrashFilled} />;
}

View File

@@ -1,10 +1,10 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button } from '@mantine/core';
import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconTrashFilled } from '@tabler/icons-react';
import useSWR from 'swr';
import ActionButton from '../ActionButton';
export default function ClearZerosButton() {
const { data } = useSWR<Response['/api/server/clear_zeros']>('/api/server/clear_zeros');
@@ -32,11 +32,5 @@ export default function ClearZerosButton() {
},
});
return (
<>
<Button size='sm' leftSection={<IconTrashFilled size='1rem' />} onClick={openModal}>
Clear Zero Byte Files
</Button>
</>
);
return <ActionButton onClick={openModal} Icon={IconTrashFilled} />;
}

View File

@@ -2,8 +2,9 @@ import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { Button, Group, Modal, Stack, Switch } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconVideo, IconVideoOff } from '@tabler/icons-react';
import { IconVideoOff, IconVideoPlusFilled } from '@tabler/icons-react';
import { useState } from 'react';
import ActionButton from '../ActionButton';
export default function GenThumbsButton() {
const [rerun, setRerun] = useState(false);
@@ -53,9 +54,8 @@ export default function GenThumbsButton() {
</Button>
</Group>
</Modal>
<Button size='sm' leftSection={<IconVideo size='1rem' />} onClick={() => setOpen(true)}>
Generate Thumbnails
</Button>
<ActionButton onClick={() => setOpen(true)} Icon={IconVideoPlusFilled} />
</>
);
}

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);
@@ -93,8 +93,6 @@ export default function ImportButton() {
color: 'green',
icon: <IconDeviceFloppy size='1rem' />,
});
await fetch('/reload');
}
};
@@ -262,7 +260,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 +313,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,463 @@
import HighlightCode from '@/components/render/code/HighlightCode';
import { bytes } from '@/lib/bytes';
import { Export4 } from '@/lib/import/version4/validateExport';
import {
Accordion,
Anchor,
Avatar,
Button,
Center,
Collapse,
Paper,
ScrollArea,
Stack,
Table,
Text,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconCheck,
IconFiles,
IconFolder,
IconGraphFilled,
IconLink,
IconTag,
IconTagPlus,
IconTarget,
IconUsers,
IconVersions,
IconX,
} from '@tabler/icons-react';
function findOauthProviders(export4: Export4, userId: string) {
return export4.data.userOauthProviders.filter((provider) => provider.userId === userId);
}
function findUser(export4: Export4, userId: string) {
return export4.data.users.find((user) => user.id === userId);
}
function TextDetail({ name, children }: { name: string; children: React.ReactNode }) {
return (
<span>
<b>{name}:</b> {children}
</span>
);
}
export default function Export3Details({ export4 }: { export4: Export4 }) {
const [envOpened, { toggle: toggleEnv }] = useDisclosure(false);
const [osOpened, { toggle: toggleOs }] = useDisclosure(false);
const [reqId, reqUsername] = export4.request.user.split(':').map((s) => s.trim());
const envRows = Object.entries(export4.request.env).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td ff='monospace'>{value}</Table.Td>
</Table.Tr>
));
const osRows = Object.entries(export4.request.os).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td ff='monospace'>{String(value)}</Table.Td>
</Table.Tr>
));
const userRows = export4.data.users.map((user, i) => (
<Table.Tr key={i}>
<Table.Td>{user.avatar ? <Avatar src={user.avatar} size={24} radius='sm' /> : ''}</Table.Td>
<Table.Td>{user.id}</Table.Td>
<Table.Td>{user.username}</Table.Td>
<Table.Td>{user.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
<Table.Td>{{ USER: 'User', ADMIN: 'Admin', SUPERADMIN: 'Super Admin' }[user.role]}</Table.Td>
<Table.Td>
{findOauthProviders(export4, user.id)
.map((x) => x.provider.toLowerCase())
.join(', ')}
</Table.Td>
<Table.Td>
{export4.data.userQuotas.find((x) => x.userId === user.id) ? (
<IconCheck size='1rem' />
) : (
<IconX size='1rem' />
)}
</Table.Td>
<Table.Td>{export4.data.userPasskeys.filter((x) => x.userId === user.id).length}</Table.Td>
</Table.Tr>
));
const userOauthProvidersRows = export4.data.userOauthProviders.map((provider, i) => (
<Table.Tr key={i}>
<Table.Td>{findUser(export4, provider.userId)?.username ?? <i>unknown</i>}</Table.Td>
<Table.Td>{provider.provider.toLowerCase()}</Table.Td>
<Table.Td>{provider.username}</Table.Td>
<Table.Td>{provider.oauthId}</Table.Td>
</Table.Tr>
));
const fileRows = export4.data.files.map((file, i) => (
<Table.Tr key={i}>
<Table.Td>{file.name}</Table.Td>
<Table.Td>{new Date(file.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{file.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
<Table.Td>{bytes(file.size)}</Table.Td>
<Table.Td>
{file.userId ? (findUser(export4, file.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
</Table.Tr>
));
const folderRows = export4.data.folders.map((folder, i) => (
<Table.Tr key={i}>
<Table.Td>{folder.name}</Table.Td>
<Table.Td>
{folder.userId ? (findUser(export4, folder.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
<Table.Td>{folder.public ? 'Yes' : 'No'}</Table.Td>
<Table.Td>{new Date(folder.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{folder.files.length}</Table.Td>
</Table.Tr>
));
const urlRows = export4.data.urls.map((url, i) => (
<Table.Tr key={i}>
<Table.Td>{url.code}</Table.Td>
<Table.Td>
{url.userId ? (findUser(export4, url.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
<Table.Td>
<Anchor href={url.destination}>{url.destination}</Anchor>
</Table.Td>
<Table.Td>{url.vanity ?? ''}</Table.Td>
<Table.Td>{url.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
<Table.Td>{new Date(url.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{url.enabled ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
</Table.Tr>
));
const invitesRows = export4.data.invites.map((invite, i) => (
<Table.Tr key={i}>
<Table.Td>{invite.code}</Table.Td>
<Table.Td>
{invite.inviterId ? (
(findUser(export4, invite.inviterId)?.username ?? <i>unknown</i>)
) : (
<i>unknown</i>
)}
</Table.Td>
<Table.Td>{new Date(invite.createdAt).toLocaleString()}</Table.Td>
<Table.Td>{invite.uses}</Table.Td>
</Table.Tr>
));
const tagsRows = export4.data.userTags.map((tag, i) => (
<Table.Tr key={i}>
<Table.Td>
{tag.userId ? (findUser(export4, tag.userId)?.username ?? <i>unknown</i>) : <i>unknown</i>}
</Table.Td>
<Table.Td c={tag.color ?? undefined}>{tag.name}</Table.Td>
<Table.Td>{tag.files.length}</Table.Td>
</Table.Tr>
));
return (
<>
<Text c='dimmed' size='sm' my='xs'>
This data is not sent to the server. It is parsed and displayed in the browser. Data is only sent to
the server when you click the &quot;Import&quot; button.
</Text>
<Accordion defaultValue='version' variant='contained'>
<Accordion.Item value='version'>
<Accordion.Control icon={<IconVersions size='1rem' />}>Version Details</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
<TextDetail name='Export Version'>{export4.versions.export}</TextDetail>
<TextDetail name='Node'>{export4.versions.node}</TextDetail>
<TextDetail name='Zipline'>v{export4.versions.zipline}</TextDetail>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='request'>
<Accordion.Control icon={<IconTarget size='1rem' />}>Request Details</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
<TextDetail name='User'>
{reqUsername} ({reqId})
</TextDetail>
<TextDetail name='At'>{new Date(export4.request.date).toLocaleString()}</TextDetail>
<Button my='xs' onClick={toggleOs} size='compact-sm'>
{envOpened ? 'Hide' : 'Show'} OS Details
</Button>
<Collapse in={osOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{osRows}</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleOs} size='compact-sm'>
Hide OS Details
</Button>
</Collapse>
<Button my='xs' onClick={toggleEnv} size='compact-sm'>
{envOpened ? 'Hide' : 'Show'} Environment
</Button>
<Collapse in={envOpened}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{envRows}</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleEnv} size='compact-sm'>
Hide Environment
</Button>
</Collapse>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='users'>
<Accordion.Control icon={<IconUsers size='1rem' />}>Users</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{Object.keys(export4.data.users).length ? (
<ScrollArea w='100%'>
<Table w='120%'>
<Table.Thead>
<Table.Tr>
<Table.Th></Table.Th>
<Table.Th>ID</Table.Th>
<Table.Th>Username</Table.Th>
<Table.Th>Password</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>OAuth Providers</Table.Th>
<Table.Th>Quota</Table.Th>
<Table.Th>Passkeys</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{userRows}</Table.Tbody>
</Table>
</ScrollArea>
) : (
<Center m='sm'>
<b>No users found (how?)</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='user_oauth_providers'>
<Accordion.Control icon={<IconUsers size='1rem' />}>User OAuth Providers</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{Object.keys(export4.data.userOauthProviders).length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Provider</Table.Th>
<Table.Th>OAuth Username</Table.Th>
<Table.Th>OAuth ID</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{userOauthProvidersRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No user oauth providers found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='files'>
<Accordion.Control icon={<IconFiles size='1rem' />}>Files</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.files.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Password</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Owner</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{fileRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No files found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tags'>
<Accordion.Control icon={<IconTag size='1rem' />}>User Tags</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.userTags.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{tagsRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No user tags found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='folders'>
<Accordion.Control icon={<IconFolder size='1rem' />}>Folders</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.folders.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Public</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{folderRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No folders found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='urls'>
<Accordion.Control icon={<IconLink size='1rem' />}>Urls</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.urls.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Destination</Table.Th>
<Table.Th>Vanity</Table.Th>
<Table.Th>Password</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Enabled</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{urlRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No urls found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='invites'>
<Accordion.Control icon={<IconTagPlus size='1rem' />}>Invites</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
{export4.data.invites.length ? (
<Table.ScrollContainer minWidth={100}>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Created By</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Uses</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{invitesRows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<Center m='sm'>
<b>No invites found</b>
</Center>
)}
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='metrics'>
<Accordion.Control icon={<IconGraphFilled size='1rem' />}>Metrics</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
<TextDetail name='Total Metrics Entries'>{export4.data.metrics.length}</TextDetail>
<Text fw={700} c='dimmed' mb={-10}>
Latest Metrics Entry:
</Text>
<HighlightCode
language='json'
code={JSON.stringify(
export4.data.metrics.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)[export4.data.metrics.length - 1],
null,
2,
)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
);
}

View File

@@ -0,0 +1,77 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { Box, Button, Checkbox, Collapse, Group, Paper, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
export default function Export4ImportSettings({
export4,
setImportSettings,
importSettings,
}: {
export4: Export4;
setImportSettings: (importSettings: boolean) => void;
importSettings: boolean;
}) {
const [showSettings, { toggle: toggleSettings }] = useDisclosure(false);
const filteredSettings = Object.fromEntries(
Object.entries(export4.data.settings).filter(
([key, _value]) => !['createdAt', 'updatedAt', 'id'].includes(key),
),
);
return (
<Box my='lg'>
<Text size='md'>Import settings?</Text>
<Text size='sm' c='dimmed'>
Import all settings from your previous instance into this v4 instance.
<br />
After importing, it is recommended to restart Zipline for all settings to take full effect.
</Text>
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
<Collapse in={showSettings}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.entries(filteredSettings).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td>
<Text c='dimmed' fz='xs' ff='monospace'>
{JSON.stringify(value)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
</Collapse>
<Checkbox.Card
checked={importSettings}
onClick={() => setImportSettings(!importSettings)}
radius='md'
my='sm'
>
<Group wrap='nowrap' align='flex-start'>
<Checkbox.Indicator m='md' />
<Text my='sm'>Import {Object.keys(filteredSettings).length} settings</Text>
</Group>
</Checkbox.Card>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { Avatar, Box, Group, Radio, Stack, Text } from '@mantine/core';
export default function Export4UserChoose({
export4,
setImportFrom,
importFrom,
}: {
export4: Export4;
setImportFrom: (importFrom: string) => void;
importFrom: string;
}) {
return (
<Box my='lg'>
<Text size='md'>Select a user to import data from into the current user.</Text>
<Text size='sm' c='dimmed'>
This option allows you to import data from a user in your export into the currently logged-in user,
even if both have the same username. Normally, the system skips importing users with usernames that
already exist in the system. <br /> <br /> <b>However</b>, if you&apos;ve just set up your instance
and reused the same username as your old instance, this option enables you to merge data from that
user into your logged-in account without needing to delete or replace it.{' '}
<b>It is recommended to select a user with super-administrator permissions for this operation.</b>
</Text>
<Radio.Group value={importFrom} onChange={(value) => setImportFrom(value)} name='importFrom'>
{export4.data.users.map((user, i) => (
<Radio.Card key={i} value={user.id} my='sm'>
<Group wrap='nowrap' align='flex-start'>
<Radio.Indicator m='md' />
{user.avatar && <Avatar my='md' src={user.avatar} alt={user.username} radius='sm' />}
<Stack gap={0}>
<Text my='sm'>
{user.username} ({user.id})
</Text>{' '}
{user.role === 'SUPERADMIN' && (
<Text c='red' size='xs' mb='xs'>
Super Administrator
</Text>
)}
</Stack>
</Group>
</Radio.Card>
))}
<Radio.Card value='' my='sm'>
<Group wrap='nowrap' align='flex-start'>
<Radio.Indicator m='md' />
<Stack gap={0}>
<Text my='sm'>Do not merge data</Text>{' '}
<Text c='dimmed' size='xs' mb='xs'>
Select this option if you do not want to merge data from any user into the current user.
</Text>
</Stack>
</Group>
</Radio.Card>
</Radio.Group>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { useUserStore } from '@/lib/store/user';
import { Box, Checkbox, Group, Text } from '@mantine/core';
export function detectSameInstance(export4?: Export4 | null, currentUserId?: string) {
if (!export4) return false;
if (!currentUserId) return false;
const idInExport = export4.data.users.find((user) => user.id === currentUserId);
return !!idInExport;
}
export default function Export4WarningSameInstance({
export4,
sameInstanceAgree,
setSameInstanceAgree,
}: {
export4: Export4;
sameInstanceAgree: boolean;
setSameInstanceAgree: (sameInstanceAgree: boolean) => void;
}) {
const currentUserId = useUserStore((state) => state.user?.id);
const isSameInstance = detectSameInstance(export4, currentUserId);
if (!isSameInstance) return null;
return (
<Box my='lg'>
<Text size='md' c='red'>
Same Instance Detected
</Text>
<Text size='sm' c='dimmed'>
Detected that you are importing data from the same instance as the current running one. Proceeding
with this import may lead to data conflicts or overwriting existing data. Please ensure that you
understand the implications before continuing.
</Text>
<Checkbox.Card
checked={sameInstanceAgree}
onClick={() => setSameInstanceAgree(!sameInstanceAgree)}
radius='md'
my='sm'
>
<Group wrap='nowrap' align='flex-start'>
<Checkbox.Indicator m='md' />
<Text my='sm'>I agree, and understand the implications.</Text>
</Group>
</Checkbox.Card>
</Box>
);
}

View File

@@ -0,0 +1,277 @@
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { Button, FileButton, Modal, Pill, Text } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import Export4Details from './Export4Details';
import Export4ImportSettings from './Export4ImportSettings';
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
import Export4UserChoose from './Export4UserChoose';
import { useUserStore } from '@/lib/store/user';
import { modals } from '@mantine/modals';
import { fetchApi } from '@/lib/fetchApi';
import { Response } from '@/lib/api/response';
import { mutate } from 'swr';
export default function ImportV4Button() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [export4, setExport4] = useState<Export4 | null>(null);
const [importSettings, setImportSettings] = useState(true);
const [sameInstanceAgree, setSameInstanceAgree] = useState(false);
const [importFrom, setImportFrom] = useState('');
const currentUserId = useUserStore((state) => state.user?.id);
const isSameInstance = detectSameInstance(export4, currentUserId);
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);
};
const handleImportSettings = async () => {
if (!export4) return;
const { error } = await fetchApi<Response['/api/server/settings']>(
'/api/server/settings',
'PATCH',
export4.data.settings,
);
if (error) {
showNotification({
title: 'Failed to import settings',
message: error.issues
? error.issues.map((x: { message: string }) => x.message).join('\n')
: error.error,
color: 'red',
});
} else {
showNotification({
title: 'Settings imported',
message: 'To ensure that all settings take effect, it is recommended to restart Zipline.',
color: 'green',
});
mutate('/api/server/settings');
mutate('/api/server/settings/web');
mutate('/api/server/public');
}
};
const handleImport = async () => {
if (!export4) return;
if (isSameInstance && !sameInstanceAgree) {
modals.openContextModal({
modal: 'alert',
title: 'Same Instance Detected',
innerProps: {
modalBody:
'Detected that you are importing data from the same instance as the current running one. You must agree to the warning before proceeding with the import.',
},
});
return;
}
modals.openConfirmModal({
title: 'Are you sure?',
children:
'This process will NOT overwrite existing data but will append to it. In case of conflicts, the imported data will be skipped and logged.',
labels: {
confirm: 'Yes, import data.',
cancel: 'Cancel',
},
onConfirm: async () => {
showNotification({
title: 'Importing data...',
message:
'The export file will be uploaded. This amy take a few moments. The import is running in the background and is logged, so you can close this browser tab if you want.',
color: 'blue',
autoClose: 5000,
id: 'importing-data',
loading: true,
});
setOpen(false);
await handleImportSettings();
const { error, data } = await fetchApi<Response['/api/server/import/v4']>(
'/api/server/import/v4',
'POST',
{
export4,
config: {
settings: importSettings,
mergeCurrentUser: importFrom === '' ? undefined : importFrom,
},
},
);
if (error) {
updateNotification({
title: 'Failed to import data...',
message:
error.error ?? 'An error occurred while importing data. Check the logs for more details.',
color: 'red',
icon: <IconDatabaseOff size='1rem' />,
id: 'importing-data',
autoClose: 10000,
});
} else {
if (!data) return;
modals.open({
title: 'Import Completed.',
children: (
<Text size='md'>
The import has been completed. To make sure files are properly viewable, make sure that you
have configured the datasource correctly to match your previous instance. For example, if you
were using local storage before, make sure to set it to the same directory (or same backed up
directory) as before. If you are using S3, make sure you are using the same bucket. <br />{' '}
<br />
Additionally, it is recommended to restart Zipline to ensure all settings take full effect.
<br /> <br />
<b>Users: </b>
{data.imported.users} imported.
<br />
<b>OAuth Providers: </b>
{data.imported.oauthProviders} imported.
<br />
<b>Quotas: </b>
{data.imported.quotas} imported.
<br />
<b>Passkeys: </b>
{data.imported.passkeys} imported.
<br />
<b>Folders: </b>
{data.imported.folders} imported.
<br />
<b>Files: </b>
{data.imported.files} imported.
<br />
<b>Tags: </b>
{data.imported.tags} imported.
<br />
<b>URLs: </b>
{data.imported.urls} imported.
<br />
<b>Invites: </b>
{data.imported.invites} imported.
<br />
<b>Metrics: </b>
{data.imported.metrics} imported.
</Text>
),
});
}
},
});
setFile(null);
setExport4(null);
};
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 && (
<>
<Export4Details export4={export4} />
<Export4ImportSettings
export4={export4}
importSettings={importSettings}
setImportSettings={setImportSettings}
/>
<Export4UserChoose export4={export4} importFrom={importFrom} setImportFrom={setImportFrom} />
<Export4WarningSameInstance
export4={export4}
sameInstanceAgree={sameInstanceAgree}
setSameInstanceAgree={setSameInstanceAgree}
/>
</>
)}
{export4 && (
<Button onClick={handleImport} 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,28 @@
import { Divider, Group, Modal } from '@mantine/core';
import { useState } from 'react';
import ImportV3Button from './ImportV3Button';
import ImportV4Button from './ImportV4Button';
import ExportButton from './ExportButton';
import ActionButton from '../../ActionButton';
import { IconDatabasePlus } from '@tabler/icons-react';
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>
<ActionButton onClick={() => setOpen(true)} Icon={IconDatabasePlus} />
</>
);
}

View File

@@ -4,6 +4,7 @@ import { Button, Group, Modal, Stack, Switch } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconFileSearch } from '@tabler/icons-react';
import { useState } from 'react';
import ActionButton from '../ActionButton';
export default function RequerySizeButton() {
const [forceUpdate, setForceUpdate] = useState(false);
@@ -65,9 +66,8 @@ export default function RequerySizeButton() {
</Button>
</Group>
</Modal>
<Button size='sm' leftSection={<IconFileSearch size='1rem' />} onClick={() => setOpen(true)}>
Requery Size of Files
</Button>
<ActionButton onClick={() => setOpen(true)} />
</>
);
}

View File

@@ -0,0 +1,61 @@
import { Group, Paper, Stack, Text, Title } from '@mantine/core';
import ClearTempButton from './actions/ClearTempButton';
import ClearZerosButton from './actions/ClearZerosButton';
import GenThumbsButton from './actions/GenThumbsButton';
import ImportExport from './actions/ImportExportButton';
import RequerySizeButton from './actions/RequerySizeButton';
const ACTIONS = [
{
name: 'Import/Export Data',
desc: 'Allows you to import or export server data and configurations.',
Component: ImportExport,
},
{
name: 'Clear Temporary Files',
desc: 'Removes all temporary files from the temporary directory.',
Component: ClearTempButton,
},
{
name: 'Clear Zero Byte Files',
desc: 'Deletes all files with zero bytes from the database and/or storage.',
Component: ClearZerosButton,
},
{
name: 'Requery File Sizes',
desc: 'Recalculates and updates the sizes of all files in the database.',
Component: RequerySizeButton,
},
{
name: 'Generate Thumbnails',
desc: 'Creates thumbnails for all image and video files that lack them.',
Component: GenThumbsButton,
},
];
export default function DashboardServerActions() {
return (
<>
<Group gap='sm'>
<Title order={1}>Server Actions</Title>
</Group>
<Text c='dimmed' mb='xs'>
Useful tools and scripts for server management.
</Text>
<Stack gap='xs' my='sm'>
{ACTIONS.map(({ name, desc, Component }) => (
<Paper withBorder p='sm' key={name}>
<Group gap='md'>
<Component />
<div>
<Title order={4}>{name}</Title>
<Text c='dimmed'>{desc}</Text>
</div>
</Group>
</Paper>
))}
</Stack>
</>
);
}

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

@@ -39,7 +39,6 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
icon: <IconDeviceFloppy size='1rem' />,
});
await fetch('/reload');
mutate('/api/server/settings', data);
mutate('/api/server/settings/web');
mutate('/api/server/public');

View File

@@ -1,7 +1,5 @@
import { useConfig } from '@/components/ConfigProvider';
import { eitherTrue } from '@/lib/primitive';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import { Group, SimpleGrid, Stack, Title } from '@mantine/core';
import { lazy } from 'react';
@@ -10,7 +8,6 @@ const SettingsDashboard = lazy(() => import('./parts/SettingsDashboard'));
const SettingsFileView = lazy(() => import('./parts/SettingsFileView'));
const SettingsGenerators = lazy(() => import('./parts/SettingsGenerators'));
const SettingsMfa = lazy(() => import('./parts/SettingsMfa'));
const SettingsServerActions = lazy(() => import('./parts/SettingsServerUtil'));
const SettingsUser = lazy(() => import('./parts/SettingsUser'));
const SettingsExports = lazy(() => import('./parts/SettingsExports'));
const SettingsSessions = lazy(() => import('./parts/SettingsSessions'));
@@ -18,9 +15,6 @@ const SettingsOAuth = lazy(() => import('./parts/SettingsOAuth'));
export default function DashboardSettings() {
const config = useConfig();
const user = useUserStore((state) => state.user);
console.log(config.oauthEnabled);
return (
<>
@@ -51,8 +45,6 @@ export default function DashboardSettings() {
<SettingsExports />
<SettingsGenerators />
{isAdministrator(user?.role) && <SettingsServerActions />}
</SimpleGrid>
</>
);

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

@@ -1,25 +0,0 @@
import { Group, Paper, Text, Title } from '@mantine/core';
import ClearTempButton from './ClearTempButton';
import ClearZerosButton from './ClearZerosButton';
import GenThumbsButton from './GenThumbsButton';
import RequerySizeButton from './RequerySizeButton';
import ImportButton from './ImportButton';
export default function SettingsServerActions() {
return (
<Paper withBorder p='sm'>
<Title order={2}>Server Actions</Title>
<Text size='sm' c='dimmed' mt={3}>
Helpful scripts and tools for server management.
</Text>
<Group mt='xs'>
<ClearZerosButton />
<ClearTempButton />
<RequerySizeButton />
<GenThumbsButton />
<ImportButton />
</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

@@ -86,4 +86,6 @@ export async function exportConfig({ yml, showDefaults }: { yml?: boolean; showD
console.log(`${yml ? '- ' : ''}${envVar.variable}=${envValue}`);
}
process.exit(0);
}

View File

@@ -1,9 +1,9 @@
import { bytes } from '@/lib/bytes';
import { config, reloadSettings } from '@/lib/config';
import { guess } from '@/lib/mimes';
import { statSync } from 'fs';
import { readFile, readdir } from 'fs/promises';
import { mkdir, readdir } from 'fs/promises';
import { join, parse, resolve } from 'path';
import { reloadSettings } from '@/lib/config';
import { bytes } from '@/lib/bytes';
export async function importDir(
directory: string,
@@ -57,16 +57,18 @@ export async function importDir(
for (let i = 0; i !== files.length; ++i) {
const info = parse(files[i]);
if (info.base.startsWith('.thumbnail')) continue;
const mime = await guess(info.ext.replace('.', ''));
const { size } = statSync(join(fullPath, files[i]));
data[i] = {
data.push({
name: info.base,
type: mime,
size,
userId,
...(folder ? { folderId: folder } : {}),
};
});
}
if (!skipDb) {
@@ -78,16 +80,25 @@ export async function importDir(
const totalSize = data.reduce((acc, file) => acc + file.size, 0);
let completed = 0;
let imported = 0;
if (config.datasource.type === 'local')
await mkdir(config.datasource.local!.directory, { recursive: true });
const { getDatasource } = await import('@/lib/datasource/index.js');
const datasource = getDatasource(config);
if (!datasource) return console.error('No datasource configured');
for (let i = 0; i !== data.length; ++i) {
if (!data[i]) continue;
const { datasource } = await import('@/lib/datasource/index.js');
for (let i = 0; i !== files.length; ++i) {
console.log(`Uploading ${data[i].name} (${bytes(data[i].size)})...`);
const start = process.hrtime();
const buff = await readFile(join(fullPath, files[i]));
await datasource.put(data[i].name, buff, {
await datasource.put(data[i].name, join(fullPath, files[i]), {
mimetype: data[i].type ?? 'application/octet-stream',
noDelete: true,
});
const diff = process.hrtime(start);
@@ -104,7 +115,11 @@ export async function importDir(
console.log(
`Uploaded ${data[i].name} in ${timeStr} (${bytes(data[i].size)}) ${i + 1}/${files.length} ${bytes(completed)}/${bytes(totalSize)} ${uploadSpeedStr}`,
);
++imported;
}
console.log('Done importing files.');
console.log(`Done importing ${imported} files.`);
process.exit(0);
}

View File

@@ -27,4 +27,5 @@ export async function listUsers({ extra, format, id }: { extra?: string[]; forma
});
console.log(JSON.stringify(users, null, format ? 2 : 0));
process.exit(0);
}

View File

@@ -6,4 +6,5 @@ export async function readConfig({ format }: { format: boolean }) {
const { config } = await import('@/lib/config/index.js');
console.log(JSON.stringify(config, null, format ? 2 : 0));
process.exit(0);
}

View File

@@ -34,4 +34,5 @@ export async function setUser(property: string, value: string, { id }: { id: str
if (property === 'password') parsed = '*********';
console.log(`updated user(${id}) -> ${property} = ${parsed || value}`);
process.exit(0);
}

View File

@@ -1,5 +1,6 @@
import { ApiAuthInvitesResponse } from '@/server/routes/api/auth/invites';
import { ApiAuthInvitesIdResponse } from '@/server/routes/api/auth/invites/[id]';
import { ApiAuthInvitesWebResponse } from '@/server/routes/api/auth/invites/web';
import { ApiLoginResponse } from '@/server/routes/api/auth/login';
import { ApiLogoutResponse } from '@/server/routes/api/auth/logout';
import { ApiAuthOauthResponse } from '@/server/routes/api/auth/oauth';
@@ -10,6 +11,7 @@ import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_tem
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
import { ApiServerFolderResponse } from '@/server/routes/api/server/folder';
import { ApiServerImportV3 } from '@/server/routes/api/server/import/v3';
import { ApiServerImportV4 } from '@/server/routes/api/server/import/v4';
import { ApiServerPublicResponse } from '@/server/routes/api/server/public';
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
import { ApiServerSettingsResponse, ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
@@ -45,6 +47,7 @@ import { ApiVersionResponse } from '@/server/routes/api/version';
export type Response = {
'/api/auth/invites/[id]': ApiAuthInvitesIdResponse;
'/api/auth/invites': ApiAuthInvitesResponse;
'/api/auth/invites/web': ApiAuthInvitesWebResponse;
'/api/auth/register': ApiAuthRegisterResponse;
'/api/auth/webauthn': ApiAuthWebauthnResponse;
'/api/auth/oauth': ApiAuthOauthResponse;
@@ -81,6 +84,7 @@ export type Response = {
'/api/server/themes': ApiServerThemesResponse;
'/api/server/thumbnails': ApiServerThumbnailsResponse;
'/api/server/import/v3': ApiServerImportV3;
'/api/server/import/v4': ApiServerImportV4;
'/api/healthcheck': ApiHealthcheckResponse;
'/api/setup': ApiSetupResponse;
'/api/upload': ApiUploadResponse;

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;
@@ -6,6 +7,7 @@ export type CompressType = (typeof COMPRESS_TYPES)[number];
export type CompressResult = {
mimetype: string;
ext: CompressType;
buffer: Buffer;
};
export type CompressOptions = {
@@ -22,11 +24,14 @@ 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: '',
ext: 'jpg',
buffer: Buffer.alloc(0),
};
let buffer: Buffer;
@@ -56,7 +61,8 @@ export async function compressFile(filePath: string, options: CompressOptions):
break;
}
await sharp(buffer).toFile(filePath);
return result;
return {
...result,
buffer,
};
}

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

@@ -1,10 +1,12 @@
import { Readable } from 'stream';
export type PutOptions = { mimetype?: string; noDelete?: boolean };
export abstract class Datasource {
public name: string | undefined;
public abstract get(file: string): null | Readable | Promise<Readable | null>;
public abstract put(file: string, data: Buffer | string, options?: { mimetype?: string }): Promise<void>;
public abstract put(file: string, data: Buffer | string, options?: PutOptions): Promise<void>;
public abstract delete(file: string | string[]): Promise<void>;
public abstract size(file: string): Promise<number>;
public abstract totalSize(): Promise<number>;

View File

@@ -1,8 +1,8 @@
import { createReadStream, existsSync } from 'fs';
import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { join, resolve, sep } from 'path';
import { Readable } from 'stream';
import { Datasource } from './Datasource';
import { Datasource, PutOptions } from './Datasource';
async function existsAndCanRW(path: string): Promise<boolean> {
try {
@@ -20,17 +20,29 @@ export class LocalDatasource extends Datasource {
super();
}
private resolvePath(file: string): string | void {
const resolved = resolve(this.dir, file);
const uploadsDir = resolve(this.dir);
if (!resolved.startsWith(uploadsDir + sep)) return;
return resolved;
}
public get(file: string): Readable | null {
const path = join(this.dir, file);
const path = this.resolvePath(file);
if (!path) return null;
if (!existsSync(path)) return null;
const readStream = createReadStream(path);
return readStream;
}
public async put(file: string, data: Buffer | string): Promise<void> {
const path = join(this.dir, file);
public async put(file: string, data: Buffer | string, { noDelete }: PutOptions): Promise<void> {
const path = this.resolvePath(file);
if (!path) {
throw new Error('Invalid path provided');
}
// handles if given a path to a file, it will just move it instead of doing unecessary writes
if (typeof data === 'string' && data.startsWith('/')) {
@@ -41,7 +53,8 @@ export class LocalDatasource extends Datasource {
);
await copyFile(data, path);
await rm(data);
if (!noDelete) await rm(data);
return;
}

View File

@@ -1,11 +1,15 @@
import {
CompleteMultipartUploadCommand,
CopyObjectCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsCommand,
PutObjectCommand,
S3Client,
UploadPartCopyCommand,
} from '@aws-sdk/client-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { createReadStream } from 'fs';
@@ -15,7 +19,7 @@ import { Readable } from 'stream';
import { ReadableStream } from 'stream/web';
import Logger, { log } from '../logger';
import { randomCharacters } from '../random';
import { Datasource } from './Datasource';
import { Datasource, PutOptions } from './Datasource';
function isOk(code: number) {
return code >= 200 && code < 300;
@@ -64,7 +68,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
@@ -160,13 +164,7 @@ export class S3Datasource extends Datasource {
}
}
public async put(
file: string,
data: Buffer | string,
options: {
mimetype?: string;
} = {},
): Promise<void> {
public async put(file: string, data: Buffer | string, options: PutOptions = {}): Promise<void> {
let command = new PutObjectCommand({
Bucket: this.options.bucket,
Key: this.key(file),
@@ -231,7 +229,7 @@ export class S3Datasource extends Datasource {
}
public async size(file: string): Promise<number> {
const command = new GetObjectCommand({
const command = new HeadObjectCommand({
Bucket: this.options.bucket,
Key: this.key(file),
});
@@ -329,6 +327,96 @@ export class S3Datasource extends Datasource {
}
public async rename(from: string, to: string): Promise<void> {
const size = await this.size(from);
if (size !== 0 && size > 5 * 1024 * 1024 * 1024) {
this.logger.debug('object larger than 5GB, using multipart copy for rename', { from, to, size });
const createCommand = new CreateMultipartUploadCommand({
Bucket: this.options.bucket,
Key: this.key(to),
});
let uploadId: string;
try {
const createRes = await this.client.send(createCommand);
if (!isOk(createRes.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while initiating multipart upload');
this.logger.error('error metadata', createRes.$metadata as Record<string, unknown>);
throw new Error('Failed to initiate multipart upload');
}
if (!createRes.UploadId) {
this.logger.error('no upload ID returned while initiating multipart upload');
throw new Error('Failed to initiate multipart upload');
}
uploadId = createRes.UploadId;
} catch (e) {
this.logger.error('there was an error while initiating multipart upload');
this.logger.error('error metadata', e as Record<string, unknown>);
throw new Error('Failed to initiate multipart upload');
}
const partSize = 5 * 1024 * 1024;
const eTags = [];
for (let start = 0, part = 1; start < size; start += partSize, part++) {
const end = Math.min(start + partSize - 1, size - 1);
const uploadPartCopyCommand = new UploadPartCopyCommand({
Bucket: this.options.bucket,
Key: this.key(to),
CopySource: this.options.bucket + '/' + this.key(from),
CopySourceRange: `bytes=${start}-${end}`,
PartNumber: part,
UploadId: uploadId,
});
try {
const copyRes = await this.client.send(uploadPartCopyCommand);
if (!isOk(copyRes.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while copying part of the object');
this.logger.error('error metadata', copyRes.$metadata as Record<string, unknown>);
throw new Error('Failed to copy part of the object');
}
eTags.push({ ETag: copyRes.CopyPartResult?.ETag, PartNumber: part });
} catch (e) {
this.logger.error('there was an error while renaming object using multipart copy');
this.logger.error('error metadata', e as Record<string, unknown>);
throw new Error('Failed to rename object using multipart copy');
}
}
const completeMultipartUploadCommand = new CompleteMultipartUploadCommand({
Bucket: this.options.bucket,
Key: this.key(to),
UploadId: uploadId,
MultipartUpload: {
Parts: eTags,
},
});
try {
const completeRes = await this.client.send(completeMultipartUploadCommand);
if (!isOk(completeRes.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while completing multipart upload');
this.logger.error('error metadata', completeRes.$metadata as Record<string, unknown>);
throw new Error('Failed to complete multipart upload');
}
} catch (e) {
this.logger.error('there was an error while completing multipart upload');
this.logger.error('error metadata', e as Record<string, unknown>);
throw new Error('Failed to complete multipart upload');
}
return;
}
const copyCommand = new CopyObjectCommand({
Bucket: this.options.bucket,
Key: this.key(to),

View File

@@ -11,7 +11,7 @@ declare global {
var __datasource__: Datasource;
}
function getDatasource(config?: Config): void {
function getDatasource(config?: Config): Datasource | void {
if (!config) return;
const logger = log('datasource');
@@ -35,6 +35,8 @@ function getDatasource(config?: Config): void {
logger.error(`Datasource type ${config.datasource.type} is not supported`);
process.exit(1);
}
return datasource;
}
datasource = global.__datasource__;

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

@@ -1,30 +1,57 @@
import { Migrate } from '@prisma/migrate';
import { log } from '@/lib/logger';
import { exec } from 'child_process';
import { loadSchemaContext } from '@prisma/internals';
// @ts-ignore
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
// TODO: don't run prisma cli
export async function runMigrations() {
const schemaContext = await loadSchemaContext({
schemaPathFromArg: './prisma/schema.prisma',
printLoadMessage: false,
});
const migrate = await Migrate.setup({
schemaContext,
migrationsDirPath: './prisma/migrations',
});
const logger = log('migrations');
logger.debug('running migrations...');
try {
await new Promise((res, rej) => {
const proc = exec('pnpm prisma migrate deploy');
logger.debug('ensuring database exists...');
proc.stdout?.on('data', (data) => {
process.stdout.write(data);
});
proc.stderr?.on('data', (data) => {
process.stderr.write(data);
});
proc.on('close', (code) => {
if (code !== 0) {
rej(new Error('Migration process exited with code ' + code));
} else {
res(true);
}
});
});
const dbCreated = await ensureDatabaseExists(schemaContext.primaryDatasource);
if (dbCreated) {
logger.info('database created');
}
} catch (e) {
log('db').error('Error running migrations: ' + e);
logger.error('failed to create database' + e);
logger.error('try creating the database manually and running the server again');
migrate.stop();
process.exit(1);
}
let migrationIds: string[];
try {
logger.debug('applying migrations...');
const { appliedMigrationNames } = await migrate.applyMigrations();
migrationIds = appliedMigrationNames;
} catch (e) {
logger.error('failed to apply migrations' + e);
migrate.stop();
process.exit(1);
} finally {
migrate.stop();
}
if (migrationIds?.length === 0) {
logger.debug('no migrations applied');
return;
}
logger.info(`applied migrations: ${migrationIds.join(', ')}`);
}

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,5 +1,6 @@
import { PathLike } from 'fs';
import { access } from 'fs/promises';
import { basename, isAbsolute, normalize, sep } from 'path';
export async function exists(path: PathLike): Promise<boolean> {
try {
@@ -9,3 +10,16 @@ export async function exists(path: PathLike): Promise<boolean> {
return false;
}
}
export function sanitizeFilename(name: string): string | null {
const decoded = decodeURIComponent(name);
const normalized = normalize(decoded);
if (normalized.includes('/') || normalized.includes('\\')) return null;
if (isAbsolute(normalized)) return null;
if (normalized.includes('..' + sep) || normalized === '..') return null;
return basename(normalized);
}

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

@@ -10,5 +10,5 @@ export function useTitle(title?: string) {
useEffect(() => {
if (!data || error || isLoading) return;
document.title = title ? `${data.website.title} ${title}` : data.website.title || 'Zipline';
}, [title, location]);
}, [title, location, data, isLoading]);
}

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, {
@@ -133,6 +142,10 @@ async function main() {
}
}
server.get<{ Params: { id: string } }>('/r/:id', async (req, res) => {
return res.redirect('/raw/' + req.params.id, 301);
});
server.get<{ Params: { id: string } }>('/view/:id', async (_req, res) => {
return res.ssr('view');
});
@@ -196,7 +209,7 @@ async function main() {
}
});
server.setErrorHandler((error, _, res) => {
server.setErrorHandler((error: { statusCode: number; message: string }, _, res) => {
if (error.statusCode) {
res.status(error.statusCode);
res.send({ error: error.message, statusCode: error.statusCode });
@@ -271,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

@@ -1,60 +1,55 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { Invite } from '@/lib/db/models/invite';
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 ApiAuthInvitesResponse = Invite | Invite[];
export type ApiAuthInvitesWebResponse = Invite & {
inviter: {
username: string;
};
};
type Query = {
code: string;
};
const logger = log('api').c('auth').c('invites').c('web');
export const PATH = '/api/auth/invites/web';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Querystring: Query }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(10) },
async (req, res) => {
const { code } = req.query;
server.get<{ Querystring: Query }>(PATH, secondlyRatelimit(10), async (req, res) => {
const { code } = req.query;
if (!code) return res.send({ invite: null });
if (!config.invites.enabled) return res.notFound();
if (!code) return res.send({ invite: null });
if (!config.invites.enabled) return res.notFound();
const invite = await prisma.invite.findFirst({
where: {
OR: [{ id: code }, { code }],
const invite = await prisma.invite.findFirst({
where: {
OR: [{ id: code }, { code }],
},
select: {
code: true,
maxUses: true,
uses: true,
expiresAt: true,
inviter: {
select: { username: true },
},
select: {
code: true,
maxUses: true,
uses: true,
expiresAt: true,
inviter: {
select: { username: true },
},
},
});
},
});
if (
!invite ||
(invite.expiresAt && new Date(invite.expiresAt) < new Date()) ||
(invite.maxUses && invite.uses >= invite.maxUses)
) {
return res.notFound();
}
if (
!invite ||
(invite.expiresAt && new Date(invite.expiresAt) < new Date()) ||
(invite.maxUses && invite.uses >= invite.maxUses)
) {
return res.notFound();
}
delete (invite as any).expiresAt;
delete (invite as any).expiresAt;
return res.send({ invite });
},
);
return res.send({ invite });
});
done();
},

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="zipline4_export_${Date.now()}.json"`)
.type('application/json')
.send(export4);
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,530 @@
import { createToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
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 = {
imported: {
users: number;
oauthProviders: number;
quotas: number;
passkeys: number;
folders: number;
files: number;
tags: number;
urls: number;
invites: number;
metrics: number;
};
};
type Body = {
export4: Export4;
config: {
settings: boolean;
mergeCurrentUser: string | null;
};
};
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, config: importConfig } = 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,
});
}
// users
const importedUsers: Record<string, string> = {};
for (const user of export4.data.users) {
let mergeCurrent = false;
if (importConfig.mergeCurrentUser && user.id === importConfig.mergeCurrentUser) {
logger.info('importing to current user', {
from: user.id,
to: req.user.id,
});
mergeCurrent = true;
}
const existing = await prisma.user.findFirst({
where: {
OR: [{ username: user.username }, { id: user.id }],
},
});
if (!mergeCurrent && existing) {
logger.warn('user already exists with a username or id, skipping importing', {
id: user.id,
conflict: existing.id,
});
continue;
}
if (mergeCurrent) {
const updated = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
avatar: user.avatar ?? null,
totpSecret: user.totpSecret ?? null,
view: user.view as any,
},
});
importedUsers[user.id] = updated.id;
continue;
}
const created = await prisma.user.create({
data: {
username: user.username,
password: user.password ?? null,
avatar: user.avatar ?? null,
role: user.role,
view: user.view as any,
totpSecret: user.totpSecret ?? null,
token: createToken(),
createdAt: new Date(user.createdAt),
},
});
importedUsers[user.id] = created.id;
}
logger.debug('imported users', { users: importedUsers });
// oauth providers from users
const importedOauthProviders: Record<string, string> = {};
for (const oauthProvider of export4.data.userOauthProviders) {
const userId = importedUsers[oauthProvider.userId];
if (!userId) {
logger.warn('failed to find user for oauth provider, skipping', {
provider: oauthProvider.id,
user: oauthProvider.userId,
});
continue;
}
const existing = await prisma.oAuthProvider.findFirst({
where: {
provider: oauthProvider.provider,
oauthId: oauthProvider.oauthId,
},
});
if (existing) {
logger.warn('oauth provider already exists, skipping importing', {
id: oauthProvider.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.oAuthProvider.create({
data: {
provider: oauthProvider.provider,
oauthId: oauthProvider.oauthId,
username: oauthProvider.username,
accessToken: oauthProvider.accessToken,
refreshToken: oauthProvider.refreshToken ?? null,
userId,
},
});
importedOauthProviders[oauthProvider.id] = created.id;
}
logger.debug('imported oauth providers', { oauthProviders: importedOauthProviders });
// quotas from users
const importedQuotas: Record<string, string> = {};
for (const quota of export4.data.userQuotas) {
const userId = importedUsers[quota.userId ?? ''];
if (!userId) {
logger.warn('failed to find user for quota, skipping', {
quota: quota.id,
user: quota.userId,
});
continue;
}
const existing = await prisma.userQuota.findFirst({
where: {
userId,
},
});
if (existing) {
logger.warn('quota already exists for user, skipping importing', {
id: quota.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.userQuota.create({
data: {
filesQuota: quota.filesQuota,
maxBytes: quota.maxBytes ?? null,
maxFiles: quota.maxFiles ?? null,
maxUrls: quota.maxUrls ?? null,
userId,
createdAt: new Date(quota.createdAt),
},
});
importedQuotas[quota.id] = created.id;
}
logger.debug('imported quotas', { quotas: importedQuotas });
const importedPasskeys: Record<string, string> = {};
for (const passkey of export4.data.userPasskeys) {
const userId = importedUsers[passkey.userId];
if (!userId) {
logger.warn('failed to find user for passkey, skipping', {
passkey: passkey.id,
user: passkey.userId,
});
continue;
}
const existing = await prisma.userPasskey.findFirst({
where: {
name: passkey.name,
userId,
},
});
if (existing) {
logger.warn('passkey already exists for user, skipping importing', {
id: passkey.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.userPasskey.create({
data: {
name: passkey.name,
reg: passkey.reg as any,
userId,
},
});
importedPasskeys[passkey.id] = created.id;
}
logger.debug('imported passkeys', { passkeys: importedPasskeys });
// folders
const importedFolders: Record<string, string> = {};
for (const folder of export4.data.folders) {
const userId = importedUsers[folder.userId ?? ''];
if (!userId) {
logger.warn('failed to find user for folder, skipping', {
folder: folder.id,
user: folder.userId,
});
continue;
}
const existing = await prisma.folder.findFirst({
where: {
name: folder.name,
userId,
},
});
if (existing) {
logger.warn('folder already exists, skipping importing', {
id: folder.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.folder.create({
data: {
userId,
name: folder.name,
allowUploads: folder.allowUploads,
public: folder.public,
createdAt: new Date(folder.createdAt),
},
});
importedFolders[folder.id] = created.id;
}
// files
const importedFiles: Record<string, string> = {};
for (const file of export4.data.files) {
const userId = importedUsers[file.userId ?? ''];
if (!userId) {
logger.warn('failed to find user for file, skipping', {
file: file.id,
user: file.userId,
});
continue;
}
const existing = await prisma.file.findFirst({
where: {
name: file.name,
},
});
if (existing) {
logger.warn('file already exists, skipping importing', {
id: file.id,
conflict: existing.id,
});
continue;
}
const folderId = file.folderId ? importedFolders[file.folderId] : null;
const created = await prisma.file.create({
data: {
userId,
name: file.name,
size: file.size,
type: file.type,
folderId,
originalName: file.originalName ?? null,
maxViews: file.maxViews ?? null,
views: file.views ?? 0,
deletesAt: file.deletesAt ? new Date(file.deletesAt) : null,
createdAt: new Date(file.createdAt),
favorite: file.favorite ?? false,
password: file.password ?? null,
},
});
importedFiles[file.id] = created.id;
}
logger.debug('imported files', { files: importedFiles });
// tags, mapped to files and users
const importedTags: Record<string, string> = {};
for (const tag of export4.data.userTags) {
const userId = tag.userId ? importedUsers[tag.userId] : null;
const existing = await prisma.tag.findFirst({
where: {
name: tag.name,
userId: userId ?? null,
createdAt: new Date(tag.createdAt),
},
});
if (existing) {
logger.warn('tag already exists, skipping importing', {
id: tag.id,
conflict: existing.id,
});
continue;
}
if (!userId) {
logger.warn('tag has no user, skipping', { id: tag.id });
continue;
}
const created = await prisma.tag.create({
data: {
name: tag.name,
color: tag.color ?? '#000000',
files: {
connect: tag.files.map((fileId) => ({ id: importedFiles[fileId] })),
},
userId,
},
});
importedTags[tag.id] = created.id;
}
logger.debug('imported tags', { tags: importedTags });
// urls
const importedUrls: Record<string, string> = {};
for (const url of export4.data.urls) {
const userId = url.userId ? importedUsers[url.userId] : null;
if (!userId) {
logger.warn('failed to find user for url, skipping', {
url: url.id,
user: url.userId,
});
continue;
}
const existing = await prisma.url.findFirst({
where: {
code: url.code,
userId,
},
});
if (existing) {
logger.warn('url already exists, skipping importing', {
id: url.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.url.create({
data: {
userId,
destination: url.destination,
vanity: url.vanity ?? null,
code: url.code,
maxViews: url.maxViews ?? null,
views: url.views,
enabled: url.enabled,
createdAt: new Date(url.createdAt),
password: url.password ?? null,
},
});
importedUrls[url.id] = created.id;
}
logger.debug('imported urls', { urls: importedUrls });
// invites
const importedInvites: Record<string, string> = {};
for (const invite of export4.data.invites) {
const inviterId = importedUsers[invite.inviterId];
if (!inviterId) {
logger.warn('failed to find inviter for invite, skipping', {
invite: invite.id,
inviter: invite.inviterId,
});
continue;
}
const existing = await prisma.invite.findFirst({
where: {
code: invite.code,
inviterId,
},
});
if (existing) {
logger.warn('invite already exists, skipping importing', {
id: invite.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.invite.create({
data: {
code: invite.code,
uses: invite.uses,
maxUses: invite.maxUses ?? null,
inviterId,
createdAt: new Date(invite.createdAt),
expiresAt: invite.expiresAt ? new Date(invite.expiresAt) : null,
},
});
importedInvites[invite.id] = created.id;
}
logger.debug('imported invites', { invites: importedInvites });
const metricRes = await prisma.metric.createMany({
data: export4.data.metrics.map((metric) => ({
createdAt: new Date(metric.createdAt),
data: metric.data as any,
})),
});
// metrics, through batch
logger.debug('imported metrics', { count: metricRes.count });
const response = {
imported: {
users: Object.keys(importedUsers).length,
oauthProviders: Object.keys(importedOauthProviders).length,
quotas: Object.keys(importedQuotas).length,
passkeys: Object.keys(importedPasskeys).length,
folders: Object.keys(importedFolders).length,
files: Object.keys(importedFiles).length,
tags: Object.keys(importedTags).length,
urls: Object.keys(importedUrls).length,
invites: Object.keys(importedInvites).length,
metrics: metricRes.count,
},
};
return res.send(response);
},
);
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

@@ -16,6 +16,7 @@ import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
import { stat } from 'fs/promises';
import { extname } from 'path';
import { sanitizeFilename } from '@/lib/fs';
const commonDoubleExts = [
'.tar.gz',
@@ -41,6 +42,7 @@ export const getExtension = (filename: string, override?: string): string => {
export type ApiUploadResponse = {
files: {
id: string;
name: string;
type: string;
url: string;
pending?: boolean;
@@ -151,7 +153,13 @@ export default fastifyPlugin(
const format = options.format || config.files.defaultFormat;
let fileName = formatFileName(format, file.filename);
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
if (options.overrides?.filename) {
const sanitized = sanitizeFilename(options.overrides.filename!);
if (!sanitized) return res.badRequest(`file[${i}]: Invalid characters in filename override`);
fileName = sanitized;
}
const fullFileName = `${fileName}${extension}`;
const existing = await prisma.file.findFirst({ where: { name: fullFileName } });
if (existing)
@@ -174,6 +182,7 @@ export default fastifyPlugin(
quality: options.imageCompression.percent,
type: options.imageCompression.type,
});
logger.c('compress').debug(`compressed file ${file.filename}`);
}
@@ -190,7 +199,7 @@ export default fastifyPlugin(
const data: Prisma.FileCreateInput = {
name: `${fileName}${compressed ? '.' + compressed.ext : extension}`,
size: tempFileStats.size,
size: compressed?.buffer?.length ?? tempFileStats.size,
type: compressed?.mimetype ?? mimetype,
User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } },
};
@@ -206,12 +215,15 @@ export default fastifyPlugin(
select: fileSelect,
});
await datasource.put(fileUpload.name, file.filepath, { mimetype: fileUpload.type });
await datasource.put(fileUpload.name, compressed?.buffer ?? file.filepath, {
mimetype: fileUpload.type,
});
const responseUrl = `${domain}${config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`}/${fileUpload.name}`;
response.files.push({
id: fileUpload.id,
name: fileUpload.name,
type: fileUpload.type,
url: encodeURI(responseUrl),
removedGps: removedGps || undefined,
@@ -220,7 +232,7 @@ export default fastifyPlugin(
logger.info(
`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`,
{ size: bytes(fileUpload.size), ip: req.ip },
{ size: bytes(compressed?.buffer?.length ?? fileUpload.size), ip: req.ip },
);
await onUpload(config, {

View File

@@ -7,13 +7,14 @@ 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';
import { sanitizeFilename } from '@/lib/fs';
const logger = log('api').c('upload').c('partial');
@@ -157,7 +158,12 @@ export default fastifyPlugin(
let fileName = formatFileName(format, decodeURIComponent(options.partial.filename));
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
if (options.overrides?.filename) {
const sanitized = sanitizeFilename(options.overrides!.filename!);
if (!sanitized) return res.badRequest('Invalid characters in filename override');
fileName = sanitized;
}
const fullFileName = `${fileName}${extension}`;
const existing = await prisma.file.findFirst({
@@ -256,6 +262,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

@@ -4,6 +4,7 @@ import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { canInteract } from '@/lib/role';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
@@ -44,10 +45,16 @@ export default fastifyPlugin(
const file = await prisma.file.findFirst({
where: {
id,
userId: req.user.id,
},
include: {
User: true,
},
});
if (file && file.userId !== req.user.id) {
if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound();
}
if (file?.deletesAt && file.deletesAt <= new Date()) {
try {
await datasource.delete(file.name);

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: {