mirror of
https://github.com/diced/zipline.git
synced 2026-01-21 17:03:29 -08:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574bd9114c | ||
|
|
73c46b875d | ||
|
|
e21670f292 | ||
|
|
09b3ef4e26 | ||
|
|
afdee6994e | ||
|
|
6f6879c58a | ||
|
|
66a2f760cf | ||
|
|
fb3199a9d5 | ||
|
|
274a84397a | ||
|
|
4b585d8634 | ||
|
|
260c283872 | ||
|
|
4d978c11b1 | ||
|
|
8bdd9e8315 | ||
|
|
d4a3e877d2 | ||
|
|
db3c5f48a5 | ||
|
|
cdcaa926fe | ||
|
|
01503968ab | ||
|
|
8aa5ec6917 | ||
|
|
9befcaaf80 | ||
|
|
bfc0e4d40c | ||
|
|
4fb21f678e | ||
|
|
f49598c760 | ||
|
|
bfd6a8769d | ||
|
|
87cf4916a5 | ||
|
|
12ea806f0a | ||
|
|
6269b457d8 | ||
|
|
78f5875464 | ||
|
|
05df685bd1 | ||
|
|
eaf245a4c9 | ||
|
|
8a7b401b6e | ||
|
|
bb13e44bc9 | ||
|
|
2c21e119c4 | ||
|
|
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 |
100
.github/workflows/gen-openapi.yml
vendored
Normal file
100
.github/workflows/gen-openapi.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
name: Generate OpenAPI Spec
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [v4, trunk]
|
||||
pull_request:
|
||||
branches: [v4, trunk]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
gen-openapi:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [24.x]
|
||||
arch: [amd64]
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: zipline
|
||||
POSTGRES_PASSWORD: zipline
|
||||
POSTGRES_DB: zipline
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U zipline -d zipline"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use node@${{ matrix.node }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ steps.pnpm-cache.outputs.store_path }}
|
||||
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
ZIPLINE_BUILD: 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Generate secret
|
||||
id: secret
|
||||
run: |
|
||||
SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9')
|
||||
echo "secret=$SECRET" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Wait for Postgres
|
||||
run: |
|
||||
until pg_isready -h localhost -p 5432 -U zipline; do
|
||||
echo "Waiting for postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run app
|
||||
env:
|
||||
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
|
||||
CORE_SECRET: ${{ steps.secret.outputs.secret }}
|
||||
ZIPLINE_OUTPUT_OPENAPI: true
|
||||
|
||||
run: pnpm start
|
||||
|
||||
- name: Verify openapi.json exists
|
||||
run: |
|
||||
if [ ! -f "./openapi.json" ]; then
|
||||
echo "openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openapi-json
|
||||
path: ./openapi.json
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,4 +48,6 @@ yarn-error.log*
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
src/prisma
|
||||
src/prisma
|
||||
.memory.log*
|
||||
openapi.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRESQL_USER:-zipline}
|
||||
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESSQL_PASSWORD is required}
|
||||
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?POSTGRESQL_PASSWORD is required}
|
||||
POSTGRES_DB: ${POSTGRESQL_DB:-zipline}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
out: './src/drizzle',
|
||||
schema: './src/drizzle/schema.ts',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL as string,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
@@ -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,53 @@ const gitignorePatterns = gitignoreContent
|
||||
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||
.map((pattern) => pattern.trim());
|
||||
|
||||
export default tseslint.config(
|
||||
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 +79,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' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
115
package.json
115
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.3.1",
|
||||
"version": "4.4.1",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
@@ -23,98 +23,103 @@
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/swagger": "^9.6.1",
|
||||
"@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",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@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",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"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",
|
||||
"fastify-type-provider-zod": "^6.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",
|
||||
"pg": "^8.16.3",
|
||||
"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/pg": "^8.15.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"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"
|
||||
|
||||
4483
pnpm-lock.yaml
generated
4483
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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `mfaPasskeys` on the `Zipline` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" DROP COLUMN "mfaPasskeys",
|
||||
ADD COLUMN "mfaPasskeysEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "mfaPasskeysOrigin" TEXT,
|
||||
ADD COLUMN "mfaPasskeysRpID" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';
|
||||
@@ -1,7 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -30,19 +31,21 @@ model Zipline {
|
||||
tasksMaxViewsInterval String @default("30m")
|
||||
tasksThumbnailsInterval String @default("30m")
|
||||
tasksMetricsInterval String @default("30m")
|
||||
tasksCleanThumbnailsInterval String @default("1d")
|
||||
|
||||
filesRoute String @default("/u")
|
||||
filesLength Int @default(6)
|
||||
filesDefaultFormat String @default("random")
|
||||
filesDisabledExtensions String[]
|
||||
filesMaxFileSize String @default("100mb")
|
||||
filesDefaultExpiration String?
|
||||
filesAssumeMimetypes Boolean @default(false)
|
||||
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
|
||||
filesRemoveGpsMetadata Boolean @default(false)
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesRoute String @default("/u")
|
||||
filesLength Int @default(6)
|
||||
filesDefaultFormat String @default("random")
|
||||
filesDisabledExtensions String[]
|
||||
filesMaxFileSize String @default("100mb")
|
||||
filesDefaultExpiration String?
|
||||
filesMaxExpiration String?
|
||||
filesAssumeMimetypes Boolean @default(false)
|
||||
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
|
||||
filesRemoveGpsMetadata Boolean @default(false)
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -63,7 +66,7 @@ model Zipline {
|
||||
featuresMetricsShowUserSpecific Boolean @default(true)
|
||||
|
||||
featuresVersionChecking Boolean @default(true)
|
||||
featuresVersionAPI String @default("https://zipline-version.diced.sh")
|
||||
featuresVersionAPI String @default("https://zipline-version.diced.sh")
|
||||
|
||||
invitesEnabled Boolean @default(true)
|
||||
invitesLength Int @default(6)
|
||||
@@ -106,7 +109,10 @@ model Zipline {
|
||||
|
||||
mfaTotpEnabled Boolean @default(false)
|
||||
mfaTotpIssuer String @default("Zipline")
|
||||
mfaPasskeys Boolean @default(false)
|
||||
|
||||
mfaPasskeysEnabled Boolean @default(false)
|
||||
mfaPasskeysRpID String?
|
||||
mfaPasskeysOrigin String?
|
||||
|
||||
ratelimitEnabled Boolean @default(true)
|
||||
ratelimitMax Int @default(10)
|
||||
@@ -140,7 +146,7 @@ model Zipline {
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -294,8 +300,8 @@ model Folder {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
allowUploads Boolean @default(false)
|
||||
|
||||
files File[]
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { ContextModalProps, ModalsProvider } from '@mantine/modals';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SWRConfig } from 'swr';
|
||||
import ThemeProvider from '@/components/ThemeProvider';
|
||||
import { type ZiplineTheme } from '@/lib/theme';
|
||||
import { type Config } from '@/lib/config/validate';
|
||||
import { Button, Text } from '@mantine/core';
|
||||
|
||||
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
|
||||
<>
|
||||
<Text size='sm'>{innerProps.modalBody}</Text>
|
||||
|
||||
<Button fullWidth mt='md' onClick={() => context.closeModal(id)}>
|
||||
OK
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const contextModals = {
|
||||
alert: AlertModal,
|
||||
};
|
||||
|
||||
declare module '@mantine/modals' {
|
||||
export interface MantineModalsOverride {
|
||||
modals: typeof contextModals;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Root({
|
||||
themes,
|
||||
@@ -37,6 +58,7 @@ export default function Root({
|
||||
},
|
||||
centered: true,
|
||||
}}
|
||||
modals={contextModals}
|
||||
>
|
||||
<Notifications zIndex={10000000} />
|
||||
<Outlet />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function FourOhFour() {
|
||||
useTitle('404');
|
||||
|
||||
return (
|
||||
<Center h='100vh'>
|
||||
<Stack>
|
||||
|
||||
@@ -2,7 +2,7 @@ import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { authenticateWeb } from '@/lib/passkey';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import {
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
@@ -35,7 +36,6 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
@@ -86,6 +86,9 @@ export default function Login() {
|
||||
username: (value) => (value.length > 1 ? null : 'Username is required'),
|
||||
password: (value) => (value.length > 1 ? null : 'Password is required'),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
|
||||
@@ -128,9 +131,24 @@ export default function Login() {
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
setPasskeyLoading(true);
|
||||
const res = await authenticateWeb();
|
||||
const { data: options, error: optionsError } = await fetchApi<Response['/api/auth/webauthn/options']>(
|
||||
'/api/auth/webauthn/options',
|
||||
'GET',
|
||||
);
|
||||
if (optionsError) {
|
||||
setPasskeyErrored(true);
|
||||
setPasskeyLoading(false);
|
||||
notifications.show({
|
||||
title: 'Error while authenticating with passkey',
|
||||
message: optionsError.error,
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await startAuthentication({ optionsJSON: options!.options! });
|
||||
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
|
||||
auth: res.toJSON(),
|
||||
response: res,
|
||||
});
|
||||
if (error) {
|
||||
setPasskeyErrored(true);
|
||||
@@ -157,7 +175,6 @@ export default function Login() {
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ willRedirect, config });
|
||||
if (willRedirect && config) {
|
||||
const provider = Object.keys(config.oauthEnabled).find(
|
||||
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
|
||||
@@ -300,6 +317,7 @@ export default function Login() {
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -311,6 +329,7 @@ export default function Login() {
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='current-password'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -337,7 +356,7 @@ export default function Login() {
|
||||
<Divider label='or' />
|
||||
)}
|
||||
|
||||
{config.mfa.passkeys && (
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && (
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
size='md'
|
||||
|
||||
@@ -67,6 +67,9 @@ export function Component() {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +217,7 @@ export function Component() {
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -225,6 +229,7 @@ export function Component() {
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='new-password'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
|
||||
@@ -65,6 +65,9 @@ export function Component() {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -180,12 +183,14 @@ export function Component() {
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Enter a password...'
|
||||
autoComplete='new-password'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
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';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
@@ -19,6 +20,8 @@ export async function loader({ params }: { params: Params<string> }) {
|
||||
export function Component() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
|
||||
useTitle(folder.name ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
|
||||
@@ -2,6 +2,7 @@ import ConfigProvider from '@/components/ConfigProvider';
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { Anchor, Center, Container, Text } from '@mantine/core';
|
||||
import { data, Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
@@ -27,6 +28,8 @@ export function Component() {
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
|
||||
useTitle(`Upload to ${folder.name ?? 'folder'}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSsrData } from '../../../components/ZiplineSSRProvider';
|
||||
import { getFile } from '../../ssr-view/server';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
|
||||
type SsrData = {
|
||||
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
|
||||
@@ -55,6 +56,8 @@ export default function ViewFileId() {
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
|
||||
useTitle(file.name ?? 'View File');
|
||||
|
||||
return password && !pw ? (
|
||||
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
|
||||
<form
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -265,11 +265,11 @@ export async function render(
|
||||
: ''
|
||||
}
|
||||
|
||||
<title>${file.name}</title>
|
||||
<title>${file.originalName ?? file.name}</title>
|
||||
`;
|
||||
|
||||
return {
|
||||
html,
|
||||
meta: `${meta}\n${createZiplineSsr(data)}`,
|
||||
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import {
|
||||
@@ -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);
|
||||
@@ -100,7 +103,7 @@ export default function FileModal({
|
||||
const [editFileOpen, setEditFileOpen] = useState(false);
|
||||
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
'/api/user/folders?noincl=true' + (user ? `&user=${user}` : ''),
|
||||
);
|
||||
|
||||
const folderCombobox = useCombobox();
|
||||
@@ -114,10 +117,14 @@ export default function FileModal({
|
||||
}
|
||||
};
|
||||
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
|
||||
user ? `/api/users/${user}/tags` : '/api/user/tags',
|
||||
);
|
||||
|
||||
const tagsCombobox = useCombobox();
|
||||
const [value, setValue] = useState(file?.tags?.map((x) => x.id) ?? []);
|
||||
|
||||
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
|
||||
|
||||
const handleValueSelect = (val: string) => {
|
||||
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
|
||||
};
|
||||
@@ -167,14 +174,6 @@ export default function FileModal({
|
||||
|
||||
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
setValue(file.tags?.map((x) => x.id) ?? []);
|
||||
} else {
|
||||
setValue([]);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
|
||||
@@ -234,15 +233,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 +253,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 +289,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 +312,8 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
store={folderCombobox}
|
||||
withinPortal={false}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
<Combobox.Target>
|
||||
@@ -398,6 +400,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)}
|
||||
|
||||
@@ -6,12 +6,12 @@ import FileModal from './FileModal';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
export default function DashboardFile({ file, reduce }: { file: File; reduce?: boolean }) {
|
||||
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} />
|
||||
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
|
||||
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
@@ -202,36 +212,23 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
| 'favorite'
|
||||
>('createdAt');
|
||||
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']) => {
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.query,
|
||||
};
|
||||
},
|
||||
(
|
||||
_state: { name: string; originalName: string; type: string; tags: string; id: string },
|
||||
action: { field: keyof ReducerQuery['state']; query: string },
|
||||
) => ({
|
||||
name: action.field === 'name' ? action.query : '',
|
||||
originalName: action.field === 'originalName' ? action.query : '',
|
||||
type: action.field === 'type' ? action.query : '',
|
||||
tags: action.field === 'tags' ? action.query : '',
|
||||
id: action.field === 'id' ? action.query : '',
|
||||
}),
|
||||
{ name: '', originalName: '', type: '', tags: '', id: '' },
|
||||
);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (idSearchOpen) return;
|
||||
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: '',
|
||||
});
|
||||
}, [idSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
const combobox = useCombobox();
|
||||
@@ -264,26 +261,112 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
|
||||
const selectedFile = selectedFileId
|
||||
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
|
||||
: null;
|
||||
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
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;
|
||||
});
|
||||
|
||||
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
|
||||
|
||||
useEffect(() => {
|
||||
for (const field of ['name', 'originalName', 'type', 'tags', 'id'] as const) {
|
||||
if (field !== searchField) {
|
||||
setSearchQuery({
|
||||
field,
|
||||
query: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [searchField]);
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -293,22 +376,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 +408,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 +474,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 +493,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',
|
||||
@@ -558,7 +572,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
setSort(data.columnAccessor as any);
|
||||
setOrder(data.direction);
|
||||
}}
|
||||
onCellClick={({ record }) => setSelectedFile(record)}
|
||||
onCellClick={({ record }) => setSelectedFile(record.id)}
|
||||
selectedRecords={selectedFiles}
|
||||
onSelectedRecordsChange={setSelectedFiles}
|
||||
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -11,11 +12,10 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
@@ -24,7 +24,6 @@ const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
export default function Files({ id }: { id?: string }) {
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [cachedPages, setCachedPages] = useState(1);
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
@@ -32,15 +31,10 @@ export default function Files({ id }: { id?: string }) {
|
||||
id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.pages) {
|
||||
setCachedPages(data.pages);
|
||||
}
|
||||
}, [data?.pages]);
|
||||
|
||||
const from = (page - 1) * perpage + 1;
|
||||
const to = Math.min(page * perpage, data?.total ?? 0);
|
||||
const totalRecords = data?.total ?? 0;
|
||||
const cachedPages = data?.pages ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,7 +53,7 @@ export default function Files({ id }: { id?: string }) {
|
||||
) : (data?.page?.length ?? 0 > 0) ? (
|
||||
data?.page.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} />
|
||||
<DashboardFile file={file} id={id} />
|
||||
</Suspense>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -3,10 +3,6 @@ import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyFolderUrl, deleteFolder, editFolderVisibility, editFolderUploads } from '../actions';
|
||||
import {
|
||||
IconCopy,
|
||||
IconFiles,
|
||||
@@ -16,9 +12,14 @@ import {
|
||||
IconShare,
|
||||
IconShareOff,
|
||||
IconTrashFilled,
|
||||
IconZip,
|
||||
} from '@tabler/icons-react';
|
||||
import ViewFilesModal from '../ViewFilesModal';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyFolderUrl, deleteFolder, editFolderUploads, editFolderVisibility } from '../actions';
|
||||
import EditFolderNameModal from '../EditFolderNameModal';
|
||||
import ViewFilesModal from '../ViewFilesModal';
|
||||
|
||||
export default function FolderTableView() {
|
||||
const clipboard = useClipboard();
|
||||
@@ -29,28 +30,23 @@ export default function FolderTableView() {
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Folder[]>(data ?? []);
|
||||
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
|
||||
|
||||
const [editNameOpen, setEditNameOpen] = useState<Folder | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Folder;
|
||||
const sorted = useMemo<Folder[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Folder;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,6 +165,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'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyInviteUrl, deleteInvite } from '../actions';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
|
||||
export default function InviteTableView() {
|
||||
const clipboard = useClipboard();
|
||||
@@ -20,25 +20,21 @@ export default function InviteTableView() {
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Invite[]>(data ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Invite;
|
||||
const sorted = useMemo<Invite[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Invite;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { lazy, useState } from 'react';
|
||||
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { useApiStats } from './useStats';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import dayjs from 'dayjs';
|
||||
import { useApiStats } from './useStats';
|
||||
|
||||
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
|
||||
@@ -35,9 +35,10 @@ export default function DashboardMetrics() {
|
||||
setDateRange(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (allTime) setDateRange([null, null]);
|
||||
}, [allTime]);
|
||||
const showAllTime = () => {
|
||||
setAllTime(true);
|
||||
setDateRange([null, null]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -118,7 +119,7 @@ export default function DashboardMetrics() {
|
||||
size='compact-sm'
|
||||
variant='outline'
|
||||
leftSection={<IconCalendarTime size='1rem' />}
|
||||
onClick={() => setAllTime(true)}
|
||||
onClick={() => showAllTime()}
|
||||
disabled={allTime}
|
||||
>
|
||||
Show All Time
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -1,125 +1,111 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { ActionIcon, Group, LoadingOverlay, Paper, Table, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
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,30})$/gim;
|
||||
|
||||
export default function Domains({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
swr: {
|
||||
data: Response['/api/server/settings'] | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
newDomain: '',
|
||||
},
|
||||
// using 'domains' here so that settingsOnSubmit picks up errors correctly
|
||||
initialValues: { domains: '' },
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(navigate, form);
|
||||
const submitSettings = settingsOnSubmit(navigate, form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const domainsData = Array.isArray(data.settings.domains)
|
||||
? data.settings.domains.map((d) => String(d))
|
||||
: [];
|
||||
setDomains(domainsData);
|
||||
}, [data]);
|
||||
const domains = Array.isArray(data?.settings.domains) ? data!.settings.domains.map(String) : [];
|
||||
|
||||
const addDomain = () => {
|
||||
const { newDomain } = form.values;
|
||||
if (!newDomain) return;
|
||||
async function updateDomains(nextDomains: string[]) {
|
||||
setSubmitting(true);
|
||||
|
||||
if (!DOMAIN_REGEX.test(newDomain)) {
|
||||
return form.setFieldError('newDomain', 'Invalid Domain');
|
||||
try {
|
||||
const error = await submitSettings({ domains: nextDomains });
|
||||
if (!error) form.setFieldValue('domains', '');
|
||||
} catch (err: any) {
|
||||
form.setFieldError('domains', err?.message ?? err?.error ?? 'Failed to update domains');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedDomains = [...domains, newDomain.trim()];
|
||||
setDomains(updatedDomains);
|
||||
form.setValues({ newDomain: '' });
|
||||
onSubmit({ domains: updatedDomains });
|
||||
const addDomain = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const domain = form.values.domains.trim();
|
||||
if (!domain) return;
|
||||
|
||||
if (domains.includes(domain)) return form.setFieldError('domains', 'This domain already exists');
|
||||
|
||||
await updateDomains([...domains, domain]);
|
||||
};
|
||||
|
||||
const removeDomain = (index: number) => {
|
||||
const updatedDomains = domains.filter((_, i) => i !== index);
|
||||
setDomains(updatedDomains);
|
||||
onSubmit({ domains: updatedDomains });
|
||||
const removeDomain = async (domain: string) => {
|
||||
await updateDomains(domains.filter((d) => d !== domain));
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<LoadingOverlay visible={isLoading || submitting} />
|
||||
|
||||
<Title order={2}>Domains</Title>
|
||||
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
label='Domain'
|
||||
description='Enter a domain name (e.g. example.com)'
|
||||
placeholder='example.com'
|
||||
{...form.getInputProps('newDomain')}
|
||||
/>
|
||||
<Button onClick={addDomain} leftSection={<IconPlus size='1rem' />}>
|
||||
Add Domain
|
||||
</Button>
|
||||
</Group>
|
||||
<form onSubmit={addDomain}>
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
description='Enter a domain name'
|
||||
placeholder='example.com'
|
||||
flex={1}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
<ActionIcon type='submit' color='blue' size='lg' variant='filled' disabled={submitting}>
|
||||
<IconPlus size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, sm: 2, md: 3 }} spacing='md' verticalSpacing='md'>
|
||||
{domains.map((domain, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
withBorder
|
||||
p='md'
|
||||
radius='md'
|
||||
shadow='xs'
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<Group justify='space-between' align='center' wrap='nowrap'>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{domain}
|
||||
</div>
|
||||
<Button
|
||||
variant='subtle'
|
||||
color='red'
|
||||
size='xs'
|
||||
onClick={() => removeDomain(index)}
|
||||
px={8}
|
||||
style={{
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IconTrash size='1rem' />
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{domains.length > 0 ? (
|
||||
<Paper withBorder p={0} mt='md'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Domain</Table.Th>
|
||||
<Table.Th w={30}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{domains.map((domain) => (
|
||||
<Table.Tr key={domain}>
|
||||
<Table.Td>
|
||||
<Text fw={500} truncate>
|
||||
{domain}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon color='red' onClick={() => removeDomain(domain)} disabled={submitting}>
|
||||
<IconTrash size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text mt='md' c='dimmed'>
|
||||
No domains added yet.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function Files({
|
||||
filesDisabledExtensions: string;
|
||||
filesMaxFileSize: string;
|
||||
filesDefaultExpiration: string | null;
|
||||
filesMaxExpiration: string | null;
|
||||
filesAssumeMimetypes: boolean;
|
||||
filesDefaultDateFormat: string;
|
||||
filesRemoveGpsMetadata: boolean;
|
||||
@@ -44,6 +45,7 @@ export default function Files({
|
||||
filesDisabledExtensions: '',
|
||||
filesMaxFileSize: '100mb',
|
||||
filesDefaultExpiration: '',
|
||||
filesMaxExpiration: '',
|
||||
filesAssumeMimetypes: false,
|
||||
filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss',
|
||||
filesRemoveGpsMetadata: false,
|
||||
@@ -63,6 +65,12 @@ export default function Files({
|
||||
values.filesDefaultExpiration = values.filesDefaultExpiration.trim();
|
||||
}
|
||||
|
||||
if (values.filesMaxExpiration?.trim() === '' || !values.filesMaxExpiration) {
|
||||
values.filesMaxExpiration = null;
|
||||
} else {
|
||||
values.filesMaxExpiration = values.filesMaxExpiration.trim();
|
||||
}
|
||||
|
||||
if (!values.filesDisabledExtensions) {
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = [];
|
||||
@@ -95,6 +103,7 @@ export default function Files({
|
||||
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
|
||||
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
|
||||
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
|
||||
filesMaxExpiration: data.settings.filesMaxExpiration ?? '',
|
||||
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
|
||||
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
||||
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
|
||||
@@ -161,6 +170,13 @@ export default function Files({
|
||||
{...form.getInputProps('filesMaxFileSize')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Date Format'
|
||||
description='The default date format to use.'
|
||||
placeholder='YYYY-MM-DD_HH:mm:ss'
|
||||
{...form.getInputProps('filesDefaultDateFormat')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Expiration'
|
||||
description='The default expiration time for files.'
|
||||
@@ -169,10 +185,10 @@ export default function Files({
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Date Format'
|
||||
description='The default date format to use.'
|
||||
placeholder='YYYY-MM-DD_HH:mm:ss'
|
||||
{...form.getInputProps('filesDefaultDateFormat')}
|
||||
label='Max Expiration'
|
||||
description='The maximum expiration time allowed for files.'
|
||||
placeholder='365d'
|
||||
{...form.getInputProps('filesMaxExpiration')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
||||
@@ -17,7 +17,9 @@ export default function Mfa({
|
||||
initialValues: {
|
||||
mfaTotpEnabled: false,
|
||||
mfaTotpIssuer: 'Zipline',
|
||||
mfaPasskeys: false,
|
||||
mfaPasskeysEnabled: false,
|
||||
mfaPasskeysRpID: '',
|
||||
mfaPasskeysOrigin: '',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -32,7 +34,9 @@ export default function Mfa({
|
||||
form.setValues({
|
||||
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
|
||||
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
|
||||
mfaPasskeys: data.settings.mfaPasskeys,
|
||||
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled ?? false,
|
||||
mfaPasskeysRpID: data.settings.mfaPasskeysRpID ?? '',
|
||||
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -47,7 +51,21 @@ export default function Mfa({
|
||||
<Switch
|
||||
label='Passkeys'
|
||||
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
|
||||
{...form.getInputProps('mfaPasskeys', { type: 'checkbox' })}
|
||||
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Relying Party ID'
|
||||
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
|
||||
placeholder='example.com'
|
||||
{...form.getInputProps('mfaPasskeysRpID')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Origin'
|
||||
description='The Origin to use for WebAuthn passkeys.'
|
||||
placeholder='https://example.com'
|
||||
{...form.getInputProps('mfaPasskeysOrigin')}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function Tasks({
|
||||
tasksMaxViewsInterval: '30m',
|
||||
tasksThumbnailsInterval: '30m',
|
||||
tasksMetricsInterval: '30m',
|
||||
tasksCleanThumbnailsInterval: '1d',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -37,6 +38,7 @@ export default function Tasks({
|
||||
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
|
||||
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
|
||||
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
|
||||
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval ?? '1d',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -79,6 +81,13 @@ export default function Tasks({
|
||||
placeholder='30m'
|
||||
{...form.getInputProps('tasksThumbnailsInterval')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Clean Thumbnails Interval'
|
||||
description='How often to check and delete orphaned thumbnails from the filesystem or database.'
|
||||
placeholder='1d'
|
||||
{...form.getInputProps('tasksCleanThumbnailsInterval')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
|
||||
@@ -32,6 +32,8 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return error;
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Settings saved',
|
||||
@@ -39,7 +41,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconDeviceFloppy,
|
||||
IconFileX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
@@ -94,6 +95,24 @@ export default function SettingsFileView() {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.setValues({
|
||||
enabled: user.view.enabled || false,
|
||||
content: user.view.content || '',
|
||||
embed: user.view.embed || false,
|
||||
embedTitle: user.view.embedTitle || '',
|
||||
embedDescription: user.view.embedDescription || '',
|
||||
embedSiteName: user.view.embedSiteName || '',
|
||||
embedColor: user.view.embedColor || '',
|
||||
align: user.view.align || 'left',
|
||||
showMimetype: user.view.showMimetype || false,
|
||||
showTags: user.view.showTags || false,
|
||||
showFolder: user.view.showFolder || false,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>Viewing Files</Title>
|
||||
|
||||
@@ -104,14 +104,12 @@ export default function GeneratorButton({
|
||||
);
|
||||
|
||||
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
|
||||
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const { data: settingsData } = useSWR<Response['/api/server/public']>('/api/server/public');
|
||||
|
||||
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
|
||||
const onlyFile = generatorType === 'file';
|
||||
|
||||
const domains = Array.isArray(settingsData?.settings.domains)
|
||||
? settingsData?.settings.domains.map((d) => String(d))
|
||||
: [];
|
||||
const domains = Array.isArray(settingsData?.domains) ? settingsData?.domains.map((d) => String(d)) : [];
|
||||
const domainOptions = [
|
||||
{ value: '', label: 'Default Domain' },
|
||||
...domains.map((domain) => ({
|
||||
@@ -232,7 +230,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}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { registerWeb } from '@/lib/passkey';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
|
||||
import { UserPasskey } from '@/prisma/client';
|
||||
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { UserPasskey } from '@/prisma/client';
|
||||
import {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser';
|
||||
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
@@ -23,10 +26,15 @@ export default function PasskeyButton() {
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
try {
|
||||
const { data } = await fetchApi<PublicKeyCredentialCreationOptionsJSON>(
|
||||
'/api/user/mfa/passkey/options',
|
||||
'GET',
|
||||
);
|
||||
|
||||
setPasskeyLoading(true);
|
||||
const res = await registerWeb(user!);
|
||||
const res = await startRegistration({ optionsJSON: data! });
|
||||
setNamerShown(true);
|
||||
setSavedKey(res.toJSON());
|
||||
setSavedKey(res);
|
||||
} catch (e: any) {
|
||||
setPasskeyError(e.message ?? 'An error occurred while creating a passkey');
|
||||
setPasskeyLoading(false);
|
||||
@@ -38,7 +46,7 @@ export default function PasskeyButton() {
|
||||
if (!savedKey) return;
|
||||
|
||||
const { error } = await fetchApi('/api/user/mfa/passkey', 'POST', {
|
||||
reg: savedKey,
|
||||
response: savedKey,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
@@ -23,13 +24,14 @@ import useSWR, { mutate } from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export default function TwoFAButton() {
|
||||
const size = useMediaQuery('(max-width: 600px)') ? 'sm' : 'xl';
|
||||
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
|
||||
|
||||
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 +53,7 @@ export default function TwoFAButton() {
|
||||
'POST',
|
||||
{
|
||||
code: pin,
|
||||
secret: twoData!.secret,
|
||||
secret: mfaData!.secret,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function TwoFAButton() {
|
||||
>
|
||||
Google Authenticator
|
||||
</Anchor>
|
||||
, and{' '}
|
||||
,{' '}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to='https://www.microsoft.com/en-us/security/mobile-authenticator-app'
|
||||
@@ -148,6 +150,14 @@ export default function TwoFAButton() {
|
||||
>
|
||||
Microsoft Authenticator
|
||||
</Anchor>
|
||||
, and{' '}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to='https://support.apple.com/guide/iphone/automatically-fill-in-verification-codes-ipha6173c19f/ios'
|
||||
target='_blank'
|
||||
>
|
||||
Apple Passwords
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
|
||||
@@ -156,25 +166,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'>
|
||||
@@ -194,7 +199,7 @@ export default function TwoFAButton() {
|
||||
autoFocus={true}
|
||||
error={!!pinError}
|
||||
disabled={pinDisabled}
|
||||
size='xl'
|
||||
size={size}
|
||||
/>
|
||||
</Center>
|
||||
{pinError && (
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function SettingsMfa() {
|
||||
|
||||
<Group mt='xs'>
|
||||
{config.mfa.totp.enabled && <TwoFAButton />}
|
||||
{config.mfa.passkeys && <PasskeyButton />}
|
||||
{config.mfa.passkeys.enabled && <PasskeyButton />}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import ms from 'ms';
|
||||
|
||||
function checkDomains(domains?: unknown): string[] {
|
||||
if (!domains) return [];
|
||||
@@ -101,42 +102,65 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
|
||||
<Stack gap='xs' my='sm'>
|
||||
<Select
|
||||
data={[
|
||||
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '30d', label: '1 month (30 days)' },
|
||||
{ value: '45.625d', label: '1.5 months (~45 days)' },
|
||||
{ value: '60d', label: '2 months (60 days)' },
|
||||
{ value: '90d', label: '3 months (90 days)' },
|
||||
{ value: '120d', label: '4 months (120 days)' },
|
||||
{ value: '0.5 year', label: '6 months (0.5 year)' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
{
|
||||
value: '_',
|
||||
label: 'Need more freedom? Set an exact date and time through the API.',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
data={(() => {
|
||||
// Build the full option list, then clamp by config.files.maxExpiration if provided.
|
||||
const opts = [
|
||||
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '30d', label: '1 month (30 days)' },
|
||||
{ value: '45.625d', label: '1.5 months (~45 days)' },
|
||||
{ value: '60d', label: '2 months (60 days)' },
|
||||
{ value: '90d', label: '3 months (90 days)' },
|
||||
{ value: '120d', label: '4 months (120 days)' },
|
||||
{ value: '0.5 year', label: '6 months (0.5 year)' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
{
|
||||
value: '_',
|
||||
label: 'Need more freedom? Set an exact date and time through the API.',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const maxExp = settingsData?.files?.maxExpiration ?? null;
|
||||
if (!maxExp) return opts;
|
||||
|
||||
const maxMs = ms(String(maxExp) as any);
|
||||
if (!maxMs || isNaN(Number(maxMs))) return opts;
|
||||
|
||||
// Keep 'default' and 'never' always visible; clamp other duration options.
|
||||
return opts.filter((o) => {
|
||||
if (o.value === 'default' || o.value === 'never' || o.value === '_') return true;
|
||||
const val = String(o.value);
|
||||
const parsed = (ms as unknown as (v: string) => number)(val);
|
||||
// Some labels like '45.625d' or '0.5 year' may be parseable; if not parseable, keep them to avoid excessive hiding.
|
||||
if (!parsed || isNaN(Number(parsed))) return true;
|
||||
return parsed <= Number(maxMs);
|
||||
});
|
||||
} catch {
|
||||
return opts;
|
||||
}
|
||||
})()}
|
||||
label={
|
||||
<>
|
||||
Deletes at{' '}
|
||||
@@ -162,6 +186,11 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
{'.'}
|
||||
</>
|
||||
)}
|
||||
{settingsData?.files?.maxExpiration ? (
|
||||
<div style={{ marginTop: 6, color: 'var(--mantine-color-dimmed)' }}>
|
||||
Note: maximum allowed expiration is <b>{settingsData.files.maxExpiration}</b>.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
leftSection={<IconAlarmFilled size='1rem' />}
|
||||
|
||||
@@ -8,6 +8,40 @@ import { notifications } from '@mantine/notifications';
|
||||
import { IconClipboardCopy, IconExternalLink, IconFileUpload, IconFileXFilled } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function handleResponse<R = Response['/api/upload']>(
|
||||
xml: XMLHttpRequest,
|
||||
): { data: R | null; error: ErrorBody | null } {
|
||||
if (xml.status < 200 || xml.status >= 300) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
statusCode: xml.status,
|
||||
error: `Request failed with status code ${xml.status}: ${xml.responseText}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = JSON.parse(xml.responseText) as R | ErrorBody;
|
||||
|
||||
if ((res as ErrorBody).statusCode) {
|
||||
return { data: null, error: res as ErrorBody };
|
||||
}
|
||||
|
||||
return { data: res as R, error: null };
|
||||
} catch (e) {
|
||||
console.error('Failed to parse server response:', e, xml.responseText);
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
statusCode: 500,
|
||||
error: 'Failed to parse server response. See browser console for more details.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function filesModal(
|
||||
files: Response['/api/upload']['files'],
|
||||
{
|
||||
@@ -150,21 +184,21 @@ export function uploadFiles(
|
||||
req.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const res: Response['/api/upload'] = JSON.parse(req.responseText);
|
||||
const { data: res, error } = handleResponse<Response['/api/upload']>(req);
|
||||
|
||||
setLoading(false);
|
||||
setProgress({ percent: 0, remaining: 0, speed: 0 });
|
||||
|
||||
if ((res as ErrorBody).statusCode) {
|
||||
if (error || !res) {
|
||||
notifications.update({
|
||||
id: 'upload',
|
||||
title: 'Error uploading files',
|
||||
message: (res as ErrorBody).error,
|
||||
message: error?.error ?? 'An unknown error occurred',
|
||||
color: 'red',
|
||||
icon: <IconFileXFilled size='1rem' />,
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,8 +211,9 @@ export function uploadFiles(
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
setFiles([]);
|
||||
filesModal(res.files, { clipboard, clearEphemeral });
|
||||
filesModal(res!.files, { clipboard, clearEphemeral });
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -189,6 +224,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);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { ErrorBody } from '@/lib/response';
|
||||
import { UploadOptionsStore } from '@/lib/store/uploadOptions';
|
||||
import { ActionIcon, Anchor, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
@@ -9,6 +8,7 @@ import { modals } from '@mantine/modals';
|
||||
import { hideNotification, notifications } from '@mantine/notifications';
|
||||
import { IconClipboardCopy, IconExternalLink, IconFileUpload, IconFileXFilled } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { handleResponse } from './uploadFiles';
|
||||
|
||||
export function filesModal(
|
||||
files: Response['/api/upload']['files'],
|
||||
@@ -162,13 +162,13 @@ export async function uploadPartialFiles(
|
||||
req.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const res: Response['/api/upload/partial'] = JSON.parse(req.responseText);
|
||||
const { data: res, error } = handleResponse<Response['/api/upload/partial']>(req);
|
||||
|
||||
if ((res as ErrorBody).error) {
|
||||
if (error || !res) {
|
||||
notifications.update({
|
||||
id: 'upload-partial',
|
||||
title: 'Error uploading files',
|
||||
message: (res as ErrorBody).error,
|
||||
message: error?.error ?? 'An unknown error occurred',
|
||||
color: 'red',
|
||||
icon: <IconFileXFilled size='1rem' />,
|
||||
autoClose: false,
|
||||
@@ -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 &&
|
||||
|
||||
@@ -53,15 +53,21 @@ export default function EditUrlModal({
|
||||
|
||||
const handleSave = async () => {
|
||||
const data: {
|
||||
maxViews?: number;
|
||||
maxViews?: number | null;
|
||||
password?: string;
|
||||
vanity?: string;
|
||||
destination?: string;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (password !== null) data['password'] = password?.trim();
|
||||
console.log(password);
|
||||
|
||||
if (maxViews === null) data['maxViews'] = null;
|
||||
else data['maxViews'] = maxViews;
|
||||
|
||||
// dont include password if empty or null
|
||||
if (password !== null && password.trim() !== '') data['password'] = password?.trim();
|
||||
|
||||
if (vanity !== null && vanity !== url.vanity) data['vanity'] = vanity?.trim();
|
||||
if (destination !== null && destination !== url.destination) data['destination'] = destination?.trim();
|
||||
if (enabled !== url.enabled) data['enabled'] = enabled;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
import { ActionIcon, Anchor, Box, Checkbox, Group, TextInput, Tooltip } from '@mantine/core';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyUrl, deleteUrl } from '../actions';
|
||||
import { IconCopy, IconPencil, IconTrashFilled } from '@tabler/icons-react';
|
||||
@@ -112,27 +112,23 @@ export default function UrlTableView() {
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Url[]>(data ?? []);
|
||||
|
||||
const [selectedUrl, setSelectedUrl] = useState<Url | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Url;
|
||||
const sorted = useMemo<Url[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Url;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const field of ['code', 'vanity', 'destination'] as const) {
|
||||
|
||||
@@ -69,6 +69,9 @@ export default function EditUserModal({
|
||||
if (typeof value !== 'number' || value < 0) return 'Invalid value';
|
||||
},
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -165,6 +168,7 @@ export default function EditUserModal({
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ export default function DashboardUsers() {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -101,6 +104,7 @@ export default function DashboardUsers() {
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { canInteract, roleName } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import EditUserModal from '../EditUserModal';
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { canInteract, roleName } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { deleteUser } from '../actions';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { deleteUser } from '../actions';
|
||||
import EditUserModal from '../EditUserModal';
|
||||
|
||||
export default function UserTableView() {
|
||||
const currentUser = useUserStore((state) => state.user);
|
||||
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<User[]>(data ?? []);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof User;
|
||||
const sorted = useMemo<User[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof User;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
.theme {
|
||||
color: var(--_color);
|
||||
background: var(--_background);
|
||||
display: block;
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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 { useLocation } from 'react-router-dom';
|
||||
import './HighlightCode.theme.scss';
|
||||
import { type HLJSApi } from 'highlight.js';
|
||||
|
||||
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
||||
const { pathname } = useLocation();
|
||||
const noClamp = pathname.startsWith('/view/');
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hljs, setHljs] = useState<HLJSApi | null>(null);
|
||||
@@ -14,15 +19,60 @@ 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 || noClamp ? lines.length : Math.min(lines.length, 50);
|
||||
const expandable = !noClamp && 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, fontSize: '0.8rem' }}
|
||||
dangerouslySetInnerHTML={{ __html: hlLines[index] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' my='md' pos='relative'>
|
||||
@@ -44,37 +94,27 @@ 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>
|
||||
{noClamp ? (
|
||||
<ScrollArea type='auto' offsetScrollbars={false}>
|
||||
<div>
|
||||
{hlLines.map((_, index) => (
|
||||
<Row key={index} index={index} style={{}} />
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
|
||||
<List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
|
||||
{Row}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{lines.length > 50 && (
|
||||
{expandable && (
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='light'
|
||||
size='compact-sm'
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />}
|
||||
style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
CREATE TYPE "public"."IncompleteFileStatus" AS ENUM('PENDING', 'PROCESSING', 'COMPLETE', 'FAILED');--> statement-breakpoint
|
||||
CREATE TYPE "public"."OAuthProviderType" AS ENUM('DISCORD', 'GOOGLE', 'GITHUB', 'OIDC');--> statement-breakpoint
|
||||
CREATE TYPE "public"."Role" AS ENUM('USER', 'ADMIN', 'SUPERADMIN');--> statement-breakpoint
|
||||
CREATE TYPE "public"."UserFilesQuota" AS ENUM('BY_BYTES', 'BY_FILES');--> statement-breakpoint
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Zipline" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"firstSetup" boolean DEFAULT true NOT NULL,
|
||||
"coreReturnHttpsUrls" boolean DEFAULT false NOT NULL,
|
||||
"coreDefaultDomain" text,
|
||||
"coreTempDirectory" text NOT NULL,
|
||||
"chunksEnabled" boolean DEFAULT true NOT NULL,
|
||||
"chunksMax" text DEFAULT '95mb' NOT NULL,
|
||||
"chunksSize" text DEFAULT '25mb' NOT NULL,
|
||||
"tasksDeleteInterval" text DEFAULT '30m' NOT NULL,
|
||||
"tasksClearInvitesInterval" text DEFAULT '30m' NOT NULL,
|
||||
"tasksMaxViewsInterval" text DEFAULT '30m' NOT NULL,
|
||||
"tasksThumbnailsInterval" text DEFAULT '30m' NOT NULL,
|
||||
"tasksMetricsInterval" text DEFAULT '30m' NOT NULL,
|
||||
"filesRoute" text DEFAULT '/u' NOT NULL,
|
||||
"filesLength" integer DEFAULT 6 NOT NULL,
|
||||
"filesDefaultFormat" text DEFAULT 'random' NOT NULL,
|
||||
"filesDisabledExtensions" text[],
|
||||
"filesMaxFileSize" text DEFAULT '100mb' NOT NULL,
|
||||
"filesDefaultExpiration" text,
|
||||
"filesAssumeMimetypes" boolean DEFAULT false NOT NULL,
|
||||
"filesDefaultDateFormat" text DEFAULT 'YYYY-MM-DD_HH:mm:ss' NOT NULL,
|
||||
"filesRemoveGpsMetadata" boolean DEFAULT false NOT NULL,
|
||||
"urlsRoute" text DEFAULT '/go' NOT NULL,
|
||||
"urlsLength" integer DEFAULT 6 NOT NULL,
|
||||
"featuresImageCompression" boolean DEFAULT true NOT NULL,
|
||||
"featuresRobotsTxt" boolean DEFAULT true NOT NULL,
|
||||
"featuresHealthcheck" boolean DEFAULT true NOT NULL,
|
||||
"featuresUserRegistration" boolean DEFAULT false NOT NULL,
|
||||
"featuresOauthRegistration" boolean DEFAULT false NOT NULL,
|
||||
"featuresDeleteOnMaxViews" boolean DEFAULT true NOT NULL,
|
||||
"featuresThumbnailsEnabled" boolean DEFAULT true NOT NULL,
|
||||
"featuresThumbnailsNumberThreads" integer DEFAULT 4 NOT NULL,
|
||||
"featuresMetricsEnabled" boolean DEFAULT true NOT NULL,
|
||||
"featuresMetricsAdminOnly" boolean DEFAULT false NOT NULL,
|
||||
"featuresMetricsShowUserSpecific" boolean DEFAULT true NOT NULL,
|
||||
"invitesEnabled" boolean DEFAULT true NOT NULL,
|
||||
"invitesLength" integer DEFAULT 6 NOT NULL,
|
||||
"websiteTitle" text DEFAULT 'Zipline' NOT NULL,
|
||||
"websiteTitleLogo" text,
|
||||
"websiteExternalLinks" jsonb DEFAULT '[{"url":"https://github.com/diced/zipline","name":"GitHub"},{"url":"https://zipline.diced.sh/","name":"Documentation"}]'::jsonb NOT NULL,
|
||||
"websiteLoginBackground" text,
|
||||
"websiteDefaultAvatar" text,
|
||||
"websiteTos" text,
|
||||
"websiteThemeDefault" text DEFAULT 'system' NOT NULL,
|
||||
"websiteThemeDark" text DEFAULT 'builtin:dark_gray' NOT NULL,
|
||||
"websiteThemeLight" text DEFAULT 'builtin:light_gray' NOT NULL,
|
||||
"oauthBypassLocalLogin" boolean DEFAULT false NOT NULL,
|
||||
"oauthLoginOnly" boolean DEFAULT false NOT NULL,
|
||||
"oauthDiscordClientId" text,
|
||||
"oauthDiscordClientSecret" text,
|
||||
"oauthDiscordRedirectUri" text,
|
||||
"oauthGoogleClientId" text,
|
||||
"oauthGoogleClientSecret" text,
|
||||
"oauthGoogleRedirectUri" text,
|
||||
"oauthGithubClientId" text,
|
||||
"oauthGithubClientSecret" text,
|
||||
"oauthGithubRedirectUri" text,
|
||||
"oauthOidcClientId" text,
|
||||
"oauthOidcClientSecret" text,
|
||||
"oauthOidcAuthorizeUrl" text,
|
||||
"oauthOidcTokenUrl" text,
|
||||
"oauthOidcUserinfoUrl" text,
|
||||
"oauthOidcRedirectUri" text,
|
||||
"mfaTotpEnabled" boolean DEFAULT false NOT NULL,
|
||||
"mfaTotpIssuer" text DEFAULT 'Zipline' NOT NULL,
|
||||
"mfaPasskeys" boolean DEFAULT false NOT NULL,
|
||||
"ratelimitEnabled" boolean DEFAULT true NOT NULL,
|
||||
"ratelimitMax" integer DEFAULT 10 NOT NULL,
|
||||
"ratelimitWindow" integer,
|
||||
"ratelimitAdminBypass" boolean DEFAULT true NOT NULL,
|
||||
"ratelimitAllowList" text[],
|
||||
"httpWebhookOnUpload" text,
|
||||
"httpWebhookOnShorten" text,
|
||||
"discordWebhookUrl" text,
|
||||
"discordUsername" text,
|
||||
"discordAvatarUrl" text,
|
||||
"discordOnUploadWebhookUrl" text,
|
||||
"discordOnUploadUsername" text,
|
||||
"discordOnUploadAvatarUrl" text,
|
||||
"discordOnUploadContent" text,
|
||||
"discordOnUploadEmbed" jsonb,
|
||||
"discordOnShortenWebhookUrl" text,
|
||||
"discordOnShortenUsername" text,
|
||||
"discordOnShortenAvatarUrl" text,
|
||||
"discordOnShortenContent" text,
|
||||
"discordOnShortenEmbed" jsonb,
|
||||
"pwaEnabled" boolean DEFAULT false NOT NULL,
|
||||
"pwaTitle" text DEFAULT 'Zipline' NOT NULL,
|
||||
"pwaShortName" text DEFAULT 'Zipline' NOT NULL,
|
||||
"pwaDescription" text DEFAULT 'Zipline' NOT NULL,
|
||||
"pwaThemeColor" text DEFAULT '#000000' NOT NULL,
|
||||
"pwaBackgroundColor" text DEFAULT '#000000' NOT NULL,
|
||||
"websiteLoginBackgroundBlur" boolean DEFAULT true NOT NULL,
|
||||
"filesRandomWordsNumAdjectives" integer DEFAULT 2 NOT NULL,
|
||||
"filesRandomWordsSeparator" text DEFAULT '-' NOT NULL,
|
||||
"featuresVersionAPI" text DEFAULT 'https://zipline-version.diced.sh' NOT NULL,
|
||||
"featuresVersionChecking" boolean DEFAULT true NOT NULL,
|
||||
"oauthDiscordAllowedIds" text[] DEFAULT '{"RAY"}',
|
||||
"oauthDiscordDeniedIds" text[] DEFAULT '{"RAY"}',
|
||||
"domains" text[] DEFAULT '{"RAY"}',
|
||||
"filesDefaultCompressionFormat" text DEFAULT 'jpg',
|
||||
"featuresThumbnailsFormat" text DEFAULT 'jpg' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Metric" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Url" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"vanity" text,
|
||||
"destination" text NOT NULL,
|
||||
"views" integer DEFAULT 0 NOT NULL,
|
||||
"maxViews" integer,
|
||||
"password" text,
|
||||
"userId" text,
|
||||
"enabled" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Folder" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"public" boolean DEFAULT false NOT NULL,
|
||||
"userId" text NOT NULL,
|
||||
"allowUploads" boolean DEFAULT false NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "User" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"password" text,
|
||||
"avatar" text,
|
||||
"token" text NOT NULL,
|
||||
"role" "Role" DEFAULT 'USER' NOT NULL,
|
||||
"view" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"totpSecret" text,
|
||||
"sessions" text[]
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Export" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"completed" boolean DEFAULT false NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"files" integer NOT NULL,
|
||||
"size" text NOT NULL,
|
||||
"userId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "UserQuota" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"filesQuota" "UserFilesQuota" NOT NULL,
|
||||
"maxBytes" text,
|
||||
"maxFiles" integer,
|
||||
"maxUrls" integer,
|
||||
"userId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "UserPasskey" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"lastUsed" timestamp(3),
|
||||
"name" text NOT NULL,
|
||||
"reg" jsonb NOT NULL,
|
||||
"userId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "OAuthProvider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"userId" text NOT NULL,
|
||||
"provider" "OAuthProviderType" NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"accessToken" text NOT NULL,
|
||||
"refreshToken" text,
|
||||
"oauthId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "File" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"deletesAt" timestamp(3),
|
||||
"name" text NOT NULL,
|
||||
"originalName" text,
|
||||
"size" bigint NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"views" integer DEFAULT 0 NOT NULL,
|
||||
"maxViews" integer,
|
||||
"favorite" boolean DEFAULT false NOT NULL,
|
||||
"password" text,
|
||||
"userId" text,
|
||||
"folderId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Thumbnail" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"fileId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "IncompleteFile" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"status" "IncompleteFileStatus" NOT NULL,
|
||||
"chunksTotal" integer NOT NULL,
|
||||
"chunksComplete" integer NOT NULL,
|
||||
"metadata" jsonb NOT NULL,
|
||||
"userId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Tag" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text NOT NULL,
|
||||
"userId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "Invite" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp(3) NOT NULL,
|
||||
"expiresAt" timestamp(3),
|
||||
"code" text NOT NULL,
|
||||
"uses" integer DEFAULT 0 NOT NULL,
|
||||
"maxUses" integer,
|
||||
"inviterId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "_FileToTag" (
|
||||
"A" text NOT NULL,
|
||||
"B" text NOT NULL,
|
||||
CONSTRAINT "_FileToTag_AB_pkey" PRIMARY KEY("A","B")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "UserQuota" ADD CONSTRAINT "UserQuota_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "UserPasskey" ADD CONSTRAINT "UserPasskey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "OAuthProvider" ADD CONSTRAINT "OAuthProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "public"."Folder"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Thumbnail" ADD CONSTRAINT "Thumbnail_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "public"."File"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."File"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Tag"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "Url_code_vanity_key" ON "Url" USING btree ("code" text_ops,"vanity" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "User_token_key" ON "User" USING btree ("token" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User" USING btree ("username" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "UserQuota_userId_key" ON "UserQuota" USING btree ("userId" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "OAuthProvider_provider_oauthId_key" ON "OAuthProvider" USING btree ("provider" text_ops,"oauthId" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "Thumbnail_fileId_key" ON "Thumbnail" USING btree ("fileId" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag" USING btree ("name" text_ops);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite" USING btree ("code" text_ops);--> statement-breakpoint
|
||||
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag" USING btree ("B" text_ops);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1756926875085,
|
||||
"tag": "0000_bouncy_mantis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1756931651183,
|
||||
"tag": "0001_next_red_ghost",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { relations } from 'drizzle-orm/relations';
|
||||
import {
|
||||
user,
|
||||
url,
|
||||
folder,
|
||||
exportTable,
|
||||
userQuota,
|
||||
userPasskey,
|
||||
oauthProvider,
|
||||
file,
|
||||
thumbnail,
|
||||
incompleteFile,
|
||||
tag,
|
||||
invite,
|
||||
fileToTag,
|
||||
} from './schema';
|
||||
|
||||
export const urlRelations = relations(url, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [url.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
urls: many(url),
|
||||
folders: many(folder),
|
||||
exports: many(exportTable),
|
||||
userQuotas: many(userQuota),
|
||||
userPasskeys: many(userPasskey),
|
||||
oauthProviders: many(oauthProvider),
|
||||
files: many(file),
|
||||
incompleteFiles: many(incompleteFile),
|
||||
tags: many(tag),
|
||||
invites: many(invite),
|
||||
}));
|
||||
|
||||
export const folderRelations = relations(folder, ({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [folder.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
files: many(file),
|
||||
}));
|
||||
|
||||
export const exportRelations = relations(exportTable, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [exportTable.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userQuotaRelations = relations(userQuota, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [userQuota.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userPasskeyRelations = relations(userPasskey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [userPasskey.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const oauthProviderRelations = relations(oauthProvider, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [oauthProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const fileRelations = relations(file, ({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [file.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
folder: one(folder, {
|
||||
fields: [file.folderId],
|
||||
references: [folder.id],
|
||||
}),
|
||||
thumbnails: many(thumbnail),
|
||||
fileToTags: many(fileToTag),
|
||||
}));
|
||||
|
||||
export const thumbnailRelations = relations(thumbnail, ({ one }) => ({
|
||||
file: one(file, {
|
||||
fields: [thumbnail.fileId],
|
||||
references: [file.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const incompleteFileRelations = relations(incompleteFile, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [incompleteFile.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const tagRelations = relations(tag, ({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [tag.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
fileToTags: many(fileToTag),
|
||||
}));
|
||||
|
||||
export const inviteRelations = relations(invite, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [invite.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const fileToTagRelations = relations(fileToTag, ({ one }) => ({
|
||||
file: one(file, {
|
||||
fields: [fileToTag.a],
|
||||
references: [file.id],
|
||||
}),
|
||||
tag: one(tag, {
|
||||
fields: [fileToTag.b],
|
||||
references: [tag.id],
|
||||
}),
|
||||
}));
|
||||
@@ -1,497 +0,0 @@
|
||||
import {
|
||||
pgTable,
|
||||
timestamp,
|
||||
text,
|
||||
integer,
|
||||
boolean,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
bigint,
|
||||
index,
|
||||
primaryKey,
|
||||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const incompleteFileStatus = pgEnum('IncompleteFileStatus', [
|
||||
'PENDING',
|
||||
'PROCESSING',
|
||||
'COMPLETE',
|
||||
'FAILED',
|
||||
]);
|
||||
export const oauthProviderType = pgEnum('OAuthProviderType', ['DISCORD', 'GOOGLE', 'GITHUB', 'OIDC']);
|
||||
export const role = pgEnum('Role', ['USER', 'ADMIN', 'SUPERADMIN']);
|
||||
export const userFilesQuota = pgEnum('UserFilesQuota', ['BY_BYTES', 'BY_FILES']);
|
||||
|
||||
export const zipline = pgTable('Zipline', {
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
firstSetup: boolean().default(true).notNull(),
|
||||
coreReturnHttpsUrls: boolean().default(false).notNull(),
|
||||
coreDefaultDomain: text(),
|
||||
coreTempDirectory: text().notNull(),
|
||||
chunksEnabled: boolean().default(true).notNull(),
|
||||
chunksMax: text().default('95mb').notNull(),
|
||||
chunksSize: text().default('25mb').notNull(),
|
||||
tasksDeleteInterval: text().default('30m').notNull(),
|
||||
tasksClearInvitesInterval: text().default('30m').notNull(),
|
||||
tasksMaxViewsInterval: text().default('30m').notNull(),
|
||||
tasksThumbnailsInterval: text().default('30m').notNull(),
|
||||
tasksMetricsInterval: text().default('30m').notNull(),
|
||||
filesRoute: text().default('/u').notNull(),
|
||||
filesLength: integer().default(6).notNull(),
|
||||
filesDefaultFormat: text().default('random').notNull(),
|
||||
filesDisabledExtensions: text().array(),
|
||||
filesMaxFileSize: text().default('100mb').notNull(),
|
||||
filesDefaultExpiration: text(),
|
||||
filesAssumeMimetypes: boolean().default(false).notNull(),
|
||||
filesDefaultDateFormat: text().default('YYYY-MM-DD_HH:mm:ss').notNull(),
|
||||
filesRemoveGpsMetadata: boolean().default(false).notNull(),
|
||||
urlsRoute: text().default('/go').notNull(),
|
||||
urlsLength: integer().default(6).notNull(),
|
||||
featuresImageCompression: boolean().default(true).notNull(),
|
||||
featuresRobotsTxt: boolean().default(true).notNull(),
|
||||
featuresHealthcheck: boolean().default(true).notNull(),
|
||||
featuresUserRegistration: boolean().default(false).notNull(),
|
||||
featuresOauthRegistration: boolean().default(false).notNull(),
|
||||
featuresDeleteOnMaxViews: boolean().default(true).notNull(),
|
||||
featuresThumbnailsEnabled: boolean().default(true).notNull(),
|
||||
featuresThumbnailsNumberThreads: integer().default(4).notNull(),
|
||||
featuresMetricsEnabled: boolean().default(true).notNull(),
|
||||
featuresMetricsAdminOnly: boolean().default(false).notNull(),
|
||||
featuresMetricsShowUserSpecific: boolean().default(true).notNull(),
|
||||
invitesEnabled: boolean().default(true).notNull(),
|
||||
invitesLength: integer().default(6).notNull(),
|
||||
websiteTitle: text().default('Zipline').notNull(),
|
||||
websiteTitleLogo: text(),
|
||||
websiteExternalLinks: jsonb()
|
||||
.default([
|
||||
{ url: 'https://github.com/diced/zipline', name: 'GitHub' },
|
||||
{ url: 'https://zipline.diced.sh/', name: 'Documentation' },
|
||||
])
|
||||
.notNull(),
|
||||
websiteLoginBackground: text(),
|
||||
websiteDefaultAvatar: text(),
|
||||
websiteTos: text(),
|
||||
websiteThemeDefault: text().default('system').notNull(),
|
||||
websiteThemeDark: text().default('builtin:dark_gray').notNull(),
|
||||
websiteThemeLight: text().default('builtin:light_gray').notNull(),
|
||||
oauthBypassLocalLogin: boolean().default(false).notNull(),
|
||||
oauthLoginOnly: boolean().default(false).notNull(),
|
||||
oauthDiscordClientId: text(),
|
||||
oauthDiscordClientSecret: text(),
|
||||
oauthDiscordRedirectUri: text(),
|
||||
oauthGoogleClientId: text(),
|
||||
oauthGoogleClientSecret: text(),
|
||||
oauthGoogleRedirectUri: text(),
|
||||
oauthGithubClientId: text(),
|
||||
oauthGithubClientSecret: text(),
|
||||
oauthGithubRedirectUri: text(),
|
||||
oauthOidcClientId: text(),
|
||||
oauthOidcClientSecret: text(),
|
||||
oauthOidcAuthorizeUrl: text(),
|
||||
oauthOidcTokenUrl: text(),
|
||||
oauthOidcUserinfoUrl: text(),
|
||||
oauthOidcRedirectUri: text(),
|
||||
mfaTotpEnabled: boolean().default(false).notNull(),
|
||||
mfaTotpIssuer: text().default('Zipline').notNull(),
|
||||
mfaPasskeys: boolean().default(false).notNull(),
|
||||
ratelimitEnabled: boolean().default(true).notNull(),
|
||||
ratelimitMax: integer().default(10).notNull(),
|
||||
ratelimitWindow: integer(),
|
||||
ratelimitAdminBypass: boolean().default(true).notNull(),
|
||||
ratelimitAllowList: text().array(),
|
||||
httpWebhookOnUpload: text(),
|
||||
httpWebhookOnShorten: text(),
|
||||
discordWebhookUrl: text(),
|
||||
discordUsername: text(),
|
||||
discordAvatarUrl: text(),
|
||||
discordOnUploadWebhookUrl: text(),
|
||||
discordOnUploadUsername: text(),
|
||||
discordOnUploadAvatarUrl: text(),
|
||||
discordOnUploadContent: text(),
|
||||
discordOnUploadEmbed: jsonb(),
|
||||
discordOnShortenWebhookUrl: text(),
|
||||
discordOnShortenUsername: text(),
|
||||
discordOnShortenAvatarUrl: text(),
|
||||
discordOnShortenContent: text(),
|
||||
discordOnShortenEmbed: jsonb(),
|
||||
pwaEnabled: boolean().default(false).notNull(),
|
||||
pwaTitle: text().default('Zipline').notNull(),
|
||||
pwaShortName: text().default('Zipline').notNull(),
|
||||
pwaDescription: text().default('Zipline').notNull(),
|
||||
pwaThemeColor: text().default('#000000').notNull(),
|
||||
pwaBackgroundColor: text().default('#000000').notNull(),
|
||||
websiteLoginBackgroundBlur: boolean().default(true).notNull(),
|
||||
filesRandomWordsNumAdjectives: integer().default(2).notNull(),
|
||||
filesRandomWordsSeparator: text().default('-').notNull(),
|
||||
featuresVersionAPI: text().default('https://zipline-version.diced.sh').notNull(),
|
||||
featuresVersionChecking: boolean().default(true).notNull(),
|
||||
oauthDiscordAllowedIds: text().array().default(['RAY']),
|
||||
oauthDiscordDeniedIds: text().array().default(['RAY']),
|
||||
domains: text().array().default(['RAY']),
|
||||
filesDefaultCompressionFormat: text().default('jpg'),
|
||||
featuresThumbnailsFormat: text().default('jpg').notNull(),
|
||||
});
|
||||
|
||||
export const metric = pgTable('Metric', {
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
data: jsonb().notNull(),
|
||||
});
|
||||
|
||||
export const url = pgTable(
|
||||
'Url',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
code: text().notNull(),
|
||||
vanity: text(),
|
||||
destination: text().notNull(),
|
||||
views: integer().default(0).notNull(),
|
||||
maxViews: integer(),
|
||||
password: text(),
|
||||
userId: text(),
|
||||
enabled: boolean().default(true).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('Url_code_vanity_key').using(
|
||||
'btree',
|
||||
table.code.asc().nullsLast().op('text_ops'),
|
||||
table.vanity.asc().nullsLast().op('text_ops'),
|
||||
),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'Url_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('set null'),
|
||||
],
|
||||
);
|
||||
|
||||
export const folder = pgTable(
|
||||
'Folder',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
name: text().notNull(),
|
||||
public: boolean().default(false).notNull(),
|
||||
userId: text().notNull(),
|
||||
allowUploads: boolean().default(false).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'Folder_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const user = pgTable(
|
||||
'User',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
username: text().notNull(),
|
||||
password: text(),
|
||||
avatar: text(),
|
||||
token: text().notNull(),
|
||||
role: role().default('USER').notNull(),
|
||||
view: jsonb().default({}).notNull(),
|
||||
totpSecret: text(),
|
||||
sessions: text().array(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('User_token_key').using('btree', table.token.asc().nullsLast().op('text_ops')),
|
||||
uniqueIndex('User_username_key').using('btree', table.username.asc().nullsLast().op('text_ops')),
|
||||
],
|
||||
);
|
||||
|
||||
export const exportTable = pgTable(
|
||||
'Export',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
completed: boolean().default(false).notNull(),
|
||||
path: text().notNull(),
|
||||
files: integer().notNull(),
|
||||
size: text().notNull(),
|
||||
userId: text().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'Export_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const userQuota = pgTable(
|
||||
'UserQuota',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
filesQuota: userFilesQuota().notNull(),
|
||||
maxBytes: text(),
|
||||
maxFiles: integer(),
|
||||
maxUrls: integer(),
|
||||
userId: text(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('UserQuota_userId_key').using('btree', table.userId.asc().nullsLast().op('text_ops')),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'UserQuota_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPasskey = pgTable(
|
||||
'UserPasskey',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
lastUsed: timestamp({ precision: 3, mode: 'string' }),
|
||||
name: text().notNull(),
|
||||
reg: jsonb().notNull(),
|
||||
userId: text().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'UserPasskey_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const oauthProvider = pgTable(
|
||||
'OAuthProvider',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
userId: text().notNull(),
|
||||
provider: oauthProviderType().notNull(),
|
||||
username: text().notNull(),
|
||||
accessToken: text().notNull(),
|
||||
refreshToken: text(),
|
||||
oauthId: text(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('OAuthProvider_provider_oauthId_key').using(
|
||||
'btree',
|
||||
table.provider.asc().nullsLast().op('text_ops'),
|
||||
table.oauthId.asc().nullsLast().op('text_ops'),
|
||||
),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'OAuthProvider_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('restrict'),
|
||||
],
|
||||
);
|
||||
|
||||
export const file = pgTable(
|
||||
'File',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
deletesAt: timestamp({ precision: 3, mode: 'string' }),
|
||||
name: text().notNull(),
|
||||
originalName: text(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
size: bigint({ mode: 'number' }).notNull(),
|
||||
type: text().notNull(),
|
||||
views: integer().default(0).notNull(),
|
||||
maxViews: integer(),
|
||||
favorite: boolean().default(false).notNull(),
|
||||
password: text(),
|
||||
userId: text(),
|
||||
folderId: text(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'File_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('set null'),
|
||||
foreignKey({
|
||||
columns: [table.folderId],
|
||||
foreignColumns: [folder.id],
|
||||
name: 'File_folderId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('set null'),
|
||||
],
|
||||
);
|
||||
|
||||
export const thumbnail = pgTable(
|
||||
'Thumbnail',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
path: text().notNull(),
|
||||
fileId: text().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('Thumbnail_fileId_key').using('btree', table.fileId.asc().nullsLast().op('text_ops')),
|
||||
foreignKey({
|
||||
columns: [table.fileId],
|
||||
foreignColumns: [file.id],
|
||||
name: 'Thumbnail_fileId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const incompleteFile = pgTable(
|
||||
'IncompleteFile',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
status: incompleteFileStatus().notNull(),
|
||||
chunksTotal: integer().notNull(),
|
||||
chunksComplete: integer().notNull(),
|
||||
metadata: jsonb().notNull(),
|
||||
userId: text().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'IncompleteFile_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const tag = pgTable(
|
||||
'Tag',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
name: text().notNull(),
|
||||
color: text().notNull(),
|
||||
userId: text(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('Tag_name_key').using('btree', table.name.asc().nullsLast().op('text_ops')),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'Tag_userId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('set null'),
|
||||
],
|
||||
);
|
||||
|
||||
export const invite = pgTable(
|
||||
'Invite',
|
||||
{
|
||||
id: text().primaryKey().notNull(),
|
||||
createdAt: timestamp({ precision: 3, mode: 'string' })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
|
||||
expiresAt: timestamp({ precision: 3, mode: 'string' }),
|
||||
code: text().notNull(),
|
||||
uses: integer().default(0).notNull(),
|
||||
maxUses: integer(),
|
||||
inviterId: text().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('Invite_code_key').using('btree', table.code.asc().nullsLast().op('text_ops')),
|
||||
foreignKey({
|
||||
columns: [table.inviterId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'Invite_inviterId_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
export const fileToTag = pgTable(
|
||||
'_FileToTag',
|
||||
{
|
||||
a: text('A').notNull(),
|
||||
b: text('B').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index().using('btree', table.b.asc().nullsLast().op('text_ops')),
|
||||
foreignKey({
|
||||
columns: [table.a],
|
||||
foreignColumns: [file.id],
|
||||
name: '_FileToTag_A_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.b],
|
||||
foreignColumns: [tag.id],
|
||||
name: '_FileToTag_B_fkey',
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
primaryKey({ columns: [table.a, table.b], name: '_FileToTag_AB_pkey' }),
|
||||
],
|
||||
);
|
||||
@@ -5,12 +5,13 @@ import { ApiLoginResponse } from '@/server/routes/api/auth/login';
|
||||
import { ApiLogoutResponse } from '@/server/routes/api/auth/logout';
|
||||
import { ApiAuthOauthResponse } from '@/server/routes/api/auth/oauth';
|
||||
import { ApiAuthRegisterResponse } from '@/server/routes/api/auth/register';
|
||||
import { ApiAuthWebauthnResponse } from '@/server/routes/api/auth/webauthn';
|
||||
import { ApiAuthWebauthnOptionsResponse, ApiAuthWebauthnResponse } from '@/server/routes/api/auth/webauthn';
|
||||
import { ApiHealthcheckResponse } from '@/server/routes/api/healthcheck';
|
||||
import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_temp';
|
||||
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';
|
||||
@@ -49,6 +50,7 @@ export type Response = {
|
||||
'/api/auth/invites/web': ApiAuthInvitesWebResponse;
|
||||
'/api/auth/register': ApiAuthRegisterResponse;
|
||||
'/api/auth/webauthn': ApiAuthWebauthnResponse;
|
||||
'/api/auth/webauthn/options': ApiAuthWebauthnOptionsResponse;
|
||||
'/api/auth/oauth': ApiAuthOauthResponse;
|
||||
'/api/auth/login': ApiLoginResponse;
|
||||
'/api/auth/logout': ApiLogoutResponse;
|
||||
@@ -83,6 +85,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,4 +1,8 @@
|
||||
import { extname } from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { log } from './logger';
|
||||
|
||||
const logger = log('compress');
|
||||
|
||||
export const COMPRESS_TYPES = ['jpg', 'jpeg', 'png', 'webp', 'jxl'] as const;
|
||||
export type CompressType = (typeof COMPRESS_TYPES)[number];
|
||||
@@ -6,6 +10,9 @@ export type CompressType = (typeof COMPRESS_TYPES)[number];
|
||||
export type CompressResult = {
|
||||
mimetype: string;
|
||||
ext: CompressType;
|
||||
buffer: Buffer;
|
||||
|
||||
failed?: boolean;
|
||||
};
|
||||
|
||||
export type CompressOptions = {
|
||||
@@ -20,43 +27,58 @@ export function checkOutput(type: CompressType): boolean {
|
||||
}
|
||||
|
||||
export async function compressFile(filePath: string, options: CompressOptions): Promise<CompressResult> {
|
||||
const { quality, type } = options;
|
||||
try {
|
||||
const { quality, type } = options;
|
||||
|
||||
const image = sharp(filePath).withMetadata();
|
||||
const animated = ['.gif', '.webp', '.avif', '.tiff'].includes(extname(filePath).toLowerCase());
|
||||
|
||||
const result: CompressResult = {
|
||||
mimetype: '',
|
||||
ext: 'jpg',
|
||||
};
|
||||
const image = sharp(filePath, { animated }).withMetadata();
|
||||
|
||||
let buffer: Buffer;
|
||||
const result: CompressResult = {
|
||||
mimetype: '',
|
||||
ext: 'jpg',
|
||||
buffer: Buffer.alloc(0),
|
||||
};
|
||||
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'png':
|
||||
buffer = await image.png({ quality }).toBuffer();
|
||||
result.mimetype = 'image/png';
|
||||
result.ext = 'png';
|
||||
break;
|
||||
case 'webp':
|
||||
buffer = await image.webp({ quality }).toBuffer();
|
||||
result.mimetype = 'image/webp';
|
||||
result.ext = 'webp';
|
||||
break;
|
||||
case 'jxl':
|
||||
buffer = await image.jxl({ quality }).toBuffer();
|
||||
result.mimetype = 'image/jxl';
|
||||
result.ext = 'jxl';
|
||||
break;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
default:
|
||||
buffer = await image.jpeg({ quality }).toBuffer();
|
||||
result.mimetype = 'image/jpeg';
|
||||
result.ext = 'jpg';
|
||||
break;
|
||||
let buffer: Buffer;
|
||||
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'png':
|
||||
buffer = await image.png({ quality }).toBuffer();
|
||||
result.mimetype = 'image/png';
|
||||
result.ext = 'png';
|
||||
break;
|
||||
case 'webp':
|
||||
buffer = await image.webp({ quality }).toBuffer();
|
||||
result.mimetype = 'image/webp';
|
||||
result.ext = 'webp';
|
||||
break;
|
||||
case 'jxl':
|
||||
buffer = await image.jxl({ quality }).toBuffer();
|
||||
result.mimetype = 'image/jxl';
|
||||
result.ext = 'jxl';
|
||||
break;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
default:
|
||||
buffer = await image.jpeg({ quality }).toBuffer();
|
||||
result.mimetype = 'image/jpeg';
|
||||
result.ext = 'jpg';
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
buffer,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`failed to compress file: ${error}`);
|
||||
|
||||
return {
|
||||
mimetype: '',
|
||||
ext: 'jpg',
|
||||
buffer: Buffer.alloc(0),
|
||||
failed: true,
|
||||
};
|
||||
}
|
||||
|
||||
await sharp(buffer).toFile(filePath);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { read } from './read';
|
||||
import { validateConfigObject, Config } from './validate';
|
||||
import { log } from '../logger';
|
||||
|
||||
type CachedConfig = {
|
||||
tos: string | null;
|
||||
};
|
||||
|
||||
let config: Config;
|
||||
|
||||
declare global {
|
||||
var __config__: Config;
|
||||
var __tamperedConfig__: string[];
|
||||
|
||||
var __cachedConfigValues__: Partial<CachedConfig>;
|
||||
}
|
||||
|
||||
const reloadSettings = async () => {
|
||||
config = global.__config__ = validateConfigObject((await read()) as any);
|
||||
|
||||
if (!global.__cachedConfigValues__) {
|
||||
global.__cachedConfigValues__ = {};
|
||||
|
||||
if (config.website.tos) {
|
||||
try {
|
||||
const tos = await readFile(config.website.tos, 'utf-8');
|
||||
global.__cachedConfigValues__.tos = tos;
|
||||
} catch {
|
||||
log('config').error('failed to read tos', { path: config.website.tos });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
config = global.__config__;
|
||||
|
||||
@@ -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',
|
||||
@@ -16,6 +17,7 @@ export const DATABASE_TO_PROP = {
|
||||
tasksMaxViewsInterval: 'tasks.maxViewsInterval',
|
||||
tasksThumbnailsInterval: 'tasks.thumbnailsInterval',
|
||||
tasksMetricsInterval: 'tasks.metricsInterval',
|
||||
tasksCleanThumbnailsInterval: 'tasks.cleanThumbnailsInterval',
|
||||
|
||||
filesRoute: 'files.route',
|
||||
filesLength: 'files.length',
|
||||
@@ -23,6 +25,7 @@ export const DATABASE_TO_PROP = {
|
||||
filesDisabledExtensions: 'files.disabledExtensions',
|
||||
filesMaxFileSize: 'files.maxFileSize',
|
||||
filesDefaultExpiration: 'files.defaultExpiration',
|
||||
filesMaxExpiration: 'files.maxExpiration',
|
||||
filesAssumeMimetypes: 'files.assumeMimetypes',
|
||||
filesDefaultDateFormat: 'files.defaultDateFormat',
|
||||
filesRemoveGpsMetadata: 'files.removeGpsMetadata',
|
||||
@@ -94,7 +97,9 @@ export const DATABASE_TO_PROP = {
|
||||
|
||||
mfaTotpEnabled: 'mfa.totp.enabled',
|
||||
mfaTotpIssuer: 'mfa.totp.issuer',
|
||||
mfaPasskeys: 'mfa.passkeys',
|
||||
mfaPasskeysEnabled: 'mfa.passkeys.enabled',
|
||||
mfaPasskeysRpID: 'mfa.passkeys.rpID',
|
||||
mfaPasskeysOrigin: 'mfa.passkeys.origin',
|
||||
|
||||
ratelimitEnabled: 'ratelimit.enabled',
|
||||
ratelimitMax: 'ratelimit.max',
|
||||
|
||||
@@ -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),
|
||||
@@ -45,6 +54,7 @@ export const ENVS = [
|
||||
env('tasks.maxViewsInterval', 'TASKS_MAX_VIEWS_INTERVAL', 'string', true),
|
||||
env('tasks.thumbnailsInterval', 'TASKS_THUMBNAILS_INTERVAL', 'string', true),
|
||||
env('tasks.metricsInterval', 'TASKS_METRICS_INTERVAL', 'string', true),
|
||||
env('tasks.cleanThumbnailsInterval', 'TASKS_CLEAN_THUMBNAILS_INTERVAL', 'string', true),
|
||||
|
||||
env('files.route', 'FILES_ROUTE', 'string', true),
|
||||
env('files.length', 'FILES_LENGTH', 'number', true),
|
||||
@@ -122,7 +132,9 @@ export const ENVS = [
|
||||
|
||||
env('mfa.totp.enabled', 'MFA_TOTP_ENABLED', 'boolean', true),
|
||||
env('mfa.totp.issuer', 'MFA_TOTP_ISSUER', 'string', true),
|
||||
env('mfa.passkeys', 'MFA_PASSKEYS', 'boolean', true),
|
||||
env('mfa.passkeys.enabled', 'MFA_PASSKEYS_ENABLED', 'boolean', true),
|
||||
env('mfa.passkeys.rpID', 'MFA_PASSKEYS_RP_ID', 'string', true),
|
||||
env('mfa.passkeys.origin', 'MFA_PASSKEYS_ORIGIN', 'string', true),
|
||||
|
||||
env('ratelimit.enabled', 'RATELIMIT_ENABLED', 'boolean', true),
|
||||
env('ratelimit.max', 'RATELIMIT_MAX', 'number', true),
|
||||
@@ -159,11 +171,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 +236,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,
|
||||
@@ -25,6 +33,7 @@ export const rawConfig: any = {
|
||||
maxViewsInterval: undefined,
|
||||
thumbnailsInterval: undefined,
|
||||
metricsInterval: undefined,
|
||||
cleanThumbnailsInterval: undefined,
|
||||
},
|
||||
files: {
|
||||
route: undefined,
|
||||
@@ -88,7 +97,11 @@ export const rawConfig: any = {
|
||||
enabled: undefined,
|
||||
issuer: undefined,
|
||||
},
|
||||
passkeys: undefined,
|
||||
passkeys: {
|
||||
enabled: undefined,
|
||||
rpID: undefined,
|
||||
origin: undefined,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
bypassLocalLogin: 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'),
|
||||
@@ -86,6 +109,7 @@ export const schema = z.object({
|
||||
maxViewsInterval: z.string().default('30min'),
|
||||
thumbnailsInterval: z.string().default('30min'),
|
||||
metricsInterval: z.string().default('30min'),
|
||||
cleanThumbnailsInterval: z.string().default('1d'),
|
||||
}),
|
||||
files: z.object({
|
||||
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/u'),
|
||||
@@ -94,6 +118,7 @@ export const schema = z.object({
|
||||
disabledExtensions: z.array(z.string()).default([]),
|
||||
maxFileSize: z.string().default('100mb'),
|
||||
defaultExpiration: z.string().nullable().default(null),
|
||||
maxExpiration: z.string().nullable().default(null),
|
||||
assumeMimetypes: z.boolean().default(false),
|
||||
defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss'),
|
||||
removeGpsMetadata: z.boolean().default(false),
|
||||
@@ -220,7 +245,11 @@ export const schema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
issuer: z.string().default('Zipline'),
|
||||
}),
|
||||
passkeys: z.boolean().default(true),
|
||||
passkeys: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
rpID: z.string().nullable().default(null),
|
||||
origin: z.url().nullable().default(null),
|
||||
}),
|
||||
}),
|
||||
oauth: z.object({
|
||||
bypassLocalLogin: z.boolean().default(false),
|
||||
|
||||
@@ -2,45 +2,39 @@ import crypto from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { randomCharacters } from './random';
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
export function createKey(secret: string) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(secret);
|
||||
|
||||
return hash.digest('hex').slice(0, 32);
|
||||
export function createKey(secret: string): Buffer {
|
||||
return crypto.createHash('sha256').update(secret, 'utf8').digest();
|
||||
}
|
||||
|
||||
export function encrypt(value: string, secret: string): string {
|
||||
const key = createKey(secret);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const key = crypto.createHash('sha256').update(secret, 'utf8').digest();
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key), iv);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = cipher.update(value);
|
||||
const final = cipher.final();
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const buffer = Buffer.alloc(encrypted.length + final.length);
|
||||
buffer.set(encrypted);
|
||||
buffer.set(final, encrypted.length);
|
||||
|
||||
return iv.toString('hex') + '.' + buffer.toString('hex');
|
||||
return `${iv.toString('hex')}.${encrypted.toString('hex')}.${tag.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(value: string, secret: string): string {
|
||||
const key = createKey(secret);
|
||||
const [iv, encrypted] = value.split('.');
|
||||
const key = crypto.createHash('sha256').update(secret, 'utf8').digest();
|
||||
const [ivHex, encryptedHex, tagHex] = value.split('.');
|
||||
if (!ivHex || !encryptedHex || !tagHex) throw new Error('Invalid values');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(key), Buffer.from(iv, 'hex'));
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
|
||||
const decrypted = decipher.update(Buffer.from(encrypted, 'hex'));
|
||||
const final = decipher.final();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const buffer = Buffer.alloc(decrypted.length + final.length);
|
||||
buffer.set(decrypted);
|
||||
buffer.set(final, decrypted.length);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
return buffer.toString();
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
export function createToken(): string {
|
||||
@@ -54,29 +48,24 @@ export function createToken(): string {
|
||||
}
|
||||
|
||||
export function encryptToken(token: string, secret: string): string {
|
||||
const key = createKey(secret);
|
||||
|
||||
const date = Date.now();
|
||||
const date64 = Buffer.from(date.toString()).toString('base64');
|
||||
|
||||
const encrypted = encrypt(token, key);
|
||||
const encrypted = encrypt(token, secret);
|
||||
const encrypted64 = Buffer.from(encrypted).toString('base64');
|
||||
|
||||
return `${date64}.${encrypted64}`;
|
||||
}
|
||||
|
||||
export function decryptToken(encryptedToken: string, secret: string): [number, string] | null {
|
||||
const key = createKey(secret);
|
||||
const [date64, encrypted64] = encryptedToken.split('.');
|
||||
|
||||
if (!date64 || !encrypted64) return null;
|
||||
|
||||
try {
|
||||
const date = parseInt(Buffer.from(date64, 'base64').toString('ascii'), 10);
|
||||
|
||||
const encrypted = Buffer.from(encrypted64, 'base64').toString('ascii');
|
||||
|
||||
return [date, decrypt(encrypted, key)];
|
||||
return [date, decrypt(encrypted, secret)];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export type PutOptions = { mimetype?: string; noDelete?: boolean };
|
||||
export type ListOptions = { prefix: string };
|
||||
|
||||
export abstract class Datasource {
|
||||
public name: string | undefined;
|
||||
@@ -13,4 +14,5 @@ export abstract class Datasource {
|
||||
public abstract clear(): Promise<void>;
|
||||
public abstract range(file: string, start: number, end: number): Promise<Readable>;
|
||||
public abstract rename(from: string, to: string): Promise<void>;
|
||||
public abstract list(options: ListOptions): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -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, PutOptions } from './Datasource';
|
||||
import { Datasource, ListOptions, 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, { noDelete }: PutOptions): Promise<void> {
|
||||
const path = join(this.dir, file);
|
||||
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('/')) {
|
||||
@@ -101,4 +113,10 @@ export class LocalDatasource extends Datasource {
|
||||
|
||||
return rename(fromPath, toPath);
|
||||
}
|
||||
|
||||
public async list(options: ListOptions = { prefix: '' }): Promise<string[]> {
|
||||
const files = await readdir(this.dir, { withFileTypes: true });
|
||||
|
||||
return files.filter((f) => f.isFile() && f.name.startsWith(options.prefix || '')).map((f) => f.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, PutOptions } from './Datasource';
|
||||
import { Datasource, ListOptions, 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
|
||||
@@ -225,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),
|
||||
});
|
||||
@@ -323,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),
|
||||
@@ -355,4 +449,37 @@ export class S3Datasource extends Datasource {
|
||||
throw new Error('Failed to rename object');
|
||||
}
|
||||
}
|
||||
|
||||
public async list(options: ListOptions = { prefix: '' }): Promise<string[]> {
|
||||
const command = new ListObjectsCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Prefix: this.key(options.prefix || ''),
|
||||
Delimiter: this.options.subdirectory ? undefined : '/',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.client.send(command);
|
||||
|
||||
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
||||
this.logger.error('there was an error while listing objects');
|
||||
this.logger.error('error metadata', res.$metadata as Record<string, unknown>);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
res.Contents?.map((obj) => {
|
||||
if (this.options.subdirectory) {
|
||||
return obj.Key!.replace(this.options.subdirectory + '/', '');
|
||||
}
|
||||
return obj.Key!;
|
||||
}) ?? []
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error('there was an error while listing objects');
|
||||
this.logger.error('error metadata', e as Record<string, unknown>);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,87 +0,0 @@
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import pg from 'pg';
|
||||
import { join } from 'path';
|
||||
import { log } from '@/lib/logger';
|
||||
|
||||
const logger = log('db').c('drizzle');
|
||||
|
||||
async function drizzleBootstrap(client: pg.Client) {
|
||||
await client.query('CREATE SCHEMA IF NOT EXISTS "drizzle"');
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function migrateExistingPrisma(client: pg.Client) {
|
||||
// check if there is a _prisma_migrations table
|
||||
// if there is then we continue with prisma -> drizzle.
|
||||
|
||||
const resPrisma = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = '_prisma_migrations'
|
||||
)
|
||||
`);
|
||||
|
||||
const existsPrisma = resPrisma.rows[0]?.exists;
|
||||
if (!existsPrisma) {
|
||||
logger.debug('no existing prisma migrations found, skipping prisma -> drizzle migration step');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('existing prisma migrations found, migrating to drizzle');
|
||||
|
||||
// at this point, there should already be a __drizzle_migrations table
|
||||
// now looking for the first migration so we can manually insert it if needed.
|
||||
|
||||
const firstMigration = 1756926875085;
|
||||
|
||||
const res = await client.query(
|
||||
`
|
||||
SELECT COUNT(*) FROM drizzle.__drizzle_migrations WHERE created_at = $1
|
||||
`,
|
||||
[firstMigration],
|
||||
);
|
||||
|
||||
const count = parseInt(res.rows[0]?.count || '0', 10);
|
||||
|
||||
logger.debug('finding existing first migrations', { count });
|
||||
|
||||
if (count === 0) {
|
||||
logger.debug('inserting first migration manually');
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO drizzle.__drizzle_migrations (created_at, hash)
|
||||
VALUES ($1, $2)
|
||||
`,
|
||||
[firstMigration, 'manual'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDrizzleMigrations() {
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await client.connect();
|
||||
const db = drizzle(client);
|
||||
|
||||
// ensure drizzle migrations table exists
|
||||
await drizzleBootstrap(client);
|
||||
|
||||
// migrate from prisma to drizzle
|
||||
await migrateExistingPrisma(client);
|
||||
|
||||
// now we can run migrations with drizzle
|
||||
await migrate(db, {
|
||||
migrationsFolder: join(process.cwd(), 'src', 'drizzle'),
|
||||
});
|
||||
|
||||
logger.info('migrations complete');
|
||||
}
|
||||
@@ -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,22 @@ 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);
|
||||
}
|
||||
|
||||
export function sanitizeExtension(ext: string): string | null {
|
||||
if (ext.includes('/') || ext.includes('\\') || ext.includes('..')) return null;
|
||||
|
||||
return ext.startsWith('.') ? ext : `.${ext}`;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user