mirror of
https://github.com/diced/zipline.git
synced 2026-01-21 17:03:29 -08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574bd9114c | ||
|
|
73c46b875d | ||
|
|
e21670f292 | ||
|
|
09b3ef4e26 | ||
|
|
afdee6994e | ||
|
|
6f6879c58a | ||
|
|
66a2f760cf | ||
|
|
fb3199a9d5 | ||
|
|
274a84397a | ||
|
|
4b585d8634 | ||
|
|
260c283872 | ||
|
|
4d978c11b1 | ||
|
|
8bdd9e8315 | ||
|
|
d4a3e877d2 | ||
|
|
db3c5f48a5 | ||
|
|
cdcaa926fe | ||
|
|
01503968ab | ||
|
|
8aa5ec6917 | ||
|
|
9befcaaf80 | ||
|
|
bfc0e4d40c | ||
|
|
4fb21f678e | ||
|
|
f49598c760 | ||
|
|
bfd6a8769d | ||
|
|
87cf4916a5 | ||
|
|
12ea806f0a | ||
|
|
6269b457d8 | ||
|
|
78f5875464 | ||
|
|
05df685bd1 | ||
|
|
eaf245a4c9 | ||
|
|
8a7b401b6e | ||
|
|
bb13e44bc9 | ||
|
|
2c21e119c4 |
100
.github/workflows/gen-openapi.yml
vendored
Normal file
100
.github/workflows/gen-openapi.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
name: Generate OpenAPI Spec
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [v4, trunk]
|
||||
pull_request:
|
||||
branches: [v4, trunk]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
gen-openapi:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [24.x]
|
||||
arch: [amd64]
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: zipline
|
||||
POSTGRES_PASSWORD: zipline
|
||||
POSTGRES_DB: zipline
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U zipline -d zipline"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use node@${{ matrix.node }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ steps.pnpm-cache.outputs.store_path }}
|
||||
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
ZIPLINE_BUILD: 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Generate secret
|
||||
id: secret
|
||||
run: |
|
||||
SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9')
|
||||
echo "secret=$SECRET" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Wait for Postgres
|
||||
run: |
|
||||
until pg_isready -h localhost -p 5432 -U zipline; do
|
||||
echo "Waiting for postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run app
|
||||
env:
|
||||
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
|
||||
CORE_SECRET: ${{ steps.secret.outputs.secret }}
|
||||
ZIPLINE_OUTPUT_OPENAPI: true
|
||||
|
||||
run: pnpm start
|
||||
|
||||
- name: Verify openapi.json exists
|
||||
run: |
|
||||
if [ ! -f "./openapi.json" ]; then
|
||||
echo "openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openapi-json
|
||||
path: ./openapi.json
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ uploads*/
|
||||
*.key
|
||||
src/prisma
|
||||
.memory.log*
|
||||
openapi.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,10 +22,6 @@ const gitignorePatterns = gitignoreContent
|
||||
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||
.map((pattern) => pattern.trim());
|
||||
|
||||
const reactRecommendedRules = reactPlugin.configs.recommended.rules;
|
||||
const reactHooksRecommendedRules = reactHooksPlugin.configs['recommended-latest'].rules;
|
||||
const reactRefreshRules = reactRefreshPlugin.configs.vite.rules;
|
||||
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
export default defineConfig(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.4.0",
|
||||
"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",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@fastify/swagger": "^9.6.1",
|
||||
"@mantine/charts": "^8.3.9",
|
||||
"@mantine/code-highlight": "^8.3.9",
|
||||
"@mantine/core": "^8.3.9",
|
||||
@@ -47,6 +47,8 @@
|
||||
"@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.35.0",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -63,6 +65,7 @@
|
||||
"fast-glob": "^3.3.3",
|
||||
"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",
|
||||
|
||||
294
pnpm-lock.yaml
generated
294
pnpm-lock.yaml
generated
@@ -41,9 +41,9 @@ importers:
|
||||
'@fastify/static':
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0
|
||||
'@github/webauthn-json':
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
'@fastify/swagger':
|
||||
specifier: ^9.6.1
|
||||
version: 9.6.1
|
||||
'@mantine/charts':
|
||||
specifier: ^8.3.9
|
||||
version: 8.3.9(@mantine/core@8.3.9(@mantine/hooks@8.3.9(react@19.2.1))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@mantine/hooks@8.3.9(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(recharts@2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1))
|
||||
@@ -86,6 +86,12 @@ importers:
|
||||
'@prisma/migrate':
|
||||
specifier: 6.13.0
|
||||
version: 6.13.0(@prisma/internals@6.13.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^13.2.2
|
||||
version: 13.2.2
|
||||
'@simplewebauthn/server':
|
||||
specifier: ^13.2.2
|
||||
version: 13.2.2
|
||||
'@smithy/node-http-handler':
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
@@ -134,6 +140,9 @@ importers:
|
||||
fastify-plugin:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
fastify-type-provider-zod:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.13)
|
||||
fluent-ffmpeg:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
@@ -196,7 +205,7 @@ importers:
|
||||
version: 8.48.1(eslint@9.39.1(jiti@2.5.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^7.2.7
|
||||
version: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)
|
||||
version: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)
|
||||
zod:
|
||||
specifier: ^4.1.13
|
||||
version: 4.1.13
|
||||
@@ -239,7 +248,7 @@ importers:
|
||||
version: 1.8.8
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0))
|
||||
version: 5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))
|
||||
eslint:
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.1(jiti@2.5.1)
|
||||
@@ -284,7 +293,7 @@ importers:
|
||||
version: 1.8.16
|
||||
tsup:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)
|
||||
version: 8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
@@ -1038,6 +1047,9 @@ packages:
|
||||
'@fastify/static@8.3.0':
|
||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
||||
|
||||
'@fastify/swagger@9.6.1':
|
||||
resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
@@ -1059,9 +1071,8 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@github/webauthn-json@2.1.1':
|
||||
resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==}
|
||||
hasBin: true
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
@@ -1244,6 +1255,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1429,6 +1443,43 @@ packages:
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==}
|
||||
|
||||
'@peculiar/asn1-cms@2.6.0':
|
||||
resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==}
|
||||
|
||||
'@peculiar/asn1-csr@2.6.0':
|
||||
resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==}
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.0':
|
||||
resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==}
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.0':
|
||||
resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==}
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.0':
|
||||
resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==}
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.0':
|
||||
resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==}
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.0':
|
||||
resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==}
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==}
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.0':
|
||||
resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==}
|
||||
|
||||
'@peculiar/asn1-x509@2.6.0':
|
||||
resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==}
|
||||
|
||||
'@peculiar/x509@1.14.2':
|
||||
resolution: {integrity: sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@phc/format@1.0.0':
|
||||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1639,6 +1690,13 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@simplewebauthn/browser@13.2.2':
|
||||
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
|
||||
|
||||
'@simplewebauthn/server@13.2.2':
|
||||
resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@smithy/abort-controller@4.0.5':
|
||||
resolution: {integrity: sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -2227,6 +2285,10 @@ packages:
|
||||
asciinema-player@3.12.1:
|
||||
resolution: {integrity: sha512-X4tIjZEIsD7Keeu1cJbrsZZCbPSO85w2OiDRGui68JHQPjthIG2jh68TARDrf2CP2l1Lko4mevnBdwwmJfD0iw==}
|
||||
|
||||
asn1js@3.0.7:
|
||||
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
@@ -2943,6 +3005,14 @@ packages:
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify-type-provider-zod@6.1.0:
|
||||
resolution: {integrity: sha512-Sl19VZFSX4W/+AFl3hkL5YgWk3eDXZ4XYOdrq94HunK+o7GQBCAqgk7+3gPXoWkF0bNxOiIgfnFGJJ3i9a2BtQ==}
|
||||
peerDependencies:
|
||||
'@fastify/swagger': '>=9.5.1'
|
||||
fastify: ^5.5.0
|
||||
openapi-types: ^12.1.3
|
||||
zod: '>=4.1.5'
|
||||
|
||||
fastify@5.6.2:
|
||||
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
|
||||
|
||||
@@ -3434,6 +3504,10 @@ packages:
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
||||
|
||||
json-schema-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -3841,6 +3915,9 @@ packages:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4143,6 +4220,13 @@ packages:
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
pvutils@1.1.5:
|
||||
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -4314,6 +4398,9 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4749,6 +4836,9 @@ packages:
|
||||
engines: {node: '>=16.20.2'}
|
||||
hasBin: true
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -4776,6 +4866,10 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tsyringe@4.10.0:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5079,6 +5173,11 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml@2.8.2:
|
||||
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6128,6 +6227,16 @@ snapshots:
|
||||
fastq: 1.19.1
|
||||
glob: 11.1.0
|
||||
|
||||
'@fastify/swagger@9.6.1':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
json-schema-resolver: 3.0.0
|
||||
openapi-types: 12.1.3
|
||||
rfdc: 1.4.1
|
||||
yaml: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
@@ -6153,7 +6262,7 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@github/webauthn-json@2.1.1': {}
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
@@ -6296,6 +6405,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11': {}
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@mantine/charts@8.3.9(@mantine/core@8.3.9(@mantine/hooks@8.3.9(react@19.2.1))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(@mantine/hooks@8.3.9(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(recharts@2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1))':
|
||||
@@ -6471,6 +6582,102 @@ snapshots:
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
optional: true
|
||||
|
||||
'@peculiar/asn1-android@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-cms@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/asn1-x509-attr': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-csr@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-ecc@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pfx@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-pkcs8': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs8@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-pkcs9@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-pfx': 2.6.0
|
||||
'@peculiar/asn1-pkcs8': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/asn1-x509-attr': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-rsa@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-schema@2.6.0':
|
||||
dependencies:
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509-attr@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/asn1-x509@2.6.0':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
asn1js: 3.0.7
|
||||
pvtsutils: 1.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/x509@1.14.2':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.6.0
|
||||
'@peculiar/asn1-csr': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.0
|
||||
'@peculiar/asn1-pkcs9': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
pvtsutils: 1.3.6
|
||||
reflect-metadata: 0.2.2
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@phc/format@1.0.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
@@ -6724,6 +6931,19 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.53.3':
|
||||
optional: true
|
||||
|
||||
'@simplewebauthn/browser@13.2.2': {}
|
||||
|
||||
'@simplewebauthn/server@13.2.2':
|
||||
dependencies:
|
||||
'@hexagon/base64': 1.1.28
|
||||
'@levischuck/tiny-cbor': 0.2.11
|
||||
'@peculiar/asn1-android': 2.6.0
|
||||
'@peculiar/asn1-ecc': 2.6.0
|
||||
'@peculiar/asn1-rsa': 2.6.0
|
||||
'@peculiar/asn1-schema': 2.6.0
|
||||
'@peculiar/asn1-x509': 2.6.0
|
||||
'@peculiar/x509': 1.14.2
|
||||
|
||||
'@smithy/abort-controller@4.0.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.2
|
||||
@@ -7367,7 +7587,7 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0))':
|
||||
'@vitejs/plugin-react@5.1.1(vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
||||
@@ -7375,7 +7595,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.47
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)
|
||||
vite: 7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7531,6 +7751,12 @@ snapshots:
|
||||
solid-js: 1.9.10
|
||||
solid-transition-group: 0.2.3(solid-js@1.9.10)
|
||||
|
||||
asn1js@3.0.7:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.1.5
|
||||
tslib: 2.8.1
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
@@ -8338,6 +8564,14 @@ snapshots:
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.13):
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
'@fastify/swagger': 9.6.1
|
||||
fastify: 5.6.2
|
||||
openapi-types: 12.1.3
|
||||
zod: 4.1.13
|
||||
|
||||
fastify@5.6.2:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
@@ -8895,6 +9129,14 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
json-schema-resolver@3.0.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
fast-uri: 3.1.0
|
||||
rfdc: 1.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -9497,6 +9739,8 @@ snapshots:
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -9686,13 +9930,14 @@ snapshots:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.5.6
|
||||
|
||||
postcss-load-config@6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0):
|
||||
postcss-load-config@6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
optionalDependencies:
|
||||
jiti: 2.5.1
|
||||
postcss: 8.5.6
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
postcss-mixins@12.1.2(postcss@8.5.6):
|
||||
dependencies:
|
||||
@@ -9782,6 +10027,12 @@ snapshots:
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
pvutils@1.1.5: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
@@ -9983,6 +10234,8 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -10532,9 +10785,11 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
plimit-lit: 1.6.1
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3):
|
||||
tsup@8.5.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.1)
|
||||
cac: 6.7.14
|
||||
@@ -10545,7 +10800,7 @@ snapshots:
|
||||
fix-dts-default-cjs-exports: 1.0.1
|
||||
joycon: 3.1.1
|
||||
picocolors: 1.1.1
|
||||
postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)
|
||||
postcss-load-config: 6.0.1(jiti@2.5.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
|
||||
resolve-from: 5.0.0
|
||||
rollup: 4.53.3
|
||||
source-map: 0.7.6
|
||||
@@ -10569,6 +10824,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tsyringe@4.10.0:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -10770,7 +11029,7 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0):
|
||||
vite@7.2.7(@types/node@24.10.1)(jiti@2.5.1)(sass@1.94.2)(sugarss@5.0.1(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -10785,6 +11044,7 @@ snapshots:
|
||||
sass: 1.94.2
|
||||
sugarss: 5.0.1(postcss@8.5.6)
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
@@ -10886,6 +11146,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `mfaPasskeys` on the `Zipline` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" DROP COLUMN "mfaPasskeys",
|
||||
ADD COLUMN "mfaPasskeysEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "mfaPasskeysOrigin" TEXT,
|
||||
ADD COLUMN "mfaPasskeysRpID" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "tasksCleanThumbnailsInterval" TEXT NOT NULL DEFAULT '1d';
|
||||
@@ -1,7 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
output = "../src/prisma"
|
||||
moduleFormat = "cjs"
|
||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||
}
|
||||
|
||||
@@ -31,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)
|
||||
@@ -64,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)
|
||||
@@ -107,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)
|
||||
@@ -141,7 +146,7 @@ model Zipline {
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -295,8 +300,8 @@ model Folder {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
allowUploads Boolean @default(false)
|
||||
|
||||
files File[]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { Button, Center, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function FourOhFour() {
|
||||
useTitle('404');
|
||||
|
||||
return (
|
||||
<Center h='100vh'>
|
||||
<Stack>
|
||||
|
||||
@@ -2,7 +2,7 @@ import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { authenticateWeb } from '@/lib/passkey';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import {
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
@@ -35,7 +36,6 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
@@ -86,6 +86,9 @@ export default function Login() {
|
||||
username: (value) => (value.length > 1 ? null : 'Username is required'),
|
||||
password: (value) => (value.length > 1 ? null : 'Password is required'),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
|
||||
@@ -128,9 +131,24 @@ export default function Login() {
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
setPasskeyLoading(true);
|
||||
const res = await authenticateWeb();
|
||||
const { data: options, error: optionsError } = await fetchApi<Response['/api/auth/webauthn/options']>(
|
||||
'/api/auth/webauthn/options',
|
||||
'GET',
|
||||
);
|
||||
if (optionsError) {
|
||||
setPasskeyErrored(true);
|
||||
setPasskeyLoading(false);
|
||||
notifications.show({
|
||||
title: 'Error while authenticating with passkey',
|
||||
message: optionsError.error,
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await startAuthentication({ optionsJSON: options!.options! });
|
||||
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
|
||||
auth: res.toJSON(),
|
||||
response: res,
|
||||
});
|
||||
if (error) {
|
||||
setPasskeyErrored(true);
|
||||
@@ -299,6 +317,7 @@ export default function Login() {
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -310,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,
|
||||
@@ -336,7 +356,7 @@ export default function Login() {
|
||||
<Divider label='or' />
|
||||
)}
|
||||
|
||||
{config.mfa.passkeys && (
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && (
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
size='md'
|
||||
|
||||
@@ -67,6 +67,9 @@ export function Component() {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +217,7 @@ export function Component() {
|
||||
<TextInput
|
||||
size='md'
|
||||
placeholder='Enter your username...'
|
||||
autoComplete='username'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
@@ -225,6 +229,7 @@ export function Component() {
|
||||
<PasswordInput
|
||||
size='md'
|
||||
placeholder='Enter your password...'
|
||||
autoComplete='new-password'
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
|
||||
|
||||
@@ -65,6 +65,9 @@ export function Component() {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -180,12 +183,14 @@ export function Component() {
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Enter a password...'
|
||||
autoComplete='new-password'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
@@ -19,6 +20,8 @@ export async function loader({ params }: { params: Params<string> }) {
|
||||
export function Component() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
|
||||
useTitle(folder.name ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
|
||||
@@ -2,6 +2,7 @@ import ConfigProvider from '@/components/ConfigProvider';
|
||||
import UploadFile from '@/components/pages/upload/File';
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
import { Anchor, Center, Container, Text } from '@mantine/core';
|
||||
import { data, Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
@@ -27,6 +28,8 @@ export function Component() {
|
||||
revalidateIfStale: false,
|
||||
});
|
||||
|
||||
useTitle(`Upload to ${folder.name ?? 'folder'}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSsrData } from '../../../components/ZiplineSSRProvider';
|
||||
import { getFile } from '../../ssr-view/server';
|
||||
import { useTitle } from '@/lib/hooks/useTitle';
|
||||
|
||||
type SsrData = {
|
||||
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
|
||||
@@ -55,6 +56,8 @@ export default function ViewFileId() {
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
|
||||
useTitle(file.name ?? 'View File');
|
||||
|
||||
return password && !pw ? (
|
||||
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
|
||||
<form
|
||||
|
||||
@@ -270,6 +270,6 @@ export async function render(
|
||||
|
||||
return {
|
||||
html,
|
||||
meta: `${meta}\n${createZiplineSsr(data)}`,
|
||||
meta: `${user.view.embed ? meta : ''}\n${createZiplineSsr(data)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,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 {
|
||||
@@ -103,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();
|
||||
@@ -117,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]));
|
||||
};
|
||||
@@ -170,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!} />
|
||||
@@ -229,7 +225,7 @@ export default function FileModal({
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{!reduce && !user && (
|
||||
{!reduce && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='md' my='xs'>
|
||||
<Box>
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -212,35 +212,23 @@ export default function FileTable({
|
||||
| 'favorite'
|
||||
>('createdAt');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
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 (idSearch.open) return;
|
||||
|
||||
setSearchQuery({
|
||||
field: 'id',
|
||||
query: '',
|
||||
});
|
||||
}, [idSearch.open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
const combobox = useCombobox();
|
||||
@@ -273,6 +261,11 @@ export default function FileTable({
|
||||
}),
|
||||
});
|
||||
|
||||
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
|
||||
const selectedFile = selectedFileId
|
||||
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
|
||||
: null;
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
accessor: 'name',
|
||||
@@ -367,29 +360,14 @@ export default function FileTable({
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const field of ['name', 'originalName', 'type', 'tags', 'id'] as const) {
|
||||
if (field !== searchField) {
|
||||
setSearchQuery({
|
||||
field,
|
||||
query: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [searchField]);
|
||||
|
||||
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
@@ -594,7 +572,7 @@ export default function FileTable({
|
||||
setSort(data.columnAccessor as any);
|
||||
setOrder(data.direction);
|
||||
}}
|
||||
onCellClick={({ record }) => setSelectedFile(record)}
|
||||
onCellClick={({ record }) => setSelectedFile(record.id)}
|
||||
selectedRecords={selectedFiles}
|
||||
onSelectedRecordsChange={setSelectedFiles}
|
||||
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
@@ -11,11 +12,10 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
|
||||
@@ -24,7 +24,6 @@ const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
export default function Files({ id }: { id?: string }) {
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [cachedPages, setCachedPages] = useState(1);
|
||||
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
@@ -32,15 +31,10 @@ export default function Files({ id }: { id?: string }) {
|
||||
id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.pages) {
|
||||
setCachedPages(data.pages);
|
||||
}
|
||||
}, [data?.pages]);
|
||||
|
||||
const from = (page - 1) * perpage + 1;
|
||||
const to = Math.min(page * perpage, data?.total ?? 0);
|
||||
const totalRecords = data?.total ?? 0;
|
||||
const cachedPages = data?.pages ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,7 +53,7 @@ export default function Files({ id }: { id?: string }) {
|
||||
) : (data?.page?.length ?? 0 > 0) ? (
|
||||
data?.page.map((file) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} />
|
||||
<DashboardFile file={file} id={id} />
|
||||
</Suspense>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -3,10 +3,6 @@ import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { ActionIcon, Anchor, Box, Checkbox, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyFolderUrl, deleteFolder, editFolderVisibility, editFolderUploads } from '../actions';
|
||||
import {
|
||||
IconCopy,
|
||||
IconFiles,
|
||||
@@ -18,8 +14,12 @@ import {
|
||||
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();
|
||||
@@ -30,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 (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyInviteUrl, deleteInvite } from '../actions';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
|
||||
export default function InviteTableView() {
|
||||
const clipboard = useClipboard();
|
||||
@@ -20,25 +20,21 @@ export default function InviteTableView() {
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Invite[]>(data ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Invite;
|
||||
const sorted = useMemo<Invite[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Invite;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { lazy, useState } from 'react';
|
||||
import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { useApiStats } from './useStats';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import dayjs from 'dayjs';
|
||||
import { useApiStats } from './useStats';
|
||||
|
||||
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
|
||||
@@ -35,9 +35,10 @@ export default function DashboardMetrics() {
|
||||
setDateRange(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (allTime) setDateRange([null, null]);
|
||||
}, [allTime]);
|
||||
const showAllTime = () => {
|
||||
setAllTime(true);
|
||||
setDateRange([null, null]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -118,7 +119,7 @@ export default function DashboardMetrics() {
|
||||
size='compact-sm'
|
||||
variant='outline'
|
||||
leftSection={<IconCalendarTime size='1rem' />}
|
||||
onClick={() => setAllTime(true)}
|
||||
onClick={() => showAllTime()}
|
||||
disabled={allTime}
|
||||
>
|
||||
Show All Time
|
||||
|
||||
@@ -1,125 +1,111 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { ActionIcon, Group, LoadingOverlay, Paper, Table, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
const DOMAIN_REGEX =
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,30})$/gim;
|
||||
|
||||
export default function Domains({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
swr: {
|
||||
data: Response['/api/server/settings'] | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
newDomain: '',
|
||||
},
|
||||
// using 'domains' here so that settingsOnSubmit picks up errors correctly
|
||||
initialValues: { domains: '' },
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(navigate, form);
|
||||
const submitSettings = settingsOnSubmit(navigate, form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const domainsData = Array.isArray(data.settings.domains)
|
||||
? data.settings.domains.map((d) => String(d))
|
||||
: [];
|
||||
setDomains(domainsData);
|
||||
}, [data]);
|
||||
const domains = Array.isArray(data?.settings.domains) ? data!.settings.domains.map(String) : [];
|
||||
|
||||
const addDomain = () => {
|
||||
const { newDomain } = form.values;
|
||||
if (!newDomain) return;
|
||||
async function updateDomains(nextDomains: string[]) {
|
||||
setSubmitting(true);
|
||||
|
||||
if (!DOMAIN_REGEX.test(newDomain)) {
|
||||
return form.setFieldError('newDomain', 'Invalid Domain');
|
||||
try {
|
||||
const error = await submitSettings({ domains: nextDomains });
|
||||
if (!error) form.setFieldValue('domains', '');
|
||||
} catch (err: any) {
|
||||
form.setFieldError('domains', err?.message ?? err?.error ?? 'Failed to update domains');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedDomains = [...domains, newDomain.trim()];
|
||||
setDomains(updatedDomains);
|
||||
form.setValues({ newDomain: '' });
|
||||
onSubmit({ domains: updatedDomains });
|
||||
const addDomain = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const domain = form.values.domains.trim();
|
||||
if (!domain) return;
|
||||
|
||||
if (domains.includes(domain)) return form.setFieldError('domains', 'This domain already exists');
|
||||
|
||||
await updateDomains([...domains, domain]);
|
||||
};
|
||||
|
||||
const removeDomain = (index: number) => {
|
||||
const updatedDomains = domains.filter((_, i) => i !== index);
|
||||
setDomains(updatedDomains);
|
||||
onSubmit({ domains: updatedDomains });
|
||||
const removeDomain = async (domain: string) => {
|
||||
await updateDomains(domains.filter((d) => d !== domain));
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<LoadingOverlay visible={isLoading || submitting} />
|
||||
|
||||
<Title order={2}>Domains</Title>
|
||||
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
label='Domain'
|
||||
description='Enter a domain name (e.g. example.com)'
|
||||
placeholder='example.com'
|
||||
{...form.getInputProps('newDomain')}
|
||||
/>
|
||||
<Button onClick={addDomain} leftSection={<IconPlus size='1rem' />}>
|
||||
Add Domain
|
||||
</Button>
|
||||
</Group>
|
||||
<form onSubmit={addDomain}>
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
description='Enter a domain name'
|
||||
placeholder='example.com'
|
||||
flex={1}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
<ActionIcon type='submit' color='blue' size='lg' variant='filled' disabled={submitting}>
|
||||
<IconPlus size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, sm: 2, md: 3 }} spacing='md' verticalSpacing='md'>
|
||||
{domains.map((domain, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
withBorder
|
||||
p='md'
|
||||
radius='md'
|
||||
shadow='xs'
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.03)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<Group justify='space-between' align='center' wrap='nowrap'>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{domain}
|
||||
</div>
|
||||
<Button
|
||||
variant='subtle'
|
||||
color='red'
|
||||
size='xs'
|
||||
onClick={() => removeDomain(index)}
|
||||
px={8}
|
||||
style={{
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IconTrash size='1rem' />
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{domains.length > 0 ? (
|
||||
<Paper withBorder p={0} mt='md'>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Domain</Table.Th>
|
||||
<Table.Th w={30}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{domains.map((domain) => (
|
||||
<Table.Tr key={domain}>
|
||||
<Table.Td>
|
||||
<Text fw={500} truncate>
|
||||
{domain}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon color='red' onClick={() => removeDomain(domain)} disabled={submitting}>
|
||||
<IconTrash size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text mt='md' c='dimmed'>
|
||||
No domains added yet.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function Files({
|
||||
filesDisabledExtensions: string;
|
||||
filesMaxFileSize: string;
|
||||
filesDefaultExpiration: string | null;
|
||||
filesMaxExpiration: string | null;
|
||||
filesAssumeMimetypes: boolean;
|
||||
filesDefaultDateFormat: string;
|
||||
filesRemoveGpsMetadata: boolean;
|
||||
@@ -44,6 +45,7 @@ export default function Files({
|
||||
filesDisabledExtensions: '',
|
||||
filesMaxFileSize: '100mb',
|
||||
filesDefaultExpiration: '',
|
||||
filesMaxExpiration: '',
|
||||
filesAssumeMimetypes: false,
|
||||
filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss',
|
||||
filesRemoveGpsMetadata: false,
|
||||
@@ -63,6 +65,12 @@ export default function Files({
|
||||
values.filesDefaultExpiration = values.filesDefaultExpiration.trim();
|
||||
}
|
||||
|
||||
if (values.filesMaxExpiration?.trim() === '' || !values.filesMaxExpiration) {
|
||||
values.filesMaxExpiration = null;
|
||||
} else {
|
||||
values.filesMaxExpiration = values.filesMaxExpiration.trim();
|
||||
}
|
||||
|
||||
if (!values.filesDisabledExtensions) {
|
||||
// @ts-ignore
|
||||
values.filesDisabledExtensions = [];
|
||||
@@ -95,6 +103,7 @@ export default function Files({
|
||||
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
|
||||
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
|
||||
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
|
||||
filesMaxExpiration: data.settings.filesMaxExpiration ?? '',
|
||||
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
|
||||
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
||||
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
|
||||
@@ -161,6 +170,13 @@ export default function Files({
|
||||
{...form.getInputProps('filesMaxFileSize')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Date Format'
|
||||
description='The default date format to use.'
|
||||
placeholder='YYYY-MM-DD_HH:mm:ss'
|
||||
{...form.getInputProps('filesDefaultDateFormat')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Expiration'
|
||||
description='The default expiration time for files.'
|
||||
@@ -169,10 +185,10 @@ export default function Files({
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Default Date Format'
|
||||
description='The default date format to use.'
|
||||
placeholder='YYYY-MM-DD_HH:mm:ss'
|
||||
{...form.getInputProps('filesDefaultDateFormat')}
|
||||
label='Max Expiration'
|
||||
description='The maximum expiration time allowed for files.'
|
||||
placeholder='365d'
|
||||
{...form.getInputProps('filesMaxExpiration')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
||||
@@ -17,7 +17,9 @@ export default function Mfa({
|
||||
initialValues: {
|
||||
mfaTotpEnabled: false,
|
||||
mfaTotpIssuer: 'Zipline',
|
||||
mfaPasskeys: false,
|
||||
mfaPasskeysEnabled: false,
|
||||
mfaPasskeysRpID: '',
|
||||
mfaPasskeysOrigin: '',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -32,7 +34,9 @@ export default function Mfa({
|
||||
form.setValues({
|
||||
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
|
||||
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
|
||||
mfaPasskeys: data.settings.mfaPasskeys,
|
||||
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled ?? false,
|
||||
mfaPasskeysRpID: data.settings.mfaPasskeysRpID ?? '',
|
||||
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -47,7 +51,21 @@ export default function Mfa({
|
||||
<Switch
|
||||
label='Passkeys'
|
||||
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
|
||||
{...form.getInputProps('mfaPasskeys', { type: 'checkbox' })}
|
||||
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Relying Party ID'
|
||||
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
|
||||
placeholder='example.com'
|
||||
{...form.getInputProps('mfaPasskeysRpID')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Origin'
|
||||
description='The Origin to use for WebAuthn passkeys.'
|
||||
placeholder='https://example.com'
|
||||
{...form.getInputProps('mfaPasskeysOrigin')}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function Tasks({
|
||||
tasksMaxViewsInterval: '30m',
|
||||
tasksThumbnailsInterval: '30m',
|
||||
tasksMetricsInterval: '30m',
|
||||
tasksCleanThumbnailsInterval: '1d',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -37,6 +38,7 @@ export default function Tasks({
|
||||
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
|
||||
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
|
||||
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
|
||||
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval ?? '1d',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -79,6 +81,13 @@ export default function Tasks({
|
||||
placeholder='30m'
|
||||
{...form.getInputProps('tasksThumbnailsInterval')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Clean Thumbnails Interval'
|
||||
description='How often to check and delete orphaned thumbnails from the filesystem or database.'
|
||||
placeholder='1d'
|
||||
{...form.getInputProps('tasksCleanThumbnailsInterval')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
|
||||
@@ -32,6 +32,8 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return error;
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Settings saved',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconDeviceFloppy,
|
||||
IconFileX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
@@ -94,6 +95,24 @@ export default function SettingsFileView() {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.setValues({
|
||||
enabled: user.view.enabled || false,
|
||||
content: user.view.content || '',
|
||||
embed: user.view.embed || false,
|
||||
embedTitle: user.view.embedTitle || '',
|
||||
embedDescription: user.view.embedDescription || '',
|
||||
embedSiteName: user.view.embedSiteName || '',
|
||||
embedColor: user.view.embedColor || '',
|
||||
align: user.view.align || 'left',
|
||||
showMimetype: user.view.showMimetype || false,
|
||||
showTags: user.view.showTags || false,
|
||||
showFolder: user.view.showFolder || false,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>Viewing Files</Title>
|
||||
|
||||
@@ -104,14 +104,12 @@ export default function GeneratorButton({
|
||||
);
|
||||
|
||||
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
|
||||
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const { data: settingsData } = useSWR<Response['/api/server/public']>('/api/server/public');
|
||||
|
||||
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
|
||||
const onlyFile = generatorType === 'file';
|
||||
|
||||
const domains = Array.isArray(settingsData?.settings.domains)
|
||||
? settingsData?.settings.domains.map((d) => String(d))
|
||||
: [];
|
||||
const domains = Array.isArray(settingsData?.domains) ? settingsData?.domains.map((d) => String(d)) : [];
|
||||
const domainOptions = [
|
||||
{ value: '', label: 'Default Domain' },
|
||||
...domains.map((domain) => ({
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { registerWeb } from '@/lib/passkey';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
|
||||
import { UserPasskey } from '@/prisma/client';
|
||||
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { UserPasskey } from '@/prisma/client';
|
||||
import {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser';
|
||||
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
@@ -23,10 +26,15 @@ export default function PasskeyButton() {
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
try {
|
||||
const { data } = await fetchApi<PublicKeyCredentialCreationOptionsJSON>(
|
||||
'/api/user/mfa/passkey/options',
|
||||
'GET',
|
||||
);
|
||||
|
||||
setPasskeyLoading(true);
|
||||
const res = await registerWeb(user!);
|
||||
const res = await startRegistration({ optionsJSON: data! });
|
||||
setNamerShown(true);
|
||||
setSavedKey(res.toJSON());
|
||||
setSavedKey(res);
|
||||
} catch (e: any) {
|
||||
setPasskeyError(e.message ?? 'An error occurred while creating a passkey');
|
||||
setPasskeyLoading(false);
|
||||
@@ -38,7 +46,7 @@ export default function PasskeyButton() {
|
||||
if (!savedKey) return;
|
||||
|
||||
const { error } = await fetchApi('/api/user/mfa/passkey', 'POST', {
|
||||
reg: savedKey,
|
||||
response: savedKey,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
@@ -23,6 +24,7 @@ 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -189,7 +199,7 @@ export default function TwoFAButton() {
|
||||
autoFocus={true}
|
||||
error={!!pinError}
|
||||
disabled={pinDisabled}
|
||||
size='xl'
|
||||
size={size}
|
||||
/>
|
||||
</Center>
|
||||
{pinError && (
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function SettingsMfa() {
|
||||
|
||||
<Group mt='xs'>
|
||||
{config.mfa.totp.enabled && <TwoFAButton />}
|
||||
{config.mfa.passkeys && <PasskeyButton />}
|
||||
{config.mfa.passkeys.enabled && <PasskeyButton />}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import ms from 'ms';
|
||||
|
||||
function checkDomains(domains?: unknown): string[] {
|
||||
if (!domains) return [];
|
||||
@@ -101,42 +102,65 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
|
||||
<Stack gap='xs' my='sm'>
|
||||
<Select
|
||||
data={[
|
||||
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '30d', label: '1 month (30 days)' },
|
||||
{ value: '45.625d', label: '1.5 months (~45 days)' },
|
||||
{ value: '60d', label: '2 months (60 days)' },
|
||||
{ value: '90d', label: '3 months (90 days)' },
|
||||
{ value: '120d', label: '4 months (120 days)' },
|
||||
{ value: '0.5 year', label: '6 months (0.5 year)' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
{
|
||||
value: '_',
|
||||
label: 'Need more freedom? Set an exact date and time through the API.',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
data={(() => {
|
||||
// Build the full option list, then clamp by config.files.maxExpiration if provided.
|
||||
const opts = [
|
||||
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '30d', label: '1 month (30 days)' },
|
||||
{ value: '45.625d', label: '1.5 months (~45 days)' },
|
||||
{ value: '60d', label: '2 months (60 days)' },
|
||||
{ value: '90d', label: '3 months (90 days)' },
|
||||
{ value: '120d', label: '4 months (120 days)' },
|
||||
{ value: '0.5 year', label: '6 months (0.5 year)' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
{
|
||||
value: '_',
|
||||
label: 'Need more freedom? Set an exact date and time through the API.',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const maxExp = settingsData?.files?.maxExpiration ?? null;
|
||||
if (!maxExp) return opts;
|
||||
|
||||
const maxMs = ms(String(maxExp) as any);
|
||||
if (!maxMs || isNaN(Number(maxMs))) return opts;
|
||||
|
||||
// Keep 'default' and 'never' always visible; clamp other duration options.
|
||||
return opts.filter((o) => {
|
||||
if (o.value === 'default' || o.value === 'never' || o.value === '_') return true;
|
||||
const val = String(o.value);
|
||||
const parsed = (ms as unknown as (v: string) => number)(val);
|
||||
// Some labels like '45.625d' or '0.5 year' may be parseable; if not parseable, keep them to avoid excessive hiding.
|
||||
if (!parsed || isNaN(Number(parsed))) return true;
|
||||
return parsed <= Number(maxMs);
|
||||
});
|
||||
} catch {
|
||||
return opts;
|
||||
}
|
||||
})()}
|
||||
label={
|
||||
<>
|
||||
Deletes at{' '}
|
||||
@@ -162,6 +186,11 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
{'.'}
|
||||
</>
|
||||
)}
|
||||
{settingsData?.files?.maxExpiration ? (
|
||||
<div style={{ marginTop: 6, color: 'var(--mantine-color-dimmed)' }}>
|
||||
Note: maximum allowed expiration is <b>{settingsData.files.maxExpiration}</b>.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
leftSection={<IconAlarmFilled size='1rem' />}
|
||||
|
||||
@@ -8,6 +8,40 @@ import { notifications } from '@mantine/notifications';
|
||||
import { IconClipboardCopy, IconExternalLink, IconFileUpload, IconFileXFilled } from '@tabler/icons-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function handleResponse<R = Response['/api/upload']>(
|
||||
xml: XMLHttpRequest,
|
||||
): { data: R | null; error: ErrorBody | null } {
|
||||
if (xml.status < 200 || xml.status >= 300) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
statusCode: xml.status,
|
||||
error: `Request failed with status code ${xml.status}: ${xml.responseText}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = JSON.parse(xml.responseText) as R | ErrorBody;
|
||||
|
||||
if ((res as ErrorBody).statusCode) {
|
||||
return { data: null, error: res as ErrorBody };
|
||||
}
|
||||
|
||||
return { data: res as R, error: null };
|
||||
} catch (e) {
|
||||
console.error('Failed to parse server response:', e, xml.responseText);
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
statusCode: 500,
|
||||
error: 'Failed to parse server response. See browser console for more details.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function filesModal(
|
||||
files: Response['/api/upload']['files'],
|
||||
{
|
||||
@@ -150,21 +184,21 @@ export function uploadFiles(
|
||||
req.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const res: Response['/api/upload'] = JSON.parse(req.responseText);
|
||||
const { data: res, error } = handleResponse<Response['/api/upload']>(req);
|
||||
|
||||
setLoading(false);
|
||||
setProgress({ percent: 0, remaining: 0, speed: 0 });
|
||||
|
||||
if ((res as ErrorBody).statusCode) {
|
||||
if (error || !res) {
|
||||
notifications.update({
|
||||
id: 'upload',
|
||||
title: 'Error uploading files',
|
||||
message: (res as ErrorBody).error,
|
||||
message: error?.error ?? 'An unknown error occurred',
|
||||
color: 'red',
|
||||
icon: <IconFileXFilled size='1rem' />,
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,8 +211,9 @@ export function uploadFiles(
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
setFiles([]);
|
||||
filesModal(res.files, { clipboard, clearEphemeral });
|
||||
filesModal(res!.files, { clipboard, clearEphemeral });
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,15 +53,21 @@ export default function EditUrlModal({
|
||||
|
||||
const handleSave = async () => {
|
||||
const data: {
|
||||
maxViews?: number;
|
||||
maxViews?: number | null;
|
||||
password?: string;
|
||||
vanity?: string;
|
||||
destination?: string;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (password !== null) data['password'] = password?.trim();
|
||||
console.log(password);
|
||||
|
||||
if (maxViews === null) data['maxViews'] = null;
|
||||
else data['maxViews'] = maxViews;
|
||||
|
||||
// dont include password if empty or null
|
||||
if (password !== null && password.trim() !== '') data['password'] = password?.trim();
|
||||
|
||||
if (vanity !== null && vanity !== url.vanity) data['vanity'] = vanity?.trim();
|
||||
if (destination !== null && destination !== url.destination) data['destination'] = destination?.trim();
|
||||
if (enabled !== url.enabled) data['enabled'] = enabled;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
import { ActionIcon, Anchor, Box, Checkbox, Group, TextInput, Tooltip } from '@mantine/core';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import { useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { copyUrl, deleteUrl } from '../actions';
|
||||
import { IconCopy, IconPencil, IconTrashFilled } from '@tabler/icons-react';
|
||||
@@ -112,27 +112,23 @@ export default function UrlTableView() {
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<Url[]>(data ?? []);
|
||||
|
||||
const [selectedUrl, setSelectedUrl] = useState<Url | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof Url;
|
||||
const sorted = useMemo<Url[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof Url;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const field of ['code', 'vanity', 'destination'] as const) {
|
||||
|
||||
@@ -69,6 +69,9 @@ export default function EditUserModal({
|
||||
if (typeof value !== 'number' || value < 0) return 'Invalid value';
|
||||
},
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -165,6 +168,7 @@ export default function EditUserModal({
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
|
||||
@@ -48,6 +48,9 @@ export default function DashboardUsers() {
|
||||
username: (value) => (value.length < 1 ? 'Username is required' : null),
|
||||
password: (value) => (value.length < 1 ? 'Password is required' : null),
|
||||
},
|
||||
enhanceGetInputProps: ({ field }) => ({
|
||||
name: field,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -101,6 +104,7 @@ export default function DashboardUsers() {
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
autoComplete='username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { canInteract, roleName } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import EditUserModal from '../EditUserModal';
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { canInteract, roleName } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { deleteUser } from '../actions';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { deleteUser } from '../actions';
|
||||
import EditUserModal from '../EditUserModal';
|
||||
|
||||
export default function UserTableView() {
|
||||
const currentUser = useUserStore((state) => state.user);
|
||||
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<User[]>(data ?? []);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof User;
|
||||
const sorted = useMemo<User[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof User;
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
const bv = b[key]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSorted(data);
|
||||
}
|
||||
}, [data]);
|
||||
if (av === bv) return 0;
|
||||
return direction === 'asc' ? (av > bv ? 1 : -1) : av < bv ? 1 : -1;
|
||||
});
|
||||
}, [data, sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,9 +4,13 @@ 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';
|
||||
|
||||
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);
|
||||
@@ -16,8 +20,8 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
}, []);
|
||||
|
||||
const lines = useMemo(() => code.split('\n'), [code]);
|
||||
const visible = expanded ? lines.length : Math.min(lines.length, 50);
|
||||
const expandable = lines.length > 50;
|
||||
const visible = expanded || noClamp ? lines.length : Math.min(lines.length, 50);
|
||||
const expandable = !noClamp && lines.length > 50;
|
||||
|
||||
const lang = useMemo(() => {
|
||||
if (!hljs) return 'plaintext';
|
||||
@@ -62,7 +66,11 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
{index + 1}
|
||||
</Text>
|
||||
|
||||
<code className='theme hljs' style={{ flex: 1 }} dangerouslySetInnerHTML={{ __html: hlLines[index] }} />
|
||||
<code
|
||||
className='theme hljs'
|
||||
style={{ flex: 1, fontSize: '0.8rem' }}
|
||||
dangerouslySetInnerHTML={{ __html: hlLines[index] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,11 +94,21 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
)}
|
||||
</CopyButton>
|
||||
|
||||
<ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
|
||||
<List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
|
||||
{Row}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
{noClamp ? (
|
||||
<ScrollArea type='auto' offsetScrollbars={false}>
|
||||
<div>
|
||||
{hlLines.map((_, index) => (
|
||||
<Row key={index} index={index} style={{}} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
|
||||
<List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
|
||||
{Row}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{expandable && (
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@ 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';
|
||||
@@ -50,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;
|
||||
|
||||
@@ -1,5 +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];
|
||||
@@ -8,6 +11,8 @@ export type CompressResult = {
|
||||
mimetype: string;
|
||||
ext: CompressType;
|
||||
buffer: Buffer;
|
||||
|
||||
failed?: boolean;
|
||||
};
|
||||
|
||||
export type CompressOptions = {
|
||||
@@ -22,47 +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 animated = ['.gif', '.webp', '.avif', '.tiff'].includes(extname(filePath).toLowerCase());
|
||||
const animated = ['.gif', '.webp', '.avif', '.tiff'].includes(extname(filePath).toLowerCase());
|
||||
|
||||
const image = sharp(filePath, { animated }).withMetadata();
|
||||
const image = sharp(filePath, { animated }).withMetadata();
|
||||
|
||||
const result: CompressResult = {
|
||||
mimetype: '',
|
||||
ext: 'jpg',
|
||||
buffer: Buffer.alloc(0),
|
||||
};
|
||||
const result: CompressResult = {
|
||||
mimetype: '',
|
||||
ext: 'jpg',
|
||||
buffer: Buffer.alloc(0),
|
||||
};
|
||||
|
||||
let buffer: Buffer;
|
||||
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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -17,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',
|
||||
@@ -24,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',
|
||||
@@ -95,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',
|
||||
|
||||
@@ -54,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),
|
||||
@@ -131,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),
|
||||
|
||||
@@ -33,6 +33,7 @@ export const rawConfig: any = {
|
||||
maxViewsInterval: undefined,
|
||||
thumbnailsInterval: undefined,
|
||||
metricsInterval: undefined,
|
||||
cleanThumbnailsInterval: undefined,
|
||||
},
|
||||
files: {
|
||||
route: undefined,
|
||||
@@ -96,7 +97,11 @@ export const rawConfig: any = {
|
||||
enabled: undefined,
|
||||
issuer: undefined,
|
||||
},
|
||||
passkeys: undefined,
|
||||
passkeys: {
|
||||
enabled: undefined,
|
||||
rpID: undefined,
|
||||
origin: undefined,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
bypassLocalLogin: undefined,
|
||||
|
||||
@@ -109,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'),
|
||||
@@ -117,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),
|
||||
@@ -243,7 +245,11 @@ export const schema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
issuer: z.string().default('Zipline'),
|
||||
}),
|
||||
passkeys: z.boolean().default(true),
|
||||
passkeys: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
rpID: z.string().nullable().default(null),
|
||||
origin: z.url().nullable().default(null),
|
||||
}),
|
||||
}),
|
||||
oauth: z.object({
|
||||
bypassLocalLogin: z.boolean().default(false),
|
||||
|
||||
@@ -2,45 +2,39 @@ import crypto from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { randomCharacters } from './random';
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
export function createKey(secret: string) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(secret);
|
||||
|
||||
return hash.digest('hex').slice(0, 32);
|
||||
export function createKey(secret: string): Buffer {
|
||||
return crypto.createHash('sha256').update(secret, 'utf8').digest();
|
||||
}
|
||||
|
||||
export function encrypt(value: string, secret: string): string {
|
||||
const key = createKey(secret);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const key = crypto.createHash('sha256').update(secret, 'utf8').digest();
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key), iv);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = cipher.update(value);
|
||||
const final = cipher.final();
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const buffer = Buffer.alloc(encrypted.length + final.length);
|
||||
buffer.set(encrypted);
|
||||
buffer.set(final, encrypted.length);
|
||||
|
||||
return iv.toString('hex') + '.' + buffer.toString('hex');
|
||||
return `${iv.toString('hex')}.${encrypted.toString('hex')}.${tag.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(value: string, secret: string): string {
|
||||
const key = createKey(secret);
|
||||
const [iv, encrypted] = value.split('.');
|
||||
const key = crypto.createHash('sha256').update(secret, 'utf8').digest();
|
||||
const [ivHex, encryptedHex, tagHex] = value.split('.');
|
||||
if (!ivHex || !encryptedHex || !tagHex) throw new Error('Invalid values');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(key), Buffer.from(iv, 'hex'));
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
|
||||
const decrypted = decipher.update(Buffer.from(encrypted, 'hex'));
|
||||
const final = decipher.final();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const buffer = Buffer.alloc(decrypted.length + final.length);
|
||||
buffer.set(decrypted);
|
||||
buffer.set(final, decrypted.length);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
return buffer.toString();
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
export function createToken(): string {
|
||||
@@ -54,29 +48,24 @@ export function createToken(): string {
|
||||
}
|
||||
|
||||
export function encryptToken(token: string, secret: string): string {
|
||||
const key = createKey(secret);
|
||||
|
||||
const date = Date.now();
|
||||
const date64 = Buffer.from(date.toString()).toString('base64');
|
||||
|
||||
const encrypted = encrypt(token, key);
|
||||
const encrypted = encrypt(token, secret);
|
||||
const encrypted64 = Buffer.from(encrypted).toString('base64');
|
||||
|
||||
return `${date64}.${encrypted64}`;
|
||||
}
|
||||
|
||||
export function decryptToken(encryptedToken: string, secret: string): [number, string] | null {
|
||||
const key = createKey(secret);
|
||||
const [date64, encrypted64] = encryptedToken.split('.');
|
||||
|
||||
if (!date64 || !encrypted64) return null;
|
||||
|
||||
try {
|
||||
const date = parseInt(Buffer.from(date64, 'base64').toString('ascii'), 10);
|
||||
|
||||
const encrypted = Buffer.from(encrypted64, 'base64').toString('ascii');
|
||||
|
||||
return [date, decrypt(encrypted, key)];
|
||||
return [date, decrypt(encrypted, secret)];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export type PutOptions = { mimetype?: string; noDelete?: boolean };
|
||||
export type ListOptions = { prefix: string };
|
||||
|
||||
export abstract class Datasource {
|
||||
public name: string | undefined;
|
||||
@@ -13,4 +14,5 @@ export abstract class Datasource {
|
||||
public abstract clear(): Promise<void>;
|
||||
public abstract range(file: string, start: number, end: number): Promise<Readable>;
|
||||
public abstract rename(from: string, to: string): Promise<void>;
|
||||
public abstract list(options: ListOptions): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createReadStream, existsSync } from 'fs';
|
||||
import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
|
||||
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 {
|
||||
@@ -113,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,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;
|
||||
@@ -449,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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,3 +23,9 @@ export function sanitizeFilename(name: string): string | 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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const FIELDS = ['name', 'originalName', 'tags', 'type', 'size', 'createdAt', 'favorite', 'views'] as const;
|
||||
type Field = 'name' | 'originalName' | 'tags' | 'type' | 'size' | 'createdAt' | 'favorite' | 'views';
|
||||
|
||||
export const defaultFields: FieldSettings[] = [
|
||||
{ field: 'name', visible: true },
|
||||
@@ -15,7 +15,7 @@ export const defaultFields: FieldSettings[] = [
|
||||
];
|
||||
|
||||
export type FieldSettings = {
|
||||
field: (typeof FIELDS)[number];
|
||||
field: Field;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
|
||||
56
src/lib/tasks/run/cleanThumbnails.ts
Executable file
56
src/lib/tasks/run/cleanThumbnails.ts
Executable file
@@ -0,0 +1,56 @@
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { IntervalTask } from '..';
|
||||
|
||||
export default function cleanThumbnails(prisma: typeof globalThis.__db__) {
|
||||
return async function (this: IntervalTask) {
|
||||
const fsThumbnails = await datasource.list({ prefix: '.thumbnail.' });
|
||||
const dbThumbnails = await prisma.thumbnail.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
},
|
||||
});
|
||||
|
||||
const paths = new Set(dbThumbnails.map((t) => t.path));
|
||||
const fsOrphaned = fsThumbnails.filter((path) => !paths.has(path));
|
||||
|
||||
for (const path of fsOrphaned) {
|
||||
try {
|
||||
await datasource.delete(path);
|
||||
this.logger.info('deleted orphaned thumbnail', { path });
|
||||
} catch (err) {
|
||||
this.logger.error('failed to delete orphaned thumbnail', { path, error: err });
|
||||
}
|
||||
}
|
||||
|
||||
const fs = new Set(fsThumbnails);
|
||||
const dbOrphaned = dbThumbnails.filter((t) => !fs.has(t.path));
|
||||
|
||||
for (const thumb of dbOrphaned) {
|
||||
try {
|
||||
await prisma.thumbnail.delete({
|
||||
where: {
|
||||
id: thumb.id,
|
||||
},
|
||||
});
|
||||
this.logger.info('deleted orphaned thumbnail from database', { path: thumb.path });
|
||||
} catch (err) {
|
||||
this.logger.error('failed to delete orphaned thumbnail from database', {
|
||||
path: thumb.path,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('thumbnail cleanup complete', {
|
||||
fsChecked: fsThumbnails.length,
|
||||
dbChecked: dbThumbnails.length,
|
||||
fsDeleted: fsOrphaned.length,
|
||||
dbDeleted: dbOrphaned.length,
|
||||
totals: {
|
||||
fs: fsThumbnails.length - fsOrphaned.length,
|
||||
db: dbThumbnails.length - dbOrphaned.length,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
49
src/lib/timedCache.ts
Normal file
49
src/lib/timedCache.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export class TimedCache<K, V> {
|
||||
private cache = new Map<K, CacheEntry<V>>();
|
||||
private ttl: number;
|
||||
|
||||
constructor(ttl = 60_000) {
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
set(key: K, value: V, ttl = this.ttl): void {
|
||||
const expiresAt = Date.now() + ttl;
|
||||
|
||||
const existing = this.cache.get(key);
|
||||
if (existing) clearTimeout(existing.timeout);
|
||||
|
||||
const timeout = setTimeout(() => this.cache.delete(key), ttl);
|
||||
timeout.unref?.();
|
||||
|
||||
this.cache.set(key, { value, expiresAt, timeout });
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.get(key) !== undefined;
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) clearTimeout(entry.timeout);
|
||||
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import ms from 'ms';
|
||||
import { Config } from '../config/validate';
|
||||
import { checkOutput, COMPRESS_TYPES, CompressType } from '../compress';
|
||||
import { config } from '../config';
|
||||
import { sanitizeExtension, sanitizeFilename } from '../fs';
|
||||
|
||||
// from ms@3.0.0-canary.1
|
||||
type Unit =
|
||||
@@ -119,7 +120,9 @@ export function humanTime(string: StringValue | string): Date | null {
|
||||
|
||||
export function parseExpiry(header: string): Date | null {
|
||||
if (!header) return null;
|
||||
header = header.toLowerCase();
|
||||
header = header.trim().toLowerCase();
|
||||
|
||||
if (header === 'never') return null;
|
||||
|
||||
if (header.startsWith('date=')) {
|
||||
const date = new Date(header.substring(5));
|
||||
@@ -165,6 +168,18 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
const expiresAt = parseExpiry(headers['x-zipline-deletes-at']);
|
||||
if (!expiresAt) return headerError('x-zipline-deletes-at', 'Invalid expiry date');
|
||||
|
||||
if (fileConfig.maxExpiration) {
|
||||
const maxExpiryTime = ms(fileConfig.maxExpiration as StringValue);
|
||||
const requestedExpiryTime = expiresAt.getTime() - Date.now();
|
||||
|
||||
if (requestedExpiryTime > maxExpiryTime) {
|
||||
return headerError(
|
||||
'x-zipline-deletes-at',
|
||||
`Expiry exceeds maximum allowed expiration of ${fileConfig.maxExpiration}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
response.deletesAt = expiresAt;
|
||||
}
|
||||
} else {
|
||||
@@ -187,12 +202,6 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
const imageCompressionType = headers['x-zipline-image-compression-type'];
|
||||
|
||||
if (imageCompressionType) {
|
||||
if (!imageCompressionPercent)
|
||||
return headerError(
|
||||
'x-zipline-image-compression-percent',
|
||||
'missing "x-zipline-image-compression-percent" when "x-zipline-image-compression-type" is provided',
|
||||
);
|
||||
|
||||
if (!COMPRESS_TYPES.includes(imageCompressionType))
|
||||
return headerError(
|
||||
'x-zipline-image-compression-type',
|
||||
@@ -205,13 +214,15 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
`Compression type "${imageCompressionType}" is not supported on the system.`,
|
||||
);
|
||||
|
||||
const percent = parsePercent('x-zipline-image-compression-percent', imageCompressionPercent);
|
||||
if (typeof percent === 'object') return percent;
|
||||
if (imageCompressionPercent) {
|
||||
const percent = parsePercent('x-zipline-image-compression-percent', imageCompressionPercent);
|
||||
if (typeof percent === 'object') return percent;
|
||||
|
||||
response.imageCompression = {
|
||||
type: imageCompressionType,
|
||||
percent,
|
||||
};
|
||||
response.imageCompression = {
|
||||
type: imageCompressionType,
|
||||
percent,
|
||||
};
|
||||
}
|
||||
} else if (imageCompressionPercent) {
|
||||
const percent = parsePercent('x-zipline-image-compression-percent', imageCompressionPercent);
|
||||
if (typeof percent === 'object') return percent;
|
||||
@@ -245,12 +256,19 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
response.overrides = {};
|
||||
|
||||
const filename = headers['x-zipline-filename'];
|
||||
if (filename) response.overrides.filename = filename;
|
||||
if (filename) {
|
||||
const fn = sanitizeFilename(filename);
|
||||
if (!fn) return headerError('x-zipline-filename', 'Invalid filename');
|
||||
|
||||
response.overrides.filename = fn;
|
||||
}
|
||||
|
||||
const extension = headers['x-zipline-file-extension'];
|
||||
if (extension) {
|
||||
if (!extension.startsWith('.')) response.overrides.extension = `.${extension}`;
|
||||
else response.overrides.extension = extension;
|
||||
const ext = sanitizeExtension(extension);
|
||||
if (!ext) return headerError('x-zipline-file-extension', 'Invalid file extension');
|
||||
|
||||
response.overrides.extension = ext;
|
||||
}
|
||||
|
||||
const returnDomain = headers['x-zipline-domain'];
|
||||
|
||||
23
src/lib/validation.ts
Normal file
23
src/lib/validation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import z from 'zod';
|
||||
import { sanitizeFilename } from './fs';
|
||||
|
||||
export function zValidatePath(val: string | undefined, ctx: z.RefinementCtx) {
|
||||
if (!val) return;
|
||||
|
||||
const sanitized = sanitizeFilename(val);
|
||||
if (!sanitized) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid path',
|
||||
input: val,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export const zStringTrimmed = z.string().trim().min(1);
|
||||
|
||||
export const zQsBoolean = z.enum(['true', 'false']).transform((val) => val === 'true');
|
||||
@@ -19,7 +19,15 @@ import { fastifyMultipart } from '@fastify/multipart';
|
||||
import { fastifyRateLimit } from '@fastify/rate-limit';
|
||||
import { fastifySensible } from '@fastify/sensible';
|
||||
import { fastifyStatic } from '@fastify/static';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastify from 'fastify';
|
||||
import {
|
||||
hasZodFastifySchemaValidationErrors,
|
||||
jsonSchemaTransform,
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
ZodTypeProvider,
|
||||
} from 'fastify-type-provider-zod';
|
||||
import { appendFile, mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { version } from '../../package.json';
|
||||
@@ -29,6 +37,7 @@ import vitePlugin from './plugins/vite';
|
||||
import loadRoutes from './routes';
|
||||
import { filesRoute } from './routes/files.dy';
|
||||
import { urlsRoute } from './routes/urls.dy';
|
||||
import cleanThumbnails from '@/lib/tasks/run/cleanThumbnails';
|
||||
|
||||
const MODE = process.env.NODE_ENV || 'production';
|
||||
const logger = log('server');
|
||||
@@ -81,6 +90,34 @@ async function main() {
|
||||
}
|
||||
: null,
|
||||
trustProxy: config.core.trustProxy,
|
||||
}).withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
if (process.env.DEBUG_EVENT_EMITTER) {
|
||||
server.addHook('onSend', async (req, res) => {
|
||||
const counts = {
|
||||
listeners: res.raw.eventNames(),
|
||||
close: res.raw.listenerCount('close'),
|
||||
data: res.raw.listenerCount('data'),
|
||||
end: res.raw.listenerCount('end'),
|
||||
error: res.raw.listenerCount('error'),
|
||||
};
|
||||
|
||||
logger.debug('event emitter counts', { path: req.url, ...counts });
|
||||
});
|
||||
}
|
||||
server.setValidatorCompiler(validatorCompiler);
|
||||
server.setSerializerCompiler(serializerCompiler);
|
||||
|
||||
await server.register(fastifySwagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Zipline',
|
||||
description: 'Zipline API',
|
||||
version: version,
|
||||
},
|
||||
servers: [],
|
||||
},
|
||||
transform: jsonSchemaTransform,
|
||||
});
|
||||
|
||||
await server.register(fastifyCookie, {
|
||||
@@ -209,21 +246,41 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
server.setErrorHandler((error: { statusCode: number; message: string }, _, res) => {
|
||||
server.setErrorHandler((error: any, _, res) => {
|
||||
if (hasZodFastifySchemaValidationErrors(error)) {
|
||||
return res.status(400).send({
|
||||
error: error.message ?? 'Response Validation Error',
|
||||
statusCode: 400,
|
||||
issues: error.validation,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
res.status(error.statusCode);
|
||||
res.send({ error: error.message, statusCode: error.statusCode });
|
||||
} else {
|
||||
if (process.env.DEBUG === 'zipline') console.error(error);
|
||||
console.error(error);
|
||||
|
||||
res.status(500);
|
||||
res.send({ error: 'Internal Server Error', statusCode: 500, message: error.message });
|
||||
res.send({ error: 'Internal Server Error', statusCode: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
const tasks = new Tasks();
|
||||
server.decorate('tasks', tasks);
|
||||
|
||||
if (process.env.ZIPLINE_OUTPUT_OPENAPI === 'true') {
|
||||
server.ready(async (a) => {
|
||||
console.log(a);
|
||||
const openapi = server.swagger();
|
||||
await writeFile('./openapi.json', JSON.stringify(openapi, null, 2), 'utf8');
|
||||
|
||||
logger.info('OpenAPI schema written to openapi.json');
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
await server.listen({
|
||||
port: config.core.port,
|
||||
host: config.core.hostname,
|
||||
@@ -235,6 +292,11 @@ async function main() {
|
||||
tasks.interval('deletefiles', ms(config.tasks.deleteInterval as StringValue), deleteFiles(prisma));
|
||||
tasks.interval('maxviews', ms(config.tasks.maxViewsInterval as StringValue), maxViews(prisma));
|
||||
tasks.interval('clearinvites', ms(config.tasks.clearInvitesInterval as StringValue), clearInvites(prisma));
|
||||
tasks.interval(
|
||||
'cleanthumbnails',
|
||||
ms(config.tasks.cleanThumbnailsInterval as StringValue),
|
||||
cleanThumbnails(prisma),
|
||||
);
|
||||
|
||||
if (config.features.metrics)
|
||||
tasks.interval('metrics', ms(config.tasks.metricsInterval as StringValue), metrics(prisma));
|
||||
|
||||
@@ -9,10 +9,12 @@ import fastifyStatic from '@fastify/static';
|
||||
import { renderHtml } from '@/lib/ssr/renderHtml';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import { ZIPLINE_SSR_INSERT, ZIPLINE_SSR_META } from '@/lib/ssr/constants';
|
||||
import { log } from '@/lib/logger';
|
||||
|
||||
export const ALL_METHODS: HTTPMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];
|
||||
|
||||
const MODE = process.env.NODE_ENV || 'development';
|
||||
const logger = log('server').c('plugin').c('vite');
|
||||
|
||||
async function vitePlugin(fastify: FastifyInstance) {
|
||||
fastify.decorateReply('ssr', ssrRoute);
|
||||
@@ -29,10 +31,10 @@ async function vitePlugin(fastify: FastifyInstance) {
|
||||
} else {
|
||||
const vite = await createServer();
|
||||
|
||||
console.log('Vite server created in development mode');
|
||||
logger.info('Vite initialized', { mode: MODE });
|
||||
|
||||
fastify.decorate('vite', vite);
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
fastify.addHook('preHandler', async (req, reply) => {
|
||||
const url = req.raw.url || '';
|
||||
|
||||
const reserved = [
|
||||
@@ -41,9 +43,7 @@ async function vitePlugin(fastify: FastifyInstance) {
|
||||
config.urls.route,
|
||||
].some((route) => url.startsWith(route));
|
||||
|
||||
if (reserved) {
|
||||
return;
|
||||
}
|
||||
if (reserved) return;
|
||||
|
||||
reply.hijack();
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
|
||||
import { log } from '@/lib/logger';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiAuthInvitesIdResponse = Invite;
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('auth').c('invites').c('[id]');
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const PATH = '/api/auth/invites/:id';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Params: Params }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{ preHandler: [userMiddleware, administratorMiddleware] },
|
||||
{
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -37,9 +42,14 @@ export default fastifyPlugin(
|
||||
},
|
||||
);
|
||||
|
||||
server.delete<{ Params: Params }>(
|
||||
server.delete(
|
||||
PATH,
|
||||
{ preHandler: [userMiddleware, administratorMiddleware] },
|
||||
{
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -69,8 +79,6 @@ export default fastifyPlugin(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
|
||||
import { log } from '@/lib/logger';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { parseExpiry } from '@/lib/uploader/parseHeaders';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiAuthInvitesResponse = Invite | Invite[];
|
||||
|
||||
type Body = {
|
||||
expiresAt: string;
|
||||
maxUses?: number;
|
||||
};
|
||||
|
||||
const logger = log('api').c('auth').c('invites');
|
||||
|
||||
export const PATH = '/api/auth/invites';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{ preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1) },
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
expiresAt: z
|
||||
.string()
|
||||
.or(z.literal('never'))
|
||||
.transform((val) => parseExpiry(val)),
|
||||
maxUses: z.number().min(1).optional(),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
async (req, res) => {
|
||||
const { expiresAt, maxUses } = req.body;
|
||||
|
||||
if (!expiresAt) return res.badRequest('expiresAt is required');
|
||||
let expires = null;
|
||||
|
||||
if (expiresAt !== 'never') expires = parseExpiry(expiresAt);
|
||||
|
||||
const invite = await prisma.invite.create({
|
||||
data: {
|
||||
code: randomCharacters(config.invites.length),
|
||||
expiresAt: expires,
|
||||
expiresAt,
|
||||
maxUses: maxUses ?? null,
|
||||
inviterId: req.user.id,
|
||||
},
|
||||
@@ -63,8 +66,6 @@ export default fastifyPlugin(
|
||||
|
||||
return res.send(invites);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiAuthInvitesWebResponse = Invite & {
|
||||
inviter: {
|
||||
@@ -10,48 +11,51 @@ export type ApiAuthInvitesWebResponse = Invite & {
|
||||
};
|
||||
};
|
||||
|
||||
type Query = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const PATH = '/api/auth/invites/web';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Querystring: Query }>(PATH, secondlyRatelimit(10), async (req, res) => {
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code) return res.send({ invite: null });
|
||||
if (!config.invites.enabled) return res.notFound();
|
||||
|
||||
const invite = await prisma.invite.findFirst({
|
||||
where: {
|
||||
OR: [{ id: code }, { code }],
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({ code: z.string().optional() }),
|
||||
},
|
||||
select: {
|
||||
code: true,
|
||||
maxUses: true,
|
||||
uses: true,
|
||||
expiresAt: true,
|
||||
inviter: {
|
||||
select: { username: true },
|
||||
...secondlyRatelimit(10),
|
||||
},
|
||||
async (req, res) => {
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code) return res.send({ invite: null });
|
||||
if (!config.invites.enabled) return res.notFound();
|
||||
|
||||
const invite = await prisma.invite.findFirst({
|
||||
where: {
|
||||
OR: [{ id: code }, { code }],
|
||||
},
|
||||
},
|
||||
});
|
||||
select: {
|
||||
code: true,
|
||||
maxUses: true,
|
||||
uses: true,
|
||||
expiresAt: true,
|
||||
inviter: {
|
||||
select: { username: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
(invite.expiresAt && new Date(invite.expiresAt) < new Date()) ||
|
||||
(invite.maxUses && invite.uses >= invite.maxUses)
|
||||
) {
|
||||
return res.notFound();
|
||||
}
|
||||
if (
|
||||
!invite ||
|
||||
(invite.expiresAt && new Date(invite.expiresAt) < new Date()) ||
|
||||
(invite.maxUses && invite.uses >= invite.maxUses)
|
||||
) {
|
||||
return res.notFound();
|
||||
}
|
||||
|
||||
delete (invite as any).expiresAt;
|
||||
delete (invite as any).expiresAt;
|
||||
|
||||
return res.send({ invite });
|
||||
});
|
||||
|
||||
done();
|
||||
return res.send({ invite });
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -4,93 +4,98 @@ import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { verifyTotpCode } from '@/lib/totp';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import { getSession, saveSession } from '@/server/session';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiLoginResponse = {
|
||||
user?: User;
|
||||
totp?: true;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
username: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('auth').c('login');
|
||||
|
||||
export const PATH = '/api/auth/login';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(PATH, secondlyRatelimit(2), async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
session.id = null;
|
||||
session.sessionId = null;
|
||||
|
||||
const { username, password, code } = req.body;
|
||||
|
||||
if (!username) return res.badRequest('Username is required');
|
||||
if (!password) return res.badRequest('Password is required');
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
username: zStringTrimmed,
|
||||
password: zStringTrimmed,
|
||||
code: z.string().min(1).optional(),
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
if (!user) return res.badRequest('Invalid username or password');
|
||||
...secondlyRatelimit(2),
|
||||
},
|
||||
async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
if (!valid) {
|
||||
logger.warn('invalid login attempt', {
|
||||
username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
session.id = null;
|
||||
session.sessionId = null;
|
||||
|
||||
const { username, password, code } = req.body;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
return res.badRequest('Invalid username or password');
|
||||
}
|
||||
if (!user) return res.badRequest('Invalid username or password');
|
||||
if (!user.password) return res.badRequest('Invalid username or password');
|
||||
|
||||
if (user.totpSecret && code) {
|
||||
const valid = verifyTotpCode(code, user.totpSecret);
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
if (!valid) {
|
||||
logger.warn('invalid totp code', {
|
||||
logger.warn('invalid login attempt', {
|
||||
username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.badRequest('Invalid code');
|
||||
return res.badRequest('Invalid username or password');
|
||||
}
|
||||
}
|
||||
|
||||
if (user.totpSecret && !code)
|
||||
return res.send({
|
||||
totp: true,
|
||||
if (user.totpSecret && code) {
|
||||
const valid = verifyTotpCode(code, user.totpSecret);
|
||||
if (!valid) {
|
||||
logger.warn('invalid totp code', {
|
||||
username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.badRequest('Invalid code');
|
||||
}
|
||||
}
|
||||
|
||||
if (user.totpSecret && !code)
|
||||
return res.send({
|
||||
totp: true,
|
||||
});
|
||||
|
||||
await saveSession(session, user, false);
|
||||
|
||||
delete (user as any).password;
|
||||
|
||||
logger.info('user logged in successfully', {
|
||||
username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
await saveSession(session, user, false);
|
||||
|
||||
delete (user as any).password;
|
||||
|
||||
logger.info('user logged in successfully', {
|
||||
username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.send({
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
return res.send({
|
||||
user,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import { getSession } from '@/server/session';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiLogoutResponse = {
|
||||
loggedOut?: boolean;
|
||||
@@ -11,8 +11,8 @@ export type ApiLogoutResponse = {
|
||||
const logger = log('api').c('auth').c('logout');
|
||||
|
||||
export const PATH = '/api/auth/logout';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const current = await getSession(req, res);
|
||||
|
||||
@@ -37,8 +37,6 @@ export default fastifyPlugin(
|
||||
|
||||
return res.send({ loggedOut: true });
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { discordAuth } from '@/lib/oauth/providerUtil';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration)
|
||||
@@ -103,13 +103,11 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
|
||||
}
|
||||
|
||||
export const PATH = '/api/auth/oauth/discord';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (req, res) => {
|
||||
return req.oauthHandle(res, 'DISCORD', discordOauth);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { githubAuth } from '@/lib/oauth/providerUtil';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration)
|
||||
@@ -88,13 +88,11 @@ async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise
|
||||
}
|
||||
|
||||
export const PATH = '/api/auth/oauth/github';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (req, res) => {
|
||||
return req.oauthHandle(res, 'GITHUB', githubOauth);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { googleAuth } from '@/lib/oauth/providerUtil';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration)
|
||||
@@ -86,13 +86,11 @@ async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): P
|
||||
}
|
||||
|
||||
export const PATH = '/api/auth/oauth/google';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (req, res) => {
|
||||
return req.oauthHandle(res, 'GOOGLE', googleOauth);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,63 +1,61 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { OAuthProvider, OAuthProviderType } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import { prisma } from '@/lib/db';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiAuthOauthResponse = OAuthProvider[];
|
||||
|
||||
type Body = {
|
||||
provider?: OAuthProviderType;
|
||||
};
|
||||
|
||||
const logger = log('api').c('auth').c('oauth');
|
||||
|
||||
export const PATH = '/api/auth/oauth';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
return res.send(req.user.oauthProviders);
|
||||
});
|
||||
|
||||
server.delete<{ Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const { password } = (await prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
},
|
||||
}))!;
|
||||
|
||||
if (!req.user.oauthProviders.length) return res.badRequest('No providers to delete');
|
||||
if (req.user.oauthProviders.length === 1 && !password)
|
||||
return res.badRequest("You can't delete your last oauth provider without a password");
|
||||
|
||||
const { provider } = req.body;
|
||||
if (!provider) return res.badRequest('Provider is required');
|
||||
|
||||
const providers = await prisma.user.update({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
data: {
|
||||
oauthProviders: {
|
||||
deleteMany: [{ provider }],
|
||||
server.delete(
|
||||
PATH,
|
||||
{ schema: { body: z.object({ provider: z.enum(OAuthProviderType) }) }, preHandler: [userMiddleware] },
|
||||
async (req, res) => {
|
||||
const { password } = (await prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
oauthProviders: true,
|
||||
},
|
||||
});
|
||||
select: {
|
||||
password: true,
|
||||
},
|
||||
}))!;
|
||||
|
||||
logger.info(`${req.user.username} unlinked an oauth provider`, {
|
||||
provider,
|
||||
});
|
||||
if (!req.user.oauthProviders.length) return res.badRequest('No providers to delete');
|
||||
if (req.user.oauthProviders.length === 1 && !password)
|
||||
return res.badRequest("You can't delete your last oauth provider without a password");
|
||||
|
||||
return res.send(providers.oauthProviders);
|
||||
});
|
||||
const { provider } = req.body;
|
||||
|
||||
done();
|
||||
const providers = await prisma.user.update({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
data: {
|
||||
oauthProviders: {
|
||||
deleteMany: [{ provider }],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
oauthProviders: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} unlinked an oauth provider`, {
|
||||
provider,
|
||||
});
|
||||
|
||||
return res.send(providers.oauthProviders);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { oidcAuth } from '@/lib/oauth/providerUtil';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration)
|
||||
@@ -88,13 +88,11 @@ async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Pro
|
||||
}
|
||||
|
||||
export const PATH = '/api/auth/oauth/oidc';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (req, res) => {
|
||||
return req.oauthHandle(res, 'OIDC', oidcOauth);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -5,97 +5,101 @@ import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { getSession, saveSession } from '@/server/session';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
import { ApiLoginResponse } from './login';
|
||||
|
||||
export type ApiAuthRegisterResponse = ApiLoginResponse;
|
||||
|
||||
type Body = {
|
||||
username: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('auth').c('register');
|
||||
|
||||
export const PATH = '/api/auth/register';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(PATH, { ...secondlyRatelimit(5) }, async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
const { username, password, code } = req.body;
|
||||
|
||||
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
|
||||
if (!code && !config.features.userRegistration) return res.badRequest('User registration is disabled');
|
||||
|
||||
if (!username) return res.badRequest('Username is required');
|
||||
if (!password) return res.badRequest('Password is required');
|
||||
|
||||
const oUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
code: z.string().min(1).optional(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (oUser) return res.badRequest('Username is taken');
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
if (code) {
|
||||
const invite = await prisma.invite.findFirst({
|
||||
const { username, password, code } = req.body;
|
||||
|
||||
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
|
||||
if (!code && !config.features.userRegistration)
|
||||
return res.badRequest('User registration is disabled');
|
||||
|
||||
const oUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
OR: [{ id: code }, { code }],
|
||||
username,
|
||||
},
|
||||
});
|
||||
if (oUser) return res.badRequest('Username is taken');
|
||||
|
||||
if (!invite) return res.badRequest('Invalid invite code');
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date())
|
||||
return res.badRequest('Invalid invite code');
|
||||
if (invite.maxUses && invite.uses >= invite.maxUses) return res.badRequest('Invalid invite code');
|
||||
if (code) {
|
||||
const invite = await prisma.invite.findFirst({
|
||||
where: {
|
||||
OR: [{ id: code }, { code }],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
if (!invite) return res.badRequest('Invalid invite code');
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date())
|
||||
return res.badRequest('Invalid invite code');
|
||||
if (invite.maxUses && invite.uses >= invite.maxUses) return res.badRequest('Invalid invite code');
|
||||
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
data: {
|
||||
uses: invite.uses + 1,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('invite used', {
|
||||
user: username,
|
||||
invite: invite.id,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
uses: invite.uses + 1,
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
role: 'USER',
|
||||
token: createToken(),
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('invite used', {
|
||||
user: username,
|
||||
invite: invite.id,
|
||||
});
|
||||
}
|
||||
await saveSession(session, <User>user);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
delete (user as any).password;
|
||||
|
||||
logger.info('user registered successfully', {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
role: 'USER',
|
||||
token: createToken(),
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
await saveSession(session, <User>user);
|
||||
|
||||
delete (user as any).password;
|
||||
|
||||
logger.info('user registered successfully', {
|
||||
username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.send({
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
return res.send({
|
||||
user,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,86 +1,191 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { TimedCache } from '@/lib/timedCache';
|
||||
import { getSession, saveSession } from '@/server/session';
|
||||
import { AuthenticationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { JsonObject } from '@prisma/client/runtime/client';
|
||||
import { AuthenticationResponseJSON } from '@simplewebauthn/browser';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import z from 'zod';
|
||||
import { PasskeyReg, passkeysEnabledHandler } from '../user/mfa/passkey';
|
||||
|
||||
export type ApiAuthWebauthnResponse = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
auth: AuthenticationResponseJSON;
|
||||
export type ApiAuthWebauthnOptionsResponse = {
|
||||
id: string;
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
};
|
||||
|
||||
const logger = log('api').c('auth').c('webauthn');
|
||||
|
||||
const OPTIONS_CACHE = new TimedCache<string, PublicKeyCredentialRequestOptionsJSON>(2 * 60_000);
|
||||
|
||||
export const PATH = '/api/auth/webauthn';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(PATH, async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
if (!config.mfa.passkeys) return res.badRequest('Passkeys are not enabled');
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH + '/options',
|
||||
{ preHandler: [passkeysEnabledHandler], ...secondlyRatelimit(20) },
|
||||
async (req, res) => {
|
||||
if (req.cookies['webauthn-challenge-id']) {
|
||||
const existing = OPTIONS_CACHE.get(req.cookies['webauthn-challenge-id']);
|
||||
if (existing)
|
||||
return res.send({
|
||||
id: req.cookies['webauthn-challenge-id'],
|
||||
options: existing,
|
||||
});
|
||||
}
|
||||
|
||||
const { auth } = req.body;
|
||||
if (!auth) return res.badRequest('Missing webauthn payload');
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.mfa.passkeys.rpID!,
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
passkeys: {
|
||||
some: {
|
||||
reg: {
|
||||
path: ['id'],
|
||||
equals: auth.id,
|
||||
const id = createToken();
|
||||
res.setCookie('webauthn-challenge-id', id, {
|
||||
expires: new Date(Date.now() + 2 * 60_000),
|
||||
httpOnly: true,
|
||||
secure: config.core.returnHttpsUrls,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
OPTIONS_CACHE.set(id, options);
|
||||
|
||||
return res.send({
|
||||
id,
|
||||
options,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
response: z.custom<AuthenticationResponseJSON>(),
|
||||
}),
|
||||
},
|
||||
preHandler: [passkeysEnabledHandler],
|
||||
...secondlyRatelimit(10),
|
||||
},
|
||||
async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
const webauthnChallengeId = req.cookies['webauthn-challenge-id'];
|
||||
if (!webauthnChallengeId) return res.badRequest('Missing webauthn challenge id');
|
||||
|
||||
const { response } = req.body;
|
||||
if (!response) return res.badRequest('Missing webauthn payload');
|
||||
|
||||
const cachedOptions = OPTIONS_CACHE.get(webauthnChallengeId);
|
||||
if (!cachedOptions) return res.badRequest();
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
passkeys: {
|
||||
some: {
|
||||
reg: {
|
||||
path: ['webauthn', 'id'],
|
||||
equals: response.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
logger.warn('invalid webauthn attempt', {
|
||||
id: auth.id,
|
||||
});
|
||||
logger.debug('invalid webauthn attempt', {
|
||||
request: auth,
|
||||
});
|
||||
|
||||
return res.badRequest('Invalid passkey');
|
||||
}
|
||||
|
||||
await saveSession(session, user, false);
|
||||
|
||||
delete (user as any).password;
|
||||
|
||||
await prisma.userPasskey.updateMany({
|
||||
where: {
|
||||
reg: {
|
||||
path: ['id'],
|
||||
equals: auth.id,
|
||||
select: {
|
||||
...userSelect,
|
||||
password: true,
|
||||
token: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
lastUsed: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!user) {
|
||||
logger.warn('invalid webauthn attempt', {
|
||||
req: webauthnChallengeId,
|
||||
});
|
||||
logger.debug('invalid webauthn attempt', {
|
||||
request: response,
|
||||
});
|
||||
|
||||
logger.info('user logged in with passkey', {
|
||||
user: user.username,
|
||||
passkey: auth.id,
|
||||
});
|
||||
return res.badRequest();
|
||||
}
|
||||
|
||||
return res.send({
|
||||
user,
|
||||
});
|
||||
});
|
||||
const passkey = user.passkeys.find((pk) => {
|
||||
const webauthn = (pk?.reg as JsonObject).webauthn as { id: string };
|
||||
if (!webauthn) return false;
|
||||
return webauthn.id === response.id;
|
||||
});
|
||||
|
||||
done();
|
||||
if (!passkey) return res.badRequest();
|
||||
const reg = passkey.reg as PasskeyReg;
|
||||
|
||||
if (!reg.webauthn) {
|
||||
logger.debug('invalid webauthn attempt, legacy passkey found...');
|
||||
return res.badRequest();
|
||||
}
|
||||
|
||||
OPTIONS_CACHE.delete(webauthnChallengeId);
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: response,
|
||||
expectedChallenge: cachedOptions.challenge,
|
||||
expectedRPID: cachedOptions.rpId!,
|
||||
expectedOrigin: config.mfa.passkeys.origin!,
|
||||
credential: {
|
||||
id: reg.webauthn.id,
|
||||
counter: reg.webauthn.counter,
|
||||
publicKey: new Uint8Array(Buffer.from(reg.webauthn.publicKey, 'base64')),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logger.warn('error verifying passkey authentication');
|
||||
return res.badRequest('Error verifying passkey authentication');
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
logger.warn('failed passkey authentication attempt', {
|
||||
user: user.username,
|
||||
});
|
||||
return res.badRequest('Could not verify passkey authentication');
|
||||
}
|
||||
|
||||
const { newCounter } = verification.authenticationInfo;
|
||||
|
||||
await saveSession(session, user, false);
|
||||
|
||||
delete (user as any).password;
|
||||
|
||||
await prisma.userPasskey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
data: {
|
||||
lastUsed: new Date(),
|
||||
reg: { webauthn: { ...reg.webauthn, counter: newCounter } },
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('user logged in with passkey', {
|
||||
user: user.username,
|
||||
passkey: passkey.name,
|
||||
});
|
||||
|
||||
return res.send({
|
||||
user,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiHealthcheckResponse = {
|
||||
pass: boolean;
|
||||
@@ -10,9 +10,9 @@ export type ApiHealthcheckResponse = {
|
||||
const logger = log('api').c('healthcheck');
|
||||
|
||||
export const PATH = '/api/healthcheck';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get(PATH, async (req, res) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (_, res) => {
|
||||
if (!config.features.healthcheck) return res.notFound();
|
||||
|
||||
try {
|
||||
@@ -23,8 +23,6 @@ export default fastifyPlugin(
|
||||
return res.internalServerError('there was an error during a healthcheck');
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { clearTemp } from '@/lib/server-util/clearTemp';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiServerClearTempResponse = {
|
||||
status?: string;
|
||||
@@ -12,8 +12,8 @@ export type ApiServerClearTempResponse = {
|
||||
const logger = log('api').c('server').c('clear_temp');
|
||||
|
||||
export const PATH = '/api/server/clear_temp';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
@@ -31,8 +31,6 @@ export default fastifyPlugin(
|
||||
return res.send({ status });
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { clearZeros, clearZerosFiles } from '@/lib/server-util/clearZeros';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiServerClearZerosResponse = {
|
||||
status?: string;
|
||||
@@ -13,8 +13,8 @@ export type ApiServerClearZerosResponse = {
|
||||
const logger = log('api').c('server').c('clear_zeros');
|
||||
|
||||
export const PATH = '/api/server/clear_zeros';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
@@ -46,8 +46,6 @@ export default fastifyPlugin(
|
||||
return res.send({ status });
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { cpus, hostname, platform, release } from 'os';
|
||||
import z from 'zod';
|
||||
import { version } from '../../../../../package.json';
|
||||
|
||||
async function getCounts() {
|
||||
@@ -30,19 +31,20 @@ async function getCounts() {
|
||||
|
||||
export type ApiServerExport = Export4;
|
||||
|
||||
type Query = {
|
||||
nometrics?: string;
|
||||
counts?: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('export');
|
||||
|
||||
export const PATH = '/api/server/export';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Querystring: Query }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
nometrics: z.string().optional(),
|
||||
counts: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
@@ -278,8 +280,6 @@ export default fastifyPlugin(
|
||||
.send(export4);
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { cleanFolder, Folder } from '@/lib/db/models/folder';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerFolderResponse = Partial<Folder>;
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
uploads?: boolean;
|
||||
};
|
||||
|
||||
export const PATH = '/api/server/folder/:id';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Params: Params; Querystring: Query }>(PATH, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { uploads } = req.query;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
uploads: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
tags: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { uploads } = req.query;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
tags: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!folder) return res.notFound();
|
||||
if (!folder) return res.notFound();
|
||||
|
||||
if ((uploads && !folder.allowUploads) || (!uploads && !folder.public)) return res.notFound();
|
||||
if ((uploads && !folder.allowUploads) || (!uploads && !folder.public)) return res.notFound();
|
||||
|
||||
return res.send(cleanFolder(folder, true));
|
||||
});
|
||||
|
||||
done();
|
||||
return res.send(cleanFolder(folder, true));
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Export3, validateExport } from '@/lib/import/version3/validateExport';
|
||||
import { export3Schema } from '@/lib/import/version3/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerImportV3 = {
|
||||
users: Record<string, string>;
|
||||
@@ -14,23 +15,22 @@ export type ApiServerImportV3 = {
|
||||
urls: Record<string, string>;
|
||||
settings: string[];
|
||||
};
|
||||
|
||||
type Body = {
|
||||
export3: Export3;
|
||||
|
||||
importFromUser?: string;
|
||||
};
|
||||
|
||||
const parseDate = (date: string) => (isNaN(Date.parse(date)) ? new Date() : new Date(date));
|
||||
|
||||
const logger = log('api').c('server').c('import').c('v3');
|
||||
|
||||
export const PATH = '/api/server/import/v3';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
export3: export3Schema.required(),
|
||||
importFromUser: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
// 24gb, just in case
|
||||
bodyLimit: 24 * 1024 * 1024 * 1024,
|
||||
@@ -40,18 +40,6 @@ export default fastifyPlugin(
|
||||
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
|
||||
|
||||
const { export3 } = req.body;
|
||||
if (!export3) return res.badRequest('missing export3 in request body');
|
||||
|
||||
const validated = validateExport(export3);
|
||||
if (!validated.success) {
|
||||
logger.error('Failed to validate import data', { error: validated.error });
|
||||
|
||||
return res.status(400).send({
|
||||
error: 'Failed to validate import data',
|
||||
statusCode: 400,
|
||||
details: validated.error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
// users
|
||||
const usersImportedToId: Record<string, string> = {};
|
||||
@@ -303,8 +291,6 @@ export default fastifyPlugin(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
|
||||
import { export4Schema } from '@/lib/import/version4/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerImportV4 = {
|
||||
imported: {
|
||||
@@ -22,23 +23,23 @@ export type ApiServerImportV4 = {
|
||||
};
|
||||
};
|
||||
|
||||
type Body = {
|
||||
export4: Export4;
|
||||
|
||||
config: {
|
||||
settings: boolean;
|
||||
mergeCurrentUser: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('import').c('v4');
|
||||
|
||||
export const PATH = '/api/server/import/v4';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
export4: export4Schema.required(),
|
||||
config: z.object({
|
||||
settings: z.boolean().optional().default(false),
|
||||
mergeCurrentUser: z.string().nullable().optional().default(null),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
// 24gb, just in case
|
||||
bodyLimit: 24 * 1024 * 1024 * 1024,
|
||||
@@ -48,18 +49,6 @@ export default fastifyPlugin(
|
||||
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
|
||||
|
||||
const { export4, config: importConfig } = req.body;
|
||||
if (!export4) return res.badRequest('missing export4 in request body');
|
||||
|
||||
const validated = validateExport(export4);
|
||||
if (!validated.success) {
|
||||
logger.error('Failed to validate import data', { error: validated.error });
|
||||
|
||||
return res.status(400).send({
|
||||
error: 'Failed to validate import data',
|
||||
statusCode: 400,
|
||||
details: validated.error.issues,
|
||||
});
|
||||
}
|
||||
|
||||
// users
|
||||
const importedUsers: Record<string, string> = {};
|
||||
@@ -523,8 +512,6 @@ export default fastifyPlugin(
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { log } from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { isTruthy } from '@/lib/primitive';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiServerPublicResponse = {
|
||||
oauth: {
|
||||
@@ -37,20 +36,17 @@ export type ApiServerPublicResponse = {
|
||||
files: {
|
||||
maxFileSize: string;
|
||||
defaultFormat: Config['files']['defaultFormat'];
|
||||
maxExpiration?: string | null;
|
||||
};
|
||||
chunks: Config['chunks'];
|
||||
firstSetup: boolean;
|
||||
domains?: string[];
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('public');
|
||||
|
||||
let tosCache: string | null = null;
|
||||
|
||||
export const PATH = '/api/server/public';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Body: Body }>(PATH, async (req, res) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get<{ Body: Body }>(PATH, async (_, res) => {
|
||||
const zipline = await getZipline();
|
||||
|
||||
const response: ApiServerPublicResponse = {
|
||||
@@ -70,11 +66,16 @@ export default fastifyPlugin(
|
||||
userRegistration: config.features.userRegistration,
|
||||
},
|
||||
mfa: {
|
||||
passkeys: config.mfa.passkeys,
|
||||
passkeys: isTruthy(
|
||||
config.mfa.passkeys.enabled,
|
||||
config.mfa.passkeys.rpID,
|
||||
config.mfa.passkeys.origin,
|
||||
),
|
||||
},
|
||||
files: {
|
||||
maxFileSize: config.files.maxFileSize,
|
||||
defaultFormat: config.files.defaultFormat,
|
||||
maxExpiration: config.files.maxExpiration,
|
||||
},
|
||||
chunks: config.chunks,
|
||||
firstSetup: zipline.firstSetup,
|
||||
@@ -86,21 +87,11 @@ export default fastifyPlugin(
|
||||
}
|
||||
|
||||
if (config.website.tos) {
|
||||
try {
|
||||
if (tosCache === null) {
|
||||
const tos = await readFile(config.website.tos, 'utf8');
|
||||
tosCache = tos;
|
||||
}
|
||||
response.tos = tosCache;
|
||||
} catch {
|
||||
response.tos = null;
|
||||
}
|
||||
response.tos = global.__cachedConfigValues__.tos!;
|
||||
}
|
||||
|
||||
return res.send(response);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -3,46 +3,47 @@ import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { requerySize } from '@/lib/server-util/requerySize';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerRequerySizeResponse = {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
forceDelete?: boolean;
|
||||
forceUpdate?: boolean;
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('requery_size');
|
||||
|
||||
export const PATH = '/api/server/requery_size';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
forceDelete: z.boolean().default(false),
|
||||
forceUpdate: z.boolean().default(false),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
async (req, res) => {
|
||||
const { forceDelete, forceUpdate } = req.body;
|
||||
const status = await requerySize({
|
||||
forceDelete: req.body.forceDelete || false,
|
||||
forceUpdate: req.body.forceUpdate || false,
|
||||
forceDelete,
|
||||
forceUpdate,
|
||||
});
|
||||
|
||||
logger.info('requerying size', {
|
||||
status,
|
||||
requester: req.user.username,
|
||||
forceDelete: req.body.forceDelete || false,
|
||||
forceUpdate: req.body.forceUpdate || false,
|
||||
forceDelete,
|
||||
forceUpdate,
|
||||
});
|
||||
|
||||
return res.send({ status });
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { statSync } from 'fs';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { cpus } from 'os';
|
||||
@@ -23,8 +23,6 @@ export type ApiServerSettingsWebResponse = {
|
||||
config: ReturnType<typeof safeConfig>;
|
||||
codeMap: { ext: string; mime: string; name: string }[];
|
||||
};
|
||||
type Body = Partial<Settings>;
|
||||
|
||||
export const reservedRoutes = [
|
||||
'/dashboard',
|
||||
'/auth',
|
||||
@@ -37,6 +35,16 @@ export const reservedRoutes = [
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
const jsonTransform = (value: any, ctx: z.RefinementCtx) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
|
||||
return z.NEVER;
|
||||
}
|
||||
};
|
||||
|
||||
const zMs = z.string().refine((value) => ms(value as StringValue) > 0, 'Value must be greater than 0');
|
||||
const zBytes = z.string().refine((value) => bytes(value) > 0, 'Value must be greater than 0');
|
||||
|
||||
@@ -61,7 +69,7 @@ const discordEmbed = z
|
||||
z.string(),
|
||||
])
|
||||
.nullable()
|
||||
.transform((value) => (typeof value === 'string' ? JSON.parse(value) : value))
|
||||
.transform(jsonTransform)
|
||||
.transform((value) =>
|
||||
typeof value === 'object' ? (Object.keys(value || {}).length ? value : null) : value,
|
||||
);
|
||||
@@ -69,9 +77,9 @@ const discordEmbed = z
|
||||
const logger = log('api').c('server').c('settings');
|
||||
|
||||
export const PATH = '/api/server/settings';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Body: Body }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
@@ -92,9 +100,12 @@ export default fastifyPlugin(
|
||||
},
|
||||
);
|
||||
|
||||
server.patch<{ Body: Body }>(
|
||||
server.patch(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.custom<Partial<Settings>>(),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
@@ -129,6 +140,7 @@ export default fastifyPlugin(
|
||||
tasksMaxViewsInterval: zMs,
|
||||
tasksThumbnailsInterval: zMs,
|
||||
tasksMetricsInterval: zMs,
|
||||
tasksCleanThumbnailsInterval: zMs,
|
||||
|
||||
filesRoute: z
|
||||
.string()
|
||||
@@ -150,6 +162,7 @@ export default fastifyPlugin(
|
||||
filesMaxFileSize: zBytes,
|
||||
|
||||
filesDefaultExpiration: zMs.nullable(),
|
||||
filesMaxExpiration: zMs.nullable(),
|
||||
filesAssumeMimetypes: z.boolean(),
|
||||
filesDefaultDateFormat: z.string(),
|
||||
filesRemoveGpsMetadata: z.boolean(),
|
||||
@@ -207,7 +220,7 @@ export default fastifyPlugin(
|
||||
),
|
||||
z.string(),
|
||||
])
|
||||
.transform((value) => (typeof value === 'string' ? JSON.parse(value) : value)),
|
||||
.transform(jsonTransform),
|
||||
websiteLoginBackground: z.url().nullable(),
|
||||
websiteLoginBackgroundBlur: z.boolean(),
|
||||
websiteDefaultAvatar: z
|
||||
@@ -281,7 +294,9 @@ export default fastifyPlugin(
|
||||
|
||||
mfaTotpEnabled: z.boolean(),
|
||||
mfaTotpIssuer: z.string(),
|
||||
mfaPasskeys: z.boolean(),
|
||||
mfaPasskeysEnabled: z.boolean(),
|
||||
mfaPasskeysRpID: z.string(),
|
||||
mfaPasskeysOrigin: z.string(),
|
||||
|
||||
ratelimitEnabled: z.boolean(),
|
||||
ratelimitMax: z.number().refine((value) => value > 0, 'Value must be greater than 0'),
|
||||
@@ -383,6 +398,23 @@ export default fastifyPlugin(
|
||||
.refine((data) => !data.ratelimitWindow || (data.ratelimitMax && data.ratelimitMax > 0), {
|
||||
message: 'ratelimitMax must be set if ratelimitWindow is set',
|
||||
path: ['ratelimitMax'],
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.filesDefaultExpiration || !data.filesMaxExpiration) return;
|
||||
|
||||
const def = ms(data.filesDefaultExpiration as StringValue);
|
||||
const max = ms(data.filesMaxExpiration as StringValue);
|
||||
|
||||
if (def > max) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'filesDefaultExpiration must be less than or equal to filesMaxExpiration',
|
||||
path: ['filesDefaultExpiration'],
|
||||
});
|
||||
}
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: 'No settings provided to update',
|
||||
});
|
||||
|
||||
const result = settingsBodySchema.safeParse(req.body);
|
||||
@@ -423,8 +455,6 @@ export default fastifyPlugin(
|
||||
return res.send({ settings: newSettings, tampered: global.__tamperedConfig__ || [] });
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { config } from '@/lib/config';
|
||||
import { safeConfig } from '@/lib/config/safe';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -17,8 +17,8 @@ const codeJsonPath = join(process.cwd(), 'code.json');
|
||||
let codeMap: ApiServerSettingsWebResponse['codeMap'] = [];
|
||||
|
||||
export const PATH = '/api/server/settings/web';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => {
|
||||
const webConfig = safeConfig(config);
|
||||
|
||||
@@ -37,8 +37,6 @@ export default fastifyPlugin(
|
||||
codeMap: codeMap,
|
||||
} satisfies ApiServerSettingsWebResponse);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { config } from '@/lib/config';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { ZiplineTheme } from '@/lib/theme';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiServerThemesResponse = {
|
||||
themes: ZiplineTheme[];
|
||||
@@ -10,15 +10,13 @@ export type ApiServerThemesResponse = {
|
||||
};
|
||||
|
||||
export const PATH = '/api/server/themes';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get(PATH, async (req, res) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (_, res) => {
|
||||
const themes = await readThemes();
|
||||
|
||||
return res.send({ themes, defaultTheme: config.website.theme });
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,24 +2,26 @@ import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerThumbnailsResponse = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
rerun: boolean;
|
||||
};
|
||||
|
||||
const logger = log('api').c('server').c('thumbnails');
|
||||
|
||||
export const PATH = '/api/server/thumbnails';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
rerun: z.boolean().default(false),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
@@ -29,7 +31,7 @@ export default fastifyPlugin(
|
||||
|
||||
thumbnailTask.logger.debug('manually running thumbnails task');
|
||||
|
||||
await server.tasks.runJob(thumbnailTask.id, !!req.body.rerun);
|
||||
await server.tasks.runJob(thumbnailTask.id, req.body.rerun);
|
||||
|
||||
logger.info('thumbnails task manually run', {
|
||||
requester: req.user.username,
|
||||
@@ -43,8 +45,6 @@ export default fastifyPlugin(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -4,23 +4,20 @@ import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiSetupResponse = {
|
||||
firstSetup?: boolean;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('setup');
|
||||
|
||||
export const PATH = '/api/setup';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (_, res) => {
|
||||
const { firstSetup } = await getZipline();
|
||||
if (!firstSetup) return res.forbidden();
|
||||
@@ -28,45 +25,53 @@ export default fastifyPlugin(
|
||||
return res.send({ firstSetup });
|
||||
});
|
||||
|
||||
server.post<{ Body: Body }>(PATH, secondlyRatelimit(5), async (req, res) => {
|
||||
const { firstSetup, id } = await getZipline();
|
||||
|
||||
if (!firstSetup) return res.forbidden();
|
||||
|
||||
logger.info('first setup running');
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username) return res.badRequest('Username is required');
|
||||
if (!password) return res.badRequest('Password is required');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
role: 'SUPERADMIN',
|
||||
token: createToken(),
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
username: zStringTrimmed,
|
||||
password: zStringTrimmed,
|
||||
}),
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
async (req, res) => {
|
||||
const { firstSetup, id } = await getZipline();
|
||||
|
||||
logger.info('first setup complete');
|
||||
if (!firstSetup) return res.forbidden();
|
||||
|
||||
await prisma.zipline.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
firstSetup: false,
|
||||
},
|
||||
});
|
||||
logger.info('first setup running');
|
||||
|
||||
return res.send({
|
||||
firstSetup,
|
||||
user,
|
||||
});
|
||||
});
|
||||
const { username, password } = req.body;
|
||||
|
||||
done();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
role: 'SUPERADMIN',
|
||||
token: createToken(),
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
logger.info('first setup complete');
|
||||
|
||||
await prisma.zipline.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
firstSetup: false,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send({
|
||||
firstSetup,
|
||||
user,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,65 +2,84 @@ import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { zQsBoolean } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiStatsResponse = Metric[];
|
||||
|
||||
type Query = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
all?: string;
|
||||
};
|
||||
|
||||
export const PATH = '/api/stats';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
if (!config.features.metrics) return res.forbidden('metrics are disabled');
|
||||
|
||||
if (config.features.metrics.adminOnly && !isAdministrator(req.user.role))
|
||||
return res.forbidden('admin only');
|
||||
|
||||
const { from, to, all } = req.query;
|
||||
|
||||
const fromDate = from ? new Date(from) : new Date(Date.now() - 86400000 * 7); // defaults to a week ago
|
||||
const toDate = to ? new Date(to) : new Date();
|
||||
|
||||
if (!all) {
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return res.badRequest('invalid date(s)');
|
||||
|
||||
if (fromDate > toDate) return res.badRequest('from date must be before to date');
|
||||
if (fromDate > new Date()) return res.badRequest('from date must be in the past');
|
||||
}
|
||||
|
||||
const stats = await prisma.metric.findMany({
|
||||
where: {
|
||||
...(!all && {
|
||||
createdAt: {
|
||||
gte: fromDate,
|
||||
lte: toDate,
|
||||
},
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
from: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
if (!val) return true;
|
||||
const date = new Date(val);
|
||||
return !isNaN(date.getTime());
|
||||
}, 'Invalid date'),
|
||||
to: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
if (!val) return true;
|
||||
const date = new Date(val);
|
||||
return !isNaN(date.getTime());
|
||||
}, 'Invalid date'),
|
||||
all: zQsBoolean,
|
||||
}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!config.features.metrics) return res.forbidden('metrics are disabled');
|
||||
|
||||
if (!config.features.metrics.showUserSpecific) {
|
||||
for (let i = 0; i !== stats.length; ++i) {
|
||||
const stat = stats[i].data;
|
||||
if (config.features.metrics.adminOnly && !isAdministrator(req.user.role))
|
||||
return res.forbidden('admin only');
|
||||
|
||||
stat.filesUsers = [];
|
||||
stat.urlsUsers = [];
|
||||
const { from, to, all } = req.query;
|
||||
|
||||
const fromDate = from ? new Date(from) : new Date(Date.now() - 86400000 * 7); // defaults to a week ago
|
||||
const toDate = to ? new Date(to) : new Date();
|
||||
|
||||
if (!all) {
|
||||
if (fromDate > toDate) return res.badRequest('from date must be before to date');
|
||||
if (fromDate > new Date()) return res.badRequest('from date must be in the past');
|
||||
}
|
||||
}
|
||||
|
||||
return res.send(stats);
|
||||
});
|
||||
const stats = await prisma.metric.findMany({
|
||||
where: {
|
||||
...(!all && {
|
||||
createdAt: {
|
||||
gte: fromDate,
|
||||
lte: toDate,
|
||||
},
|
||||
}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
done();
|
||||
if (!config.features.metrics.showUserSpecific) {
|
||||
for (let i = 0; i !== stats.length; ++i) {
|
||||
const stat = stats[i].data;
|
||||
|
||||
stat.filesUsers = [];
|
||||
stat.urlsUsers = [];
|
||||
}
|
||||
}
|
||||
|
||||
return res.send(stats);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { compressFile, CompressResult } from '@/lib/compress';
|
||||
import { config } from '@/lib/config';
|
||||
@@ -6,17 +5,18 @@ import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { removeGps } from '@/lib/gps';
|
||||
import { log } from '@/lib/logger';
|
||||
import { guess } from '@/lib/mimes';
|
||||
import { formatFileName } from '@/lib/uploader/formatFileName';
|
||||
import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders';
|
||||
import { parseHeaders, UploadHeaders } from '@/lib/uploader/parseHeaders';
|
||||
import { onUpload } from '@/lib/webhooks';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { stat } from 'fs/promises';
|
||||
import { extname } from 'path';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
|
||||
const commonDoubleExts = [
|
||||
'.tar.gz',
|
||||
@@ -57,8 +57,8 @@ export type ApiUploadResponse = {
|
||||
const logger = log('api').c('upload');
|
||||
|
||||
export const PATH = '/api/upload';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
const rateLimit = server.rateLimit
|
||||
? server.rateLimit()
|
||||
: (_req: any, _res: any, next: () => any) => next();
|
||||
@@ -183,7 +183,12 @@ export default fastifyPlugin(
|
||||
type: options.imageCompression.type,
|
||||
});
|
||||
|
||||
logger.c('compress').debug(`compressed file ${file.filename}`);
|
||||
if (compressed.failed) {
|
||||
compressed = undefined;
|
||||
logger.warn('failed to compress file, using original.');
|
||||
} else {
|
||||
logger.c('compress').debug(`compressed file ${file.filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
// remove gps metadata if requested
|
||||
@@ -259,8 +264,6 @@ export default fastifyPlugin(
|
||||
|
||||
return res.send(response);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { bytes } from '@/lib/bytes';
|
||||
import { config } from '@/lib/config';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { log } from '@/lib/logger';
|
||||
import { guess } from '@/lib/mimes';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
@@ -9,12 +10,11 @@ import { formatFileName } from '@/lib/uploader/formatFileName';
|
||||
import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parseHeaders';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { readdir, rename, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { ApiUploadResponse, getExtension } from '.';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
|
||||
const logger = log('api').c('upload').c('partial');
|
||||
|
||||
@@ -26,8 +26,8 @@ export type ApiUploadPartialResponse = ApiUploadResponse & {
|
||||
};
|
||||
|
||||
export const PATH = '/api/upload/partial';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
const rateLimit = server.rateLimit
|
||||
? server.rateLimit()
|
||||
: (_req: any, _res: any, next: () => any) => next();
|
||||
@@ -280,8 +280,6 @@ export default fastifyPlugin(
|
||||
|
||||
return res.send(response);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
export type ApiUserTokenResponse = {
|
||||
user?: User;
|
||||
@@ -9,8 +9,8 @@ export type ApiUserTokenResponse = {
|
||||
};
|
||||
|
||||
export const PATH = '/api/user/avatar';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const u = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -25,8 +25,6 @@ export default fastifyPlugin(
|
||||
|
||||
return res.send(u.avatar);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,77 +1,94 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { config } from '@/lib/config';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { Export } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import archiver from 'archiver';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { rm, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Export } from '@/prisma/client';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserExportResponse = {
|
||||
running?: boolean;
|
||||
deleted?: boolean;
|
||||
} & Export[];
|
||||
|
||||
type Query = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export const PATH = '/api/user/export';
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
const logger = log('api').c('user').c('export');
|
||||
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const exports = await prisma.export.findMany({
|
||||
where: { userId: req.user.id },
|
||||
});
|
||||
|
||||
if (req.query.id) {
|
||||
const file = exports.find((x) => x.id === req.query.id);
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (!file.completed) return res.badRequest('Export is not completed');
|
||||
|
||||
return res.sendFile(file.path);
|
||||
}
|
||||
|
||||
return res.send(exports);
|
||||
});
|
||||
|
||||
server.delete<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
if (!req.query.id) return res.badRequest('No id provided');
|
||||
|
||||
const exportDb = await prisma.export.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id: req.query.id,
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
querystring: querySchema,
|
||||
},
|
||||
});
|
||||
if (!exportDb) return res.notFound();
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const exports = await prisma.export.findMany({
|
||||
where: { userId: req.user.id },
|
||||
});
|
||||
|
||||
const path = join(config.core.tempDirectory, exportDb.path);
|
||||
if (req.query.id) {
|
||||
const file = exports.find((x) => x.id === req.query.id);
|
||||
if (!file) return res.notFound();
|
||||
|
||||
try {
|
||||
await rm(path);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`failed to delete export file, it might already be deleted. ${exportDb.id}: ${exportDb.path}`,
|
||||
{ e },
|
||||
);
|
||||
}
|
||||
if (!file.completed) return res.badRequest('Export is not completed');
|
||||
|
||||
await prisma.export.delete({ where: { id: req.query.id } });
|
||||
return res.sendFile(file.path);
|
||||
}
|
||||
|
||||
logger.info(`deleted export ${exportDb.id}: ${exportDb.path}`);
|
||||
return res.send(exports);
|
||||
},
|
||||
);
|
||||
|
||||
return res.send({ deleted: true });
|
||||
});
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: { querystring: querySchema },
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!req.query.id) return res.badRequest('No id provided');
|
||||
|
||||
const exportDb = await prisma.export.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id: req.query.id,
|
||||
},
|
||||
});
|
||||
if (!exportDb) return res.notFound();
|
||||
|
||||
const path = join(config.core.tempDirectory, exportDb.path);
|
||||
|
||||
try {
|
||||
await rm(path);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`failed to delete export file, it might already be deleted. ${exportDb.id}: ${exportDb.path}`,
|
||||
{ e },
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.export.delete({ where: { id: req.query.id } });
|
||||
|
||||
logger.info(`deleted export ${exportDb.id}: ${exportDb.path}`);
|
||||
|
||||
return res.send({ deleted: true });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(PATH, { preHandler: [userMiddleware], ...secondlyRatelimit(5) }, async (req, res) => {
|
||||
const files = await prisma.file.findMany({
|
||||
@@ -137,8 +154,6 @@ export default fastifyPlugin(
|
||||
|
||||
return res.send({ running: true });
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zValidatePath } from '@/lib/validation';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserFilesIdResponse = File;
|
||||
|
||||
type Body = {
|
||||
favorite?: boolean;
|
||||
maxViews?: number;
|
||||
password?: string | null;
|
||||
originalName?: string;
|
||||
type?: string;
|
||||
tags?: string[];
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('user').c('files').c('[id]');
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const PATH = '/api/user/files/:id';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { schema: { params: paramsSchema }, preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
@@ -45,130 +37,146 @@ export default fastifyPlugin(
|
||||
return res.send(file);
|
||||
});
|
||||
|
||||
server.patch<{
|
||||
Body: Body;
|
||||
Params: Params;
|
||||
}>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
server.patch(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
body: z.object({
|
||||
favorite: z.boolean().optional(),
|
||||
maxViews: z.number().min(0).optional(),
|
||||
password: z.string().optional().nullable(),
|
||||
originalName: z.string().trim().min(1).optional().transform(zValidatePath),
|
||||
type: z.string().min(1).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
name: z.string().trim().min(1).optional().transform(zValidatePath),
|
||||
}),
|
||||
},
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
const data: Prisma.FileUpdateInput = {};
|
||||
|
||||
if (req.body.favorite !== undefined) data.favorite = req.body.favorite;
|
||||
if (req.body.originalName !== undefined) data.originalName = req.body.originalName;
|
||||
if (req.body.type !== undefined) data.type = req.body.type;
|
||||
|
||||
if (req.body.maxViews !== undefined) {
|
||||
if (req.body.maxViews < 0) return res.badRequest('maxViews must be >= 0');
|
||||
|
||||
data.maxViews = req.body.maxViews;
|
||||
}
|
||||
|
||||
if (req.body.password !== undefined) {
|
||||
if (req.body.password === null || req.body.password === '') {
|
||||
data.password = null;
|
||||
} else if (typeof req.body.password === 'string') {
|
||||
data.password = await hashPassword(req.body.password);
|
||||
} else {
|
||||
return res.badRequest('password must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.tags !== undefined) {
|
||||
const tags = await prisma.tag.findMany({
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id: {
|
||||
in: req.body.tags,
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
},
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
const data: Prisma.FileUpdateInput = {};
|
||||
|
||||
if (req.body.favorite !== undefined) data.favorite = req.body.favorite;
|
||||
if (req.body.originalName !== undefined) data.originalName = req.body.originalName;
|
||||
if (req.body.type !== undefined) data.type = req.body.type;
|
||||
|
||||
if (req.body.maxViews !== undefined) {
|
||||
data.maxViews = req.body.maxViews;
|
||||
}
|
||||
|
||||
if (req.body.password !== undefined) {
|
||||
if (req.body.password === null || req.body.password === '') {
|
||||
data.password = null;
|
||||
} else {
|
||||
data.password = await hashPassword(req.body.password);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.tags !== undefined) {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
userId: req.user.id !== file.User?.id ? file.User?.id : req.user.id,
|
||||
id: {
|
||||
in: req.body.tags,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
|
||||
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
|
||||
|
||||
data.tags = {
|
||||
set: req.body.tags.map((tag) => ({ id: tag })),
|
||||
};
|
||||
}
|
||||
|
||||
if (req.body.name !== undefined && req.body.name !== file.name) {
|
||||
const name = req.body.name.trim();
|
||||
const existingFile = await prisma.file.findFirst({
|
||||
where: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingFile && existingFile.id !== file.id)
|
||||
return res.badRequest('File with this name already exists');
|
||||
|
||||
data.name = name;
|
||||
|
||||
try {
|
||||
await datasource.rename(file.name, data.name);
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename file in datasource', { error });
|
||||
return res.internalServerError('Failed to rename file in datasource');
|
||||
data.tags = {
|
||||
set: req.body.tags.map((tag) => ({ id: tag })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newFile = await prisma.file.update({
|
||||
where: {
|
||||
id: req.params.id,
|
||||
},
|
||||
data,
|
||||
select: fileSelect,
|
||||
});
|
||||
if (req.body.name !== undefined && req.body.name !== file.name) {
|
||||
const name = req.body.name!;
|
||||
const existingFile = await prisma.file.findFirst({
|
||||
where: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} updated file ${newFile.name}`, {
|
||||
updated: Object.keys(req.body),
|
||||
id: newFile.id,
|
||||
owner: file.User?.id,
|
||||
});
|
||||
if (existingFile && existingFile.id !== file.id)
|
||||
return res.badRequest('File with this name already exists');
|
||||
|
||||
return res.send(newFile);
|
||||
});
|
||||
data.name = name;
|
||||
|
||||
server.delete<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
try {
|
||||
await datasource.rename(file.name, data.name);
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename file in datasource', { error });
|
||||
return res.internalServerError('Failed to rename file in datasource');
|
||||
}
|
||||
}
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
const newFile = await prisma.file.update({
|
||||
where: {
|
||||
id: req.params.id,
|
||||
},
|
||||
data,
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
const deletedFile = await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
logger.info(`${req.user.username} updated file ${newFile.name}`, {
|
||||
updated: Object.keys(req.body),
|
||||
id: newFile.id,
|
||||
owner: file.User?.id,
|
||||
});
|
||||
|
||||
await datasource.delete(deletedFile.name);
|
||||
return res.send(newFile);
|
||||
},
|
||||
);
|
||||
|
||||
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
|
||||
size: bytes(deletedFile.size),
|
||||
owner: file.User?.id,
|
||||
});
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: { params: paramsSchema },
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
return res.send(deletedFile);
|
||||
});
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
done();
|
||||
const deletedFile = await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
await datasource.delete(deletedFile.name);
|
||||
|
||||
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
|
||||
size: bytes(deletedFile.size),
|
||||
owner: file.User?.id,
|
||||
});
|
||||
|
||||
return res.send(deletedFile);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -2,63 +2,68 @@ import { verifyPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserFilesIdPasswordResponse = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('user').c('files').c('[id]').c('password');
|
||||
|
||||
export const PATH = '/api/user/files/:id/password';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body; Params: Params }>(PATH, { ...secondlyRatelimit(2) }, async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
password: z.string().trim().min(1),
|
||||
}),
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
password: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
if (!file.password) return res.notFound();
|
||||
...secondlyRatelimit(2),
|
||||
},
|
||||
async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
password: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
if (!file.password) return res.notFound();
|
||||
|
||||
const verified = await verifyPassword(req.body.password, file.password);
|
||||
if (!verified) {
|
||||
logger.warn('invalid password for file', {
|
||||
file: file.name,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
const verified = await verifyPassword(req.body.password, file.password);
|
||||
if (!verified) {
|
||||
logger.warn('invalid password for file', {
|
||||
file: file.name,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.forbidden('Incorrect password');
|
||||
}
|
||||
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
|
||||
|
||||
res.cookie('file_pw_' + file.id, req.body.password, {
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return res.forbidden('Incorrect password');
|
||||
}
|
||||
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
|
||||
|
||||
res.cookie('file_pw_' + file.id, req.body.password, {
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return res.send({ success: true });
|
||||
});
|
||||
|
||||
done();
|
||||
return res.send({ success: true });
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -3,117 +3,151 @@ import { config } from '@/lib/config';
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { log } from '@/lib/logger';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
type Params = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Querystring = {
|
||||
pw?: string;
|
||||
download?: string;
|
||||
};
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
const logger = log('routes').c('raw');
|
||||
|
||||
export const PATH = '/api/user/files/:id/raw';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.get<{
|
||||
Querystring: Querystring;
|
||||
Params: Params;
|
||||
}>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { pw, download } = req.query;
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
pw: z.string().optional(),
|
||||
download: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { pw, download } = req.query;
|
||||
|
||||
if (id.startsWith('.thumbnail')) {
|
||||
const thumbnail = await prisma.thumbnail.findFirst({
|
||||
where: {
|
||||
path: id,
|
||||
file: {
|
||||
userId: req.user.id,
|
||||
const id = sanitizeFilename(req.params.id);
|
||||
if (!id) return res.callNotFound();
|
||||
|
||||
if (id.startsWith('.thumbnail')) {
|
||||
const thumbnail = await prisma.thumbnail.findFirst({
|
||||
where: {
|
||||
path: id,
|
||||
},
|
||||
include: {
|
||||
file: {
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!thumbnail) return res.callNotFound();
|
||||
if (thumbnail.file && thumbnail.file.userId !== req.user.id) {
|
||||
if (!canInteract(req.user.role, thumbnail.file.User?.role)) return res.callNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!thumbnail) return res.callNotFound();
|
||||
}
|
||||
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (file && file.userId !== req.user.id) {
|
||||
if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound();
|
||||
}
|
||||
|
||||
if (file?.deletesAt && file.deletesAt <= new Date()) {
|
||||
try {
|
||||
await datasource.delete(file.name);
|
||||
await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger
|
||||
.error('failed to delete file on expiration', {
|
||||
id: file.id,
|
||||
})
|
||||
.error(e as Error);
|
||||
if (file && file.userId !== req.user.id) {
|
||||
if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound();
|
||||
}
|
||||
|
||||
return res.callNotFound();
|
||||
}
|
||||
if (file?.deletesAt && file.deletesAt <= new Date()) {
|
||||
try {
|
||||
await datasource.delete(file.name);
|
||||
await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger
|
||||
.error('failed to delete file on expiration', {
|
||||
id: file.id,
|
||||
})
|
||||
.error(e as Error);
|
||||
}
|
||||
|
||||
if (file?.maxViews && file.views >= file.maxViews) {
|
||||
if (!config.features.deleteOnMaxViews) return res.callNotFound();
|
||||
|
||||
try {
|
||||
await datasource.delete(file.name);
|
||||
await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger
|
||||
.error('failed to delete file on max views', {
|
||||
id: file.id,
|
||||
})
|
||||
.error(e as Error);
|
||||
return res.callNotFound();
|
||||
}
|
||||
|
||||
return res.callNotFound();
|
||||
}
|
||||
if (file?.maxViews && file.views >= file.maxViews) {
|
||||
if (!config.features.deleteOnMaxViews) return res.callNotFound();
|
||||
|
||||
if (file?.password) {
|
||||
if (!pw) return res.forbidden('Password protected.');
|
||||
const verified = await verifyPassword(pw, file.password!);
|
||||
try {
|
||||
await datasource.delete(file.name);
|
||||
await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger
|
||||
.error('failed to delete file on max views', {
|
||||
id: file.id,
|
||||
})
|
||||
.error(e as Error);
|
||||
}
|
||||
|
||||
if (!verified) return res.forbidden('Incorrect password.');
|
||||
}
|
||||
return res.callNotFound();
|
||||
}
|
||||
|
||||
const size = file?.size || (await datasource.size(file?.name ?? id));
|
||||
if (file?.password) {
|
||||
if (!pw) return res.forbidden('Password protected.');
|
||||
const verified = await verifyPassword(pw, file.password!);
|
||||
|
||||
if (req.headers.range) {
|
||||
const [start, end] = parseRange(req.headers.range, size);
|
||||
if (start >= size || end >= size) {
|
||||
const buf = await datasource.get(file?.name ?? id);
|
||||
if (!verified) return res.forbidden('Incorrect password.');
|
||||
}
|
||||
|
||||
const size = file?.size || (await datasource.size(file?.name ?? id));
|
||||
|
||||
if (req.headers.range) {
|
||||
const [start, end] = parseRange(req.headers.range, size);
|
||||
if (start >= size || end >= size) {
|
||||
const buf = await datasource.get(file?.name ?? id);
|
||||
if (!buf) return res.callNotFound();
|
||||
|
||||
return res
|
||||
.type(file?.type || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Length': size,
|
||||
...(file?.originalName
|
||||
? {
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
|
||||
}
|
||||
: download && {
|
||||
'Content-Disposition': 'attachment;',
|
||||
}),
|
||||
})
|
||||
.status(416)
|
||||
.send(buf);
|
||||
}
|
||||
|
||||
const buf = await datasource.range(file?.name ?? id, start || 0, end);
|
||||
if (!buf) return res.callNotFound();
|
||||
|
||||
return res
|
||||
.type(file?.type || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Length': size,
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
...(file?.originalName
|
||||
? {
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
|
||||
@@ -122,19 +156,18 @@ export default fastifyPlugin(
|
||||
'Content-Disposition': 'attachment;',
|
||||
}),
|
||||
})
|
||||
.status(416)
|
||||
.status(206)
|
||||
.send(buf);
|
||||
}
|
||||
|
||||
const buf = await datasource.range(file?.name ?? id, start || 0, end);
|
||||
const buf = await datasource.get(file?.name ?? id);
|
||||
if (!buf) return res.callNotFound();
|
||||
|
||||
return res
|
||||
.type(file?.type || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Content-Length': size,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
...(file?.originalName
|
||||
? {
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
|
||||
@@ -143,31 +176,10 @@ export default fastifyPlugin(
|
||||
'Content-Disposition': 'attachment;',
|
||||
}),
|
||||
})
|
||||
.status(206)
|
||||
.status(200)
|
||||
.send(buf);
|
||||
}
|
||||
|
||||
const buf = await datasource.get(file?.name ?? id);
|
||||
if (!buf) return res.callNotFound();
|
||||
|
||||
return res
|
||||
.type(file?.type || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Length': size,
|
||||
'Accept-Ranges': 'bytes',
|
||||
...(file?.originalName
|
||||
? {
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(file.originalName)}"`,
|
||||
}
|
||||
: download && {
|
||||
'Content-Disposition': 'attachment;',
|
||||
}),
|
||||
})
|
||||
.status(200)
|
||||
.send(buf);
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user