mirror of
https://github.com/diced/zipline.git
synced 2025-12-25 04:15:41 -08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1585287b63 | ||
|
|
1d4c3f26b4 | ||
|
|
589f06b460 | ||
|
|
ca09b1319d | ||
|
|
5d27c14b77 | ||
|
|
9da74054ff | ||
|
|
7572f7f3da | ||
|
|
ef979d8853 | ||
|
|
d090ed2cc1 | ||
|
|
3fc8b044bb | ||
|
|
61af46f136 | ||
|
|
771aa67673 | ||
|
|
b2db0c15a3 | ||
|
|
d49afe60c8 | ||
|
|
3370d4b663 | ||
|
|
1f1bcd3a47 | ||
|
|
d9df04bac5 | ||
|
|
2bf2809269 | ||
|
|
9bb9e7e399 | ||
|
|
89d6b2908d | ||
|
|
63c268cd1e | ||
|
|
6e2da52f77 | ||
|
|
04b27a2dee | ||
|
|
6f4c3271c1 | ||
|
|
b014f10240 | ||
|
|
d3a417aff0 | ||
|
|
63596d983e | ||
|
|
ffbad41994 | ||
|
|
2a6f1f418a | ||
|
|
2402c6f0ef | ||
|
|
317e97e3a6 | ||
|
|
f7753ccf2e | ||
|
|
2ad10e9a52 | ||
|
|
b4be96c7a8 | ||
|
|
69dfad201b | ||
|
|
ee1681497e | ||
|
|
2f19140085 | ||
|
|
c9d492f9d2 | ||
|
|
a7a23f3fd9 | ||
|
|
36ffb669b2 | ||
|
|
f0ee4cdab3 | ||
|
|
ac41dab2b2 | ||
|
|
26661f7a83 | ||
|
|
01a73df7f3 | ||
|
|
6b1304f37b | ||
|
|
19fc87818c | ||
|
|
f168fa676d | ||
|
|
44cb10acf2 | ||
|
|
2c21101e9e | ||
|
|
ecb83d96e3 | ||
|
|
bfae105e5f | ||
|
|
3240e19710 | ||
|
|
40c12ca3f0 | ||
|
|
4907f4e450 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,4 +48,5 @@ yarn-error.log*
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
src/prisma
|
||||
src/prisma
|
||||
.memory.log*
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
119
package.json
119
package.json
@@ -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
5546
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "coreTrustProxy" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -20,6 +20,7 @@ model Zipline {
|
||||
coreReturnHttpsUrls Boolean @default(false)
|
||||
coreDefaultDomain String?
|
||||
coreTempDirectory String // default join(tmpdir(), 'zipline')
|
||||
coreTrustProxy Boolean @default(false)
|
||||
|
||||
chunksEnabled Boolean @default(true)
|
||||
chunksMax String @default("95mb")
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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'>
|
||||
You’ve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b> by{' '}
|
||||
<b>{invite.inviter?.username}</b>
|
||||
You’ve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b>
|
||||
{invite.inviter && (
|
||||
<>
|
||||
{' '}
|
||||
by <b>{invite.inviter.username}</b>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
10
src/client/pages/dashboard/admin/actions.tsx
Normal file
10
src/client/pages/dashboard/admin/actions.tsx
Normal 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';
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' />,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
Icon,
|
||||
IconBombFilled,
|
||||
IconClipboardTypography,
|
||||
IconCopy,
|
||||
IconDeviceSdCard,
|
||||
IconDownload,
|
||||
@@ -88,11 +89,13 @@ export default function FileModal({
|
||||
setOpen,
|
||||
file,
|
||||
reduce,
|
||||
user,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file?: File | null;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
@@ -226,7 +229,7 @@ export default function FileModal({
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{!reduce && (
|
||||
{!reduce && !user && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='md' my='xs'>
|
||||
<Box>
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
@@ -234,15 +237,15 @@ export default function FileModal({
|
||||
</Title>
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
withinPortal={false}
|
||||
store={tagsCombobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
pointer
|
||||
onClick={() => tagsCombobox.toggleDropdown()}
|
||||
onClick={() => tagsCombobox.openDropdown()}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? (
|
||||
@@ -254,9 +257,14 @@ export default function FileModal({
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type='hidden'
|
||||
onFocus={() => tagsCombobox.openDropdown()}
|
||||
onBlur={() => tagsCombobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
if (
|
||||
event.key === 'Backspace' &&
|
||||
value.length > 0 &&
|
||||
event.currentTarget.value === ''
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value[value.length - 1]);
|
||||
}
|
||||
@@ -285,9 +293,7 @@ export default function FileModal({
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<Combobox.Option value='no-tags' disabled>
|
||||
No tags found, create one outside of this menu.
|
||||
</Combobox.Option>
|
||||
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
@@ -310,8 +316,8 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
store={folderCombobox}
|
||||
withinPortal={false}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
@@ -398,6 +404,11 @@ export default function FileModal({
|
||||
tooltip='View file in a new tab'
|
||||
color='blue'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconClipboardTypography}
|
||||
onClick={() => copyFile(file, clipboard, true)}
|
||||
tooltip='Copy raw file link'
|
||||
/>
|
||||
<ActionButton
|
||||
Icon={IconCopy}
|
||||
onClick={() => copyFile(file, clipboard)}
|
||||
|
||||
@@ -27,10 +27,14 @@ export function downloadFile(file: File) {
|
||||
window.open(`/raw/${file.name}?download=true`, '_blank');
|
||||
}
|
||||
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
|
||||
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
|
||||
const domain = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
|
||||
const url = raw
|
||||
? `${domain}/raw/${file.name}`
|
||||
: file.url
|
||||
? `${domain}${file.url}`
|
||||
: `${domain}/view/${file.name}`;
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
|
||||
99
src/components/pages/files/TableEditModal.tsx
Normal file
99
src/components/pages/files/TableEditModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original Name',
|
||||
tags: 'Tags',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
favorite: 'Favorite',
|
||||
views: 'Views',
|
||||
};
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.field,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Group gap='xs'>
|
||||
<IconGripVertical size='1rem' />
|
||||
|
||||
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
|
||||
|
||||
<Text>{NAMES[item.field]}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
|
||||
const [fields, setIndex, reset] = useFileTableSettingsStore(
|
||||
useShallow((state) => [state.fields, state.setIndex, state.reset]),
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const newIndex = fields.findIndex((item) => item.field === over?.id);
|
||||
|
||||
setIndex(active.id as FieldSettings['field'], newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
|
||||
<Text mb='md' size='sm' c='dimmed'>
|
||||
Select and drag fields below to make them appear/disappear/reorder in the file table view.
|
||||
</Text>
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
|
||||
{fields.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
|
||||
>
|
||||
<SortableTableField item={item} />
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -69,20 +69,23 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkFavorite(ids: string[]) {
|
||||
export async function bulkFavorite(ids: string[], favorite: boolean) {
|
||||
const text = favorite ? 'favorite' : 'unfavorite';
|
||||
const textcaps = favorite ? 'Favorite' : 'Unfavorite';
|
||||
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
|
||||
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
|
||||
title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
|
||||
children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
|
||||
labels: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Favorite',
|
||||
confirm: `${textcaps}`,
|
||||
},
|
||||
confirmProps: { color: 'yellow' },
|
||||
onConfirm: async () => {
|
||||
notifications.show({
|
||||
title: 'Favoriting files',
|
||||
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`,
|
||||
title: `${textcaps}ing files`,
|
||||
message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
|
||||
color: 'yellow',
|
||||
loading: true,
|
||||
id: 'bulk-favorite',
|
||||
@@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) {
|
||||
{
|
||||
files: ids,
|
||||
|
||||
favorite: true,
|
||||
favorite,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
notifications.update({
|
||||
title: 'Error while favoriting files',
|
||||
title: 'Error while modifying files',
|
||||
message: error.error,
|
||||
color: 'red',
|
||||
icon: <IconStarsOff size='1rem' />,
|
||||
@@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) {
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
title: 'Favorited files',
|
||||
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`,
|
||||
title: `${textcaps}d files`,
|
||||
message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
|
||||
color: 'yellow',
|
||||
icon: <IconStarsFilled size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
|
||||
@@ -6,12 +6,16 @@ import FileTable from './views/FileTable';
|
||||
import Files from './views/Files';
|
||||
import TagsButton from './tags/TagsButton';
|
||||
import PendingFilesButton from './PendingFilesButton';
|
||||
import { IconFileUpload } from '@tabler/icons-react';
|
||||
import { IconFileUpload, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function DashboardFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
|
||||
const [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
@@ -28,6 +32,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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { bytes } from '@/lib/bytes';
|
||||
import { type File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -32,7 +34,6 @@ import {
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFile,
|
||||
IconGridPatternFilled,
|
||||
IconStar,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -40,10 +41,10 @@ import { DataTable } from 'mantine-datatable';
|
||||
import { lazy, useEffect, useReducer, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import TableEditModal, { NAMES } from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
|
||||
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
|
||||
|
||||
@@ -54,13 +55,6 @@ type ReducerQuery = {
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
|
||||
const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original name',
|
||||
type: 'Type',
|
||||
id: 'ID',
|
||||
};
|
||||
|
||||
function SearchFilter({
|
||||
setSearchField,
|
||||
searchQuery,
|
||||
@@ -88,8 +82,8 @@ function SearchFilter({
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={NAMES[field]}
|
||||
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
|
||||
label={NAMES[field as keyof typeof NAMES]}
|
||||
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
|
||||
value={searchQuery[field]}
|
||||
onChange={onChange}
|
||||
size='sm'
|
||||
@@ -179,10 +173,26 @@ function TagsFilter({
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileTable({ id }: { id?: string }) {
|
||||
export default function FileTable({
|
||||
id,
|
||||
tableEdit,
|
||||
idSearch,
|
||||
}: {
|
||||
id?: string;
|
||||
tableEdit: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
idSearch: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
|
||||
const fields = useFileTableSettingsStore((state) => state.fields);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
@@ -204,7 +214,6 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
||||
const [searchQuery, setSearchQuery] = useReducer(
|
||||
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
||||
@@ -218,13 +227,13 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (idSearchOpen) return;
|
||||
if (idSearch.open) return;
|
||||
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: '',
|
||||
});
|
||||
}, [idSearchOpen]);
|
||||
}, [idSearch.open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
@@ -264,6 +273,100 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}),
|
||||
});
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='name'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'originalName',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='originalName'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file: File) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='type'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
|
||||
},
|
||||
{ accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file: File) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
render: (file: File) => file.views,
|
||||
},
|
||||
{
|
||||
accessor: 'id',
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
|
||||
const columns = FIELDS.filter((f) => visibleFields.includes(f.accessor as any));
|
||||
columns.sort((a, b) => {
|
||||
const aIndex = fields.findIndex((f) => f.field === a.accessor);
|
||||
const bIndex = fields.findIndex((f) => f.field === b.accessor);
|
||||
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
@@ -285,6 +388,8 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}
|
||||
}, [searchField]);
|
||||
|
||||
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
@@ -293,22 +398,12 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
if (!open) setSelectedFile(null);
|
||||
}}
|
||||
file={selectedFile}
|
||||
user={id}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
// lol if it works it works :shrug:
|
||||
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} />
|
||||
|
||||
<Box>
|
||||
<Collapse in={selectedFiles.length > 0}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
@@ -335,48 +430,56 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
variant='outline'
|
||||
color='yellow'
|
||||
leftSection={<IconStar size='1rem' />}
|
||||
onClick={() => bulkFavorite(selectedFiles.map((x) => x.id))}
|
||||
onClick={() =>
|
||||
bulkFavorite(
|
||||
selectedFiles.map((x) => x.id),
|
||||
!unfavoriteAll,
|
||||
)
|
||||
}
|
||||
>
|
||||
Favorite {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
|
||||
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file
|
||||
{selectedFiles.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => handleAddFolder(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={folderSearch}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setFolderSearch(folderSearch || '');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
{!id && (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => handleAddFolder(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={folderSearch}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
setFolderSearch(folderSearch || '');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
@@ -393,8 +496,8 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={idSearchOpen}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Collapse in={idSearch.open}>
|
||||
<Paper withBorder p='sm' mt='sm'>
|
||||
<TextInput
|
||||
placeholder='Search by ID'
|
||||
value={searchQuery.id}
|
||||
@@ -412,80 +515,13 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
|
||||
{/* @ts-ignore */}
|
||||
<DataTable
|
||||
mt='xs'
|
||||
borderRadius='sm'
|
||||
withTableBorder
|
||||
minHeight={200}
|
||||
records={data?.page ?? []}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'name',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='name'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
sortable: true,
|
||||
filter: (
|
||||
<SearchFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='type'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
|
||||
},
|
||||
{ accessor: 'size', sortable: true, render: (file) => bytes(file.size) },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
render: (file) => file.views,
|
||||
},
|
||||
{
|
||||
accessor: 'id',
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
...columns,
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlign: 'right',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconShare,
|
||||
IconShareOff,
|
||||
IconTrashFilled,
|
||||
IconZip,
|
||||
} from '@tabler/icons-react';
|
||||
import ViewFilesModal from '../ViewFilesModal';
|
||||
import EditFolderNameModal from '../EditFolderNameModal';
|
||||
@@ -169,6 +170,14 @@ export default function FolderTableView() {
|
||||
<IconPencil size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Export folder as ZIP'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
onClick={() => window.open(`/api/user/folders/${folder.id}/export`, '_blank')}
|
||||
>
|
||||
<IconZip size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete Folder'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
|
||||
@@ -99,8 +99,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
|
||||
const recent = data[0]; // it is sorted by desc so 0 is the first one.
|
||||
|
||||
if (recent.data.filesUsers.length === 0) return null;
|
||||
if (recent.data.urlsUsers.length === 0) return null;
|
||||
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -121,7 +120,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
.sort((a, b) => b.sum - a.sum)
|
||||
.map((count, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{count.username}</Table.Td>
|
||||
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
|
||||
<Table.Td>{count.sum}</Table.Td>
|
||||
<Table.Td>{bytes(count.storage)}</Table.Td>
|
||||
<Table.Td>{count.views}</Table.Td>
|
||||
@@ -147,7 +146,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
|
||||
.sort((a, b) => b.sum - a.sum)
|
||||
.map((count, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{count.username}</Table.Td>
|
||||
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
|
||||
<Table.Td>{count.sum}</Table.Td>
|
||||
<Table.Td>{count.views}</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
19
src/components/pages/serverActions/ActionButton.tsx
Normal file
19
src/components/pages/serverActions/ActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Alert, Box, Button, List, Modal, Code, Group, Divider, Checkbox, Pill } from '@mantine/core';
|
||||
import { IconAlertCircle, IconDownload } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ExportButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [noMetrics, setNoMetrics] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} size='lg' title='Are you sure?'>
|
||||
<Box px='sm'>
|
||||
<p>The export provides a complete snapshot of Zipline’s data and environment. It includes:</p>
|
||||
|
||||
<List>
|
||||
<List.Item>
|
||||
<b>Users:</b> Account information including usernames, optional passwords, avatars, roles, view
|
||||
settings, and optional TOTP secrets.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>Passkeys:</b> Registered WebAuthn passkeys with creation dates, last-used timestamps, and
|
||||
credential registration data.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>User Quotas:</b> Quota settings such as max bytes, max files, max URLs, and quota types.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>OAuth Providers:</b> Linked OAuth accounts including provider type, tokens, and OAuth IDs.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>User Tags:</b> Tags created by users, including names, colors, and associated file IDs.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>Files:</b> Metadata about uploaded files including size, type, timestamps, expiration, views,
|
||||
password protection, owner, and folder association.
|
||||
<i> (Actual file contents are not included.)</i>
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>Folders:</b> Folder metadata including visibility settings, upload permissions, file lists,
|
||||
and ownership.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>URLs:</b> Metadata for shortened URLs including destinations, vanity codes, view counts,
|
||||
passwords, and user assignments.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>Thumbnails:</b> Thumbnail path and associated file ID.
|
||||
<i> (Image data is not included.)</i>
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>Invites:</b> Invite codes, creation/expiration dates, and usage counts.
|
||||
</List.Item>
|
||||
|
||||
<List.Item>
|
||||
<b>Metrics:</b> System and usage statistics stored internally by Zipline.
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<p>
|
||||
Additionally, the export includes <b>system-specific information</b>:
|
||||
</p>
|
||||
|
||||
<List>
|
||||
<List.Item>
|
||||
<b>CPU Count:</b> The number of available processor cores.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Hostname:</b> The host system’s network identifier.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Architecture:</b> The hardware architecture (e.g., <Code>x64</Code>, <Code>arm64</Code>).
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>).
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>OS Release:</b> The OS or kernel version.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Environment Variables:</b> A full snapshot of environment variables at the time of export.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Versions:</b> The Zipline version, Node version, and export format version.
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<Divider my='md' />
|
||||
|
||||
<Checkbox
|
||||
label='Exclude Metrics Data'
|
||||
description='Exclude system and usage metrics from the export. This can reduce the export file size.'
|
||||
checked={noMetrics}
|
||||
onChange={() => setNoMetrics((val) => !val)}
|
||||
/>
|
||||
|
||||
<Divider my='md' />
|
||||
|
||||
<Alert color='red' icon={<IconAlertCircle size='1rem' />} title='Warning' my='md'>
|
||||
This export contains a significant amount of sensitive data, including user accounts,
|
||||
authentication credentials, environment variables, and system metadata. Handle this file securely
|
||||
and do not share it with untrusted parties.
|
||||
</Alert>
|
||||
|
||||
<Group grow my='md'>
|
||||
<Button onClick={() => setOpen(false)} color='red'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
component='a'
|
||||
href={`/api/server/export${noMetrics ? '?nometrics=true' : ''}`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
leftSection={<IconDownload size='1rem' />}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Download Export
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<Button
|
||||
size='xl'
|
||||
fullWidth
|
||||
onClick={() => setOpen(true)}
|
||||
leftSection={<IconDownload size='1rem' />}
|
||||
rightSection={<Pill>V4</Pill>}
|
||||
>
|
||||
Export Data
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
V3_SETTINGS_TRANSFORM,
|
||||
validateExport,
|
||||
} from '@/lib/import/version3/validateExport';
|
||||
import { Alert, Button, Code, FileButton, Modal, Stack } from '@mantine/core';
|
||||
import { Alert, Button, Code, FileButton, Modal, Pill, Stack } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import Export3Details from './Export3Details';
|
||||
import Export3ImportSettings from './Export3ImportSettings';
|
||||
import Export3UserChoose from './Export3UserChoose';
|
||||
|
||||
export default function ImportButton() {
|
||||
export default function ImportV3Button() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [export3, setExport3] = useState<Export3 | null>(null);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
@@ -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 "Import" 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/components/pages/serverActions/index.tsx
Normal file
61
src/components/pages/serverActions/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ export default function DashboardServerSettings() {
|
||||
|
||||
const scrollToSetting = useMemo(() => {
|
||||
return (setting: string) => {
|
||||
console.log('scrolling to setting:', setting);
|
||||
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
|
||||
if (input) {
|
||||
const observer = new IntersectionObserver(
|
||||
|
||||
@@ -17,11 +17,13 @@ export default function Core({
|
||||
coreReturnHttpsUrls: boolean;
|
||||
coreDefaultDomain: string | null | undefined;
|
||||
coreTempDirectory: string;
|
||||
coreTrustProxy: boolean;
|
||||
}>({
|
||||
initialValues: {
|
||||
coreReturnHttpsUrls: false,
|
||||
coreDefaultDomain: '',
|
||||
coreTempDirectory: '/tmp/zipline',
|
||||
coreTrustProxy: false,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -45,6 +47,7 @@ export default function Core({
|
||||
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
|
||||
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
|
||||
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
|
||||
coreTrustProxy: data.settings.coreTrustProxy ?? false,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -55,14 +58,20 @@ export default function Core({
|
||||
<Title order={2}>Core</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
description='Return URLs with HTTPS protocol.'
|
||||
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
description='Return URLs with HTTPS protocol.'
|
||||
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Trust Proxies'
|
||||
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
|
||||
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Domain'
|
||||
description='The domain to use when generating URLs. This value should not include the protocol.'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,3})$/gim;
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,30})$/gim;
|
||||
|
||||
export default function Domains({
|
||||
swr: { data, isLoading },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function GeneratorButton({
|
||||
{name === 'ShareX' && (
|
||||
<Switch
|
||||
label='Xshare Compatibility'
|
||||
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The genereated config will not work with ShareX.'
|
||||
description='If you choose to use the Xshare app on Android, enable this option for compatibility. The generated config will not work with ShareX.'
|
||||
checked={options.sharex_xshareCompatibility ?? false}
|
||||
onChange={(event) => setOption({ sharex_xshareCompatibility: event.currentTarget.checked })}
|
||||
disabled={!onlyFile}
|
||||
|
||||
@@ -27,9 +27,9 @@ export default function TwoFAButton() {
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const {
|
||||
data: twoData,
|
||||
error: twoError,
|
||||
isLoading: twoLoading,
|
||||
data: mfaData,
|
||||
error: mfaError,
|
||||
isLoading: mfaLoading,
|
||||
} = useSWR<Extract<Response['/api/user/mfa/totp'], { secret: string; qrcode: string }>>(
|
||||
totpOpen && !user?.totpSecret ? '/api/user/mfa/totp' : null,
|
||||
null,
|
||||
@@ -51,7 +51,7 @@ export default function TwoFAButton() {
|
||||
'POST',
|
||||
{
|
||||
code: pin,
|
||||
secret: twoData!.secret,
|
||||
secret: mfaData!.secret,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -156,25 +156,20 @@ export default function TwoFAButton() {
|
||||
</Text>
|
||||
|
||||
<Box pos='relative'>
|
||||
{twoLoading && !twoError ? (
|
||||
{mfaLoading && !mfaError ? (
|
||||
<Box w={180} h={180}>
|
||||
<LoadingOverlay visible pos='relative' />
|
||||
</Box>
|
||||
) : (
|
||||
<Center>
|
||||
<Image
|
||||
width={180}
|
||||
height={180}
|
||||
src={twoData?.qrcode}
|
||||
alt={'qr code ' + twoData?.secret}
|
||||
/>
|
||||
<Image h={180} w={180} src={mfaData?.qrcode} alt={'qr code ' + mfaData?.secret} />
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
If you can't scan the QR code, you can manually enter the following code into your
|
||||
authenticator app: <Code>{twoData?.secret ?? ''}</Code>
|
||||
authenticator app: <Code>{mfaData?.secret ?? ''}</Code>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -189,6 +189,8 @@ export function uploadFiles(
|
||||
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
|
||||
options.imageCompressionPercent &&
|
||||
req.setRequestHeader('x-zipline-image-compression-percent', options.imageCompressionPercent.toString());
|
||||
options.imageCompressionFormat !== 'default' &&
|
||||
req.setRequestHeader('x-zipline-image-compression-type', options.imageCompressionFormat);
|
||||
options.maxViews && req.setRequestHeader('x-zipline-max-views', options.maxViews.toString());
|
||||
options.addOriginalName && req.setRequestHeader('x-zipline-original-name', 'true');
|
||||
options.overrides_returnDomain && req.setRequestHeader('x-zipline-domain', options.overrides_returnDomain);
|
||||
|
||||
@@ -250,6 +250,8 @@ export async function uploadPartialFiles(
|
||||
'x-zipline-image-compression-percent',
|
||||
options.imageCompressionPercent.toString(),
|
||||
);
|
||||
options.imageCompressionFormat !== 'default' &&
|
||||
req.setRequestHeader('x-zipline-image-compression-type', options.imageCompressionFormat);
|
||||
options.maxViews && req.setRequestHeader('x-zipline-max-views', options.maxViews.toString());
|
||||
options.addOriginalName && req.setRequestHeader('x-zipline-original-name', 'true');
|
||||
options.overrides_returnDomain &&
|
||||
|
||||
@@ -2,10 +2,11 @@ import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files';
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
|
||||
import { IconArrowBackUp } from '@tabler/icons-react';
|
||||
import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import FileTable from '../files/views/FileTable';
|
||||
import Files from '../files/views/Files';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ViewUserFiles() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
@@ -16,6 +17,9 @@ export default function ViewUserFiles() {
|
||||
|
||||
const view = useViewStore((state) => state.files);
|
||||
|
||||
const [tableEditOpen, setTableEditOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
@@ -26,10 +30,41 @@ export default function ViewUserFiles() {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Table Options'>
|
||||
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
|
||||
<IconTableOptions size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Search by ID'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<IconGridPatternFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<GridTableSwitcher type='files' />
|
||||
</Group>
|
||||
|
||||
{view === 'grid' ? <Files id={user.id} /> : <FileTable id={user.id} />}
|
||||
{view === 'grid' ? (
|
||||
<Files id={user.id} />
|
||||
) : (
|
||||
<FileTable
|
||||
id={user.id}
|
||||
tableEdit={{
|
||||
open: tableEditOpen,
|
||||
setOpen: setTableEditOpen,
|
||||
}}
|
||||
idSearch={{
|
||||
open: idSearchOpen,
|
||||
setOpen: setIdSearchOpen,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
.theme {
|
||||
color: var(--_color);
|
||||
background: var(--_background);
|
||||
display: block;
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||
import { IconCheck, IconClipboardCopy, IconChevronDown, IconChevronUp } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconCheck, IconChevronDown, IconChevronUp, IconClipboardCopy } from '@tabler/icons-react';
|
||||
import type { HLJSApi } from 'highlight.js';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
import './HighlightCode.theme.scss';
|
||||
import { type HLJSApi } from 'highlight.js';
|
||||
|
||||
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
||||
const theme = useMantineTheme();
|
||||
@@ -14,15 +15,56 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
import('highlight.js').then((mod) => setHljs(mod.default || mod));
|
||||
}, []);
|
||||
|
||||
const lines = code.split('\n');
|
||||
const lineNumbers = lines.map((_, i) => i + 1);
|
||||
const displayLines = expanded ? lines : lines.slice(0, 50);
|
||||
const displayLineNumbers = expanded ? lineNumbers : lineNumbers.slice(0, 50);
|
||||
const lines = useMemo(() => code.split('\n'), [code]);
|
||||
const visible = expanded ? lines.length : Math.min(lines.length, 50);
|
||||
const expandable = lines.length > 50;
|
||||
|
||||
let lang = language;
|
||||
if (!hljs || !hljs.getLanguage(lang)) {
|
||||
lang = 'text';
|
||||
}
|
||||
const lang = useMemo(() => {
|
||||
if (!hljs) return 'plaintext';
|
||||
if (hljs.getLanguage(language)) return language;
|
||||
|
||||
return 'plaintext';
|
||||
}, [hljs, language]);
|
||||
|
||||
const hlLines = useMemo(() => {
|
||||
if (!hljs) return lines;
|
||||
|
||||
return lines.map(
|
||||
(line) =>
|
||||
hljs.highlight(line, {
|
||||
language: lang,
|
||||
}).value,
|
||||
);
|
||||
}, [lines, hljs, lang]);
|
||||
|
||||
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
whiteSpace: 'pre',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
component='span'
|
||||
c='dimmed'
|
||||
mr='md'
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
width: 40,
|
||||
textAlign: 'right',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</Text>
|
||||
|
||||
<code className='theme hljs' style={{ flex: 1 }} dangerouslySetInnerHTML={{ __html: hlLines[index] }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' my='md' pos='relative'>
|
||||
@@ -44,37 +86,17 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
)}
|
||||
</CopyButton>
|
||||
|
||||
<ScrollArea type='auto' dir='ltr' offsetScrollbars={false}>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre', overflowX: 'auto' }} className='theme'>
|
||||
<code className='theme'>
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i}>
|
||||
<Text
|
||||
component='span'
|
||||
size='sm'
|
||||
c='dimmed'
|
||||
mr='md'
|
||||
style={{ userSelect: 'none', fontFamily: 'monospace' }}
|
||||
>
|
||||
{displayLineNumbers[i]}
|
||||
</Text>
|
||||
<span
|
||||
className='line'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: lang === 'none' || !hljs ? line : hljs.highlight(line, { language: lang }).value,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
|
||||
<List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
|
||||
{Row}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
|
||||
{lines.length > 50 && (
|
||||
{expandable && (
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='light'
|
||||
size='compact-sm'
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />}
|
||||
style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
|
||||
@@ -86,4 +86,6 @@ export async function exportConfig({ yml, showDefaults }: { yml?: boolean; showD
|
||||
|
||||
console.log(`${yml ? '- ' : ''}${envVar.variable}=${envValue}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DATABASE_TO_PROP = {
|
||||
coreReturnHttpsUrls: 'core.returnHttpsUrls',
|
||||
coreDefaultDomain: 'core.defaultDomain',
|
||||
coreTempDirectory: 'core.tempDirectory',
|
||||
coreTrustProxy: 'core.trustProxy',
|
||||
|
||||
chunksMax: 'chunks.max',
|
||||
chunksSize: 'chunks.size',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { log } from '@/lib/logger';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parse } from './transform';
|
||||
|
||||
export type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json';
|
||||
export function env(property: string, env: string | string[], type: EnvType, isDb: boolean = false) {
|
||||
export function env(property: string, env: string, type: EnvType, isDb: boolean = false) {
|
||||
return {
|
||||
variable: env,
|
||||
property,
|
||||
@@ -15,7 +16,14 @@ export const ENVS = [
|
||||
env('core.port', 'CORE_PORT', 'number'),
|
||||
env('core.hostname', 'CORE_HOSTNAME', 'string'),
|
||||
env('core.secret', 'CORE_SECRET', 'string'),
|
||||
env('core.databaseUrl', ['DATABASE_URL', 'CORE_DATABASE_URL'], 'string'),
|
||||
|
||||
env('core.databaseUrl', 'DATABASE_URL', 'string'),
|
||||
// or
|
||||
env('core.database.username', 'DATABASE_USERNAME', 'string', true),
|
||||
env('core.database.password', 'DATABASE_PASSWORD', 'string', true),
|
||||
env('core.database.host', 'DATABASE_HOST', 'string', true),
|
||||
env('core.database.port', 'DATABASE_PORT', 'number', true),
|
||||
env('core.database.name', 'DATABASE_NAME', 'string', true),
|
||||
|
||||
env('datasource.type', 'DATASOURCE_TYPE', 'string'),
|
||||
env('datasource.s3.accessKeyId', 'DATASOURCE_S3_ACCESS_KEY_ID', 'string'),
|
||||
@@ -32,6 +40,7 @@ export const ENVS = [
|
||||
env('ssl.cert', 'SSL_CERT', 'string'),
|
||||
|
||||
// database stuff
|
||||
env('core.trustProxy', 'CORE_TRUST_PROXY', 'boolean', true),
|
||||
env('core.returnHttpsUrls', 'CORE_RETURN_HTTPS_URLS', 'boolean', true),
|
||||
env('core.defaultDomain', 'CORE_DEFAULT_DOMAIN', 'string', true),
|
||||
env('core.tempDirectory', 'CORE_TEMP_DIRECTORY', 'string', true),
|
||||
@@ -159,11 +168,62 @@ export const PROP_TO_ENV: Record<string, string | string[]> = Object.fromEntries
|
||||
ENVS.map((env) => [env.property, env.variable]),
|
||||
);
|
||||
|
||||
export const REQUIRED_DB_VARS = [
|
||||
'DATABASE_USERNAME',
|
||||
'DATABASE_PASSWORD',
|
||||
'DATABASE_HOST',
|
||||
'DATABASE_PORT',
|
||||
'DATABASE_NAME',
|
||||
];
|
||||
|
||||
type EnvResult = {
|
||||
env: Record<string, any>;
|
||||
dbEnv: Record<string, any>;
|
||||
};
|
||||
|
||||
export function checkDbVars(): boolean {
|
||||
if (process.env.DATABASE_URL) return true;
|
||||
|
||||
for (let i = 0; i !== REQUIRED_DB_VARS.length; ++i) {
|
||||
if (process.env[REQUIRED_DB_VARS[i]] === undefined) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readDbVars(): Record<string, string> {
|
||||
const logger = log('config').c('readDbVars');
|
||||
|
||||
if (process.env.DATABASE_URL) return { DATABASE_URL: process.env.DATABASE_URL };
|
||||
|
||||
const dbVars: Record<string, string> = {};
|
||||
for (let i = 0; i !== REQUIRED_DB_VARS.length; ++i) {
|
||||
const value = process.env[REQUIRED_DB_VARS[i]];
|
||||
const valueFileName = process.env[`${REQUIRED_DB_VARS[i]}_FILE`];
|
||||
if (valueFileName) {
|
||||
try {
|
||||
dbVars[REQUIRED_DB_VARS[i]] = readFileSync(valueFileName, 'utf-8').trim();
|
||||
} catch {
|
||||
logger.error(`Failed to read database env value from file for ${REQUIRED_DB_VARS[i]}. Exiting...`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (value) {
|
||||
dbVars[REQUIRED_DB_VARS[i]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(dbVars).length || Object.keys(dbVars).length !== REQUIRED_DB_VARS.length) {
|
||||
logger.error(
|
||||
`No database environment variables found (DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}]), exiting...`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return dbVars;
|
||||
}
|
||||
|
||||
export function readEnv(): EnvResult {
|
||||
const logger = log('config').c('readEnv');
|
||||
const envResult: EnvResult = {
|
||||
@@ -173,11 +233,18 @@ export function readEnv(): EnvResult {
|
||||
|
||||
for (let i = 0; i !== ENVS.length; ++i) {
|
||||
const env = ENVS[i];
|
||||
if (Array.isArray(env.variable)) {
|
||||
env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL';
|
||||
}
|
||||
|
||||
const value = process.env[env.variable];
|
||||
let value = process.env[env.variable];
|
||||
const valueFileName = process.env[`${env.variable}_FILE`];
|
||||
if (valueFileName) {
|
||||
try {
|
||||
value = readFileSync(valueFileName, 'utf-8').trim();
|
||||
logger.debug('Using env value from file', { variable: env.variable, file: valueFileName });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to read env value from file for ${env.variable}. Skipping...`).error(e as Error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ export const rawConfig: any = {
|
||||
databaseUrl: undefined,
|
||||
returnHttpsUrls: undefined,
|
||||
tempDirectory: undefined,
|
||||
trustProxy: undefined,
|
||||
database: {
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
name: undefined,
|
||||
},
|
||||
},
|
||||
chunks: {
|
||||
max: undefined,
|
||||
|
||||
@@ -67,13 +67,36 @@ export const schema = z.object({
|
||||
});
|
||||
}
|
||||
}),
|
||||
databaseUrl: z.url(),
|
||||
returnHttpsUrls: z.boolean().default(false),
|
||||
defaultDomain: z.string().nullable().default(null),
|
||||
tempDirectory: z
|
||||
.string()
|
||||
.transform((s) => resolve(s))
|
||||
.default(join(tmpdir(), 'zipline')),
|
||||
trustProxy: z.boolean().default(false),
|
||||
|
||||
databaseUrl: z.url(),
|
||||
|
||||
database: z
|
||||
.object({
|
||||
username: z.string().nullable().default(null),
|
||||
password: z.string().nullable().default(null),
|
||||
host: z.string().nullable().default(null),
|
||||
port: z.number().nullable().default(null),
|
||||
name: z.string().nullable().default(null),
|
||||
})
|
||||
.superRefine((val, c) => {
|
||||
const values = Object.values(val);
|
||||
const someSet = values.some((v) => v !== null);
|
||||
const allSet = values.every((v) => v !== null);
|
||||
|
||||
if (someSet && !allSet) {
|
||||
c.addIssue({
|
||||
code: 'custom',
|
||||
message: 'If one database field is set, all fields must be set',
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
chunks: z.object({
|
||||
max: z.string().default('95mb'),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const metricDataSchema = z.object({
|
||||
|
||||
filesUsers: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
username: z.string().nullable(),
|
||||
sum: z.number(),
|
||||
storage: z.number(),
|
||||
views: z.number(),
|
||||
@@ -27,7 +27,7 @@ export const metricDataSchema = z.object({
|
||||
),
|
||||
urlsUsers: z.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
username: z.string().nullable(),
|
||||
sum: z.number(),
|
||||
views: z.number(),
|
||||
}),
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ export const V3_SETTINGS_TRANSFORM: Record<keyof typeof V3_COMPATIBLE_SETTINGS,
|
||||
export function validateExport(data: unknown): ReturnType<typeof export3Schema.safeParse> {
|
||||
const result = export3Schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
if (typeof window === 'object') console.error('Failed to validate export data', result.error);
|
||||
if (typeof window === 'object') console.error('Failed to validate export3 data', result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
205
src/lib/import/version4/validateExport.ts
Normal file
205
src/lib/import/version4/validateExport.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Zipline } from '@/prisma/client';
|
||||
import { OAuthProviderType, Role, UserFilesQuota } from '@/prisma/enums';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type Export4 = z.infer<typeof export4Schema>;
|
||||
|
||||
export const export4Schema = z.object({
|
||||
versions: z.object({
|
||||
zipline: z.string(),
|
||||
node: z.string(),
|
||||
export: z.literal('4'),
|
||||
}),
|
||||
request: z.object({
|
||||
user: z.custom<`${string}:${string}`>((data) => {
|
||||
if (typeof data !== 'string') return false;
|
||||
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [username, id] = parts;
|
||||
if (!username || !id) return false;
|
||||
|
||||
return data;
|
||||
}),
|
||||
date: z.string(),
|
||||
os: z.object({
|
||||
platform: z.union([
|
||||
z.literal('aix'),
|
||||
z.literal('darwin'),
|
||||
z.literal('freebsd'),
|
||||
z.literal('linux'),
|
||||
z.literal('openbsd'),
|
||||
z.literal('sunos'),
|
||||
z.literal('win32'),
|
||||
z.literal('android'),
|
||||
]),
|
||||
arch: z.union([
|
||||
z.literal('arm'),
|
||||
z.literal('arm64'),
|
||||
z.literal('ia32'),
|
||||
z.literal('loong64'),
|
||||
z.literal('mips'),
|
||||
z.literal('mipsel'),
|
||||
z.literal('ppc'),
|
||||
z.literal('ppc64'),
|
||||
z.literal('riscv64'),
|
||||
z.literal('s390'),
|
||||
z.literal('s390x'),
|
||||
z.literal('x64'),
|
||||
]),
|
||||
cpus: z.number(),
|
||||
hostname: z.string(),
|
||||
release: z.string(),
|
||||
}),
|
||||
env: z.record(z.string(), z.string()),
|
||||
}),
|
||||
data: z.object({
|
||||
settings: z.custom<Zipline>(),
|
||||
users: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
username: z.string(),
|
||||
password: z.string().nullable().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
role: z.enum(Role),
|
||||
view: z.record(z.string(), z.unknown()),
|
||||
totpSecret: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
userPasskeys: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
lastUsed: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
|
||||
name: z.string(),
|
||||
reg: z.record(z.string(), z.unknown()),
|
||||
userId: z.string(),
|
||||
}),
|
||||
),
|
||||
userQuotas: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
filesQuota: z.enum(UserFilesQuota),
|
||||
maxBytes: z.string().nullable().optional(),
|
||||
maxFiles: z.number().nullable().optional(),
|
||||
maxUrls: z.number().nullable().optional(),
|
||||
userId: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
userOauthProviders: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
provider: z.enum(OAuthProviderType),
|
||||
username: z.string(),
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().nullable().optional(),
|
||||
oauthId: z.string().nullable().optional(),
|
||||
userId: z.string(),
|
||||
}),
|
||||
),
|
||||
userTags: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
name: z.string(),
|
||||
color: z.string().nullable().optional(),
|
||||
files: z.array(z.string()),
|
||||
userId: z.string(),
|
||||
}),
|
||||
),
|
||||
invites: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
expiresAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
|
||||
code: z.string(),
|
||||
uses: z.number(),
|
||||
maxUses: z.number().nullable().optional(),
|
||||
inviterId: z.string(),
|
||||
}),
|
||||
),
|
||||
folders: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
name: z.string(),
|
||||
public: z.boolean(),
|
||||
allowUploads: z.boolean(),
|
||||
files: z.array(z.string()),
|
||||
userId: z.string(),
|
||||
}),
|
||||
),
|
||||
urls: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
code: z.string(),
|
||||
vanity: z.string().nullable().optional(),
|
||||
destination: z.string(),
|
||||
views: z.number(),
|
||||
maxViews: z.number().nullable().optional(),
|
||||
password: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
userId: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
files: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
deletesAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
|
||||
name: z.string(),
|
||||
originalName: z.string().nullable().optional(),
|
||||
size: z.number(),
|
||||
type: z.string(),
|
||||
views: z.number(),
|
||||
maxViews: z.number().nullable().optional(),
|
||||
favorite: z.boolean(),
|
||||
password: z.string().nullable().optional(),
|
||||
userId: z.string().nullable(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
thumbnails: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
path: z.string(),
|
||||
fileId: z.string(),
|
||||
}),
|
||||
),
|
||||
metrics: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export function validateExport(data: unknown): ReturnType<typeof export4Schema.safeParse> {
|
||||
const result = export4Schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
if (typeof window === 'object') console.error('Failed to validate export4 data', result.error.issues);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -39,9 +39,12 @@ export async function queryStats(): Promise<MetricData> {
|
||||
});
|
||||
|
||||
for (let i = 0; i !== filesByUser.length; ++i) {
|
||||
const id = filesByUser[i].userId;
|
||||
if (!id) continue;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: filesByUser[i].userId!,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,9 +52,12 @@ export async function queryStats(): Promise<MetricData> {
|
||||
}
|
||||
|
||||
for (let i = 0; i !== urlsByUser.length; ++i) {
|
||||
const id = urlsByUser[i].userId;
|
||||
if (!id) continue;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: urlsByUser[i].userId!,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
58
src/lib/store/fileTableSettings.ts
Executable file
58
src/lib/store/fileTableSettings.ts
Executable file
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const FIELDS = ['name', 'originalName', 'tags', 'type', 'size', 'createdAt', 'favorite', 'views'] as const;
|
||||
|
||||
export const defaultFields: FieldSettings[] = [
|
||||
{ field: 'name', visible: true },
|
||||
{ field: 'originalName', visible: false },
|
||||
{ field: 'tags', visible: true },
|
||||
{ field: 'type', visible: true },
|
||||
{ field: 'size', visible: true },
|
||||
{ field: 'createdAt', visible: true },
|
||||
{ field: 'favorite', visible: true },
|
||||
{ field: 'views', visible: true },
|
||||
];
|
||||
|
||||
export type FieldSettings = {
|
||||
field: (typeof FIELDS)[number];
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type FileTableSettings = {
|
||||
fields: FieldSettings[];
|
||||
|
||||
setVisible: (field: FieldSettings['field'], visible: boolean) => void;
|
||||
setIndex: (field: FieldSettings['field'], index: number) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const useFileTableSettingsStore = create<FileTableSettings>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
fields: defaultFields,
|
||||
|
||||
setVisible: (field, visible) =>
|
||||
set((state) => ({
|
||||
fields: state.fields.map((f) => (f.field === field ? { ...f, visible } : f)),
|
||||
})),
|
||||
|
||||
setIndex: (field, index) =>
|
||||
set((state) => {
|
||||
const currentIndex = state.fields.findIndex((f) => f.field === field);
|
||||
if (currentIndex === -1 || index < 0 || index >= state.fields.length) return state;
|
||||
|
||||
const newFields = [...state.fields];
|
||||
const [movedField] = newFields.splice(currentIndex, 1);
|
||||
newFields.splice(index, 0, movedField);
|
||||
|
||||
return { fields: newFields };
|
||||
}),
|
||||
|
||||
reset: () => set({ fields: defaultFields }),
|
||||
}),
|
||||
{
|
||||
name: 'zipline-file-table-settings',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -185,6 +185,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
|
||||
const imageCompressionPercent = headers['x-zipline-image-compression-percent'];
|
||||
const imageCompressionType = headers['x-zipline-image-compression-type'];
|
||||
|
||||
if (imageCompressionType) {
|
||||
if (!imageCompressionPercent)
|
||||
return headerError(
|
||||
|
||||
@@ -152,7 +152,7 @@ async function main() {
|
||||
client: s3datasource.client,
|
||||
params: {
|
||||
Bucket: s3datasource.options.bucket,
|
||||
Key: file.filename,
|
||||
Key: s3datasource.key(file.filename),
|
||||
Body: bodyStream,
|
||||
},
|
||||
partSize: bytes(config.chunks.size),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import { checkDbVars, REQUIRED_DB_VARS } from '@/lib/config/read/env';
|
||||
import { getDatasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { runMigrations } from '@/lib/db/migration';
|
||||
@@ -19,7 +20,7 @@ import { fastifyRateLimit } from '@fastify/rate-limit';
|
||||
import { fastifySensible } from '@fastify/sensible';
|
||||
import { fastifyStatic } from '@fastify/static';
|
||||
import fastify from 'fastify';
|
||||
import { mkdir, readFile } from 'fs/promises';
|
||||
import { appendFile, mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { version } from '../../package.json';
|
||||
import { checkRateLimit } from './plugins/checkRateLimit';
|
||||
@@ -46,8 +47,8 @@ async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
logger.info('starting zipline', { mode: MODE, version: version, argv });
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
logger.error('DATABASE_URL not set, exiting...');
|
||||
if (!checkDbVars()) {
|
||||
logger.error(`either DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}] not set, exiting...`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -65,6 +66,13 @@ async function main() {
|
||||
|
||||
await mkdir(config.core.tempDirectory, { recursive: true });
|
||||
|
||||
logger.debug('creating server', {
|
||||
port: config.core.port,
|
||||
hostname: config.core.hostname,
|
||||
ssl: notNull(config.ssl.key, config.ssl.cert),
|
||||
trustProxy: config.core.trustProxy,
|
||||
});
|
||||
|
||||
const server = fastify({
|
||||
https: notNull(config.ssl.key, config.ssl.cert)
|
||||
? {
|
||||
@@ -72,6 +80,7 @@ async function main() {
|
||||
cert: await readFile(config.ssl.cert!, 'utf8'),
|
||||
}
|
||||
: null,
|
||||
trustProxy: config.core.trustProxy,
|
||||
});
|
||||
|
||||
await server.register(fastifyCookie, {
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
285
src/server/routes/api/server/export.ts
Normal file
285
src/server/routes/api/server/export.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { cpus, hostname, platform, release } from 'os';
|
||||
import { version } from '../../../../../package.json';
|
||||
|
||||
async function getCounts() {
|
||||
const users = await prisma.user.count();
|
||||
const files = await prisma.file.count();
|
||||
const urls = await prisma.url.count();
|
||||
const folders = await prisma.folder.count();
|
||||
const invites = await prisma.invite.count();
|
||||
const thumbnails = await prisma.thumbnail.count();
|
||||
const metrics = await prisma.metric.count();
|
||||
|
||||
return {
|
||||
users,
|
||||
files,
|
||||
urls,
|
||||
folders,
|
||||
invites,
|
||||
thumbnails,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiServerExport = Export4;
|
||||
|
||||
type Query = {
|
||||
nometrics?: string;
|
||||
counts?: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('export');
|
||||
|
||||
export const PATH = '/api/server/export';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Querystring: Query }>(
|
||||
PATH,
|
||||
{
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.query.counts === 'true') {
|
||||
const counts = await getCounts();
|
||||
|
||||
return res.send(counts);
|
||||
}
|
||||
|
||||
logger.debug('exporting server data', { format: '4', requester: req.user.username });
|
||||
|
||||
const settingsTable = await prisma.zipline.findFirst();
|
||||
if (!settingsTable)
|
||||
return res.badRequest(
|
||||
'Invalid setup, no settings found. Run the setup process again before exporting data.',
|
||||
);
|
||||
|
||||
const export4: Export4 = {
|
||||
versions: {
|
||||
export: '4',
|
||||
node: process.version,
|
||||
zipline: version,
|
||||
},
|
||||
request: {
|
||||
date: new Date().toISOString(),
|
||||
env: process.env as Record<string, string>,
|
||||
user: `${req.user.id}:${req.user.username}`,
|
||||
os: {
|
||||
arch: process.arch,
|
||||
cpus: cpus().length,
|
||||
hostname: hostname(),
|
||||
platform: platform() as Export4['request']['os']['platform'],
|
||||
release: release(),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
settings: settingsTable,
|
||||
|
||||
users: [],
|
||||
userPasskeys: [],
|
||||
userQuotas: [],
|
||||
userOauthProviders: [],
|
||||
userTags: [],
|
||||
|
||||
invites: [],
|
||||
folders: [],
|
||||
urls: [],
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
metrics: [],
|
||||
},
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
passkeys: true,
|
||||
quota: true,
|
||||
oauthProviders: true,
|
||||
invites: true,
|
||||
urls: true,
|
||||
tags: {
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
export4.data.users.push({
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
view: user.view,
|
||||
totpSecret: user.totpSecret,
|
||||
});
|
||||
|
||||
for (const passkey of user.passkeys) {
|
||||
export4.data.userPasskeys.push({
|
||||
createdAt: passkey.createdAt.toISOString(),
|
||||
id: passkey.id,
|
||||
lastUsed: passkey.lastUsed ? passkey.lastUsed.toISOString() : null,
|
||||
name: passkey.name,
|
||||
reg: passkey.reg as Record<string, unknown>,
|
||||
userId: passkey.userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const oauthProvider of user.oauthProviders) {
|
||||
export4.data.userOauthProviders.push({
|
||||
createdAt: oauthProvider.createdAt.toISOString(),
|
||||
id: oauthProvider.id,
|
||||
provider: oauthProvider.provider,
|
||||
username: oauthProvider.username,
|
||||
accessToken: oauthProvider.accessToken,
|
||||
refreshToken: oauthProvider.refreshToken,
|
||||
oauthId: oauthProvider.oauthId,
|
||||
userId: oauthProvider.userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const tag of user.tags) {
|
||||
export4.data.userTags.push({
|
||||
createdAt: tag.createdAt.toISOString(),
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
files: tag.files.map((file) => file.id),
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const invite of user.invites) {
|
||||
export4.data.invites.push({
|
||||
createdAt: invite.createdAt.toISOString(),
|
||||
id: invite.id,
|
||||
code: invite.code,
|
||||
uses: invite.uses,
|
||||
maxUses: invite.maxUses,
|
||||
expiresAt: invite.expiresAt ? invite.expiresAt.toISOString() : null,
|
||||
inviterId: invite.inviterId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const folder of user.folders) {
|
||||
export4.data.folders.push({
|
||||
createdAt: folder.createdAt.toISOString(),
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
public: folder.public,
|
||||
allowUploads: folder.allowUploads,
|
||||
userId: folder.userId,
|
||||
files: folder.files.map((file) => file.id),
|
||||
});
|
||||
}
|
||||
|
||||
for (const url of user.urls) {
|
||||
export4.data.urls.push({
|
||||
createdAt: url.createdAt.toISOString(),
|
||||
id: url.id,
|
||||
code: url.code,
|
||||
vanity: url.vanity,
|
||||
destination: url.destination,
|
||||
views: url.views,
|
||||
maxViews: url.maxViews,
|
||||
password: url.password,
|
||||
enabled: url.enabled,
|
||||
userId: url.userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.quota) {
|
||||
export4.data.userQuotas.push({
|
||||
createdAt: user.quota.createdAt.toISOString(),
|
||||
id: user.quota.id,
|
||||
filesQuota: user.quota.filesQuota,
|
||||
maxBytes: user.quota.maxBytes,
|
||||
maxFiles: user.quota.maxFiles,
|
||||
maxUrls: user.quota.maxUrls,
|
||||
userId: user.quota.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files = await prisma.file.findMany();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.userId)
|
||||
logger.warn('file has no user associated with it, still exporting...', {
|
||||
fileId: file.id,
|
||||
name: file.name,
|
||||
});
|
||||
|
||||
export4.data.files.push({
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
deletesAt: file.deletesAt ? file.deletesAt.toISOString() : null,
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
favorite: file.favorite,
|
||||
originalName: file.originalName,
|
||||
type: file.type,
|
||||
views: file.views,
|
||||
maxViews: file.maxViews,
|
||||
password: file.password,
|
||||
userId: file.userId,
|
||||
folderId: file.folderId,
|
||||
});
|
||||
}
|
||||
|
||||
const thumbnails = await prisma.thumbnail.findMany();
|
||||
|
||||
for (const thumbnail of thumbnails) {
|
||||
export4.data.thumbnails.push({
|
||||
createdAt: thumbnail.createdAt.toISOString(),
|
||||
id: thumbnail.id,
|
||||
|
||||
path: thumbnail.path,
|
||||
|
||||
fileId: thumbnail.fileId,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.query.nometrics === undefined) {
|
||||
const metrics = await prisma.metric.findMany();
|
||||
|
||||
export4.data.metrics = metrics.map((metric) => ({
|
||||
createdAt: metric.createdAt.toISOString(),
|
||||
id: metric.id,
|
||||
data: metric.data as Record<string, unknown>,
|
||||
}));
|
||||
}
|
||||
|
||||
return res
|
||||
.header('Content-Disposition', `attachment; filename="zipline4_export_${Date.now()}.json"`)
|
||||
.type('application/json')
|
||||
.send(export4);
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
530
src/server/routes/api/server/import/v4.ts
Normal file
530
src/server/routes/api/server/import/v4.ts
Normal 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 },
|
||||
);
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,11 +5,12 @@ import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
import archiver from 'archiver';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { rm, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Export } from '@/prisma/client';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
|
||||
export type ApiUserExportResponse = {
|
||||
running?: boolean;
|
||||
@@ -92,70 +93,29 @@ export default fastifyPlugin(
|
||||
size: '0',
|
||||
},
|
||||
});
|
||||
|
||||
const writeStream = createWriteStream(exportPath);
|
||||
const zip = new Zip();
|
||||
|
||||
const onBackpressure = (stream: any, outputStream: any, cb: any) => {
|
||||
const runCb = () => {
|
||||
cb(applyOutputBackpressure || backpressureBytes > backpressureThreshold);
|
||||
};
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
const backpressureThreshold = 65536;
|
||||
const backpressure: number[] = [];
|
||||
let backpressureBytes = 0;
|
||||
const push = stream.push;
|
||||
stream.push = (data: string | any[], final: any) => {
|
||||
backpressure.push(data.length);
|
||||
backpressureBytes += data.length;
|
||||
runCb();
|
||||
push.call(stream, data, final);
|
||||
};
|
||||
let ondata = stream.ondata;
|
||||
const ondataPatched = (err: any, data: any, final: any) => {
|
||||
ondata.call(stream, err, data, final);
|
||||
backpressureBytes -= backpressure.shift()!;
|
||||
runCb();
|
||||
};
|
||||
if (ondata) {
|
||||
stream.ondata = ondataPatched;
|
||||
} else {
|
||||
Object.defineProperty(stream, 'ondata', {
|
||||
get: () => ondataPatched,
|
||||
set: (cb) => (ondata = cb),
|
||||
});
|
||||
zip.pipe(writeStream);
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn(`failed to get file ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let applyOutputBackpressure = false;
|
||||
const write = outputStream.write;
|
||||
outputStream.write = (data: any) => {
|
||||
const outputNotFull = write.call(outputStream, data);
|
||||
applyOutputBackpressure = !outputNotFull;
|
||||
runCb();
|
||||
};
|
||||
outputStream.on('drain', () => {
|
||||
applyOutputBackpressure = false;
|
||||
runCb();
|
||||
});
|
||||
};
|
||||
zip.append(stream, { name: file.name });
|
||||
totalSize += file.size;
|
||||
logger.debug('file added to zip', { name: file.name, size: file.size });
|
||||
}
|
||||
|
||||
zip.ondata = async (err, data, final) => {
|
||||
if (err) {
|
||||
writeStream.close();
|
||||
logger.debug('error while writing to zip', { err });
|
||||
logger.error(`export for ${req.user.id} failed`);
|
||||
|
||||
await prisma.export.delete({ where: { id: exportDb.id } });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
writeStream.write(data);
|
||||
|
||||
if (!final) return;
|
||||
|
||||
writeStream.end();
|
||||
logger.debug('exported', { path: exportPath, bytes: data.length });
|
||||
writeStream.on('close', async () => {
|
||||
logger.debug('exported', { path: exportPath, bytes: zip.pointer() });
|
||||
logger.info(`export for ${req.user.id} finished at ${exportPath}`);
|
||||
|
||||
await prisma.export.update({
|
||||
@@ -165,37 +125,15 @@ export default fastifyPlugin(
|
||||
size: (await stat(exportPath)).size.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
zip.on('error', (err) => {
|
||||
logger.error('export zip error', { err, exportId: exportDb.id });
|
||||
});
|
||||
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn(`failed to get file ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
zip.finalize();
|
||||
|
||||
const passThrough = new ZipPassThrough(file.name);
|
||||
zip.add(passThrough);
|
||||
|
||||
onBackpressure(passThrough, stream, (applyBackpressure: boolean) => {
|
||||
if (applyBackpressure) {
|
||||
stream.pause();
|
||||
} else if (stream.isPaused()) {
|
||||
stream.resume();
|
||||
}
|
||||
});
|
||||
stream.on('data', (c) => passThrough.push(c));
|
||||
stream.on('end', () => {
|
||||
passThrough.push(new Uint8Array(0), true);
|
||||
logger.debug(`file ${i + 1}/${files.length} added to zip`, { name: file.name });
|
||||
});
|
||||
}
|
||||
|
||||
zip.end();
|
||||
|
||||
logger.info(`export for ${req.user.id} started`);
|
||||
logger.info(`export for ${req.user.id} started`, { totalSize: bytes(totalSize) });
|
||||
|
||||
return res.send({ running: true });
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { canInteract } from '@/lib/role';
|
||||
|
||||
export type ApiUserFilesIdResponse = File;
|
||||
|
||||
@@ -33,12 +34,14 @@ export default fastifyPlugin(
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
return res.send(file);
|
||||
});
|
||||
|
||||
@@ -49,12 +52,14 @@ export default fastifyPlugin(
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
const data: Prisma.FileUpdateInput = {};
|
||||
|
||||
if (req.body.favorite !== undefined) data.favorite = req.body.favorite;
|
||||
@@ -126,6 +131,7 @@ export default fastifyPlugin(
|
||||
logger.info(`${req.user.username} updated file ${newFile.name}`, {
|
||||
updated: Object.keys(req.body),
|
||||
id: newFile.id,
|
||||
owner: file.User?.id,
|
||||
});
|
||||
|
||||
return res.send(newFile);
|
||||
@@ -135,11 +141,16 @@ export default fastifyPlugin(
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
userId: req.user.id,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
const deletedFile = await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
@@ -151,6 +162,7 @@ export default fastifyPlugin(
|
||||
|
||||
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
|
||||
size: bytes(deletedFile.size),
|
||||
owner: file.User?.id,
|
||||
});
|
||||
|
||||
return res.send(deletedFile);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { Role } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
@@ -22,6 +24,23 @@ type Body = {
|
||||
|
||||
const logger = log('api').c('user').c('files').c('transaction');
|
||||
|
||||
function checkInteraction(
|
||||
current: { id: string; role: Role },
|
||||
roles: { id: string; role: Role }[],
|
||||
): number[] {
|
||||
const indices: number[] = [];
|
||||
|
||||
for (let i = 0; i !== roles.length; ++i) {
|
||||
if (roles[i].id === current.id) continue;
|
||||
|
||||
if (!canInteract(current.role, roles[i].role)) {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
|
||||
export const PATH = '/api/user/files/transaction';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
@@ -34,14 +53,28 @@ export default fastifyPlugin(
|
||||
if (!files || !files.length) return res.badRequest('Cannot process transaction without files');
|
||||
|
||||
if (typeof favorite === 'boolean') {
|
||||
const toFavoriteFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: { in: files },
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
const invalids = checkInteraction(
|
||||
{ id: req.user.id, role: req.user.role },
|
||||
toFavoriteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
|
||||
);
|
||||
if (invalids.length > 0)
|
||||
return res.forbidden(`You don't have the permission to modify files[${invalids.join(', ')}]`);
|
||||
|
||||
const resp = await prisma.file.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
|
||||
data: {
|
||||
favorite: favorite,
|
||||
},
|
||||
@@ -51,6 +84,7 @@ export default fastifyPlugin(
|
||||
|
||||
logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, {
|
||||
user: req.user.id,
|
||||
owners: toFavoriteFiles.map((f) => f.userId),
|
||||
});
|
||||
|
||||
return res.send(resp);
|
||||
@@ -108,21 +142,28 @@ export default fastifyPlugin(
|
||||
files: files.length,
|
||||
});
|
||||
|
||||
if (delete_datasourceFiles) {
|
||||
const dFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
const toDeleteFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
id: { in: files },
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== dFiles.length; ++i) {
|
||||
await datasource.delete(dFiles[i].name);
|
||||
const invalids = checkInteraction(
|
||||
{ id: req.user.id, role: req.user.role },
|
||||
toDeleteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
|
||||
);
|
||||
if (invalids.length > 0)
|
||||
return res.forbidden(`You don't have the permission to delete files[${invalids.join(', ')}]`);
|
||||
|
||||
if (delete_datasourceFiles) {
|
||||
for (let i = 0; i !== toDeleteFiles.length; ++i) {
|
||||
await datasource.delete(toDeleteFiles[i].name);
|
||||
}
|
||||
|
||||
logger.info(`${req.user.username} deleted ${dFiles.length} files from datasource`, {
|
||||
logger.info(`${req.user.username} deleted ${toDeleteFiles.length} files from datasource`, {
|
||||
user: req.user.id,
|
||||
});
|
||||
}
|
||||
@@ -132,7 +173,6 @@ export default fastifyPlugin(
|
||||
id: {
|
||||
in: files,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -140,6 +180,7 @@ export default fastifyPlugin(
|
||||
|
||||
logger.info(`${req.user.username} deleted ${resp.count} files`, {
|
||||
user: req.user.id,
|
||||
owners: toDeleteFiles.map((f) => f.userId),
|
||||
});
|
||||
|
||||
return res.send(resp);
|
||||
|
||||
68
src/server/routes/api/user/folders/[id]/export.ts
Normal file
68
src/server/routes/api/user/folders/[id]/export.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import archiver from 'archiver';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
export type ApiUserFoldersIdExportResponse = null;
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('user').c('folders').c('[id]').c('export');
|
||||
|
||||
export const PATH = '/api/user/folders/:id/export';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
});
|
||||
if (!folder) return res.notFound('Folder not found');
|
||||
if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder');
|
||||
|
||||
if (!folder.files.length) return res.badRequest("Can't export an empty folder.");
|
||||
|
||||
logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id });
|
||||
|
||||
res.hijack();
|
||||
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
zip.pipe(res.raw);
|
||||
|
||||
for (const file of folder.files) {
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn('failed to get file stream for folder export', { file: file.id, folder: folder.id });
|
||||
continue;
|
||||
}
|
||||
|
||||
zip.append(stream, { name: file.name });
|
||||
}
|
||||
|
||||
zip.on('error', (err) => {
|
||||
logger.error('error during folder export zip creation', { folder: folder.id }).error(err as Error);
|
||||
});
|
||||
|
||||
zip.on('finish', () => {
|
||||
logger.info(`folder export completed: ${folder.name}`, { user: req.user.id, folder: folder.id });
|
||||
});
|
||||
|
||||
await zip.finalize();
|
||||
});
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
@@ -28,6 +28,7 @@ export async function filesRoute(
|
||||
|
||||
if (file.User?.view.enabled) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
if (file.type.startsWith('text/')) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
if (file.password) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
|
||||
return rawFileHandler(req, res);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
// not safe in production, but fine in dev
|
||||
allowedHosts: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user