Compare commits

...

77 Commits

Author SHA1 Message Date
diced
574bd9114c feat(v4.4.1): version 2026-01-19 17:10:21 -08:00
Radon Rosborough
73c46b875d fix: missing input field names (#963)
* fix: missing input field names

* Use enhanceGetInputProps instead
2026-01-19 17:04:49 -08:00
diced
e21670f292 fix: titles on view pages 2026-01-17 22:23:43 -08:00
Radon Rosborough
09b3ef4e26 fix: typo in docker-compose.yml (#962) 2026-01-17 22:00:26 -08:00
diced
afdee6994e fix: build errors 2026-01-13 22:53:24 -08:00
diced
6f6879c58a fix: #954 2026-01-13 22:50:30 -08:00
diced
66a2f760cf fix: #957 2026-01-13 22:47:40 -08:00
diced
fb3199a9d5 fix: improve on validation 2026-01-13 22:13:09 -08:00
diced
274a84397a fix: workflow 2026-01-10 23:46:16 -08:00
diced
4b585d8634 feat: gen-openapi workflow 2026-01-10 23:43:45 -08:00
diced
260c283872 feat: input validation schemas (very wip) 2026-01-10 23:32:59 -08:00
diced
4d978c11b1 fix: regen random-values 2026-01-10 15:13:38 -08:00
diced
8bdd9e8315 fix: settings -> domains logic 2026-01-09 21:22:21 -08:00
diced
d4a3e877d2 fix: #956 2026-01-09 20:31:44 -08:00
dicedtomato
db3c5f48a5 Merge commit from fork
* fix: passkey impl

* fix: passkey impl

* fix: passkey impl + other stuff

* fix: cookies

* fix: passkey auth w/ cookie

* fix: cookie options
2026-01-08 23:26:16 -08:00
diced
cdcaa926fe fix: use gcm 2026-01-06 15:20:01 -08:00
dicedtomato
01503968ab Merge commit from fork 2026-01-06 15:11:43 -08:00
dicedtomato
8aa5ec6917 Merge commit from fork 2026-01-06 14:51:43 -08:00
dicedtomato
9befcaaf80 Merge commit from fork 2026-01-06 14:41:27 -08:00
dicedtomato
bfc0e4d40c Merge commit from fork 2026-01-06 14:30:41 -08:00
diced
4fb21f678e fix: add debugs for event emitter warnings 2026-01-02 23:26:54 -08:00
diced
f49598c760 fix: #950 2026-01-02 21:46:15 -08:00
diced
bfd6a8769d fix: #948 and tags/folders
fixes inconsistencies when editing other user's files
- tags menu shows their tags
- folders menu shows their folders
by design, you can't and will not be able to add another user's file to
your own folder.
this also introduces a few wip stuff, might be buggy, please bear with
me!
2025-12-31 00:03:55 -08:00
diced
87cf4916a5 fix: #949 2025-12-30 23:23:04 -08:00
diced
12ea806f0a fix: #945 2025-12-30 23:15:16 -08:00
diced
6269b457d8 fix: don't clamp lines on /view 2025-12-26 16:15:23 -08:00
diced
78f5875464 fix: omit meta tags when embed is disable 2025-12-26 15:55:05 -08:00
diced
05df685bd1 fix: better max/default expiration validation 2025-12-26 15:52:31 -08:00
diced
eaf245a4c9 fix: compression-type errors when no compression-percent 2025-12-26 15:43:00 -08:00
Zarox28
8a7b401b6e feat: add maximum expiration value (#934)
* feat: add maximum expiration value

* fix(settings)

* fix: add missing migration

* Update src/lib/import/version3/validateExport.ts

* fix: x-zipline-deletes-at with maxExpiration config

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-12-18 01:50:34 -08:00
diced
bb13e44bc9 fix: PWA and favicons #938 2025-12-17 19:54:23 -08:00
diced
2c21e119c4 fix: #926 2025-12-17 00:06:55 -08:00
diced
1585287b63 feat(v4.4.0): version 2025-12-13 14:37:48 -08:00
dicedtomato
1d4c3f26b4 Merge commit from fork 2025-12-13 14:31:55 -08:00
diced
589f06b460 feat: new actions page + finish impl v4 export 2025-12-08 23:30:04 -08:00
diced
ca09b1319d chore: update packages + eslint + lint 2025-12-08 01:29:12 -08:00
diced
5d27c14b77 feat: import v4 jsons (settings wip) 2025-12-08 01:07:15 -08:00
diced
9da74054ff fix: #931 2025-12-07 21:58:56 -08:00
diced
7572f7f3da fix: #935 2025-12-07 20:36:12 -08:00
diced
ef979d8853 feat: import v4 details (wip still) 2025-12-06 21:56:32 -08:00
diced
d090ed2cc1 fix: #926 for good 2025-12-06 20:37:55 -08:00
diced
3fc8b044bb fix: #926 animated compression removes animation 2025-12-05 19:56:05 -08:00
diced
61af46f136 feat: export and import v4 (wip) (needs testing) 2025-11-19 00:22:51 -08:00
diced
771aa67673 fix: editing files that are owned by the current user again 2025-11-18 20:37:51 -08:00
diced
b2db0c15a3 fix: editing files that are owned by current user 2025-11-15 23:20:11 -08:00
diced
d49afe60c8 fix: #924 2025-11-14 23:52:10 -08:00
diced
3370d4b663 fix: remove random logs 2025-11-14 23:50:35 -08:00
diced
1f1bcd3a47 feat: export folder as zip file 2025-11-14 23:48:50 -08:00
diced
d9df04bac5 fix: transactions not working for current user 2025-11-14 23:36:03 -08:00
diced
2bf2809269 fix: metrics erroring with null usernames 2025-11-14 23:18:01 -08:00
diced
9bb9e7e399 feat: add copy raw file link button to file modal 2025-11-14 23:08:05 -08:00
diced
89d6b2908d fix: change memory monitor to csv-like 2025-11-11 22:17:46 -08:00
diced
63c268cd1e fix: actually write new buffer to file (gps removal) 2025-11-07 22:06:29 -08:00
diced
6e2da52f77 feat: actions when viewing other user files (#918) 2025-11-03 16:37:12 -08:00
diced
04b27a2dee fix: build error 2025-11-03 15:40:15 -08:00
diced
6f4c3271c1 fix: #914 2025-11-03 15:36:09 -08:00
diced
b014f10240 fix: #916 2025-11-03 15:36:03 -08:00
diced
d3a417aff0 fix: #921 2025-11-03 15:24:14 -08:00
diced
63596d983e fix: #919 2025-10-28 12:10:06 -07:00
diced
ffbad41994 fix: export issues (#915) 2025-10-27 15:05:01 -07:00
diced
2a6f1f418a feat: log memory usage with DEBUG_MEMORY_LOG 2025-10-27 15:01:19 -07:00
diced
2402c6f0ef fix: performance issues with code renderer (#911) 2025-10-23 21:51:37 -07:00
diced
317e97e3a6 fix: show original name in view route #908 2025-10-19 21:27:06 -07:00
Venipa
f7753ccf2e fix: partial s3 upload ignoring subdirectory (#910, #909) 2025-10-18 20:56:59 -07:00
diced
2ad10e9a52 feat(v4.3.2): version 2025-10-16 21:12:40 -07:00
diced
b4be96c7a8 feat: support separate db vars + file version 2025-10-16 21:02:17 -07:00
diced
69dfad201b feat: reorder/disable/enable table fields in file table 2025-10-12 21:43:50 -07:00
diced
ee1681497e feat: allow any env to be read from a file 2025-10-12 21:43:34 -07:00
diced
2f19140085 feat: add file name in upload response 2025-10-03 21:01:18 -07:00
diced
c9d492f9d2 feat: trust proxies option (#879) 2025-10-03 20:55:35 -07:00
diced
a7a23f3fd9 chore: downgrade aws sdks (#888)
newer AWS sdks introduce dumb AWS specific stuff that break
interoperability with other services.
2025-09-19 20:26:20 -07:00
diced
36ffb669b2 fix: accidental force push lmaoo (#886)
PR: #886
2025-09-18 12:41:22 -07:00
diced
f0ee4cdab3 fix: allow any host on dev 2025-09-18 12:31:59 -07:00
diced
ac41dab2b2 fix: title not updating on first-load 2025-09-09 16:19:54 -07:00
diced
26661f7a83 fix: encode id for view route 2025-09-09 16:06:27 -07:00
diced
01a73df7f3 fix: say "try again" for invites when ratelimited 2025-09-08 23:08:29 -07:00
diced
6b1304f37b fix: #885 2025-09-08 23:06:27 -07:00
174 changed files with 9903 additions and 5314 deletions

100
.github/workflows/gen-openapi.yml vendored Normal file
View 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
View File

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

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
import unusedImports from 'eslint-plugin-unused-imports';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import unusedImports from 'eslint-plugin-unused-imports';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -20,63 +22,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' },
},
},
);

View File

@@ -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",
@@ -21,96 +21,105 @@
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/lib-storage": "3.879.0",
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.0.3",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@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",
"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",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.8.2",
"react-router-dom": "^7.10.1",
"react-window": "1.8.11",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.3",
"swr": "^2.3.6",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.4",
"zod": "^4.1.5",
"zustand": "^5.0.8"
"sharp": "^0.34.5",
"swr": "^2.3.7",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bytes": "^3.1.5",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.3.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.34.0",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"sass": "^1.92.0",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22"

5341
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Button,
Center,
@@ -18,10 +19,9 @@ import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Register');
@@ -67,13 +67,16 @@ 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(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
redirect('/dashboard');
navigate('/dashboard');
} else {
setLoading(false);
}
@@ -83,7 +86,7 @@ export function Component() {
useEffect(() => {
if (!config) return;
if (!config?.features.userRegistration) {
if (!config?.features.userRegistration && !code) {
navigate('/auth/login');
}
}, [code, config]);
@@ -122,7 +125,7 @@ export function Component() {
});
mutate('/api/user');
redirect('/dashboard');
navigate('/dashboard');
}
};
@@ -142,7 +145,7 @@ export function Component() {
if (inviteError) {
showNotification({
id: 'invalid-invite',
message: 'Invalid or expired invite.',
message: 'Invalid or expired invite. Please try again later.',
color: 'red',
});
@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ import { createRoutes } from './routes';
export const getFile = async (id: string) =>
prisma.file.findFirst({
where: { name: id as string },
where: { name: decodeURIComponent(id) },
select: {
...fileSelect,
password: true,
@@ -265,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)}`,
};
}

View File

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

View File

@@ -29,6 +29,7 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
@@ -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)}

View File

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

View File

@@ -27,10 +27,14 @@ export function downloadFile(file: File) {
window.open(`/raw/${file.name}?download=true`, '_blank');
}
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>, raw: boolean = false) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
const url = raw
? `${domain}/raw/${file.name}`
: file.url
? `${domain}${file.url}`
: `${domain}/view/${file.name}`;
clipboard.copy(url);

View File

@@ -0,0 +1,99 @@
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
import { IconGripVertical } from '@tabler/icons-react';
import { useShallow } from 'zustand/shallow';
export const NAMES = {
name: 'Name',
originalName: 'Original Name',
tags: 'Tags',
type: 'Type',
size: 'Size',
createdAt: 'Created At',
favorite: 'Favorite',
views: 'Views',
};
function SortableTableField({ item }: { item: FieldSettings }) {
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.field,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: 'grab',
width: '100%',
};
return (
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Group gap='xs'>
<IconGripVertical size='1rem' />
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
<Text>{NAMES[item.field]}</Text>
</Group>
</Paper>
);
}
export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) {
const [fields, setIndex, reset] = useFileTableSettingsStore(
useShallow((state) => [state.fields, state.setIndex, state.reset]),
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const newIndex = fields.findIndex((item) => item.field === over?.id);
setIndex(active.id as FieldSettings['field'], newIndex);
}
};
return (
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
<Text mb='md' size='sm' c='dimmed'>
Select and drag fields below to make them appear/disappear/reorder in the file table view.
</Text>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
{fields.map((item, index) => (
<div
key={index}
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
>
<SortableTableField item={item} />
</div>
))}
</SortableContext>
</DndContext>
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
Reset to Default
</Button>
</Modal>
);
}

View File

@@ -69,20 +69,23 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
});
}
export async function bulkFavorite(ids: string[]) {
export async function bulkFavorite(ids: string[], favorite: boolean) {
const text = favorite ? 'favorite' : 'unfavorite';
const textcaps = favorite ? 'Favorite' : 'Unfavorite';
modals.openConfirmModal({
centered: true,
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
labels: {
cancel: 'Cancel',
confirm: 'Favorite',
confirm: `${textcaps}`,
},
confirmProps: { color: 'yellow' },
onConfirm: async () => {
notifications.show({
title: 'Favoriting files',
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`,
title: `${textcaps}ing files`,
message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow',
loading: true,
id: 'bulk-favorite',
@@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) {
{
files: ids,
favorite: true,
favorite,
},
);
if (error) {
notifications.update({
title: 'Error while favoriting files',
title: 'Error while modifying files',
message: error.error,
color: 'red',
icon: <IconStarsOff size='1rem' />,
@@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) {
});
} else if (data) {
notifications.update({
title: 'Favorited files',
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`,
title: `${textcaps}d files`,
message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow',
icon: <IconStarsFilled size='1rem' />,
id: 'bulk-favorite',

View File

@@ -6,12 +6,16 @@ import FileTable from './views/FileTable';
import Files from './views/Files';
import TagsButton from './tags/TagsButton';
import PendingFilesButton from './PendingFilesButton';
import { IconFileUpload } from '@tabler/icons-react';
import { IconFileUpload, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { useState } from 'react';
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [tableEditOpen, setTableEditOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useState(false);
return (
<>
<Group>
@@ -28,6 +32,27 @@ export default function DashboardFiles() {
<TagsButton />
<PendingFilesButton />
{view === 'table' && (
<>
<Tooltip label='Table Options'>
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
<IconTableOptions size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
</>
)}
<GridTableSwitcher type='files' />
</Group>
@@ -38,7 +63,16 @@ export default function DashboardFiles() {
<Files />
</>
) : (
<FileTable />
<FileTable
idSearch={{
open: idSearchOpen,
setOpen: setIdSearchOpen,
}}
tableEdit={{
open: tableEditOpen,
setOpen: setTableEditOpen,
}}
/>
)}
</>
);

View File

@@ -5,6 +5,8 @@ import { bytes } from '@/lib/bytes';
import { type File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { Tag } from '@/lib/db/models/tag';
import { useQueryState } from '@/lib/hooks/useQueryState';
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
import { useSettingsStore } from '@/lib/store/settings';
import {
ActionIcon,
@@ -32,7 +34,6 @@ import {
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
IconStar,
IconTrashFilled,
} from '@tabler/icons-react';
@@ -40,10 +41,10 @@ import { DataTable } from 'mantine-datatable';
import { lazy, useEffect, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import TableEditModal, { NAMES } from '../TableEditModal';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
import { useQueryState } from '@/lib/hooks/useQueryState';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
@@ -54,13 +55,6 @@ type ReducerQuery = {
const PER_PAGE_OPTIONS = [10, 20, 50];
const NAMES = {
name: 'Name',
originalName: 'Original name',
type: 'Type',
id: 'ID',
};
function SearchFilter({
setSearchField,
searchQuery,
@@ -88,8 +82,8 @@ function SearchFilter({
return (
<TextInput
label={NAMES[field]}
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
label={NAMES[field as keyof typeof NAMES]}
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
value={searchQuery[field]}
onChange={onChange}
size='sm'
@@ -179,10 +173,26 @@ function TagsFilter({
);
}
export default function FileTable({ id }: { id?: string }) {
export default function FileTable({
id,
tableEdit,
idSearch,
}: {
id?: string;
tableEdit: {
open: boolean;
setOpen: (open: boolean) => void;
};
idSearch: {
open: boolean;
setOpen: (open: boolean) => void;
};
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fields = useFileTableSettingsStore((state) => state.fields);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
@@ -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`}

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,8 +99,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
const recent = data[0]; // it is sorted by desc so 0 is the first one.
if (recent.data.filesUsers.length === 0) return null;
if (recent.data.urlsUsers.length === 0) return null;
if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null;
return (
<>
@@ -121,7 +120,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{bytes(count.storage)}</Table.Td>
<Table.Td>{count.views}</Table.Td>
@@ -147,7 +146,7 @@ export default function StatsTables({ data }: { data: Metric[] }) {
.sort((a, b) => b.sum - a.sum)
.map((count, i) => (
<Table.Tr key={i}>
<Table.Td>{count.username}</Table.Td>
<Table.Td>{count.username ?? '[unknown]'}</Table.Td>
<Table.Td>{count.sum}</Table.Td>
<Table.Td>{count.views}</Table.Td>
</Table.Tr>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,6 @@ export default function DashboardServerSettings() {
const scrollToSetting = useMemo(() => {
return (setting: string) => {
console.log('scrolling to setting:', setting);
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
if (input) {
const observer = new IntersectionObserver(

View File

@@ -17,11 +17,13 @@ export default function Core({
coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined;
coreTempDirectory: string;
coreTrustProxy: boolean;
}>({
initialValues: {
coreReturnHttpsUrls: false,
coreDefaultDomain: '',
coreTempDirectory: '/tmp/zipline',
coreTrustProxy: false,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
@@ -45,6 +47,7 @@ export default function Core({
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
coreTrustProxy: data.settings.coreTrustProxy ?? false,
});
}, [data]);
@@ -55,14 +58,20 @@ export default function Core({
<Title order={2}>Core</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<Switch
label='Trust Proxies'
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
/>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;t scan the QR code, you can manually enter the following code into your
authenticator app: <Code>{twoData?.secret ?? ''}</Code>
authenticator app: <Code>{mfaData?.secret ?? ''}</Code>
</Text>
<Text size='sm' c='dimmed'>
@@ -194,7 +199,7 @@ export default function TwoFAButton() {
autoFocus={true}
error={!!pinError}
disabled={pinDisabled}
size='xl'
size={size}
/>
</Center>
{pinError && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,11 @@ import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files';
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
import { IconArrowBackUp } from '@tabler/icons-react';
import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react';
import { Link, useLoaderData } from 'react-router-dom';
import FileTable from '../files/views/FileTable';
import Files from '../files/views/Files';
import { useState } from 'react';
export default function ViewUserFiles() {
const data = useLoaderData<typeof loader>();
@@ -16,6 +17,9 @@ export default function ViewUserFiles() {
const view = useViewStore((state) => state.files);
const [tableEditOpen, setTableEditOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useState(false);
return (
<>
<Group>
@@ -26,10 +30,41 @@ export default function ViewUserFiles() {
</ActionIcon>
</Tooltip>
<Tooltip label='Table Options'>
<ActionIcon variant='outline' onClick={() => setTableEditOpen((open) => !open)}>
<IconTableOptions size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
<GridTableSwitcher type='files' />
</Group>
{view === 'grid' ? <Files id={user.id} /> : <FileTable id={user.id} />}
{view === 'grid' ? (
<Files id={user.id} />
) : (
<FileTable
id={user.id}
tableEdit={{
open: tableEditOpen,
setOpen: setTableEditOpen,
}}
idSearch={{
open: idSearchOpen,
setOpen: setIdSearchOpen,
}}
/>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ export const DATABASE_TO_PROP = {
coreReturnHttpsUrls: 'core.returnHttpsUrls',
coreDefaultDomain: 'core.defaultDomain',
coreTempDirectory: 'core.tempDirectory',
coreTrustProxy: 'core.trustProxy',
chunksMax: 'chunks.max',
chunksSize: 'chunks.size',
@@ -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',

View File

@@ -1,8 +1,9 @@
import { log } from '@/lib/logger';
import { readFileSync } from 'node:fs';
import { parse } from './transform';
export type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json';
export function env(property: string, env: string | string[], type: EnvType, isDb: boolean = false) {
export function env(property: string, env: string, type: EnvType, isDb: boolean = false) {
return {
variable: env,
property,
@@ -15,7 +16,14 @@ export const ENVS = [
env('core.port', 'CORE_PORT', 'number'),
env('core.hostname', 'CORE_HOSTNAME', 'string'),
env('core.secret', 'CORE_SECRET', 'string'),
env('core.databaseUrl', ['DATABASE_URL', 'CORE_DATABASE_URL'], 'string'),
env('core.databaseUrl', 'DATABASE_URL', 'string'),
// or
env('core.database.username', 'DATABASE_USERNAME', 'string', true),
env('core.database.password', 'DATABASE_PASSWORD', 'string', true),
env('core.database.host', 'DATABASE_HOST', 'string', true),
env('core.database.port', 'DATABASE_PORT', 'number', true),
env('core.database.name', 'DATABASE_NAME', 'string', true),
env('datasource.type', 'DATASOURCE_TYPE', 'string'),
env('datasource.s3.accessKeyId', 'DATASOURCE_S3_ACCESS_KEY_ID', 'string'),
@@ -32,6 +40,7 @@ export const ENVS = [
env('ssl.cert', 'SSL_CERT', 'string'),
// database stuff
env('core.trustProxy', 'CORE_TRUST_PROXY', 'boolean', true),
env('core.returnHttpsUrls', 'CORE_RETURN_HTTPS_URLS', 'boolean', true),
env('core.defaultDomain', 'CORE_DEFAULT_DOMAIN', 'string', true),
env('core.tempDirectory', 'CORE_TEMP_DIRECTORY', 'string', true),
@@ -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;

View File

@@ -13,6 +13,14 @@ export const rawConfig: any = {
databaseUrl: undefined,
returnHttpsUrls: undefined,
tempDirectory: undefined,
trustProxy: undefined,
database: {
username: undefined,
password: undefined,
host: undefined,
port: undefined,
name: undefined,
},
},
chunks: {
max: undefined,
@@ -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,

View File

@@ -67,13 +67,36 @@ export const schema = z.object({
});
}
}),
databaseUrl: z.url(),
returnHttpsUrls: z.boolean().default(false),
defaultDomain: z.string().nullable().default(null),
tempDirectory: z
.string()
.transform((s) => resolve(s))
.default(join(tmpdir(), 'zipline')),
trustProxy: z.boolean().default(false),
databaseUrl: z.url(),
database: z
.object({
username: z.string().nullable().default(null),
password: z.string().nullable().default(null),
host: z.string().nullable().default(null),
port: z.number().nullable().default(null),
name: z.string().nullable().default(null),
})
.superRefine((val, c) => {
const values = Object.values(val);
const someSet = values.some((v) => v !== null);
const allSet = values.every((v) => v !== null);
if (someSet && !allSet) {
c.addIssue({
code: 'custom',
message: 'If one database field is set, all fields must be set',
});
}
}),
}),
chunks: z.object({
max: z.string().default('95mb'),
@@ -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),

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { createReadStream, existsSync } from 'fs';
import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { join, resolve, sep } from 'path';
import { Readable } from 'stream';
import { Datasource, 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);
}
}

View File

@@ -1,11 +1,15 @@
import {
CompleteMultipartUploadCommand,
CopyObjectCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsCommand,
PutObjectCommand,
S3Client,
UploadPartCopyCommand,
} from '@aws-sdk/client-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { createReadStream } from 'fs';
@@ -15,7 +19,7 @@ import { Readable } from 'stream';
import { ReadableStream } from 'stream/web';
import Logger, { log } from '../logger';
import { randomCharacters } from '../random';
import { Datasource, 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 [];
}
}
}

View File

@@ -4,6 +4,7 @@ import { type Prisma, PrismaClient } from '@/prisma/client';
import { metadataSchema } from './models/incompleteFile';
import { metricDataSchema } from './models/metric';
import { userViewSchema } from './models/user';
import { readDbVars, REQUIRED_DB_VARS } from '../config/read/env';
const building = !!process.env.ZIPLINE_BUILD;
@@ -31,12 +32,27 @@ function parseDbLog(env: string): Prisma.LogLevel[] {
.filter((v) => v) as unknown as Prisma.LogLevel[];
}
function pgConnectionString() {
const vars = readDbVars();
if (vars.DATABASE_URL) return vars.DATABASE_URL;
return `postgresql://${vars.DATABASE_USERNAME}:${vars.DATABASE_PASSWORD}@${vars.DATABASE_HOST}:${vars.DATABASE_PORT}/${vars.DATABASE_NAME}`;
}
function getClient() {
const logger = log('db');
logger.info('connecting to database ' + process.env.DATABASE_URL);
const connectionString = pgConnectionString();
if (!connectionString) {
logger.error(`either DATABASE_URL or all of [${REQUIRED_DB_VARS.join(', ')}] not set, exiting...`);
process.exit(1);
}
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
process.env.DATABASE_URL = connectionString;
logger.info('connecting to database', { url: connectionString });
const adapter = new PrismaPg({ connectionString });
const client = new PrismaClient({
adapter,
log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined,

View File

@@ -19,7 +19,7 @@ export const metricDataSchema = z.object({
filesUsers: z.array(
z.object({
username: z.string(),
username: z.string().nullable(),
sum: z.number(),
storage: z.number(),
views: z.number(),
@@ -27,7 +27,7 @@ export const metricDataSchema = z.object({
),
urlsUsers: z.array(
z.object({
username: z.string(),
username: z.string().nullable(),
sum: z.number(),
views: z.number(),
}),

View File

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

View File

@@ -1,6 +1,6 @@
// heavily modified from @xoi/gps-metadata-remover to fit the needs of zipline
import { readFileSync } from 'fs';
import { readFileSync, writeFileSync } from 'fs';
import {
PNG_TAG,
PNG_IEND,
@@ -136,5 +136,9 @@ export function removeGps(input: Buffer | string): boolean {
removed = stripGpsFromTiff(buffer, tiffIfdOffset, littleEndian);
}
if (removed && typeof input === 'string') {
writeFileSync(input, buffer);
}
return removed;
}

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import {
parseCreationOptionsFromJSON,
parseRequestOptionsFromJSON,
create,
get,
} from '@github/webauthn-json/browser-ponyfill';
import { User } from './db/models/user';
import { randomCharacters } from './random';
export async function registerWeb(user: User) {
const cro = parseCreationOptionsFromJSON({
publicKey: {
challenge: randomCharacters(64),
rp: { name: 'Zipline' },
user: {
id: randomCharacters(64),
name: user.username,
displayName: user.username,
},
pubKeyCredParams: [],
authenticatorSelection: {
userVerification: 'preferred',
authenticatorAttachment: 'cross-platform',
requireResidentKey: true,
},
},
});
return create(cro);
}
export async function authenticateWeb() {
const cro = parseRequestOptionsFromJSON({
publicKey: {
challenge: randomCharacters(64),
},
});
return get(cro);
}

View File

@@ -1,17 +1,28 @@
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const CHARSET_LENGTH = CHARSET.length;
const MAX = 256 - (256 % CHARSET_LENGTH);
function getRandomValues(array: Uint8Array) {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return crypto.getRandomValues(array);
} else {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('crypto').webcrypto.getRandomValues(array);
}
}
export function randomCharacters(length: number) {
const randomValues = new Uint8Array(length);
typeof crypto !== 'undefined' && crypto.getRandomValues
? crypto.getRandomValues(randomValues)
: // eslint-disable-next-line @typescript-eslint/no-require-imports
require('crypto').webcrypto.getRandomValues(randomValues);
const randomValues = new Uint8Array(Math.ceil(length * 1.5));
let result = '';
for (let i = 0; i < length; i++) {
result += CHARSET[randomValues[i] % CHARSET_LENGTH];
while (result.length < length) {
getRandomValues(randomValues);
for (let i = 0; i !== randomValues.length && result.length !== length; ++i) {
const value = randomValues[i];
if (value < MAX) {
result += CHARSET[value % CHARSET_LENGTH];
}
}
}
return result;
@@ -19,11 +30,7 @@ export function randomCharacters(length: number) {
export function randomIndex(length: number) {
const randomValues = new Uint8Array(1);
typeof crypto !== 'undefined' && crypto.getRandomValues
? crypto.getRandomValues(randomValues)
: // eslint-disable-next-line @typescript-eslint/no-require-imports
require('crypto').webcrypto.getRandomValues(randomValues);
getRandomValues(randomValues);
return randomValues[0] % length;
}

View File

@@ -39,9 +39,12 @@ export async function queryStats(): Promise<MetricData> {
});
for (let i = 0; i !== filesByUser.length; ++i) {
const id = filesByUser[i].userId;
if (!id) continue;
const user = await prisma.user.findUnique({
where: {
id: filesByUser[i].userId!,
id,
},
});
@@ -49,9 +52,12 @@ export async function queryStats(): Promise<MetricData> {
}
for (let i = 0; i !== urlsByUser.length; ++i) {
const id = urlsByUser[i].userId;
if (!id) continue;
const user = await prisma.user.findUnique({
where: {
id: urlsByUser[i].userId!,
id,
},
});

Some files were not shown because too many files have changed in this diff Show More