mirror of
https://github.com/diced/zipline.git
synced 2026-06-16 03:41:41 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ca9dc9b5 | |||
| 0eee082035 | |||
| 3e287e8ad7 | |||
| 5ec471050e | |||
| 842dac2660 | |||
| dee86aaa86 | |||
| 13e3a58035 | |||
| f4382d5bd9 | |||
| 8990801268 | |||
| 01b9c06513 | |||
| fc180de616 | |||
| f907133d3a | |||
| 9ae9734a3d | |||
| 770b5cf706 | |||
| 56625c664d | |||
| 056a19b946 | |||
| 281ab666c1 | |||
| 31df5341b5 | |||
| ec7024242f | |||
| ef6e0e00a0 |
@@ -78,13 +78,12 @@ jobs:
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run app
|
||||
- name: Run generator
|
||||
env:
|
||||
DATABASE_URL: postgres://zipline:zipline@localhost:5432/zipline
|
||||
CORE_SECRET: ${{ steps.secret.outputs.secret }}
|
||||
ZIPLINE_OUTPUT_OPENAPI: true
|
||||
|
||||
run: pnpm start
|
||||
NODE_ENV: production
|
||||
run: pnpm openapi
|
||||
|
||||
- name: Verify openapi.json exists
|
||||
run: |
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
@@ -12,6 +12,7 @@
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
|
||||
"validate": "tsx scripts/validate.ts",
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
|
||||
"db:migrate": "prisma migrate dev --create-only",
|
||||
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
|
||||
@@ -68,6 +69,7 @@
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fastify-type-provider-zod": "^6.1.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.33.0",
|
||||
@@ -88,13 +90,14 @@
|
||||
"swr": "^2.3.7",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.7",
|
||||
"zod": "^4.1.13",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
|
||||
Generated
+80
-25
@@ -145,10 +145,13 @@ importers:
|
||||
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)
|
||||
version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.3.6)
|
||||
fluent-ffmpeg:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
he:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
@@ -210,8 +213,8 @@ importers:
|
||||
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)(yaml@2.8.2)
|
||||
zod:
|
||||
specifier: ^4.1.13
|
||||
version: 4.1.13
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
zustand:
|
||||
specifier: ^5.0.9
|
||||
version: 5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1))
|
||||
@@ -225,6 +228,9 @@ importers:
|
||||
'@types/fluent-ffmpeg':
|
||||
specifier: ^2.1.28
|
||||
version: 2.1.28
|
||||
'@types/he':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@types/katex':
|
||||
specifier: ^0.16.7
|
||||
version: 0.16.7
|
||||
@@ -1123,89 +1129,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -1393,36 +1415,42 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@@ -1617,56 +1645,67 @@ packages:
|
||||
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.53.3':
|
||||
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.53.3':
|
||||
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.53.3':
|
||||
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.53.3':
|
||||
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.53.3':
|
||||
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.53.3':
|
||||
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
|
||||
@@ -2004,8 +2043,8 @@ packages:
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.7':
|
||||
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
@@ -2034,6 +2073,9 @@ packages:
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/he@1.2.3':
|
||||
resolution: {integrity: sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@@ -2607,8 +2649,8 @@ packages:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.0:
|
||||
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
@@ -2978,8 +3020,8 @@ packages:
|
||||
fast-diff@1.3.0:
|
||||
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||
|
||||
fast-equals@5.3.3:
|
||||
resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==}
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
@@ -3229,6 +3271,10 @@ packages:
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
@@ -3597,6 +3643,9 @@ packages:
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -5206,8 +5255,8 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
zod@4.1.13:
|
||||
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zustand@5.0.9:
|
||||
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
|
||||
@@ -7392,7 +7441,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.7':
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
@@ -7431,6 +7480,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/he@1.2.3': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -8039,7 +8090,7 @@ snapshots:
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.0: {}
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
@@ -8050,7 +8101,7 @@ snapshots:
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.0
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
@@ -8402,8 +8453,8 @@ snapshots:
|
||||
'@babel/parser': 7.28.5
|
||||
eslint: 9.39.1(jiti@2.5.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.1.13
|
||||
zod-validation-error: 4.0.2(zod@4.1.13)
|
||||
zod: 4.3.6
|
||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8535,7 +8586,7 @@ snapshots:
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
|
||||
fast-equals@5.3.3: {}
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
@@ -8572,13 +8623,13 @@ 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):
|
||||
fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.3.6):
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
'@fastify/swagger': 9.6.1
|
||||
fastify: 5.6.2
|
||||
openapi-types: 12.1.3
|
||||
zod: 4.1.13
|
||||
zod: 4.3.6
|
||||
|
||||
fastify@5.6.2:
|
||||
dependencies:
|
||||
@@ -8840,6 +8891,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
@@ -9222,6 +9275,8 @@ snapshots:
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@@ -10134,7 +10189,7 @@ snapshots:
|
||||
|
||||
react-smooth@4.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
fast-equals: 5.3.3
|
||||
fast-equals: 5.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
@@ -10233,7 +10288,7 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.17.21
|
||||
lodash: 4.17.23
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
react-is: 18.3.1
|
||||
@@ -11026,7 +11081,7 @@ snapshots:
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.7
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
@@ -11183,11 +11238,11 @@ snapshots:
|
||||
compress-commons: 6.0.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.1.13):
|
||||
zod-validation-error@4.0.2(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.1.13
|
||||
zod: 4.3.6
|
||||
|
||||
zod@4.1.13: {}
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1)):
|
||||
optionalDependencies:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxFilesPerUpload" INTEGER NOT NULL DEFAULT 1000;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."File" ADD COLUMN "anonymous" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -46,6 +46,7 @@ model Zipline {
|
||||
filesRandomWordsNumAdjectives Int @default(2)
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesMaxFilesPerUpload Int @default(1000)
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -282,6 +283,7 @@ model File {
|
||||
maxViews Int?
|
||||
favorite Boolean @default(false)
|
||||
password String?
|
||||
anonymous Boolean @default(false)
|
||||
|
||||
tags Tag[]
|
||||
|
||||
|
||||
+8
-2
@@ -1,4 +1,6 @@
|
||||
export function step(name: string, command: string, condition: () => boolean = () => true) {
|
||||
type StepCommand = string | (() => void | Promise<void>);
|
||||
|
||||
export function step(name: string, command: StepCommand, condition: () => boolean = () => true) {
|
||||
return {
|
||||
name,
|
||||
command,
|
||||
@@ -35,7 +37,11 @@ export async function run(name: string, ...steps: Step[]) {
|
||||
|
||||
try {
|
||||
log(`> Running step "${name}/${step.name}"...`);
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
if (typeof step.command === 'string') {
|
||||
execSync(step.command, { stdio: 'inherit' });
|
||||
} else {
|
||||
await step.command();
|
||||
}
|
||||
} catch {
|
||||
console.error(`x Step "${name}/${step.name}" failed.`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { run, step } from '.';
|
||||
import { API_ERRORS, ApiError, ApiErrorCode } from '../src/lib/api/errors';
|
||||
|
||||
const ALL_METHODS = ['delete', 'get', 'head', 'patch', 'post', 'put'];
|
||||
const GEN_PATH = path.resolve(__dirname, '..', 'openapi.json');
|
||||
|
||||
const ALL_ERRORS = Object.keys(API_ERRORS)
|
||||
.map((code) => new ApiError(Number(code) as ApiErrorCode).toJSON())
|
||||
.sort((a, b) => a.code - b.code);
|
||||
|
||||
const ERROR_SCHEMA = {
|
||||
type: 'object',
|
||||
description: 'Generic error for API endpoints.',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Message for the error. This may differ from the standard message for the error code, but the error code should be used to figure out the type of error.',
|
||||
},
|
||||
code: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description:
|
||||
'Zipline API error code. Ranges: 1xxx validation, 2xxx session, 3xxx permission, 4xxx not-found, 5xxx constraint, 6xxx internal, 9xxx generic.',
|
||||
enum: ALL_ERRORS.map((entry) => entry.code),
|
||||
'x-enumDescriptions': ALL_ERRORS.map((entry) => entry.message),
|
||||
},
|
||||
statusCode: {
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
description: 'HTTP status code returned alongside this error payload.',
|
||||
},
|
||||
},
|
||||
required: ['error', 'code', 'statusCode'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const ERROR_EXAMPLES = ALL_ERRORS.reduce<Record<string, unknown>>((examples, entry) => {
|
||||
examples[`E${entry.code}`] = {
|
||||
summary: `${entry.error}`,
|
||||
value: entry,
|
||||
};
|
||||
|
||||
return examples;
|
||||
}, {});
|
||||
|
||||
const generic4xxResponse = {
|
||||
description: 'API error response (4xx)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ERROR_SCHEMA,
|
||||
examples: ERROR_EXAMPLES,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function addErrorResponse(responses: Record<string, any>): void {
|
||||
const response = (responses['4xx'] ??= structuredClone(generic4xxResponse));
|
||||
|
||||
response.description ??= generic4xxResponse.description;
|
||||
response.content ??= {};
|
||||
|
||||
const jsonContent = (response.content['application/json'] ??= {});
|
||||
jsonContent.schema ??= structuredClone(ERROR_SCHEMA);
|
||||
jsonContent.examples ??= structuredClone(generic4xxResponse.content['application/json'].examples);
|
||||
}
|
||||
|
||||
function filterRoutes(paths = {}): Record<string, any> {
|
||||
return Object.fromEntries(Object.entries(paths).filter(([route]) => route.startsWith('/api')));
|
||||
}
|
||||
|
||||
async function fixSpec() {
|
||||
const spec = JSON.parse(await readFile(GEN_PATH, 'utf8'));
|
||||
|
||||
spec.paths = filterRoutes(spec.paths);
|
||||
|
||||
for (const [, pathItem] of Object.entries(spec.paths ?? {})) {
|
||||
if (!pathItem) continue;
|
||||
|
||||
for (const method of ALL_METHODS) {
|
||||
const operation = (<any>pathItem)[method];
|
||||
if (!operation) continue;
|
||||
|
||||
operation.responses ??= {};
|
||||
addErrorResponse(operation.responses);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(GEN_PATH, JSON.stringify(spec));
|
||||
}
|
||||
|
||||
process.env.ZIPLINE_OUTPUT_OPENAPI = 'true';
|
||||
|
||||
run(
|
||||
'openapi',
|
||||
step('run-prod', 'pnpm start', () => process.env.NODE_ENV === 'production'),
|
||||
step('run-dev', 'pnpm dev', () => process.env.NODE_ENV !== 'production'),
|
||||
step('check', async () => {
|
||||
try {
|
||||
await readFile(GEN_PATH);
|
||||
} catch (e) {
|
||||
console.error('\nSomething went wrong...', e);
|
||||
|
||||
throw new Error('No OpenAPI spec found at ./openapi.json');
|
||||
}
|
||||
}),
|
||||
step('fix', fixSpec),
|
||||
);
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import GenericError from './GenericError';
|
||||
import ReloadPage from './ReloadPage';
|
||||
|
||||
export default function DashboardErrorBoundary(props: Record<string, any>) {
|
||||
const error = useRouteError();
|
||||
if (error instanceof Error && error.message.startsWith('Failed to fetch dynamically imported module:')) {
|
||||
return <ReloadPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericError
|
||||
title='Dashboard Client Error'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, Collapse, Container, Text, Title } from '@mantine/core';
|
||||
import { IconReload } from '@tabler/icons-react';
|
||||
import GenericError from './GenericError';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ReloadPage() {
|
||||
const [view, setView] = useState(false);
|
||||
|
||||
return (
|
||||
<Container my='lg'>
|
||||
<Title order={3}>Update available</Title>
|
||||
|
||||
<Text size='lg'>A new version of the app is available. Please reload the page to update.</Text>
|
||||
|
||||
<Button
|
||||
leftSection={<IconReload size='1rem' />}
|
||||
mr='sm'
|
||||
mt='md'
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
|
||||
<Button variant='subtle' mt='md' onClick={() => setView((v) => !v)}>
|
||||
Why am I seeing this?
|
||||
</Button>
|
||||
|
||||
<Collapse in={view}>
|
||||
<GenericError
|
||||
title='Failed to fetch dynamically imported module'
|
||||
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
|
||||
details={{}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+11
-11
@@ -1,14 +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" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<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>
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,7 @@ import PasskeyAuthButton from '@/components/pages/login/PasskeyAuthButton';
|
||||
import SecureWarningModal from '@/components/pages/login/SecureWarningModal';
|
||||
import TotpModal from '@/components/pages/login/TotpModal';
|
||||
import { getWebClient } from '@/lib/api/detect';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import useObjectState from '@/lib/hooks/useObjectState';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import {
|
||||
IconBrandDiscordFilled,
|
||||
@@ -34,7 +36,6 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
@@ -103,7 +104,7 @@ export default function Login() {
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Invalid username or password') {
|
||||
if (ApiError.check(error, 1044)) {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { getWebClient } from '@/lib/api/detect';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Register');
|
||||
@@ -114,7 +115,7 @@ export function Component() {
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Username is taken') {
|
||||
if (ApiError.check(error, 1039)) {
|
||||
form.setFieldError('username', 'Username is taken');
|
||||
} else {
|
||||
notifications.show({
|
||||
|
||||
@@ -100,7 +100,7 @@ export function Component() {
|
||||
<Title order={1}>{folder.name}</Title>
|
||||
|
||||
{folder.allowUploads && (
|
||||
<Link to={`/folder/${folder.id}/upload`}>
|
||||
<Link to={`/folder/${folder.id}/upload`} reloadDocument>
|
||||
<ActionIcon variant='outline'>
|
||||
<IconUpload size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -8,10 +8,8 @@ import { data, Link, Params, useLoaderData } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export async function loader({ params }: { params: Params<string> }) {
|
||||
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
|
||||
if (!res.ok) {
|
||||
throw data('Folder not found', { status: 404 });
|
||||
}
|
||||
const res = await fetch(`/api/server/folder/${params.id}`);
|
||||
if (!res.ok) throw data('Folder not found', { status: 404 });
|
||||
|
||||
return {
|
||||
folder: (await res.json()) as Response['/api/server/folder/[id]'],
|
||||
@@ -40,7 +38,7 @@ export function Component() {
|
||||
{folder.public ? (
|
||||
<>
|
||||
This folder is{' '}
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`}>
|
||||
<Anchor component={Link} to={`/folder/${folder.id}`} reloadDocument>
|
||||
public
|
||||
</Anchor>
|
||||
. Anyone with the link can view its contents and upload files.
|
||||
|
||||
@@ -180,7 +180,13 @@ export default function ViewFileId() {
|
||||
file.Folder &&
|
||||
(file.Folder.public ? (
|
||||
<Tooltip label='View folder'>
|
||||
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
ml='sm'
|
||||
to={`/folder/${file.Folder.id}`}
|
||||
target='_blank'
|
||||
reloadDocument
|
||||
>
|
||||
{file.Folder.name}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FastifyRequest } from 'fastify';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
|
||||
import { createRoutes } from './routes';
|
||||
import { stripHtml } from '@/lib/stripHtml';
|
||||
|
||||
export const getFile = async (id: string) =>
|
||||
prisma.file.findFirst({
|
||||
@@ -166,49 +167,53 @@ export async function render(
|
||||
const router = createStaticRouter(routes, context);
|
||||
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
|
||||
|
||||
const safeFilename = stripHtml(file.name);
|
||||
const safeOriginalName = stripHtml(file.originalName || '');
|
||||
const safeType = stripHtml(file.type || '');
|
||||
|
||||
const meta = `
|
||||
${
|
||||
user?.view?.embedTitle && user.view.embed
|
||||
? `<meta property="og:title" content="${
|
||||
? `<meta property="og:title" content="${stripHtml(
|
||||
parseString(user.view.embedTitle, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedDescription && user.view.embed
|
||||
? `<meta property="og:description" content="${
|
||||
? `<meta property="og:description" content="${stripHtml(
|
||||
parseString(user.view.embedDescription, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedSiteName && user.view.embed
|
||||
? `<meta property="og:site_name" content="${
|
||||
? `<meta property="og:site_name" content="${stripHtml(
|
||||
parseString(user.view.embedSiteName, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
user?.view?.embedColor && user.view.embed
|
||||
? `<meta property="theme-color" content="${
|
||||
? `<meta property="theme-color" content="${stripHtml(
|
||||
parseString(user.view.embedColor, {
|
||||
file: file as unknown as File,
|
||||
user: user as User,
|
||||
...metrics,
|
||||
}) ?? ''
|
||||
}" />`
|
||||
}) ?? '',
|
||||
)}" />`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -216,11 +221,11 @@ export async function render(
|
||||
file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:type" content="image" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:image" itemProp="image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="${host}/raw/${file.name}" />
|
||||
<meta property="twitter:title" content="${file.name}" />
|
||||
<meta property="twitter:image" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="twitter:title" content="${safeFilename}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
@@ -230,7 +235,7 @@ export async function render(
|
||||
? `
|
||||
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
|
||||
<meta property="og:type" content="video.other" />
|
||||
<meta property="og:video:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:video:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:video:width" content="1920" />
|
||||
<meta property="og:video:height" content="1080" />
|
||||
`
|
||||
@@ -241,18 +246,18 @@ export async function render(
|
||||
file.type?.startsWith('audio')
|
||||
? `
|
||||
<meta name="twitter:card" content="player" />
|
||||
<meta name="twitter:player" content="${host}/raw/${file.name}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${file.name}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${file.type}" />
|
||||
<meta name="twitter:title" content="${file.name}" />
|
||||
<meta name="twitter:player" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream" content="${host}/raw/${safeFilename}" />
|
||||
<meta name="twitter:player:stream:content_type" content="${safeType}" />
|
||||
<meta name="twitter:title" content="${safeFilename}" />
|
||||
<meta name="twitter:player:width" content="720" />
|
||||
<meta name="twitter:player:height" content="480" />
|
||||
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:audio" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:audio:type" content="${file.type}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:secure_url" content="${host}/raw/${safeFilename}" />
|
||||
<meta property="og:audio:type" content="${safeType}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
@@ -260,12 +265,12 @@ export async function render(
|
||||
${
|
||||
!file.type?.startsWith('video') && !file.type?.startsWith('image')
|
||||
? `
|
||||
<meta property="og:url" content="${host}/raw/${file.name}" />
|
||||
<meta property="og:url" content="${host}/raw/${safeFilename}" />
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<title>${file.originalName ?? file.name}</title>
|
||||
<title>${file.originalName ? safeOriginalName : safeFilename}</title>
|
||||
`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SafeConfig } from '@/lib/config/safe';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useAvatar from '@/lib/hooks/useAvatar';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useLogout } from '@/lib/hooks/useLogout';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
@@ -47,11 +47,10 @@ import {
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, Outlet, useLoaderData, useLocation } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
import { Link, useLoaderData } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import { useLogout } from '@/lib/hooks/useLogout';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
@@ -167,6 +166,12 @@ export default function Layout() {
|
||||
const { user, mutate } = useLogin();
|
||||
const { avatar } = useAvatar();
|
||||
|
||||
const [prev, setPrev] = useState(location.pathname);
|
||||
if (prev !== location.pathname) {
|
||||
setPrev(location.pathname);
|
||||
setOpened(false);
|
||||
}
|
||||
|
||||
const copyToken = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Copy token?',
|
||||
@@ -241,6 +246,7 @@ export default function Layout() {
|
||||
color={theme.colors.gray[6]}
|
||||
mr='xl'
|
||||
hiddenFrom='sm'
|
||||
bdrs='md'
|
||||
/>
|
||||
|
||||
{config.website.titleLogo && (
|
||||
@@ -395,7 +401,7 @@ export default function Layout() {
|
||||
|
||||
<AppShell.Main>
|
||||
<ConfigProvider data={loaderData}>
|
||||
<Paper m='lg' withBorder p='xs'>
|
||||
<Paper withBorder m='md' p='xs' radius='md'>
|
||||
<Outlet />
|
||||
</Paper>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
IconUserQuestion,
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
@@ -229,6 +230,7 @@ export default function FileModal({
|
||||
{file.originalName && (
|
||||
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
||||
)}
|
||||
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
|
||||
</SimpleGrid>
|
||||
|
||||
{!reduce && (
|
||||
@@ -237,12 +239,7 @@ export default function FileModal({
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
Tags
|
||||
</Title>
|
||||
<Combobox
|
||||
zIndex={90000}
|
||||
withinPortal={false}
|
||||
store={tagsCombobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
>
|
||||
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
@@ -318,7 +315,7 @@ export default function FileModal({
|
||||
</Button>
|
||||
) : (
|
||||
<Combobox
|
||||
withinPortal={false}
|
||||
zIndex={90000}
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
@@ -349,6 +346,12 @@ export default function FileModal({
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
{folders?.length === 0 && (
|
||||
<Combobox.Empty>
|
||||
You have no folders. Start typing to create a new folder for this file.
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
|
||||
<FolderComboboxOptions
|
||||
folderOptions={folderOptions}
|
||||
searchValue={search}
|
||||
|
||||
@@ -141,14 +141,10 @@ export async function createFolderAndAdd(file: File, folderName: string | null)
|
||||
}
|
||||
|
||||
export async function removeFromFolder(file: File) {
|
||||
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
|
||||
`/api/user/folders/${file.folderId}`,
|
||||
'DELETE',
|
||||
{
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
},
|
||||
);
|
||||
const { data, error } = await fetchApi<{ folder: Folder }>(`/api/user/folders/${file.folderId}`, 'DELETE', {
|
||||
delete: 'file',
|
||||
id: file.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
@@ -160,7 +156,7 @@ export async function removeFromFolder(file: File) {
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'File removed from folder',
|
||||
message: `${file.name} has been removed from ${data!.name}`,
|
||||
message: `${file.name} has been removed from ${data?.folder.name}`,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function PendingFilesModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)}>
|
||||
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
|
||||
<Stack gap='xs'>
|
||||
{incompleteFiles?.map((incompleteFile) => (
|
||||
<Card key={incompleteFile.id} withBorder>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { FieldSettings, NAMES, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -14,17 +14,6 @@ import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export const NAMES = {
|
||||
name: 'Name',
|
||||
originalName: 'Original Name',
|
||||
tags: 'Tags',
|
||||
type: 'Type',
|
||||
size: 'Size',
|
||||
createdAt: 'Created At',
|
||||
favorite: 'Favorite',
|
||||
views: 'Views',
|
||||
};
|
||||
|
||||
function SortableTableField({ item }: { item: FieldSettings }) {
|
||||
const setVisible = useFileTableSettingsStore((state) => state.setVisible);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Tag } from '@/lib/db/models/tag';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { useFolders } from '@/lib/hooks/useFolders';
|
||||
import { useQueryState } from '@/lib/hooks/useQueryState';
|
||||
import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { NAMES, useFileTableSettingsStore } from '@/lib/store/fileTableSettings';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -46,7 +46,7 @@ import useSWR from 'swr';
|
||||
|
||||
import { UpdateFn } from '@/lib/hooks/useObjectState';
|
||||
import { DashboardFilesModals } from '..';
|
||||
import TableEditModal, { NAMES } from '../TableEditModal';
|
||||
import TableEditModal from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
@@ -342,6 +342,7 @@ export default function FileTable({
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
title: 'Favorite?',
|
||||
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : 'No'),
|
||||
},
|
||||
{
|
||||
@@ -354,6 +355,12 @@ export default function FileTable({
|
||||
hidden: searchField !== 'id' || searchQuery.id.trim() === '',
|
||||
filtering: searchField === 'id' && searchQuery.id.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'anonymous',
|
||||
sortable: true,
|
||||
title: 'Anonymous?',
|
||||
render: (file: File) => (file.anonymous ? <Text c='green'>Yes</Text> : 'No'),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleFields = fields.filter((f) => f.visible).map((f) => f.field);
|
||||
@@ -384,8 +391,8 @@ export default function FileTable({
|
||||
user={id}
|
||||
/>
|
||||
|
||||
{modals && setModals && modals.table && (
|
||||
<TableEditModal opened={modals.table} onClose={() => setModals('table', false)} />
|
||||
{modals && setModals && (
|
||||
<TableEditModal opened={!!modals.table} onClose={() => setModals('table', false)} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function editFolderUploads(folder: Folder, allowUploads: boolean) {
|
||||
}
|
||||
|
||||
export async function mutateFolder(folderId?: string) {
|
||||
if (!folderId) return mutate(`/api/user/folders/${folderId}`);
|
||||
if (folderId) return mutate(`/api/user/folders/${folderId}`);
|
||||
|
||||
return mutate((key) => typeof key === 'string' && key.startsWith('/api/user/folders'));
|
||||
}
|
||||
|
||||
@@ -5,14 +5,7 @@ const ICON_SIZE = '1.75rem';
|
||||
|
||||
export default function ActionButton({ onClick, Icon }: { onClick: () => void; Icon?: React.FC<any> }) {
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={onClick}
|
||||
variant='filled'
|
||||
color='blue'
|
||||
radius='md'
|
||||
size='xl'
|
||||
className='zip-click-action-button'
|
||||
>
|
||||
<ActionIcon onClick={onClick} variant='filled' radius='md' size='xl' className='zip-click-action-button'>
|
||||
{Icon ? <Icon size={ICON_SIZE} /> : <IconPlayerPlayFilled size={ICON_SIZE} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function Files({
|
||||
filesRandomWordsNumAdjectives: number;
|
||||
filesRandomWordsSeparator: string;
|
||||
filesDefaultCompressionFormat: string;
|
||||
filesMaxFilesPerUpload: number;
|
||||
}>({
|
||||
initialValues: {
|
||||
filesRoute: '/u',
|
||||
@@ -52,6 +53,7 @@ export default function Files({
|
||||
filesRandomWordsNumAdjectives: 3,
|
||||
filesRandomWordsSeparator: '-',
|
||||
filesDefaultCompressionFormat: 'jpg',
|
||||
filesMaxFilesPerUpload: 1000,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
@@ -110,6 +112,7 @@ export default function Files({
|
||||
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
|
||||
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
|
||||
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat ?? 'jpg',
|
||||
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload ?? 1000,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -218,6 +221,13 @@ export default function Files({
|
||||
]}
|
||||
{...form.getInputProps('filesDefaultCompressionFormat')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label='Max Files Per Upload'
|
||||
description='The maximum number of files allowed per upload. Requires a server restart.'
|
||||
min={1}
|
||||
{...form.getInputProps('filesMaxFilesPerUpload')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
|
||||
import React, { useReducer, useState } from 'react';
|
||||
import { useReducer, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { flameshot } from './generators/flameshot';
|
||||
import { sharex } from './generators/sharex';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
@@ -66,7 +67,7 @@ export default function SettingsUser() {
|
||||
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', send);
|
||||
|
||||
if (!data && error) {
|
||||
if (error.error === 'Username already exists') {
|
||||
if (ApiError.check(error, 1039)) {
|
||||
form.setFieldError('username', error.error);
|
||||
} else {
|
||||
notifications.show({
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconTrashFilled } from '@tabler/icons-react';
|
||||
|
||||
export default function ToUploadFile({
|
||||
export default function DropzoneFile({
|
||||
file,
|
||||
onDelete,
|
||||
loading,
|
||||
@@ -43,7 +43,9 @@ export default function ToUploadFile({
|
||||
<Center h='100%'>
|
||||
<Group justify='center' gap='xl'>
|
||||
<IconFileUpload size={48} />
|
||||
<Text size='md'>{file.name}</Text>
|
||||
<Text size='md' ff='monospace'>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
</Paper>
|
||||
@@ -25,7 +25,7 @@ import { useShallow } from 'zustand/shallow';
|
||||
import UploadOptionsButton from '../UploadOptionsButton';
|
||||
import { uploadFiles } from '../uploadFiles';
|
||||
import { uploadPartialFiles } from '../uploadPartialFiles';
|
||||
import ToUploadFile from './ToUploadFile';
|
||||
import DropzoneFile from './DropzoneFile';
|
||||
|
||||
export default function UploadFile({ title, folder }: { title?: string; folder?: string }) {
|
||||
const theme = useMantineTheme();
|
||||
@@ -213,7 +213,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
<Grid grow my='sm'>
|
||||
{files.map((file, i) => (
|
||||
<Grid.Col span={3} key={i}>
|
||||
<ToUploadFile
|
||||
<DropzoneFile
|
||||
loading={dropLoading}
|
||||
file={file}
|
||||
onDelete={() => setFiles(files.filter((_, j) => i !== j))}
|
||||
@@ -239,7 +239,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
disabled={files.length === 0 || dropLoading}
|
||||
onClick={upload}
|
||||
>
|
||||
Upload {files.length} files ({bytes(aggSize())})
|
||||
Upload {files.length} file{files.length !== 1 && 's'} ({bytes(aggSize())})
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCodeMap } from '@/components/ConfigProvider';
|
||||
import Render from '@/components/render/Render';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useUploadOptionsStore } from '@/lib/store/uploadOptions';
|
||||
import { ActionIcon, Button, Group, Select, Tabs, Textarea, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
@@ -60,6 +61,11 @@ export default function UploadText() {
|
||||
[selected, setFile],
|
||||
);
|
||||
|
||||
const aggSize = useCallback(
|
||||
() => files.reduce((acc, file) => acc + new Blob([file.text]).size, 0),
|
||||
[files],
|
||||
);
|
||||
|
||||
const upload = () => {
|
||||
const fileBlobs = files.map((file) => {
|
||||
const blob = new Blob([file.text], {
|
||||
@@ -179,7 +185,7 @@ export default function UploadText() {
|
||||
disabled={files.some((file) => file.text.length === 0) || loading}
|
||||
onClick={upload}
|
||||
>
|
||||
Upload
|
||||
Upload {files.length} file{files.length !== 1 && 's'} ({bytes(aggSize())})
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function filesModal(
|
||||
{files.map((file, idx) => (
|
||||
<Group key={idx} justify='space-between'>
|
||||
<Group justify='left'>
|
||||
<Anchor component={Link} to={file.url}>
|
||||
<Anchor component={Link} to={file.url} target='_blank'>
|
||||
{file.url}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function filesModal(
|
||||
{files.map((file, idx) => (
|
||||
<Group key={idx} justify='space-between'>
|
||||
<Group justify='left'>
|
||||
<Anchor component={Link} to={file.url}>
|
||||
<Anchor component={Link} to={file.url} target='_blank'>
|
||||
{file.url}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
export const API_ERRORS = {
|
||||
// 1xxx, validation and client error
|
||||
1000: 'Invalid request schema',
|
||||
1001: 'Invalid upload options',
|
||||
1002: 'Invalid partial upload',
|
||||
1003: 'Partial upload identifier is invalid',
|
||||
1004: 'Partial upload was not detected',
|
||||
1005: 'Partial uploads only support one file field',
|
||||
1006: 'File extension is not allowed',
|
||||
1007: 'Invalid characters in filename',
|
||||
1008: 'Invalid characters in original filename',
|
||||
1009: 'Invalid filename',
|
||||
1010: 'Unrecognized file mimetype',
|
||||
1011: 'File already in folder',
|
||||
1012: 'File not in folder',
|
||||
1013: 'File ID is required',
|
||||
1014: 'File with this name already exists',
|
||||
1015: 'A folder cannot be its own parent',
|
||||
1016: 'Cannot move folder into one of its descendants',
|
||||
1019: 'Invalid action',
|
||||
1020: 'Cannot PATCH without an action',
|
||||
1021: 'Cannot delete current session, use log out instead.',
|
||||
1022: 'Invalid settings update',
|
||||
1023: 'Invalid setup, no settings found. Run the setup process again before exporting data.',
|
||||
1024: 'Export is not completed',
|
||||
1025: 'No files to export',
|
||||
1026: 'No files found for the given request',
|
||||
1027: 'No files were deleted.',
|
||||
1028: 'No files were updated.',
|
||||
1029: 'No ID provided',
|
||||
1030: 'No providers to delete',
|
||||
1031: 'Session not found in logged in sessions',
|
||||
1032: 'Invalid tag specified',
|
||||
1033: 'Cannot create tag with the same name',
|
||||
1034: 'Tag name already exists',
|
||||
1035: 'Invalid invite code',
|
||||
1036: "Invites aren't enabled",
|
||||
1037: 'User registration is disabled',
|
||||
1038: 'Username already exists',
|
||||
1039: 'Username is taken',
|
||||
1040: 'A user with this username already exists',
|
||||
1041: 'Vanity already exists',
|
||||
1042: 'Vanity already taken',
|
||||
1043: "You can't delete your last OAuth provider without a password",
|
||||
1044: 'Invalid username or password',
|
||||
1045: 'Invalid code',
|
||||
1046: 'Missing WebAuthn challenge ID',
|
||||
1047: 'Missing WebAuthn payload',
|
||||
1048: 'Passkey registration timed out, try again later',
|
||||
1049: 'Error verifying passkey registration',
|
||||
1050: 'Could not verify passkey registration',
|
||||
1051: 'Error verifying passkey authentication',
|
||||
1052: 'Could not verify passkey authentication',
|
||||
1053: "You don't have TOTP enabled",
|
||||
1054: 'TOTP is disabled',
|
||||
1055: 'Password must be a string',
|
||||
1056: "The 'maxBytes' value is required",
|
||||
1057: "The 'maxFiles' value is required",
|
||||
1058: 'From date must be before to date',
|
||||
1059: 'From date must be in the past',
|
||||
1060: 'Passkey has legacy registration data and cannot be used',
|
||||
|
||||
// 2xxx, session errors
|
||||
2000: 'Invalid login session',
|
||||
2001: 'Invalid token',
|
||||
2002: 'Not logged in',
|
||||
|
||||
// 3xxx, permission errors
|
||||
3000: 'Admin only',
|
||||
3001: 'Metrics are disabled',
|
||||
3002: 'Folder is not open',
|
||||
3003: 'Parent folder does not belong to you',
|
||||
3004: 'Password protected',
|
||||
3005: 'Incorrect password',
|
||||
3006: 'Target folder not found',
|
||||
3007: 'You cannot assign this role',
|
||||
3008: 'You cannot create this role',
|
||||
3009: 'You cannot delete this user',
|
||||
3010: 'You cannot delete yourself',
|
||||
3011: 'You do not own this folder',
|
||||
3012: 'Shortening this URL would exceed your quota of X URLs',
|
||||
3013: "You don't have permission to delete the selected files",
|
||||
3014: "You don't have permission to modify the selected files",
|
||||
3015: 'Not super admin',
|
||||
|
||||
// 4xxx, not founds
|
||||
4000: 'File not found',
|
||||
4001: 'Folder not found',
|
||||
4002: 'Folder or file not found',
|
||||
4003: 'Folder or related records not found during deletion',
|
||||
4004: 'Invite not found',
|
||||
4005: 'Invite not found through ID or code',
|
||||
4006: 'No files were moved.',
|
||||
4007: 'Parent folder not found',
|
||||
4008: 'Target folder not found',
|
||||
4009: 'User not found',
|
||||
4010: 'No settings table found',
|
||||
4011: 'Thumbnails task not found',
|
||||
|
||||
// 5xxx, constraint
|
||||
5000: 'File size exceeds the configured limit',
|
||||
5001: 'File is too large',
|
||||
5002: 'Storage quota exceeded',
|
||||
|
||||
// 6xxx, internal errors
|
||||
6000: 'Failed to delete invite',
|
||||
6001: 'Failed to fetch version details',
|
||||
6002: 'Failed to rename file in datasource',
|
||||
6003: 'There was an error during a healthcheck',
|
||||
|
||||
// 9xxx catch all
|
||||
9000: 'Bad request',
|
||||
9001: 'Forbidden',
|
||||
9002: 'Not found',
|
||||
9004: 'Internal server error',
|
||||
} as const satisfies Record<number, string>;
|
||||
|
||||
export type ApiErrorCode = keyof typeof API_ERRORS;
|
||||
|
||||
export type ApiErrorPayload = {
|
||||
error: string;
|
||||
code: ApiErrorCode;
|
||||
statusCode: number;
|
||||
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly code: ApiErrorCode;
|
||||
public readonly status: number;
|
||||
public additional: Record<string, any>;
|
||||
|
||||
constructor(code: ApiErrorCode, message?: string, status?: number) {
|
||||
super(message ?? API_ERRORS[code] ?? 'Unknown API error');
|
||||
|
||||
this.code = code;
|
||||
this.status = status ?? ApiError.codeToHttpStatus(code);
|
||||
this.additional = {} as Record<string, any>;
|
||||
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
|
||||
add(key: string, value: any): this {
|
||||
this.additional[key] = value;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toJSON(): ApiErrorPayload {
|
||||
const formattedMessage = API_ERRORS[this.code]
|
||||
? `E${this.code}${this.message ? `: ${this.message}` : ''}`
|
||||
: this.message;
|
||||
|
||||
return {
|
||||
error: formattedMessage,
|
||||
code: this.code,
|
||||
statusCode: this.status,
|
||||
...this.additional,
|
||||
};
|
||||
}
|
||||
|
||||
public static check(payload: ApiErrorPayload, code: ApiErrorCode): boolean {
|
||||
return payload.code === code;
|
||||
}
|
||||
|
||||
public static codeToHttpStatus(code: ApiErrorCode): number {
|
||||
const override = {
|
||||
9000: 400,
|
||||
9001: 403,
|
||||
9002: 404,
|
||||
9004: 500,
|
||||
}[code as unknown as number];
|
||||
if (override) return override;
|
||||
|
||||
if (code >= 1000 && code < 2000) return 400;
|
||||
if (code >= 2000 && code < 3000) return 401;
|
||||
if (code >= 3000 && code < 4000) return 403;
|
||||
if (code >= 4000 && code < 5000) return 404;
|
||||
if (code >= 5000 && code < 6000) return 413;
|
||||
if (code >= 6000 && code < 7000) return 500;
|
||||
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const DATABASE_TO_PROP = {
|
||||
filesRandomWordsNumAdjectives: 'files.randomWordsNumAdjectives',
|
||||
filesRandomWordsSeparator: 'files.randomWordsSeparator',
|
||||
filesDefaultCompressionFormat: 'files.defaultCompressionFormat',
|
||||
filesMaxFilesPerUpload: 'files.maxFilesPerUpload',
|
||||
|
||||
urlsRoute: 'urls.route',
|
||||
urlsLength: 'urls.length',
|
||||
|
||||
@@ -65,6 +65,7 @@ export const ENVS = [
|
||||
env('files.randomWordsNumAdjectives', 'FILES_RANDOM_WORDS_NUM_ADJECTIVES', 'number', true),
|
||||
env('files.randomWordsSeparator', 'FILES_RANDOM_WORDS_SEPARATOR', 'string', true),
|
||||
env('files.defaultCompressionFormat', 'FILES_DEFAULT_COMPRESSION_FORMAT', 'string', true),
|
||||
env('files.maxFilesPerUpload', 'FILES_MAX_FILES_PER_UPLOAD', 'number', true),
|
||||
|
||||
env('urls.route', 'URLS_ROUTE', 'string', true),
|
||||
env('urls.length', 'URLS_LENGTH', 'number', true),
|
||||
|
||||
@@ -48,6 +48,7 @@ export const rawConfig: any = {
|
||||
randomWordsNumAdjectives: undefined,
|
||||
randomWordsSeparator: undefined,
|
||||
defaultCompressionFormat: undefined,
|
||||
maxFilesPerUpload: undefined,
|
||||
},
|
||||
urls: {
|
||||
route: undefined,
|
||||
|
||||
@@ -144,6 +144,7 @@ export const schema = z.object({
|
||||
.enum(COMPRESS_TYPES)
|
||||
.default('jpg')
|
||||
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
|
||||
maxFilesPerUpload: z.number().max(2147483647).min(1).default(1000),
|
||||
}),
|
||||
urls: z.object({
|
||||
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import z from 'zod';
|
||||
|
||||
export const exportSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
||||
completed: z.boolean(),
|
||||
path: z.string(),
|
||||
files: z.number(),
|
||||
size: z.string(),
|
||||
});
|
||||
|
||||
export type Export = z.infer<typeof exportSchema>;
|
||||
+36
-29
@@ -1,31 +1,7 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { formatRootUrl } from '@/lib/url';
|
||||
import { Tag, tagSelectNoFiles } from './tag';
|
||||
|
||||
export type File = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletesAt: Date | null;
|
||||
favorite: boolean;
|
||||
id: string;
|
||||
originalName: string | null;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
views: number;
|
||||
maxViews?: number | null;
|
||||
password?: string | boolean | null;
|
||||
folderId: string | null;
|
||||
|
||||
thumbnail: {
|
||||
path: string;
|
||||
} | null;
|
||||
|
||||
tags?: Tag[];
|
||||
|
||||
url?: string;
|
||||
similarity?: number;
|
||||
};
|
||||
import { z } from 'zod';
|
||||
import { tagSchema, tagSelectNoFiles } from './tag';
|
||||
|
||||
export const fileSelect = {
|
||||
createdAt: true,
|
||||
@@ -40,6 +16,7 @@ export const fileSelect = {
|
||||
views: true,
|
||||
maxViews: true,
|
||||
folderId: true,
|
||||
anonymous: true,
|
||||
thumbnail: {
|
||||
select: {
|
||||
path: true,
|
||||
@@ -64,9 +41,9 @@ export function cleanFiles(files: File[], stringifyDates = false) {
|
||||
if (file.password) file.password = true;
|
||||
|
||||
if (stringifyDates) {
|
||||
(file as any).createdAt = file.createdAt.toISOString();
|
||||
(file as any).updatedAt = file.updatedAt.toISOString();
|
||||
(file as any).deletesAt = file.deletesAt?.toISOString() || null;
|
||||
if (file.createdAt instanceof Date) file.createdAt = file.createdAt.toISOString();
|
||||
if (file.updatedAt instanceof Date) file.updatedAt = file.updatedAt.toISOString();
|
||||
if (file.deletesAt && file.deletesAt instanceof Date) file.deletesAt = file.deletesAt.toISOString();
|
||||
}
|
||||
|
||||
file.url = formatRootUrl(config.files.route, file.name);
|
||||
@@ -74,3 +51,33 @@ export function cleanFiles(files: File[], stringifyDates = false) {
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export const fileSchema = z.object({
|
||||
createdAt: z.union([z.date(), z.string()]),
|
||||
updatedAt: z.union([z.date(), z.string()]),
|
||||
deletesAt: z.union([z.date(), z.string()]).nullable(),
|
||||
favorite: z.boolean(),
|
||||
id: z.string(),
|
||||
originalName: z.string().nullable(),
|
||||
name: z.string(),
|
||||
size: z.number(),
|
||||
type: z.string(),
|
||||
views: z.number(),
|
||||
maxViews: z.number().nullish(),
|
||||
password: z.union([z.string(), z.boolean()]).nullish(),
|
||||
folderId: z.string().nullable(),
|
||||
anonymous: z.boolean().nullish(),
|
||||
|
||||
thumbnail: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
|
||||
tags: z.array(tagSchema).optional(),
|
||||
|
||||
url: z.string().optional(),
|
||||
similarity: z.number().optional(),
|
||||
});
|
||||
|
||||
export type File = z.infer<typeof fileSchema>;
|
||||
|
||||
+56
-27
@@ -1,27 +1,6 @@
|
||||
import type { Folder as PrismaFolder } from '@/prisma/client';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles } from './file';
|
||||
|
||||
export type Folder = PrismaFolder & {
|
||||
files?: File[];
|
||||
parent?: Partial<PrismaFolder> | null;
|
||||
children?: Partial<Folder>[];
|
||||
_count?: {
|
||||
children?: number;
|
||||
files?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type FolderParent = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
parent?: FolderParent | null;
|
||||
};
|
||||
|
||||
export type FolderParentPublic = {
|
||||
public: boolean;
|
||||
} & FolderParent;
|
||||
import { z } from 'zod';
|
||||
import { fileSchema, cleanFiles } from './file';
|
||||
|
||||
export async function buildParentChain(parentId: string | null): Promise<FolderParent | null> {
|
||||
if (!parentId) return null;
|
||||
@@ -59,12 +38,14 @@ export async function buildPublicParentChain(parentId: string | null): Promise<F
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanFolder(folder: Partial<Folder>, stringifyDates = false): Partial<Folder> {
|
||||
export function cleanFolder<T extends Partial<Folder>>(folder: T, stringifyDates = false): T {
|
||||
if (folder.files && Array.isArray(folder.files)) cleanFiles(folder.files as any, stringifyDates);
|
||||
|
||||
if (stringifyDates) {
|
||||
if (folder.createdAt) (folder.createdAt as unknown) = (folder.createdAt as Date).toISOString();
|
||||
if (folder.updatedAt) (folder.updatedAt as unknown) = (folder.updatedAt as Date).toISOString();
|
||||
if (folder.createdAt && folder.createdAt instanceof Date)
|
||||
folder.createdAt = folder.createdAt.toISOString();
|
||||
if (folder.updatedAt && folder.updatedAt instanceof Date)
|
||||
folder.updatedAt = folder.updatedAt.toISOString();
|
||||
}
|
||||
|
||||
if (folder.children && Array.isArray(folder.children)) {
|
||||
@@ -80,10 +61,58 @@ export function cleanFolder(folder: Partial<Folder>, stringifyDates = false): Pa
|
||||
return folder;
|
||||
}
|
||||
|
||||
export function cleanFolders(folders: Partial<Folder>[], stringifyDates = false): Partial<Folder>[] {
|
||||
export function cleanFolders<T extends Partial<Folder>>(folders: T[], stringifyDates = false): T[] {
|
||||
for (let i = 0; i !== folders.length; ++i) {
|
||||
cleanFolder(folders[i], stringifyDates);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
export const folderSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.union([z.string(), z.date()]),
|
||||
updatedAt: z.union([z.string(), z.date()]),
|
||||
|
||||
name: z.string(),
|
||||
public: z.boolean(),
|
||||
allowUploads: z.boolean(),
|
||||
|
||||
parentId: z.string().nullable(),
|
||||
userId: z.string(),
|
||||
|
||||
files: z.array(fileSchema).optional(),
|
||||
parent: z.any().nullable().optional(),
|
||||
children: z.array(z.any()).optional(),
|
||||
|
||||
_count: z
|
||||
.object({
|
||||
children: z.number().optional(),
|
||||
files: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type Folder = z.infer<typeof folderSchema>;
|
||||
|
||||
export const folderParentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
parentId: z.string().nullable(),
|
||||
get parent() {
|
||||
return folderParentSchema.nullable().optional();
|
||||
},
|
||||
});
|
||||
|
||||
export const folderParentPublicSchema = z.object({
|
||||
public: z.boolean(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
parentId: z.string().nullable(),
|
||||
get parent() {
|
||||
return folderParentPublicSchema.nullable().optional();
|
||||
},
|
||||
});
|
||||
|
||||
export type FolderParent = z.infer<typeof folderParentSchema>;
|
||||
export type FolderParentPublic = z.infer<typeof folderParentPublicSchema>;
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { IncompleteFileStatus } from '@/prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type IncompleteFile = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
status: IncompleteFileStatus;
|
||||
chunksTotal: number;
|
||||
chunksComplete: number;
|
||||
|
||||
userId: string;
|
||||
|
||||
metadata: IncompleteFileMetadata;
|
||||
};
|
||||
|
||||
export type IncompleteFileMetadata = z.infer<typeof metadataSchema>;
|
||||
export const metadataSchema = z.object({
|
||||
file: z.object({
|
||||
@@ -23,3 +9,19 @@ export const metadataSchema = z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const incompleteFileSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
||||
status: z.enum(IncompleteFileStatus),
|
||||
chunksTotal: z.number(),
|
||||
chunksComplete: z.number(),
|
||||
|
||||
userId: z.string(),
|
||||
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
export type IncompleteFile = z.infer<typeof incompleteFileSchema>;
|
||||
|
||||
+25
-10
@@ -1,13 +1,5 @@
|
||||
import type { Invite as PrismaInvite } from '@/prisma/client';
|
||||
import type { User } from './user';
|
||||
|
||||
export type Invite = PrismaInvite & {
|
||||
inviter?: {
|
||||
username: string;
|
||||
id: string;
|
||||
role: User['role'];
|
||||
};
|
||||
};
|
||||
import { Role } from '@/prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const inviteInviterSelect = {
|
||||
select: {
|
||||
@@ -16,3 +8,26 @@ export const inviteInviterSelect = {
|
||||
role: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
expiresAt: z.date().nullable(),
|
||||
|
||||
code: z.string(),
|
||||
uses: z.number(),
|
||||
maxUses: z.number().nullable(),
|
||||
|
||||
inviterId: z.string(),
|
||||
|
||||
inviter: z
|
||||
.object({
|
||||
username: z.string(),
|
||||
id: z.string(),
|
||||
role: z.enum(Role),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type Invite = z.infer<typeof inviteSchema>;
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type Metric = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
data: MetricData;
|
||||
};
|
||||
|
||||
export type MetricData = z.infer<typeof metricDataSchema>;
|
||||
|
||||
export const metricDataSchema = z.object({
|
||||
users: z.number(),
|
||||
files: z.number(),
|
||||
@@ -39,3 +32,12 @@ export const metricDataSchema = z.object({
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const metricSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
data: metricDataSchema,
|
||||
});
|
||||
|
||||
export type Metric = z.infer<typeof metricSchema>;
|
||||
|
||||
+18
-10
@@ -1,13 +1,4 @@
|
||||
export type Tag = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
name: string;
|
||||
color: string;
|
||||
files?: {
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
import { z } from 'zod';
|
||||
|
||||
export const tagSelect = {
|
||||
id: true,
|
||||
@@ -29,3 +20,20 @@ export const tagSelectNoFiles = {
|
||||
name: true,
|
||||
color: true,
|
||||
};
|
||||
|
||||
export const tagSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
files: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type Tag = z.infer<typeof tagSchema>;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { Url as PrismaUrl } from '@/prisma/client';
|
||||
|
||||
export type Url = PrismaUrl & {
|
||||
similarity?: number;
|
||||
};
|
||||
import { z } from 'zod';
|
||||
|
||||
export function cleanUrlPasswords(urls: Url[]) {
|
||||
for (const url of urls) {
|
||||
@@ -11,3 +7,23 @@ export function cleanUrlPasswords(urls: Url[]) {
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
export const urlSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
||||
code: z.string(),
|
||||
vanity: z.string().nullable(),
|
||||
destination: z.string(),
|
||||
views: z.number(),
|
||||
maxViews: z.number().nullable(),
|
||||
password: z.union([z.string(), z.boolean()]).nullable(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
userId: z.string().nullable(),
|
||||
|
||||
similarity: z.number().optional(),
|
||||
});
|
||||
|
||||
export type Url = z.infer<typeof urlSchema>;
|
||||
|
||||
+76
-24
@@ -1,28 +1,5 @@
|
||||
import { OAuthProvider, UserPasskey, UserQuota, UserSession } from '@/prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
|
||||
view: UserViewSettings;
|
||||
|
||||
sessions: UserSession[];
|
||||
|
||||
oauthProviders: OAuthProvider[];
|
||||
|
||||
totpSecret?: string | null;
|
||||
passkeys?: UserPasskey[];
|
||||
|
||||
quota?: UserQuota | null;
|
||||
|
||||
avatar?: string | null;
|
||||
password?: string | null;
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export const userSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
@@ -37,7 +14,6 @@ export const userSelect = {
|
||||
sessions: true,
|
||||
};
|
||||
|
||||
export type UserViewSettings = z.infer<typeof userViewSchema>;
|
||||
export const userViewSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().nullish(),
|
||||
@@ -53,3 +29,79 @@ export const userViewSchema = z
|
||||
embedSiteName: z.string().nullish(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type UserViewSettings = z.infer<typeof userViewSchema>;
|
||||
|
||||
export const userSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
ua: z.string(),
|
||||
client: z.string(),
|
||||
device: z.string(),
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export type UserSession = z.infer<typeof userSessionSchema>;
|
||||
|
||||
export const userQuotaSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
filesQuota: z.enum(['BY_BYTES', 'BY_FILES']),
|
||||
maxBytes: z.string().nullable(),
|
||||
maxFiles: z.number().nullable(),
|
||||
maxUrls: z.number().nullable(),
|
||||
userId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type UserQuota = z.infer<typeof userQuotaSchema>;
|
||||
|
||||
export const userPasskeySchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
lastUsed: z.date().nullable(),
|
||||
name: z.string(),
|
||||
reg: z.any(),
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export type UserPasskey = z.infer<typeof userPasskeySchema>;
|
||||
|
||||
export const oauthProviderSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
userId: z.string(),
|
||||
provider: z.enum(['DISCORD', 'GOOGLE', 'GITHUB', 'OIDC']),
|
||||
username: z.string(),
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().nullable(),
|
||||
oauthId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
|
||||
export type OAuthProviderType = OAuthProvider['provider'];
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
role: z.enum(['USER', 'ADMIN', 'SUPERADMIN']),
|
||||
view: userViewSchema,
|
||||
|
||||
sessions: z.array(userSessionSchema),
|
||||
oauthProviders: z.array(oauthProviderSchema),
|
||||
|
||||
totpSecret: z.string().nullable().optional(),
|
||||
passkeys: z.array(userPasskeySchema).optional(),
|
||||
|
||||
quota: userQuotaSchema.nullable().optional(),
|
||||
|
||||
avatar: z.string().nullable().optional(),
|
||||
password: z.string().nullable().optional(),
|
||||
token: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
+6
-5
@@ -1,4 +1,4 @@
|
||||
import { ErrorBody } from './response';
|
||||
import { ApiErrorPayload } from './api/errors';
|
||||
|
||||
const bodyMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
@@ -9,10 +9,10 @@ export async function fetchApi<Response = any>(
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<{
|
||||
data: Response | null;
|
||||
error: ErrorBody | null;
|
||||
error: ApiErrorPayload | null;
|
||||
}> {
|
||||
let data: Response | null = null;
|
||||
let error: ErrorBody | null = null;
|
||||
let error: ApiErrorPayload | null = null;
|
||||
|
||||
if ((bodyMethods.includes(method) && body !== null) || (body && !Object.keys(body).length)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
@@ -31,9 +31,10 @@ export async function fetchApi<Response = any>(
|
||||
error = await res.json();
|
||||
} else {
|
||||
error = {
|
||||
message: await res.text(),
|
||||
code: 9000,
|
||||
error: await res.text(),
|
||||
statusCode: res.status,
|
||||
} as ErrorBody;
|
||||
} as ApiErrorPayload;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export const export3Schema = z.object({
|
||||
totp_secret: z.string().optional().nullable(),
|
||||
oauth: z.array(
|
||||
z.object({
|
||||
provider: z.union([z.literal('DISCORD'), z.literal('GITHUB'), z.literal('GOOGLE')]),
|
||||
provider: z.enum(['DISCORD', 'GITHUB', 'GOOGLE']),
|
||||
username: z.string(),
|
||||
oauth_id: z.string().nullable(),
|
||||
access_token: z.string().nullable(),
|
||||
|
||||
@@ -61,11 +61,11 @@ export const export4Schema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
username: z.string(),
|
||||
password: z.string().nullable().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
password: z.string().nullish(),
|
||||
avatar: z.string().nullish(),
|
||||
role: z.enum(Role),
|
||||
view: z.record(z.string(), z.unknown()),
|
||||
totpSecret: z.string().nullable().optional(),
|
||||
view: z.record(z.string(), z.any()),
|
||||
totpSecret: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
userPasskeys: z.array(
|
||||
@@ -74,11 +74,10 @@ export const export4Schema = z.object({
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
lastUsed: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.nullish()
|
||||
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
|
||||
name: z.string(),
|
||||
reg: z.record(z.string(), z.unknown()),
|
||||
reg: z.record(z.string(), z.any()),
|
||||
userId: z.string(),
|
||||
}),
|
||||
),
|
||||
@@ -87,10 +86,10 @@ export const export4Schema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
filesQuota: z.enum(UserFilesQuota),
|
||||
maxBytes: z.string().nullable().optional(),
|
||||
maxFiles: z.number().nullable().optional(),
|
||||
maxUrls: z.number().nullable().optional(),
|
||||
userId: z.string().nullable().optional(),
|
||||
maxBytes: z.string().nullish(),
|
||||
maxFiles: z.number().nullish(),
|
||||
maxUrls: z.number().nullish(),
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
userOauthProviders: z.array(
|
||||
@@ -100,8 +99,8 @@ export const export4Schema = z.object({
|
||||
provider: z.enum(OAuthProviderType),
|
||||
username: z.string(),
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().nullable().optional(),
|
||||
oauthId: z.string().nullable().optional(),
|
||||
refreshToken: z.string().nullish(),
|
||||
oauthId: z.string().nullish(),
|
||||
userId: z.string(),
|
||||
}),
|
||||
),
|
||||
@@ -110,7 +109,7 @@ export const export4Schema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
name: z.string(),
|
||||
color: z.string().nullable().optional(),
|
||||
color: z.string().nullish(),
|
||||
files: z.array(z.string()),
|
||||
userId: z.string(),
|
||||
}),
|
||||
@@ -121,12 +120,11 @@ export const export4Schema = z.object({
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
expiresAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.nullish()
|
||||
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
|
||||
code: z.string(),
|
||||
uses: z.number(),
|
||||
maxUses: z.number().nullable().optional(),
|
||||
maxUses: z.number().nullish(),
|
||||
inviterId: z.string(),
|
||||
}),
|
||||
),
|
||||
@@ -139,7 +137,7 @@ export const export4Schema = z.object({
|
||||
allowUploads: z.boolean(),
|
||||
files: z.array(z.string()),
|
||||
userId: z.string(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
parentId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
urls: z.array(
|
||||
@@ -147,13 +145,13 @@ export const export4Schema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
code: z.string(),
|
||||
vanity: z.string().nullable().optional(),
|
||||
vanity: z.string().nullish(),
|
||||
destination: z.string(),
|
||||
views: z.number(),
|
||||
maxViews: z.number().nullable().optional(),
|
||||
password: z.string().nullable().optional(),
|
||||
maxViews: z.number().nullish(),
|
||||
password: z.string().nullish(),
|
||||
enabled: z.boolean(),
|
||||
userId: z.string().nullable().optional(),
|
||||
userId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
files: z.array(
|
||||
@@ -162,19 +160,18 @@ export const export4Schema = z.object({
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
deletesAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.nullish()
|
||||
.refine((date) => (date ? !isNaN(Date.parse(date)) : true), 'Invalid date'),
|
||||
name: z.string(),
|
||||
originalName: z.string().nullable().optional(),
|
||||
originalName: z.string().nullish(),
|
||||
size: z.number(),
|
||||
type: z.string(),
|
||||
views: z.number(),
|
||||
maxViews: z.number().nullable().optional(),
|
||||
maxViews: z.number().nullish(),
|
||||
favorite: z.boolean(),
|
||||
password: z.string().nullable().optional(),
|
||||
password: z.string().nullish(),
|
||||
userId: z.string().nullable(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
folderId: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
thumbnails: z.array(
|
||||
@@ -189,7 +186,7 @@ export const export4Schema = z.object({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string().refine((date) => !isNaN(Date.parse(date)), 'Invalid date'),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
data: z.record(z.string(), z.any()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Field = 'name' | 'originalName' | 'tags' | 'type' | 'size' | 'createdAt' | 'favorite' | 'views';
|
||||
type Field =
|
||||
| 'name'
|
||||
| 'originalName'
|
||||
| 'tags'
|
||||
| 'type'
|
||||
| 'size'
|
||||
| 'createdAt'
|
||||
| 'favorite'
|
||||
| 'views'
|
||||
| 'anonymous';
|
||||
|
||||
export const defaultFields: FieldSettings[] = [
|
||||
{ field: 'name', visible: true },
|
||||
{ field: 'originalName', visible: false },
|
||||
{ field: 'tags', visible: true },
|
||||
{ field: 'type', visible: true },
|
||||
{ field: 'size', visible: true },
|
||||
{ field: 'createdAt', visible: true },
|
||||
{ field: 'favorite', visible: true },
|
||||
{ field: 'views', visible: true },
|
||||
const FIELDS: {
|
||||
property: Field;
|
||||
visible: boolean;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ property: 'name', visible: true, title: 'Name' },
|
||||
{ property: 'originalName', visible: false, title: 'Original Name' },
|
||||
{ property: 'tags', visible: true, title: 'Tags' },
|
||||
{ property: 'type', visible: true, title: 'Type' },
|
||||
{ property: 'size', visible: true, title: 'Size' },
|
||||
{ property: 'createdAt', visible: true, title: 'Created At' },
|
||||
{ property: 'favorite', visible: true, title: 'Favorite' },
|
||||
{ property: 'views', visible: true, title: 'Views' },
|
||||
{ property: 'anonymous', visible: false, title: 'Anonymous?' },
|
||||
];
|
||||
|
||||
export const defaultFields: FieldSettings[] = FIELDS.map(({ property, visible }) => ({
|
||||
field: property,
|
||||
visible,
|
||||
}));
|
||||
|
||||
export const NAMES: Record<Field, string> = Object.fromEntries(
|
||||
FIELDS.map(({ property, title }) => [property, title]),
|
||||
) as Record<Field, string>;
|
||||
|
||||
export type FieldSettings = {
|
||||
field: Field;
|
||||
visible: boolean;
|
||||
@@ -53,6 +76,23 @@ export const useFileTableSettingsStore = create<FileTableSettings>()(
|
||||
}),
|
||||
{
|
||||
name: 'zipline-file-table-settings',
|
||||
merge: (persistedState: any, currentState) => {
|
||||
const fields = Object.keys(NAMES);
|
||||
const stored = persistedState.fields?.map((item: any) => item.field) || [];
|
||||
|
||||
const needsUpdate =
|
||||
fields.length !== stored.length || !fields.every((field) => stored.includes(field));
|
||||
|
||||
if (needsUpdate) {
|
||||
return {
|
||||
...currentState,
|
||||
...persistedState,
|
||||
fields: currentState.fields,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...currentState, ...persistedState };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import he from 'he';
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
return he.encode(html);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function buildResponse(
|
||||
title: content.embed.title,
|
||||
description: content.embed.description,
|
||||
color: hexString(content.embed.color),
|
||||
timestamp: content.embed.timestamp ? (file! || url!).createdAt.toISOString() : null,
|
||||
timestamp: content.embed.timestamp ? (<Date>(file! || url!).createdAt).toISOString() : null,
|
||||
footer: content.embed.footer
|
||||
? {
|
||||
text: content.embed.footer,
|
||||
|
||||
+28
-19
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import { checkDbVars, REQUIRED_DB_VARS } from '@/lib/config/read/env';
|
||||
@@ -7,6 +8,7 @@ import { runMigrations } from '@/lib/db/migration';
|
||||
import { log } from '@/lib/logger';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Tasks } from '@/lib/tasks';
|
||||
import cleanThumbnails from '@/lib/tasks/run/cleanThumbnails';
|
||||
import clearInvites from '@/lib/tasks/run/clearInvites';
|
||||
import deleteFiles from '@/lib/tasks/run/deleteFiles';
|
||||
import maxViews from '@/lib/tasks/run/maxViews';
|
||||
@@ -22,6 +24,7 @@ import fastifySwagger from '@fastify/swagger';
|
||||
import fastify from 'fastify';
|
||||
import {
|
||||
hasZodFastifySchemaValidationErrors,
|
||||
isResponseSerializationError,
|
||||
jsonSchemaTransform,
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
@@ -36,7 +39,6 @@ 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');
|
||||
@@ -84,19 +86,6 @@ async function main() {
|
||||
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);
|
||||
|
||||
@@ -124,6 +113,7 @@ async function main() {
|
||||
await server.register(fastifyMultipart, {
|
||||
limits: {
|
||||
fileSize: bytes(config.files.maxFileSize),
|
||||
parts: config.files.maxFilesPerUpload,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,20 +231,39 @@ async function main() {
|
||||
server.setErrorHandler((error: any, _, res) => {
|
||||
if (hasZodFastifySchemaValidationErrors(error)) {
|
||||
return res.status(400).send({
|
||||
error: error.message ?? 'Response Validation Error',
|
||||
error: error.message ?? 'E1000: Invalid response schema',
|
||||
statusCode: 400,
|
||||
code: 1000,
|
||||
issues: error.validation,
|
||||
});
|
||||
}
|
||||
|
||||
if (isResponseSerializationError(error)) {
|
||||
console.log(error);
|
||||
|
||||
return res.status(500).send({
|
||||
error: 'E1000: Response serialization error',
|
||||
statusCode: 500,
|
||||
code: 1000,
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
const apiError = error as ApiError;
|
||||
return res.status(apiError.status).send(apiError.toJSON());
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
res.status(error.statusCode);
|
||||
res.send({ error: error.message, statusCode: error.statusCode });
|
||||
return res.status(error.statusCode).send({ error: error.message, statusCode: error.statusCode });
|
||||
} else {
|
||||
console.error(error);
|
||||
|
||||
res.status(500);
|
||||
res.send({ error: 'Internal Server Error', statusCode: 500 });
|
||||
return res.status(500).send({
|
||||
code: 9000,
|
||||
error: 'E9000: Internal Server Error',
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function administratorMiddleware(req: FastifyRequest, res: FastifyReply) {
|
||||
if (!req.user) return res.forbidden('not logged in');
|
||||
export async function administratorMiddleware(req: FastifyRequest) {
|
||||
if (!req.user) throw new ApiError(2000);
|
||||
|
||||
if (!isAdministrator(req.user.role)) return res.forbidden();
|
||||
if (!isAdministrator(req.user.role)) throw new ApiError(3000);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { FastifyRequest } from 'fastify/types/request';
|
||||
import { getSession } from '../session';
|
||||
// import cookie from 'cookie';
|
||||
import * as cookie from 'cookie';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
|
||||
declare module 'fastify' {
|
||||
export interface FastifyRequest {
|
||||
@@ -28,13 +28,15 @@ export function parseUserToken(
|
||||
const decryptedToken = decryptToken(encryptedToken, config.core.secret);
|
||||
if (!decryptedToken) {
|
||||
if (noThrow) return null;
|
||||
throw { error: 'could not decrypt token' };
|
||||
// throw { error: 'could not decrypt token' };
|
||||
throw new ApiError(2001);
|
||||
}
|
||||
|
||||
const [date, token] = decryptedToken;
|
||||
if (isNaN(new Date(date).getTime())) {
|
||||
if (noThrow) return null;
|
||||
throw { error: 'invalid token' };
|
||||
|
||||
throw new ApiError(2001);
|
||||
}
|
||||
|
||||
return token;
|
||||
@@ -58,7 +60,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
|
||||
// eslint-disable-next-line no-var
|
||||
var token = parseUserToken(authorization);
|
||||
} catch (e) {
|
||||
return res.unauthorized((e as { error: string }).error);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
@@ -67,7 +69,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
if (!user) return res.unauthorized('invalid authorization token');
|
||||
if (!user) throw new ApiError(2001);
|
||||
|
||||
req.user = user;
|
||||
|
||||
@@ -76,7 +78,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
|
||||
|
||||
const session = await getSession(req, res);
|
||||
|
||||
if (!session.id || !session.sessionId) return res.unauthorized('not logged in');
|
||||
if (!session.id || !session.sessionId) throw new ApiError(2000);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -88,7 +90,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
if (!user) return res.unauthorized('invalid login session');
|
||||
if (!user) throw new ApiError(2001);
|
||||
|
||||
req.user = user;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
|
||||
import { Invite, inviteInviterSelect, inviteSchema } from '@/lib/db/models/invite';
|
||||
import { log } from '@/lib/logger';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
@@ -21,7 +22,13 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Fetch a specific invite by ID or code, including information about the inviter (admin only).',
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: inviteSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
@@ -36,7 +43,7 @@ export default typedPlugin(
|
||||
inviter: inviteInviterSelect,
|
||||
},
|
||||
});
|
||||
if (!invite) return res.notFound('Invite not found through id or code');
|
||||
if (!invite) throw new ApiError(4005);
|
||||
|
||||
return res.send(invite);
|
||||
},
|
||||
@@ -46,7 +53,11 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete a specific invite by ID (admin only).',
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: inviteSchema,
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
@@ -71,11 +82,11 @@ export default typedPlugin(
|
||||
return res.send(invite);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
|
||||
return res.notFound('Invite not found');
|
||||
throw new ApiError(4004);
|
||||
}
|
||||
|
||||
logger.error(`Failed to delete invite with id ${id}`, { error });
|
||||
return res.internalServerError('Failed to delete invite');
|
||||
throw new ApiError(6000);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
|
||||
import { Invite, inviteInviterSelect, inviteSchema } from '@/lib/db/models/invite';
|
||||
import { log } from '@/lib/logger';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
@@ -21,6 +21,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Create a new invite code for user registration, optionally limiting uses and expiration (admin only).',
|
||||
body: z.object({
|
||||
expiresAt: z
|
||||
.string()
|
||||
@@ -28,6 +30,10 @@ export default typedPlugin(
|
||||
.transform((val) => parseExpiry(val)),
|
||||
maxUses: z.number().min(1).optional(),
|
||||
}),
|
||||
response: {
|
||||
200: inviteSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
@@ -57,15 +63,27 @@ export default typedPlugin(
|
||||
},
|
||||
);
|
||||
|
||||
server.get(PATH, { preHandler: [userMiddleware, administratorMiddleware] }, async (_, res) => {
|
||||
const invites = await prisma.invite.findMany({
|
||||
include: {
|
||||
inviter: inviteInviterSelect,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'List all existing invite codes and their metadata (admin only).',
|
||||
response: {
|
||||
200: z.array(inviteSchema),
|
||||
},
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (_, res) => {
|
||||
const invites = await prisma.invite.findMany({
|
||||
include: {
|
||||
inviter: inviteInviterSelect,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(invites);
|
||||
});
|
||||
return res.send(invites);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
@@ -18,7 +19,21 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Look up a public invite by code for the web UI, returning basic information about the inviter and usage limits.',
|
||||
querystring: z.object({ code: z.string().optional() }),
|
||||
response: {
|
||||
200: z.object({
|
||||
invite: z
|
||||
.object({
|
||||
code: z.string(),
|
||||
maxUses: z.number().nullable(),
|
||||
uses: z.number(),
|
||||
inviter: z.object({ username: z.string() }),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(10),
|
||||
},
|
||||
@@ -26,7 +41,7 @@ export default typedPlugin(
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code) return res.send({ invite: null });
|
||||
if (!config.invites.enabled) return res.notFound();
|
||||
if (!config.invites.enabled) throw new ApiError(9002);
|
||||
|
||||
const invite = await prisma.invite.findFirst({
|
||||
where: {
|
||||
@@ -48,7 +63,7 @@ export default typedPlugin(
|
||||
(invite.expiresAt && new Date(invite.expiresAt) < new Date()) ||
|
||||
(invite.maxUses && invite.uses >= invite.maxUses)
|
||||
) {
|
||||
return res.notFound();
|
||||
throw new ApiError(9002);
|
||||
}
|
||||
|
||||
delete (invite as any).expiresAt;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { ziplineClientParseSchema } from '@/lib/api/detect';
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { verifyTotpCode } from '@/lib/totp';
|
||||
@@ -24,6 +25,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Authenticate a user, creating a session and optionally requiring a TOTP code when multi-factor auth is enabled.',
|
||||
body: z.object({
|
||||
username: zStringTrimmed,
|
||||
password: zStringTrimmed,
|
||||
@@ -32,6 +35,12 @@ export default typedPlugin(
|
||||
headers: z.object({
|
||||
'x-zipline-client': ziplineClientParseSchema.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
user: userSchema.optional(),
|
||||
totp: z.literal(true).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(2),
|
||||
},
|
||||
@@ -53,8 +62,8 @@ export default typedPlugin(
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
if (!user) return res.badRequest('Invalid username or password');
|
||||
if (!user.password) return res.badRequest('Invalid username or password');
|
||||
if (!user) throw new ApiError(1044);
|
||||
if (!user.password) throw new ApiError(1044);
|
||||
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
if (!valid) {
|
||||
@@ -64,7 +73,7 @@ export default typedPlugin(
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.badRequest('Invalid username or password');
|
||||
throw new ApiError(1044);
|
||||
}
|
||||
|
||||
if (user.totpSecret && code) {
|
||||
@@ -76,7 +85,7 @@ export default typedPlugin(
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.badRequest('Invalid code');
|
||||
throw new ApiError(1045);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { log } from '@/lib/logger';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import { getSession } from '@/server/session';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiLogoutResponse = {
|
||||
loggedOut?: boolean;
|
||||
@@ -13,26 +14,41 @@ const logger = log('api').c('auth').c('logout');
|
||||
export const PATH = '/api/auth/logout';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const current = await getSession(req, res);
|
||||
|
||||
await prisma.userSession.deleteMany({
|
||||
where: {
|
||||
id: current.sessionId!,
|
||||
userId: req.user.id,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Log out the currently authenticated user and invalidate their active session.',
|
||||
response: {
|
||||
200: z.object({
|
||||
loggedOut: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const current = await getSession(req, res);
|
||||
|
||||
current.destroy();
|
||||
await prisma.userSession.deleteMany({
|
||||
where: {
|
||||
id: current.sessionId!,
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('user logged out', {
|
||||
user: req.user.username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
current.destroy();
|
||||
|
||||
return res.send({ loggedOut: true });
|
||||
});
|
||||
logger.info('user logged out', {
|
||||
user: req.user.username,
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.send({ loggedOut: true });
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { OAuthProvider, oauthProviderSchema } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { OAuthProvider, OAuthProviderType } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
@@ -12,13 +13,35 @@ const logger = log('api').c('auth').c('oauth');
|
||||
export const PATH = '/api/auth/oauth';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
return res.send(req.user.oauthProviders);
|
||||
});
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'List OAuth providers currently linked to the authenticated user.',
|
||||
response: {
|
||||
200: z.array(oauthProviderSchema),
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
return res.send(req.user.oauthProviders);
|
||||
},
|
||||
);
|
||||
|
||||
server.delete(
|
||||
PATH,
|
||||
{ schema: { body: z.object({ provider: z.enum(OAuthProviderType) }) }, preHandler: [userMiddleware] },
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Unlink one OAuth provider from the authenticated user, enforcing that at least one login method remains.',
|
||||
body: z.object({ provider: oauthProviderSchema.shape.provider }),
|
||||
response: {
|
||||
200: z.array(oauthProviderSchema),
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { password } = (await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -29,9 +52,8 @@ export default typedPlugin(
|
||||
},
|
||||
}))!;
|
||||
|
||||
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");
|
||||
if (!req.user.oauthProviders.length) throw new ApiError(1030);
|
||||
if (req.user.oauthProviders.length === 1 && !password) throw new ApiError(1043);
|
||||
|
||||
const { provider } = req.body;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { ziplineClientParseSchema } from '@/lib/api/detect';
|
||||
import { config } from '@/lib/config';
|
||||
import { createToken, hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { getSession, saveSession } from '@/server/session';
|
||||
@@ -9,7 +11,6 @@ import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
import { ApiLoginResponse } from './login';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import { ziplineClientParseSchema } from '@/lib/api/detect';
|
||||
|
||||
export type ApiAuthRegisterResponse = ApiLoginResponse;
|
||||
|
||||
@@ -22,6 +23,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Register a new user account and immediately authenticate them, optionally consuming an invite code.',
|
||||
body: z.object({
|
||||
username: zStringTrimmed,
|
||||
password: zStringTrimmed,
|
||||
@@ -30,6 +33,11 @@ export default typedPlugin(
|
||||
headers: z.object({
|
||||
'x-zipline-client': ziplineClientParseSchema.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
user: userSchema.optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
@@ -38,16 +46,15 @@ export default typedPlugin(
|
||||
|
||||
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 (code && !config.invites.enabled) throw new ApiError(1036);
|
||||
if (!code && !config.features.userRegistration) throw new ApiError(1037);
|
||||
|
||||
const oUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
});
|
||||
if (oUser) return res.badRequest('Username is taken');
|
||||
if (oUser) throw new ApiError(1039);
|
||||
|
||||
if (code) {
|
||||
const invite = await prisma.invite.findFirst({
|
||||
@@ -56,10 +63,9 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
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 (!invite) throw new ApiError(1035);
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) throw new ApiError(1035);
|
||||
if (invite.maxUses && invite.uses >= invite.maxUses) throw new ApiError(1035);
|
||||
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { ziplineClientParseSchema } from '@/lib/api/detect';
|
||||
import { config } from '@/lib/config';
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
import z from 'zod';
|
||||
import { PasskeyReg, passkeysEnabledHandler } from '../user/mfa/passkey';
|
||||
import { ziplineClientParseSchema } from '@/lib/api/detect';
|
||||
|
||||
export type ApiAuthWebauthnResponse = {
|
||||
user: User;
|
||||
@@ -36,7 +37,16 @@ export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH + '/options',
|
||||
{ preHandler: [passkeysEnabledHandler], ...secondlyRatelimit(20) },
|
||||
{
|
||||
schema: {
|
||||
description: 'Generate WebAuthn authentication options for logging in with an existing passkey.',
|
||||
response: {
|
||||
200: z.custom<ApiAuthWebauthnOptionsResponse>(),
|
||||
},
|
||||
},
|
||||
preHandler: [passkeysEnabledHandler],
|
||||
...secondlyRatelimit(20),
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.cookies['webauthn-challenge-id']) {
|
||||
const existing = OPTIONS_CACHE.get(req.cookies['webauthn-challenge-id']);
|
||||
@@ -72,6 +82,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Verify a WebAuthn authentication response and log in the user associated with the matching passkey.',
|
||||
body: z.object({
|
||||
response: z.custom<AuthenticationResponseJSON>(),
|
||||
}),
|
||||
@@ -86,13 +98,13 @@ export default typedPlugin(
|
||||
const session = await getSession(req, res);
|
||||
|
||||
const webauthnChallengeId = req.cookies['webauthn-challenge-id'];
|
||||
if (!webauthnChallengeId) return res.badRequest('Missing webauthn challenge id');
|
||||
if (!webauthnChallengeId) throw new ApiError(1046);
|
||||
|
||||
const { response } = req.body;
|
||||
if (!response) return res.badRequest('Missing webauthn payload');
|
||||
if (!response) throw new ApiError(1047);
|
||||
|
||||
const cachedOptions = OPTIONS_CACHE.get(webauthnChallengeId);
|
||||
if (!cachedOptions) return res.badRequest();
|
||||
if (!cachedOptions) throw new ApiError(1048);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -119,7 +131,7 @@ export default typedPlugin(
|
||||
request: response,
|
||||
});
|
||||
|
||||
return res.badRequest();
|
||||
throw new ApiError(1052);
|
||||
}
|
||||
|
||||
const passkey = user.passkeys.find((pk) => {
|
||||
@@ -128,12 +140,12 @@ export default typedPlugin(
|
||||
return webauthn.id === response.id;
|
||||
});
|
||||
|
||||
if (!passkey) return res.badRequest();
|
||||
if (!passkey) throw new ApiError(1052);
|
||||
const reg = passkey.reg as PasskeyReg;
|
||||
|
||||
if (!reg.webauthn) {
|
||||
logger.debug('invalid webauthn attempt, legacy passkey found...');
|
||||
return res.badRequest();
|
||||
throw new ApiError(1060);
|
||||
}
|
||||
|
||||
OPTIONS_CACHE.delete(webauthnChallengeId);
|
||||
@@ -154,14 +166,14 @@ export default typedPlugin(
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logger.warn('error verifying passkey authentication');
|
||||
return res.badRequest('Error verifying passkey authentication');
|
||||
throw new ApiError(1051);
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
logger.warn('failed passkey authentication attempt', {
|
||||
user: user.username,
|
||||
});
|
||||
return res.badRequest('Could not verify passkey authentication');
|
||||
throw new ApiError(1052);
|
||||
}
|
||||
|
||||
const { newCounter } = verification.authenticationInfo;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiHealthcheckResponse = {
|
||||
pass: boolean;
|
||||
@@ -12,17 +14,31 @@ const logger = log('api').c('healthcheck');
|
||||
export const PATH = '/api/healthcheck';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (_, res) => {
|
||||
if (!config.features.healthcheck) return res.notFound();
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Perform a simple healthcheck on the database and backend of Zipline. Returns a simple pass/fail response.',
|
||||
response: {
|
||||
200: z.object({
|
||||
pass: z.boolean().describe('true if the server and db are reachable and functioning.'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_, res) => {
|
||||
if (!config.features.healthcheck) throw new ApiError(9002);
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1;`;
|
||||
return res.send({ pass: true });
|
||||
} catch (e) {
|
||||
logger.error('there was an error during a healthcheck').error(e as Error);
|
||||
return res.internalServerError('there was an error during a healthcheck');
|
||||
}
|
||||
});
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1;`;
|
||||
return res.send({ pass: true });
|
||||
} catch (e) {
|
||||
logger.error('there was an error during a healthcheck').error(e as Error);
|
||||
throw new ApiError(6003);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clearTemp } from '@/lib/server-util/clearTemp';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerClearTempResponse = {
|
||||
status?: string;
|
||||
@@ -17,6 +18,16 @@ export default typedPlugin(
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Delete temporary files on the Zipline server and return a short status message (admin only).',
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clearZeros, clearZerosFiles } from '@/lib/server-util/clearZeros';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerClearZerosResponse = {
|
||||
status?: string;
|
||||
@@ -18,6 +19,21 @@ export default typedPlugin(
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Scan for zero-byte files on disk and return the list of candidates to delete (admin only).',
|
||||
response: {
|
||||
200: z.object({
|
||||
files: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (_, res) => {
|
||||
@@ -30,6 +46,16 @@ export default typedPlugin(
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Delete zero-byte files previously detected on disk and return a short status message (admin only).',
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Export4 } from '@/lib/import/version4/validateExport';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { Export4, export4Schema } from '@/lib/import/version4/validateExport';
|
||||
import { log } from '@/lib/logger';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { zQsBoolean } from '@/lib/validation';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { cpus, hostname, platform, release } from 'os';
|
||||
import z from 'zod';
|
||||
import { version } from '../../../../../package.json';
|
||||
|
||||
async function getCounts() {
|
||||
const exportCountsSchema = z.object({
|
||||
users: z.number(),
|
||||
files: z.number(),
|
||||
urls: z.number(),
|
||||
folders: z.number(),
|
||||
invites: z.number(),
|
||||
thumbnails: z.number(),
|
||||
metrics: z.number(),
|
||||
});
|
||||
|
||||
type ExportCounts = z.infer<typeof exportCountsSchema>;
|
||||
|
||||
async function getCounts(): Promise<ExportCounts> {
|
||||
const users = await prisma.user.count();
|
||||
const files = await prisma.file.count();
|
||||
const urls = await prisma.url.count();
|
||||
@@ -40,15 +54,23 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Export Zipline server data as a version 4 export bundle or return aggregate counts of core resources.',
|
||||
querystring: z.object({
|
||||
nometrics: z.string().optional(),
|
||||
counts: z.string().optional(),
|
||||
nometrics: zQsBoolean.optional(),
|
||||
counts: zQsBoolean.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.union([
|
||||
exportCountsSchema.describe('if ?counts=true'),
|
||||
export4Schema.describe('if ?counts is not true or not there'),
|
||||
]),
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.query.counts === 'true') {
|
||||
if (req.query.counts) {
|
||||
const counts = await getCounts();
|
||||
|
||||
return res.send(counts);
|
||||
@@ -57,10 +79,13 @@ export default typedPlugin(
|
||||
logger.debug('exporting server data', { format: '4', requester: req.user.username });
|
||||
|
||||
const settingsTable = await prisma.zipline.findFirst();
|
||||
if (!settingsTable)
|
||||
return res.badRequest(
|
||||
'Invalid setup, no settings found. Run the setup process again before exporting data.',
|
||||
);
|
||||
if (!settingsTable) throw new ApiError(1023);
|
||||
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
);
|
||||
|
||||
const export4: Export4 = {
|
||||
versions: {
|
||||
@@ -70,7 +95,7 @@ export default typedPlugin(
|
||||
},
|
||||
request: {
|
||||
date: new Date().toISOString(),
|
||||
env: process.env as Record<string, string>,
|
||||
env,
|
||||
user: `${req.user.id}:${req.user.username}`,
|
||||
os: {
|
||||
arch: process.arch,
|
||||
@@ -144,7 +169,7 @@ export default typedPlugin(
|
||||
id: passkey.id,
|
||||
lastUsed: passkey.lastUsed ? passkey.lastUsed.toISOString() : null,
|
||||
name: passkey.name,
|
||||
reg: passkey.reg as Record<string, unknown>,
|
||||
reg: passkey.reg as Record<string, any>,
|
||||
userId: passkey.userId,
|
||||
});
|
||||
}
|
||||
@@ -276,9 +301,9 @@ export default typedPlugin(
|
||||
}
|
||||
|
||||
return res
|
||||
.header('Content-Disposition', `attachment; filename*=utf-8''zipline4_export_${Date.now()}.json`)
|
||||
.header('Content-Disposition', `attachment; filename=zipline4_export_${Date.now()}.json`)
|
||||
.type('application/json')
|
||||
.send(export4);
|
||||
.send(export4 satisfies Export4);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { buildPublicParentChain, cleanFolder, Folder } from '@/lib/db/models/folder';
|
||||
import { buildPublicParentChain, cleanFolder, Folder, folderSchema } from '@/lib/db/models/folder';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -13,32 +14,24 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Fetch a folder by ID. Behavior varies based on public and allowUploads flags.',
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
querystring: z.object({
|
||||
uploads: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: folderSchema.partial(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { uploads } = req.query;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
where: { id },
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
tags: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: { ...fileSelect, password: true, tags: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
children: {
|
||||
where: { public: true },
|
||||
@@ -49,9 +42,7 @@ export default typedPlugin(
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
public: true,
|
||||
_count: {
|
||||
select: { children: true, files: true },
|
||||
},
|
||||
_count: { select: { children: true, files: true } },
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
@@ -60,12 +51,20 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) return res.notFound();
|
||||
if (!folder) throw new ApiError(9002);
|
||||
if (!folder.public && !folder.allowUploads) throw new ApiError(9002);
|
||||
|
||||
if ((uploads && !folder.allowUploads) || (!uploads && !folder.public)) return res.notFound();
|
||||
if (!folder.public && folder.allowUploads) {
|
||||
return res.send({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
allowUploads: folder.allowUploads,
|
||||
public: folder.public,
|
||||
});
|
||||
}
|
||||
|
||||
if (folder.parentId) {
|
||||
(folder as any).parent = await buildPublicParentChain(folder.parentId);
|
||||
folder.parent = await buildPublicParentChain(folder.parentId);
|
||||
}
|
||||
|
||||
return res.send(cleanFolder(folder, true));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { export3Schema } from '@/lib/import/version3/validateExport';
|
||||
@@ -8,13 +9,15 @@ import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerImportV3 = {
|
||||
users: Record<string, string>;
|
||||
files: Record<string, string>;
|
||||
folders: Record<string, string>;
|
||||
urls: Record<string, string>;
|
||||
settings: string[];
|
||||
};
|
||||
export type ApiServerImportV3 = z.infer<typeof serverImportSchema>;
|
||||
|
||||
const serverImportSchema = z.object({
|
||||
users: z.record(z.string(), z.string()),
|
||||
files: z.record(z.string(), z.string()),
|
||||
folders: z.record(z.string(), z.string()),
|
||||
urls: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const parseDate = (date: string) => (isNaN(Date.parse(date)) ? new Date() : new Date(date));
|
||||
|
||||
const logger = log('api').c('server').c('import').c('v3');
|
||||
@@ -26,10 +29,16 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Import data from a legacy Zipline v3 export file, creating users, files, folders and URLs and returning a mapping of old IDs to new IDs.',
|
||||
body: z.object({
|
||||
export3: export3Schema.required(),
|
||||
importFromUser: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: serverImportSchema,
|
||||
},
|
||||
tags: ['auth', 'superadmin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
// 24gb, just in case
|
||||
@@ -37,7 +46,7 @@ export default typedPlugin(
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
|
||||
if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015);
|
||||
|
||||
const { export3 } = req.body;
|
||||
|
||||
@@ -288,7 +297,7 @@ export default typedPlugin(
|
||||
files: filesImportedToId,
|
||||
folders: foldersImportedToId,
|
||||
urls: urlsImportedToId,
|
||||
});
|
||||
} satisfies ApiServerImportV3);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { createToken } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { export4Schema } from '@/lib/import/version4/validateExport';
|
||||
@@ -8,20 +9,22 @@ import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerImportV4 = {
|
||||
imported: {
|
||||
users: number;
|
||||
oauthProviders: number;
|
||||
quotas: number;
|
||||
passkeys: number;
|
||||
folders: number;
|
||||
files: number;
|
||||
tags: number;
|
||||
urls: number;
|
||||
invites: number;
|
||||
metrics: number;
|
||||
};
|
||||
};
|
||||
export type ApiServerImportV4 = z.infer<typeof serverImportSchema>;
|
||||
|
||||
const serverImportSchema = z.object({
|
||||
imported: z.object({
|
||||
users: z.number(),
|
||||
oauthProviders: z.number(),
|
||||
quotas: z.number(),
|
||||
passkeys: z.number(),
|
||||
folders: z.number(),
|
||||
files: z.number(),
|
||||
tags: z.number(),
|
||||
urls: z.number(),
|
||||
invites: z.number(),
|
||||
metrics: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
const logger = log('api').c('server').c('import').c('v4');
|
||||
|
||||
@@ -32,6 +35,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Import data from a Zipline v4 export file, optionally merging into the current user and returning counts of imported records.',
|
||||
body: z.object({
|
||||
export4: export4Schema.required(),
|
||||
config: z.object({
|
||||
@@ -39,6 +44,10 @@ export default typedPlugin(
|
||||
mergeCurrentUser: z.string().nullish().default(null),
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
200: serverImportSchema,
|
||||
},
|
||||
tags: ['auth', 'superadmin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
// 24gb, just in case
|
||||
@@ -46,7 +55,7 @@ export default typedPlugin(
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
|
||||
if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015);
|
||||
|
||||
const { export4, config: importConfig } = req.body;
|
||||
|
||||
|
||||
@@ -1,99 +1,116 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { schema as configSchema } from '@/lib/config/validate';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { isTruthy } from '@/lib/primitive';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerPublicResponse = {
|
||||
oauth: {
|
||||
bypassLocalLogin: boolean;
|
||||
loginOnly: boolean;
|
||||
};
|
||||
oauthEnabled: {
|
||||
discord: boolean;
|
||||
github: boolean;
|
||||
google: boolean;
|
||||
oidc: boolean;
|
||||
};
|
||||
website: {
|
||||
loginBackground?: string | null;
|
||||
loginBackgroundBlur?: boolean;
|
||||
title?: string;
|
||||
tos: boolean;
|
||||
};
|
||||
features: {
|
||||
oauthRegistration: boolean;
|
||||
userRegistration: boolean;
|
||||
metrics?: {
|
||||
adminOnly?: boolean;
|
||||
};
|
||||
};
|
||||
mfa: {
|
||||
passkeys: boolean;
|
||||
};
|
||||
tos?: string | null;
|
||||
files: {
|
||||
maxFileSize: string;
|
||||
defaultFormat: Config['files']['defaultFormat'];
|
||||
maxExpiration?: string | null;
|
||||
};
|
||||
chunks: Config['chunks'];
|
||||
firstSetup: boolean;
|
||||
domains?: string[];
|
||||
returnHttps: boolean;
|
||||
};
|
||||
export type ApiServerPublicResponse = z.infer<typeof publicConfigSchema>;
|
||||
|
||||
const publicConfigSchema = z.object({
|
||||
oauth: z.object({
|
||||
bypassLocalLogin: z.boolean(),
|
||||
loginOnly: z.boolean(),
|
||||
}),
|
||||
oauthEnabled: z.object({
|
||||
discord: z.boolean(),
|
||||
github: z.boolean(),
|
||||
google: z.boolean(),
|
||||
oidc: z.boolean(),
|
||||
}),
|
||||
website: z.object({
|
||||
loginBackground: z.string().nullable().optional(),
|
||||
loginBackgroundBlur: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
tos: z.boolean(),
|
||||
}),
|
||||
features: z.object({
|
||||
oauthRegistration: z.boolean(),
|
||||
userRegistration: z.boolean(),
|
||||
metrics: z
|
||||
.object({
|
||||
adminOnly: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
mfa: z.object({
|
||||
passkeys: z.boolean(),
|
||||
}),
|
||||
tos: z.string().nullable().optional(),
|
||||
files: z.object({
|
||||
maxFileSize: z.string(),
|
||||
defaultFormat: configSchema.shape.files.shape.defaultFormat,
|
||||
maxExpiration: z.string().nullable().optional(),
|
||||
}),
|
||||
chunks: configSchema.shape.chunks,
|
||||
firstSetup: z.boolean(),
|
||||
domains: z.array(z.string()).optional(),
|
||||
returnHttps: z.boolean(),
|
||||
});
|
||||
|
||||
export const PATH = '/api/server/public';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get<{ Body: Body }>(PATH, async (_, res) => {
|
||||
const zipline = await getZipline();
|
||||
server.get<{ Body: Body }>(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Return the public Zipline configuration used by the client, including OAuth, website, feature, file and chunk settings.',
|
||||
response: {
|
||||
200: publicConfigSchema.describe('the public configuration for the Zipline instance'),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_, res) => {
|
||||
const zipline = await getZipline();
|
||||
|
||||
const response: ApiServerPublicResponse = {
|
||||
oauth: {
|
||||
bypassLocalLogin: config.oauth.bypassLocalLogin,
|
||||
loginOnly: config.oauth.loginOnly,
|
||||
},
|
||||
oauthEnabled: enabled(config),
|
||||
website: {
|
||||
loginBackground: config.website.loginBackground,
|
||||
loginBackgroundBlur: config.website.loginBackgroundBlur,
|
||||
title: config.website.title,
|
||||
tos: config.website.tos !== undefined,
|
||||
},
|
||||
features: {
|
||||
oauthRegistration: config.features.oauthRegistration,
|
||||
userRegistration: config.features.userRegistration,
|
||||
},
|
||||
mfa: {
|
||||
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,
|
||||
domains: config.domains,
|
||||
returnHttps: config.core.returnHttpsUrls,
|
||||
};
|
||||
const response: ApiServerPublicResponse = {
|
||||
oauth: {
|
||||
bypassLocalLogin: config.oauth.bypassLocalLogin,
|
||||
loginOnly: config.oauth.loginOnly,
|
||||
},
|
||||
oauthEnabled: enabled(config),
|
||||
website: {
|
||||
loginBackground: config.website.loginBackground,
|
||||
loginBackgroundBlur: config.website.loginBackgroundBlur,
|
||||
title: config.website.title,
|
||||
tos: config.website.tos !== undefined,
|
||||
},
|
||||
features: {
|
||||
oauthRegistration: config.features.oauthRegistration,
|
||||
userRegistration: config.features.userRegistration,
|
||||
},
|
||||
mfa: {
|
||||
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,
|
||||
domains: config.domains,
|
||||
returnHttps: config.core.returnHttpsUrls,
|
||||
};
|
||||
|
||||
if (config.features.metrics.adminOnly) {
|
||||
response.features.metrics = { adminOnly: true };
|
||||
}
|
||||
if (config.features.metrics.adminOnly) {
|
||||
response.features.metrics = { adminOnly: true };
|
||||
}
|
||||
|
||||
if (config.website.tos) {
|
||||
response.tos = global.__cachedConfigValues__.tos!;
|
||||
}
|
||||
if (config.website.tos) {
|
||||
response.tos = global.__cachedConfigValues__.tos!;
|
||||
}
|
||||
|
||||
return res.send(response);
|
||||
});
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -19,10 +19,18 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Re-scan stored files to update their sizes and optionally delete missing ones, returning a short status message (admin only).',
|
||||
body: z.object({
|
||||
forceDelete: z.boolean().default(false),
|
||||
forceUpdate: z.boolean().default(false),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { checkOutput, COMPRESS_TYPES } from '@/lib/compress';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
@@ -8,6 +9,7 @@ import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
@@ -47,8 +49,11 @@ const jsonTransform = (value: any, ctx: z.RefinementCtx) => {
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
const zMs = zStringTrimmed.refine(
|
||||
(value) => ms((value ?? '0') as StringValue) > 0,
|
||||
'Value must be greater than 0',
|
||||
);
|
||||
const zBytes = zStringTrimmed.refine((value) => bytes(value) > 0, 'Value must be greater than 0');
|
||||
|
||||
const zIntervalMs = zMs.refine(
|
||||
(value) => ms(value as StringValue) <= MAX_SAFE_TIMEOUT_MS,
|
||||
@@ -89,6 +94,17 @@ export default typedPlugin(
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Fetch the full Zipline server settings row along with a list of configuration keys that were overridden at runtime (admin only).',
|
||||
response: {
|
||||
200: z.object({
|
||||
settings: z.custom<Settings>(),
|
||||
tampered: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (_, res) => {
|
||||
@@ -101,7 +117,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings) return res.notFound('no settings table found');
|
||||
if (!settings) throw new ApiError(4010);
|
||||
|
||||
return res.send({ settings, tampered: global.__tamperedConfig__ || [] });
|
||||
},
|
||||
@@ -111,14 +127,20 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Partially update Zipline server settings using a validated subset of configuration keys (admin only).',
|
||||
body: z.custom<Partial<Settings>>(),
|
||||
response: {
|
||||
200: z.custom<ApiServerSettingsResponse>(),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
async (req, res) => {
|
||||
const settings = await prisma.zipline.findFirst();
|
||||
if (!settings) return res.notFound('no settings table found');
|
||||
if (!settings) throw new ApiError(4010);
|
||||
|
||||
const themes = (await readThemes()).map((x) => x.id);
|
||||
|
||||
@@ -178,6 +200,7 @@ export default typedPlugin(
|
||||
filesDefaultCompressionFormat: z
|
||||
.enum(COMPRESS_TYPES)
|
||||
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
|
||||
filesMaxFilesPerUpload: z.number().min(1).max(2147483647),
|
||||
|
||||
urlsRoute: z
|
||||
.string()
|
||||
@@ -459,10 +482,7 @@ export default typedPlugin(
|
||||
issues: result.error.issues,
|
||||
});
|
||||
|
||||
return res.status(400).send({
|
||||
statusCode: 400,
|
||||
issues: result.error.issues,
|
||||
});
|
||||
throw new ApiError(1022).add('issues', result.error.issues);
|
||||
}
|
||||
|
||||
const newSettings = await prisma.zipline.update({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerSettingsWebResponse = {
|
||||
config: ReturnType<typeof safeConfig>;
|
||||
@@ -19,24 +20,36 @@ let codeMap: ApiServerSettingsWebResponse['codeMap'] = [];
|
||||
export const PATH = '/api/server/settings/web';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (_, res) => {
|
||||
const webConfig = safeConfig(config);
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Return the safe dashboard configuration and MIME type code map used by the web UI.',
|
||||
response: {
|
||||
200: z.custom<ApiServerSettingsWebResponse>(),
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (_, res) => {
|
||||
const webConfig = safeConfig(config);
|
||||
|
||||
if (codeMap.length === 0) {
|
||||
try {
|
||||
const codeJson = await readFile(codeJsonPath, 'utf8');
|
||||
codeMap = JSON.parse(codeJson);
|
||||
} catch (error) {
|
||||
logger.error('failed to read code.json', { error });
|
||||
codeMap = [];
|
||||
if (codeMap.length === 0) {
|
||||
try {
|
||||
const codeJson = await readFile(codeJsonPath, 'utf8');
|
||||
codeMap = JSON.parse(codeJson);
|
||||
} catch (error) {
|
||||
logger.error('failed to read code.json', { error });
|
||||
codeMap = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.send({
|
||||
config: webConfig,
|
||||
codeMap: codeMap,
|
||||
} satisfies ApiServerSettingsWebResponse);
|
||||
});
|
||||
return res.send({
|
||||
config: webConfig,
|
||||
codeMap: codeMap,
|
||||
} satisfies ApiServerSettingsWebResponse);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Config } from '@/lib/config/validate';
|
||||
import { ZiplineTheme } from '@/lib/theme';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiServerThemesResponse = {
|
||||
themes: ZiplineTheme[];
|
||||
@@ -12,11 +13,26 @@ export type ApiServerThemesResponse = {
|
||||
export const PATH = '/api/server/themes';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (_, res) => {
|
||||
const themes = await readThemes();
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'List all available themes and indicate which theme is currently configured as the default.',
|
||||
response: {
|
||||
200: z.object({
|
||||
themes: z.array(z.custom<ZiplineTheme>()),
|
||||
defaultTheme: z.custom<Config['website']['theme']>(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_, res) => {
|
||||
const themes = await readThemes();
|
||||
|
||||
return res.send({ themes, defaultTheme: config.website.theme });
|
||||
});
|
||||
return res.send({ themes, defaultTheme: config.website.theme });
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
@@ -18,16 +19,24 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Manually trigger the thumbnails background task, optionally rerunning it for existing files (admin only).',
|
||||
body: z.object({
|
||||
rerun: z.boolean().default(false),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.string(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
async (req, res) => {
|
||||
const thumbnailTask = server.tasks.tasks.find((x) => x.id === 'thumbnails');
|
||||
if (!thumbnailTask) return res.notFound('thumbnails task not found');
|
||||
if (!thumbnailTask) throw new ApiError(4011);
|
||||
|
||||
thumbnailTask.logger.debug('manually running thumbnails task');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { createToken, hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
@@ -18,28 +19,48 @@ const logger = log('api').c('setup');
|
||||
export const PATH = '/api/setup';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, async (_, res) => {
|
||||
const { firstSetup } = await getZipline();
|
||||
if (!firstSetup) return res.forbidden();
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Return whether Zipline is in first-time setup mode, used by the initial setup flow.',
|
||||
response: {
|
||||
200: z.object({
|
||||
firstSetup: z.boolean(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_, res) => {
|
||||
const { firstSetup } = await getZipline();
|
||||
if (!firstSetup) throw new ApiError(9001);
|
||||
|
||||
return res.send({ firstSetup });
|
||||
});
|
||||
return res.send({ firstSetup });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Perform the first-time setup by creating the initial SUPERADMIN user.',
|
||||
body: z.object({
|
||||
username: zStringTrimmed,
|
||||
password: zStringTrimmed,
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
firstSetup: z.boolean(),
|
||||
user: userSchema,
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
async (req, res) => {
|
||||
const { firstSetup, id } = await getZipline();
|
||||
|
||||
if (!firstSetup) return res.forbidden();
|
||||
if (!firstSetup) throw new ApiError(9001);
|
||||
|
||||
logger.info('first setup running');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { Metric, metricSchema } from '@/lib/db/models/metric';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { zQsBoolean } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
@@ -16,6 +17,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Get instance-wide metrics and statistics for Zipline over a given date range or for all time.',
|
||||
querystring: z.object({
|
||||
from: z
|
||||
.string()
|
||||
@@ -35,14 +38,17 @@ export default typedPlugin(
|
||||
}, 'Invalid date'),
|
||||
all: zQsBoolean.default(false),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(metricSchema),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!config.features.metrics) return res.forbidden('metrics are disabled');
|
||||
if (!config.features.metrics) throw new ApiError(3001);
|
||||
|
||||
if (config.features.metrics.adminOnly && !isAdministrator(req.user.role))
|
||||
return res.forbidden('admin only');
|
||||
if (config.features.metrics.adminOnly && !isAdministrator(req.user.role)) throw new ApiError(3000);
|
||||
|
||||
const { from, to, all } = req.query;
|
||||
|
||||
@@ -50,8 +56,8 @@ export default typedPlugin(
|
||||
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');
|
||||
if (fromDate > toDate) throw new ApiError(1058);
|
||||
if (fromDate > new Date()) throw new ApiError(1059);
|
||||
}
|
||||
|
||||
const stats = await prisma.metric.findMany({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { checkQuota, getDomain, getExtension, getFilename, getMimetype } from '@/lib/api/upload';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { compressFile, CompressResult } from '@/lib/compress';
|
||||
import { COMPRESS_TYPES, compressFile, CompressResult } from '@/lib/compress';
|
||||
import { config } from '@/lib/config';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
@@ -15,6 +16,7 @@ import { Prisma } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { stat } from 'fs/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ApiUploadResponse = {
|
||||
files: {
|
||||
@@ -42,166 +44,216 @@ export default typedPlugin(
|
||||
|
||||
server.post<{
|
||||
Headers: UploadHeaders;
|
||||
}>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => {
|
||||
const options = parseHeaders(req.headers, config.files);
|
||||
if (options.header) return res.badRequest(`bad options: ${options.message}`);
|
||||
|
||||
if (options.partial) return res.badRequest('bad options, receieved: partial upload');
|
||||
|
||||
let folder = null;
|
||||
if (options.folder) {
|
||||
folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: options.folder,
|
||||
}>(
|
||||
PATH,
|
||||
{
|
||||
preHandler: [userMiddleware, rateLimit],
|
||||
schema: {
|
||||
description:
|
||||
'Upload one or more files for the authenticated user, applying quota, folder, and upload option restrictions.',
|
||||
consumes: ['multipart/form-data'],
|
||||
response: {
|
||||
200: z.union([
|
||||
z.string().describe('if the noJson option is true, returns a comma-separated list of URLs'),
|
||||
z.object({
|
||||
files: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
pending: z.boolean().optional(),
|
||||
removedGps: z.boolean().optional(),
|
||||
compressed: z
|
||||
.object({
|
||||
mimetype: z.string(),
|
||||
ext: z.enum(COMPRESS_TYPES),
|
||||
failed: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
deletesAt: z.string().optional(),
|
||||
assumedMimetypes: z.array(z.boolean()).optional(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
if (!folder) return res.badRequest('folder not found');
|
||||
if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open');
|
||||
}
|
||||
tags: ['auth'],
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const options = parseHeaders(req.headers, config.files);
|
||||
if (options.header) throw new ApiError(1001, `bad options: ${options.message}`);
|
||||
|
||||
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
|
||||
if (options.partial) throw new ApiError(1001, 'bad options, receieved: partial upload');
|
||||
|
||||
const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0);
|
||||
const quotaCheck = await checkQuota(req.user, totalFileSize, files.length);
|
||||
if (quotaCheck !== true) return res.payloadTooLarge(quotaCheck);
|
||||
|
||||
const response: ApiUploadResponse = {
|
||||
files: [],
|
||||
...(options.deletesAt && {
|
||||
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
|
||||
}),
|
||||
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
|
||||
};
|
||||
|
||||
const domain = getDomain(options.overrides?.returnDomain, config.core.defaultDomain, req.headers.host);
|
||||
|
||||
logger.debug('uploading files', { files: files.map((x) => x.filename) });
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
const extension = getExtension(file.filename, options.overrides?.extension);
|
||||
|
||||
if (config.files.disabledExtensions.includes(extension))
|
||||
return res.badRequest(`file[${i}]: File extension ${extension} is not allowed`);
|
||||
if (file.file.bytesRead > bytes(config.files.maxFileSize))
|
||||
return res.payloadTooLarge(
|
||||
`file[${i}]: File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`,
|
||||
);
|
||||
|
||||
// determine filename
|
||||
const format = options.format || config.files.defaultFormat;
|
||||
const nameResult = await getFilename(format, file.filename, extension, options.overrides?.filename);
|
||||
if ('error' in nameResult) return res.badRequest(`file[${i}]: ${nameResult.error}`);
|
||||
|
||||
const { fileName } = nameResult;
|
||||
|
||||
// determine mimetype
|
||||
const { mimetype, assumed } = await getMimetype(file.mimetype, extension);
|
||||
if (!assumed && config.files.assumeMimetypes) {
|
||||
logger.warn(
|
||||
`file[${i}]: mimetype ${file.mimetype} was not recognized, to ignore this warning, turn off assume mimetypes.`,
|
||||
);
|
||||
|
||||
return res.badRequest(
|
||||
`file[${i}]: mimetype ${file.mimetype} was not recognized, supply a valid mimetype`,
|
||||
);
|
||||
}
|
||||
|
||||
// compress the image if requested
|
||||
let compressed;
|
||||
if (mimetype.startsWith('image/') && options.imageCompression) {
|
||||
compressed = await compressFile(file.filepath, {
|
||||
quality: options.imageCompression.percent,
|
||||
type: options.imageCompression.type,
|
||||
let folder = null;
|
||||
if (options.folder) {
|
||||
folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: options.folder,
|
||||
},
|
||||
});
|
||||
|
||||
if (compressed.failed) {
|
||||
compressed = undefined;
|
||||
logger.warn('failed to compress file, using original.');
|
||||
} else {
|
||||
logger.c('compress').debug(`compressed file ${file.filename}`);
|
||||
}
|
||||
if (!folder) throw new ApiError(4001);
|
||||
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
|
||||
}
|
||||
|
||||
// remove gps metadata if requested
|
||||
let removedGps = false;
|
||||
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
|
||||
const removed = removeGps(file.filepath);
|
||||
if (removed) logger.c('gps').debug(`removed gps metadata from ${file.filename}`);
|
||||
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
|
||||
|
||||
removedGps = removed;
|
||||
}
|
||||
const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0);
|
||||
const quotaCheck = await checkQuota(req.user, totalFileSize, files.length);
|
||||
if (quotaCheck !== true)
|
||||
throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined);
|
||||
|
||||
const tempFileStats = await stat(file.filepath);
|
||||
|
||||
const data: Prisma.FileCreateInput = {
|
||||
name: `${fileName}${compressed ? '.' + compressed.ext : extension}`,
|
||||
size: compressed?.buffer?.length ?? tempFileStats.size,
|
||||
type: compressed?.mimetype ?? mimetype,
|
||||
User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } },
|
||||
const response: ApiUploadResponse = {
|
||||
files: [],
|
||||
...(options.deletesAt && {
|
||||
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
|
||||
}),
|
||||
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
|
||||
};
|
||||
|
||||
if (options.maxViews) data.maxViews = options.maxViews;
|
||||
if (options.password) data.password = await hashPassword(options.password);
|
||||
if (folder) data.Folder = { connect: { id: folder.id } };
|
||||
if (options.addOriginalName) {
|
||||
const sanitizedOG = sanitizeFilename(file.filename);
|
||||
if (!sanitizedOG) return res.badRequest(`file[${i}]: Invalid characters in original filename`);
|
||||
|
||||
data.originalName = sanitizedOG;
|
||||
}
|
||||
|
||||
data.deletesAt = options.deletesAt && options.deletesAt !== 'never' ? options.deletesAt : null;
|
||||
|
||||
const fileUpload = await prisma.file.create({
|
||||
data,
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
await datasource.put(fileUpload.name, compressed?.buffer ?? file.filepath, {
|
||||
mimetype: fileUpload.type,
|
||||
});
|
||||
|
||||
const responseUrl = `${domain}${config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`}/${fileUpload.name}`;
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
name: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
url: encodeURI(responseUrl),
|
||||
removedGps: removedGps || undefined,
|
||||
compressed: compressed || undefined,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`,
|
||||
{ size: bytes(compressed?.buffer?.length ?? fileUpload.size), ip: req.ip },
|
||||
const domain = getDomain(
|
||||
options.overrides?.returnDomain,
|
||||
config.core.defaultDomain,
|
||||
req.headers.host,
|
||||
);
|
||||
|
||||
await onUpload(config, {
|
||||
user: req.user ?? {
|
||||
id: 'anonymous',
|
||||
username: 'anonymous',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: 'USER',
|
||||
},
|
||||
file: fileUpload,
|
||||
link: {
|
||||
raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`,
|
||||
returned: encodeURI(responseUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.debug('uploading files', { files: files.map((x) => x.filename) });
|
||||
|
||||
if (options.noJson)
|
||||
return res
|
||||
.status(200)
|
||||
.type('text/plain')
|
||||
.send(response.files.map((x) => x.url).join(','));
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
const extension = getExtension(file.filename, options.overrides?.extension);
|
||||
|
||||
return res.send(response);
|
||||
});
|
||||
if (config.files.disabledExtensions.includes(extension))
|
||||
throw new ApiError(1006, `file[${i}]: File extension ${extension} is not allowed`);
|
||||
if (file.file.bytesRead > bytes(config.files.maxFileSize))
|
||||
throw new ApiError(
|
||||
5001,
|
||||
`file[${i}]: File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`,
|
||||
);
|
||||
|
||||
// determine filename
|
||||
const format = options.format || config.files.defaultFormat;
|
||||
const nameResult = await getFilename(format, file.filename, extension, options.overrides?.filename);
|
||||
if ('error' in nameResult) throw new ApiError(1009, `file[${i}]: ${nameResult.error}`);
|
||||
|
||||
const { fileName } = nameResult;
|
||||
|
||||
// determine mimetype
|
||||
const { mimetype, assumed } = await getMimetype(file.mimetype, extension);
|
||||
|
||||
if (config.files.assumeMimetypes) {
|
||||
response.assumedMimetypes![i] = assumed;
|
||||
|
||||
if (!assumed) {
|
||||
logger.warn(`file[${i}]: mimetype ${file.mimetype} was not recognized`);
|
||||
|
||||
throw new ApiError(
|
||||
1010,
|
||||
`file[${i}]: mimetype ${file.mimetype} was not recognized, supply a valid mimetype`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// compress the image if requested
|
||||
let compressed;
|
||||
if (mimetype.startsWith('image/') && options.imageCompression) {
|
||||
compressed = await compressFile(file.filepath, {
|
||||
quality: options.imageCompression.percent,
|
||||
type: options.imageCompression.type,
|
||||
});
|
||||
|
||||
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
|
||||
let removedGps = false;
|
||||
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
|
||||
const removed = removeGps(file.filepath);
|
||||
if (removed) logger.c('gps').debug(`removed gps metadata from ${file.filename}`);
|
||||
|
||||
removedGps = removed;
|
||||
}
|
||||
|
||||
const tempFileStats = await stat(file.filepath);
|
||||
|
||||
const data: Prisma.FileCreateInput = {
|
||||
name: `${fileName}${compressed ? '.' + compressed.ext : extension}`,
|
||||
size: compressed?.buffer?.length ?? tempFileStats.size,
|
||||
type: compressed?.mimetype ?? mimetype,
|
||||
User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } },
|
||||
};
|
||||
|
||||
if (!req.user && folder) data.anonymous = true;
|
||||
|
||||
if (options.maxViews) data.maxViews = options.maxViews;
|
||||
if (options.password) data.password = await hashPassword(options.password);
|
||||
if (folder) data.Folder = { connect: { id: folder.id } };
|
||||
if (options.addOriginalName) {
|
||||
const sanitizedOG = sanitizeFilename(file.filename);
|
||||
if (!sanitizedOG) throw new ApiError(1008, `file[${i}]: Invalid characters in original filename`);
|
||||
|
||||
data.originalName = sanitizedOG;
|
||||
}
|
||||
|
||||
data.deletesAt = options.deletesAt && options.deletesAt !== 'never' ? options.deletesAt : null;
|
||||
|
||||
const fileUpload = await prisma.file.create({
|
||||
data,
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
await datasource.put(fileUpload.name, compressed?.buffer ?? file.filepath, {
|
||||
mimetype: fileUpload.type,
|
||||
});
|
||||
|
||||
const responseUrl = `${domain}${config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`}/${fileUpload.name}`;
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
name: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
url: encodeURI(responseUrl),
|
||||
removedGps: removedGps || undefined,
|
||||
compressed: compressed || undefined,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`,
|
||||
{ size: bytes(compressed?.buffer?.length ?? fileUpload.size), ip: req.ip },
|
||||
);
|
||||
|
||||
await onUpload(config, {
|
||||
user: req.user ?? {
|
||||
id: 'anonymous',
|
||||
username: 'anonymous',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: 'USER',
|
||||
},
|
||||
file: fileUpload,
|
||||
link: {
|
||||
raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`,
|
||||
returned: encodeURI(responseUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.noJson)
|
||||
return res
|
||||
.status(200)
|
||||
.type('text/plain')
|
||||
.send(response.files.map((x) => x.url).join(','));
|
||||
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { checkQuota, getDomain, getExtension, getFilename } from '@/lib/api/upload';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { config } from '@/lib/config';
|
||||
@@ -11,6 +12,7 @@ import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parse
|
||||
import { Prisma } from '@/prisma/client';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { z } from 'zod';
|
||||
import { readdir, rename, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Worker } from 'worker_threads';
|
||||
@@ -57,204 +59,222 @@ export default typedPlugin(
|
||||
|
||||
server.post<{
|
||||
Headers: UploadHeaders;
|
||||
}>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => {
|
||||
const options = parseHeaders(req.headers, config.files);
|
||||
if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options));
|
||||
if (!options.partial) return res.badRequest('partial upload was not detected');
|
||||
if (!options.partial.range || options.partial.range.length !== 3)
|
||||
return res.badRequest('Invalid partial upload');
|
||||
|
||||
let folder = null;
|
||||
if (options.folder) {
|
||||
folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: options.folder,
|
||||
}>(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Upload a single file in chunks as a partial upload session, using headers to control chunking and resumption.',
|
||||
response: {
|
||||
200: z.custom<ApiUploadPartialResponse>(),
|
||||
},
|
||||
});
|
||||
if (!folder) return res.badRequest('folder not found');
|
||||
if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open');
|
||||
}
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, rateLimit],
|
||||
},
|
||||
async (req, res) => {
|
||||
const options = parseHeaders(req.headers, config.files);
|
||||
if (options.header) throw new ApiError(1001, 'bad options, receieved: ' + JSON.stringify(options));
|
||||
if (!options.partial) throw new ApiError(1004);
|
||||
if (!options.partial.range || options.partial.range.length !== 3) throw new ApiError(1002);
|
||||
|
||||
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
|
||||
|
||||
const response: ApiUploadPartialResponse = {
|
||||
files: [],
|
||||
...(options.deletesAt && {
|
||||
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
|
||||
}),
|
||||
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
|
||||
};
|
||||
|
||||
const domain = getDomain(options.overrides?.returnDomain, config.core.defaultDomain, req.headers.host);
|
||||
|
||||
logger.debug('saving partial files', { partial: options.partial, files: files.map((x) => x.filename) });
|
||||
|
||||
if (files.length > 1) return res.badRequest('partial uploads only support one file field');
|
||||
const file = files[0];
|
||||
const fileSize = file.file.bytesRead;
|
||||
|
||||
// caching for partial uploads server side checks and performance
|
||||
if (options.partial.range[0] === 0) {
|
||||
options.partial.identifier = createPartial(fileSize, options);
|
||||
} else {
|
||||
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
|
||||
return res.badRequest('No/Invalid partial upload identifier provided');
|
||||
}
|
||||
|
||||
const cache = partialsCache.get(options.partial.identifier);
|
||||
if (!cache) return res.badRequest('No/Invalid partial upload identifier provided');
|
||||
|
||||
// check quota, using the current added length, and only just adding one file
|
||||
const quotaCheck = await checkQuota(req.user, cache.length + fileSize, 1);
|
||||
if (quotaCheck !== true) {
|
||||
await deletePartial(options.partial.identifier);
|
||||
|
||||
return res.payloadTooLarge(quotaCheck);
|
||||
}
|
||||
|
||||
// file is too large so we delete everything
|
||||
if (cache.length + fileSize > bytes(config.files.maxFileSize)) {
|
||||
await deletePartial(options.partial.identifier!);
|
||||
|
||||
return res.payloadTooLarge('File is too large');
|
||||
}
|
||||
|
||||
cache.length += fileSize;
|
||||
|
||||
// handle partial stuff
|
||||
const sanitized = sanitizeFilename(
|
||||
`${cache.prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
|
||||
);
|
||||
if (!sanitized) return res.badRequest('Invalid characters in filename');
|
||||
|
||||
const tempFile = join(config.core.tempDirectory, sanitized);
|
||||
await rename(file.filepath, tempFile);
|
||||
|
||||
if (options.partial.lastchunk) {
|
||||
const extension = getExtension(options.partial.filename, options.overrides?.extension);
|
||||
if (config.files.disabledExtensions.includes(extension))
|
||||
return res.badRequest(`File extension ${extension} is not allowed`);
|
||||
|
||||
// determine filename
|
||||
const format = options.format || config.files.defaultFormat;
|
||||
const nameResult = await getFilename(
|
||||
format,
|
||||
options.partial.filename,
|
||||
extension,
|
||||
options.overrides?.filename,
|
||||
);
|
||||
if ('error' in nameResult) return res.badRequest(nameResult.error);
|
||||
|
||||
const { fileName } = nameResult;
|
||||
|
||||
// determine mimetype
|
||||
let mimetype = options.partial.contentType;
|
||||
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
|
||||
const mime = await guess(extension.substring(1));
|
||||
|
||||
if (!mime) response.assumedMimetypes![0] = false;
|
||||
else {
|
||||
response.assumedMimetypes![0] = true;
|
||||
mimetype = mime;
|
||||
}
|
||||
let folder = null;
|
||||
if (options.folder) {
|
||||
folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: options.folder,
|
||||
},
|
||||
});
|
||||
if (!folder) throw new ApiError(4001);
|
||||
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
|
||||
}
|
||||
|
||||
const data: Prisma.FileCreateInput = {
|
||||
name: `${fileName}${extension}`,
|
||||
size: 0,
|
||||
type: mimetype,
|
||||
User: {
|
||||
connect: {
|
||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
||||
},
|
||||
},
|
||||
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
|
||||
|
||||
const response: ApiUploadPartialResponse = {
|
||||
files: [],
|
||||
...(options.deletesAt && {
|
||||
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
|
||||
}),
|
||||
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
|
||||
};
|
||||
|
||||
if (options.password) data.password = await hashPassword(options.password);
|
||||
if (options.maxViews) data.maxViews = options.maxViews;
|
||||
if (folder) data.Folder = { connect: { id: folder.id } };
|
||||
if (options.addOriginalName) {
|
||||
const sanitizedOG = sanitizeFilename(options.partial.filename);
|
||||
if (!sanitizedOG) return res.badRequest('Invalid characters in original filename');
|
||||
const domain = getDomain(
|
||||
options.overrides?.returnDomain,
|
||||
config.core.defaultDomain,
|
||||
req.headers.host,
|
||||
);
|
||||
|
||||
data.originalName = sanitizedOG || file.filename; // this will prolly be "blob" but should hopefully never happen
|
||||
logger.debug('saving partial files', {
|
||||
partial: options.partial,
|
||||
files: files.map((x) => x.filename),
|
||||
});
|
||||
|
||||
if (files.length > 1) throw new ApiError(1005);
|
||||
const file = files[0];
|
||||
const fileSize = file.file.bytesRead;
|
||||
|
||||
// caching for partial uploads server side checks and performance
|
||||
if (options.partial.range[0] === 0) {
|
||||
options.partial.identifier = createPartial(fileSize, options);
|
||||
} else {
|
||||
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
|
||||
throw new ApiError(1003);
|
||||
}
|
||||
|
||||
const fileUpload = await prisma.file.create({
|
||||
data,
|
||||
});
|
||||
const cache = partialsCache.get(options.partial.identifier);
|
||||
if (!cache) throw new ApiError(1003);
|
||||
|
||||
const responseUrl = `${domain}${
|
||||
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
|
||||
}/${fileUpload.name}`;
|
||||
// check quota, using the current added length, and only just adding one file
|
||||
const quotaCheck = await checkQuota(req.user, cache.length + fileSize, 1);
|
||||
if (quotaCheck !== true) {
|
||||
await deletePartial(options.partial.identifier);
|
||||
throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined);
|
||||
}
|
||||
|
||||
const worker = new Worker('./build/offload/partial.js', {
|
||||
workerData: {
|
||||
user: {
|
||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
||||
},
|
||||
file: {
|
||||
id: fileUpload.id,
|
||||
filename: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
},
|
||||
options,
|
||||
domain,
|
||||
responseUrl,
|
||||
config,
|
||||
},
|
||||
});
|
||||
// file is too large so we delete everything
|
||||
if (cache.length + fileSize > bytes(config.files.maxFileSize)) {
|
||||
await deletePartial(options.partial.identifier!);
|
||||
throw new ApiError(5001);
|
||||
}
|
||||
|
||||
worker.on('message', async (msg) => {
|
||||
if (msg.type === 'query') {
|
||||
let result;
|
||||
cache.length += fileSize;
|
||||
|
||||
switch (msg.query) {
|
||||
case 'incompleteFile.create':
|
||||
result = await prisma.incompleteFile.create(msg.data);
|
||||
break;
|
||||
case 'incompleteFile.update':
|
||||
result = await prisma.incompleteFile.update(msg.data);
|
||||
break;
|
||||
case 'file.update':
|
||||
result = await prisma.file.update(msg.data);
|
||||
break;
|
||||
case 'user.findUnique':
|
||||
result = await prisma.user.findUnique(msg.data);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown query type: ${msg.query}`);
|
||||
result = null;
|
||||
// handle partial stuff
|
||||
const sanitized = sanitizeFilename(
|
||||
`${cache.prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
|
||||
);
|
||||
if (!sanitized) throw new ApiError(1007);
|
||||
|
||||
const tempFile = join(config.core.tempDirectory, sanitized);
|
||||
await rename(file.filepath, tempFile);
|
||||
|
||||
if (options.partial.lastchunk) {
|
||||
const extension = getExtension(options.partial.filename, options.overrides?.extension);
|
||||
if (config.files.disabledExtensions.includes(extension)) throw new ApiError(1006);
|
||||
|
||||
// determine filename
|
||||
const format = options.format || config.files.defaultFormat;
|
||||
const nameResult = await getFilename(
|
||||
format,
|
||||
options.partial.filename,
|
||||
extension,
|
||||
options.overrides?.filename,
|
||||
);
|
||||
if ('error' in nameResult) throw new ApiError(1009, nameResult.error);
|
||||
|
||||
const { fileName } = nameResult;
|
||||
|
||||
// determine mimetype
|
||||
let mimetype = options.partial.contentType;
|
||||
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
|
||||
const mime = await guess(extension.substring(1));
|
||||
|
||||
if (!mime) response.assumedMimetypes![0] = false;
|
||||
else {
|
||||
response.assumedMimetypes![0] = true;
|
||||
mimetype = mime;
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'response',
|
||||
id: msg.id,
|
||||
result: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
name: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
url: responseUrl,
|
||||
pending: true,
|
||||
});
|
||||
const data: Prisma.FileCreateInput = {
|
||||
name: `${fileName}${extension}`,
|
||||
size: 0,
|
||||
type: mimetype,
|
||||
User: {
|
||||
connect: {
|
||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await deletePartial(options.partial.identifier, false);
|
||||
}
|
||||
if (options.password) data.password = await hashPassword(options.password);
|
||||
if (options.maxViews) data.maxViews = options.maxViews;
|
||||
if (folder) data.Folder = { connect: { id: folder.id } };
|
||||
if (options.addOriginalName) {
|
||||
const sanitizedOG = sanitizeFilename(options.partial.filename);
|
||||
if (!sanitizedOG) throw new ApiError(1008);
|
||||
|
||||
response.partialSuccess = true;
|
||||
data.originalName = sanitizedOG || file.filename; // this will prolly be "blob" but should hopefully never happen
|
||||
}
|
||||
if (!req.user && folder) data.anonymous = true;
|
||||
|
||||
// send an identifier if this is the first chunk for server-side checks
|
||||
if (options.partial.range[0] === 0) {
|
||||
response.partialIdentifier = options.partial.identifier;
|
||||
}
|
||||
const fileUpload = await prisma.file.create({
|
||||
data,
|
||||
});
|
||||
|
||||
return res.send(response);
|
||||
});
|
||||
const responseUrl = `${domain}${
|
||||
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
|
||||
}/${fileUpload.name}`;
|
||||
|
||||
const worker = new Worker('./build/offload/partial.js', {
|
||||
workerData: {
|
||||
user: {
|
||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
||||
},
|
||||
file: {
|
||||
id: fileUpload.id,
|
||||
filename: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
},
|
||||
options,
|
||||
domain,
|
||||
responseUrl,
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
worker.on('message', async (msg) => {
|
||||
if (msg.type === 'query') {
|
||||
let result;
|
||||
|
||||
switch (msg.query) {
|
||||
case 'incompleteFile.create':
|
||||
result = await prisma.incompleteFile.create(msg.data);
|
||||
break;
|
||||
case 'incompleteFile.update':
|
||||
result = await prisma.incompleteFile.update(msg.data);
|
||||
break;
|
||||
case 'file.update':
|
||||
result = await prisma.file.update(msg.data);
|
||||
break;
|
||||
case 'user.findUnique':
|
||||
result = await prisma.user.findUnique(msg.data);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown query type: ${msg.query}`);
|
||||
result = null;
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'response',
|
||||
id: msg.id,
|
||||
result: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
name: fileUpload.name,
|
||||
type: fileUpload.type,
|
||||
url: responseUrl,
|
||||
pending: true,
|
||||
});
|
||||
|
||||
await deletePartial(options.partial.identifier, false);
|
||||
}
|
||||
|
||||
response.partialSuccess = true;
|
||||
|
||||
// send an identifier if this is the first chunk for server-side checks
|
||||
if (options.partial.range[0] === 0) {
|
||||
response.partialIdentifier = options.partial.identifier;
|
||||
}
|
||||
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserTokenResponse = {
|
||||
user?: User;
|
||||
token?: string;
|
||||
};
|
||||
export type ApiUserAvatarResponse = string;
|
||||
|
||||
export const PATH = '/api/user/avatar';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const u = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: "Return the current user's avatar as a base64 data URL.",
|
||||
response: {
|
||||
200: z.string().describe('data URL with base64'),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const u = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!u.avatar) return res.notFound();
|
||||
if (!u.avatar) throw new ApiError(9002);
|
||||
|
||||
return res.send(u.avatar);
|
||||
});
|
||||
return res.send(u.avatar);
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { config } from '@/lib/config';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { exportSchema } from '@/lib/db/models/export';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { Export } from '@/prisma/client';
|
||||
@@ -32,7 +34,13 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'List your exports or download a specific completed export archive by ID.',
|
||||
querystring: querySchema,
|
||||
response: {
|
||||
200: z.array(exportSchema),
|
||||
},
|
||||
produces: ['application/json', 'application/zip'],
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -43,9 +51,9 @@ export default typedPlugin(
|
||||
|
||||
if (req.query.id) {
|
||||
const file = exports.find((x) => x.id === req.query.id);
|
||||
if (!file) return res.notFound();
|
||||
if (!file) throw new ApiError(9002);
|
||||
|
||||
if (!file.completed) return res.badRequest('Export is not completed');
|
||||
if (!file.completed) throw new ApiError(1024);
|
||||
|
||||
return res.sendFile(file.path);
|
||||
}
|
||||
@@ -57,11 +65,20 @@ export default typedPlugin(
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: { querystring: querySchema },
|
||||
schema: {
|
||||
description: 'Delete a specific export and remove its archive file from storage.',
|
||||
querystring: querySchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
deleted: z.boolean(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!req.query.id) return res.badRequest('No id provided');
|
||||
if (!req.query.id) throw new ApiError(1029);
|
||||
|
||||
const exportDb = await prisma.export.findFirst({
|
||||
where: {
|
||||
@@ -69,7 +86,7 @@ export default typedPlugin(
|
||||
id: req.query.id,
|
||||
},
|
||||
});
|
||||
if (!exportDb) return res.notFound();
|
||||
if (!exportDb) throw new ApiError(9002);
|
||||
|
||||
const path = join(config.core.tempDirectory, exportDb.path);
|
||||
|
||||
@@ -90,70 +107,86 @@ export default typedPlugin(
|
||||
},
|
||||
);
|
||||
|
||||
server.post(PATH, { preHandler: [userMiddleware], ...secondlyRatelimit(5) }, async (req, res) => {
|
||||
const files = await prisma.file.findMany({
|
||||
where: { userId: req.user.id },
|
||||
});
|
||||
|
||||
if (!files.length) return res.badRequest('No files to export');
|
||||
|
||||
const exportFileName = `zexport_${req.user.id}_${Date.now()}_${files.length}.zip`;
|
||||
const exportPath = join(config.core.tempDirectory, exportFileName);
|
||||
|
||||
logger.debug(`exporting ${req.user.id}`, { exportPath, files: files.length });
|
||||
|
||||
const exportDb = await prisma.export.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
path: exportFileName,
|
||||
files: files.length,
|
||||
size: '0',
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Start an export job that zips all of your files into a downloadable archive.',
|
||||
response: {
|
||||
200: z.object({
|
||||
running: z.boolean(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
});
|
||||
const writeStream = createWriteStream(exportPath);
|
||||
preHandler: [userMiddleware],
|
||||
...secondlyRatelimit(5),
|
||||
},
|
||||
async (req, res) => {
|
||||
const files = await prisma.file.findMany({
|
||||
where: { userId: req.user.id },
|
||||
});
|
||||
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
if (!files.length) throw new ApiError(1025);
|
||||
|
||||
zip.pipe(writeStream);
|
||||
const exportFileName = `zexport_${req.user.id}_${Date.now()}_${files.length}.zip`;
|
||||
const exportPath = join(config.core.tempDirectory, exportFileName);
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn(`failed to get file ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
logger.debug(`exporting ${req.user.id}`, { exportPath, files: files.length });
|
||||
|
||||
zip.append(stream, { name: file.name });
|
||||
totalSize += file.size;
|
||||
logger.debug('file added to zip', { name: file.name, size: file.size });
|
||||
}
|
||||
|
||||
writeStream.on('close', async () => {
|
||||
logger.debug('exported', { path: exportPath, bytes: zip.pointer() });
|
||||
logger.info(`export for ${req.user.id} finished at ${exportPath}`);
|
||||
|
||||
await prisma.export.update({
|
||||
where: { id: exportDb.id },
|
||||
const exportDb = await prisma.export.create({
|
||||
data: {
|
||||
completed: true,
|
||||
size: (await stat(exportPath)).size.toString(),
|
||||
userId: req.user.id,
|
||||
path: exportFileName,
|
||||
files: files.length,
|
||||
size: '0',
|
||||
},
|
||||
});
|
||||
});
|
||||
const writeStream = createWriteStream(exportPath);
|
||||
|
||||
zip.on('error', (err) => {
|
||||
logger.error('export zip error', { err, exportId: exportDb.id });
|
||||
});
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
zip.finalize();
|
||||
zip.pipe(writeStream);
|
||||
|
||||
logger.info(`export for ${req.user.id} started`, { totalSize: bytes(totalSize) });
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
const stream = await datasource.get(file.name);
|
||||
if (!stream) {
|
||||
logger.warn(`failed to get file ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
return res.send({ running: true });
|
||||
});
|
||||
zip.append(stream, { name: file.name });
|
||||
totalSize += file.size;
|
||||
logger.debug('file added to zip', { name: file.name, size: file.size });
|
||||
}
|
||||
|
||||
writeStream.on('close', async () => {
|
||||
logger.debug('exported', { path: exportPath, bytes: zip.pointer() });
|
||||
logger.info(`export for ${req.user.id} finished at ${exportPath}`);
|
||||
|
||||
await prisma.export.update({
|
||||
where: { id: exportDb.id },
|
||||
data: {
|
||||
completed: true,
|
||||
size: (await stat(exportPath)).size.toString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
zip.on('error', (err) => {
|
||||
logger.error('export zip error', { err, exportId: exportDb.id });
|
||||
});
|
||||
|
||||
zip.finalize();
|
||||
|
||||
logger.info(`export for ${req.user.id} started`, { totalSize: bytes(totalSize) });
|
||||
|
||||
return res.send({ running: true });
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
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 { File, fileSchema, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zValidatePath } from '@/lib/validation';
|
||||
@@ -22,35 +23,17 @@ const paramsSchema = z.object({
|
||||
export const PATH = '/api/user/files/:id';
|
||||
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 }],
|
||||
},
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
|
||||
return res.send(file);
|
||||
});
|
||||
|
||||
server.patch(
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Fetch a single file owned by the authenticated user (or another user if permitted) by ID or short name.',
|
||||
params: paramsSchema,
|
||||
body: z.object({
|
||||
favorite: z.boolean().optional(),
|
||||
maxViews: z.number().min(0).optional(),
|
||||
password: z.string().nullish(),
|
||||
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),
|
||||
}),
|
||||
response: {
|
||||
200: fileSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -61,16 +44,57 @@ export default typedPlugin(
|
||||
},
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
if (!file) throw new ApiError(4000);
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
throw new ApiError(4000);
|
||||
|
||||
return res.send(file);
|
||||
},
|
||||
);
|
||||
|
||||
server.patch(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Update metadata for a single file, including favorite, name, tags, password, and view limits.',
|
||||
params: paramsSchema,
|
||||
body: z.object({
|
||||
favorite: z.boolean().optional(),
|
||||
maxViews: z.number().min(0).optional(),
|
||||
password: z.string().nullish(),
|
||||
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),
|
||||
anonymous: z.boolean().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: fileSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ id: req.params.id }, { name: req.params.id }],
|
||||
},
|
||||
select: { User: true, ...fileSelect },
|
||||
});
|
||||
if (!file) throw new ApiError(4000);
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
throw new ApiError(4000);
|
||||
|
||||
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.anonymous !== undefined) data.anonymous = req.body.anonymous;
|
||||
|
||||
if (req.body.maxViews !== undefined) {
|
||||
data.maxViews = req.body.maxViews;
|
||||
@@ -94,7 +118,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
|
||||
if (tags.length !== req.body.tags.length) throw new ApiError(1032);
|
||||
|
||||
data.tags = {
|
||||
set: req.body.tags.map((tag) => ({ id: tag })),
|
||||
@@ -109,8 +133,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (existingFile && existingFile.id !== file.id)
|
||||
return res.badRequest('File with this name already exists');
|
||||
if (existingFile && existingFile.id !== file.id) throw new ApiError(1014);
|
||||
|
||||
data.name = name;
|
||||
|
||||
@@ -118,7 +141,7 @@ export default typedPlugin(
|
||||
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');
|
||||
throw new ApiError(6002);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +166,13 @@ export default typedPlugin(
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: { params: paramsSchema },
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: fileSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
@@ -155,10 +184,10 @@ export default typedPlugin(
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
if (!file) throw new ApiError(4000);
|
||||
|
||||
if (req.user.id !== file.User?.id && !canInteract(req.user.role, file.User?.role ?? 'USER'))
|
||||
return res.notFound();
|
||||
throw new ApiError(4000);
|
||||
|
||||
const deletedFile = await prisma.file.delete({
|
||||
where: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
@@ -19,12 +20,18 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Verify the password for a password-protected file by ID or name.',
|
||||
body: z.object({
|
||||
password: zStringTrimmed,
|
||||
}),
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(2),
|
||||
},
|
||||
@@ -39,8 +46,8 @@ export default typedPlugin(
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound();
|
||||
if (!file.password) return res.notFound();
|
||||
if (!file) throw new ApiError(4000);
|
||||
if (!file.password) throw new ApiError(4000);
|
||||
|
||||
const verified = await verifyPassword(req.body.password, file.password);
|
||||
if (!verified) {
|
||||
@@ -50,7 +57,7 @@ export default typedPlugin(
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
return res.forbidden('Incorrect password');
|
||||
throw new ApiError(3005);
|
||||
}
|
||||
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { parseRange } from '@/lib/api/range';
|
||||
import { config } from '@/lib/config';
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
@@ -20,6 +21,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Stream a file or thumbnail owned by the authenticated user by ID, with optional password and download handling.',
|
||||
params: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
@@ -27,6 +30,7 @@ export default typedPlugin(
|
||||
pw: z.string().optional(),
|
||||
download: zQsBoolean.optional(),
|
||||
}),
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -34,7 +38,7 @@ export default typedPlugin(
|
||||
const { pw, download } = req.query;
|
||||
|
||||
const id = sanitizeFilename(req.params.id);
|
||||
if (!id) return res.callNotFound();
|
||||
if (!id) throw new ApiError(9002);
|
||||
|
||||
if (id.startsWith('.thumbnail')) {
|
||||
const thumbnail = await prisma.thumbnail.findFirst({
|
||||
@@ -50,9 +54,9 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (!thumbnail) return res.callNotFound();
|
||||
if (!thumbnail) throw new ApiError(9002);
|
||||
if (thumbnail.file && thumbnail.file.userId !== req.user.id) {
|
||||
if (!canInteract(req.user.role, thumbnail.file.User?.role)) return res.callNotFound();
|
||||
if (!canInteract(req.user.role, thumbnail.file.User?.role)) throw new ApiError(9002);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +70,7 @@ export default typedPlugin(
|
||||
});
|
||||
|
||||
if (file && file.userId !== req.user.id) {
|
||||
if (!canInteract(req.user.role, file.User?.role)) return res.callNotFound();
|
||||
if (!canInteract(req.user.role, file.User?.role)) throw new ApiError(9002);
|
||||
}
|
||||
|
||||
if (file?.deletesAt && file.deletesAt <= new Date()) {
|
||||
@@ -85,11 +89,11 @@ export default typedPlugin(
|
||||
.error(e as Error);
|
||||
}
|
||||
|
||||
return res.callNotFound();
|
||||
throw new ApiError(9002);
|
||||
}
|
||||
|
||||
if (file?.maxViews && file.views >= file.maxViews) {
|
||||
if (!config.features.deleteOnMaxViews) return res.callNotFound();
|
||||
if (!config.features.deleteOnMaxViews) throw new ApiError(9002);
|
||||
|
||||
try {
|
||||
await datasource.delete(file.name);
|
||||
@@ -106,14 +110,13 @@ export default typedPlugin(
|
||||
.error(e as Error);
|
||||
}
|
||||
|
||||
return res.callNotFound();
|
||||
throw new ApiError(9002);
|
||||
}
|
||||
|
||||
if (file?.password) {
|
||||
if (!pw) return res.forbidden('Password protected.');
|
||||
if (!pw) throw new ApiError(3004);
|
||||
const verified = await verifyPassword(pw, file.password!);
|
||||
|
||||
if (!verified) return res.forbidden('Incorrect password.');
|
||||
if (!verified) throw new ApiError(3005);
|
||||
}
|
||||
|
||||
const size = file?.size || (await datasource.size(file?.name ?? id));
|
||||
@@ -124,7 +127,7 @@ export default typedPlugin(
|
||||
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();
|
||||
if (!buf) throw new ApiError(9002);
|
||||
|
||||
return res
|
||||
.type(contentType)
|
||||
@@ -143,7 +146,7 @@ export default typedPlugin(
|
||||
}
|
||||
|
||||
const buf = await datasource.range(file?.name ?? id, start || 0, end);
|
||||
if (!buf) return res.callNotFound();
|
||||
if (!buf) throw new ApiError(9002);
|
||||
|
||||
return res
|
||||
.type(contentType)
|
||||
@@ -164,7 +167,7 @@ export default typedPlugin(
|
||||
}
|
||||
|
||||
const buf = await datasource.get(file?.name ?? id);
|
||||
if (!buf) return res.callNotFound();
|
||||
if (!buf) throw new ApiError(9002);
|
||||
|
||||
return res
|
||||
.type(contentType)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { IncompleteFile, incompleteFileSchema } from '@/lib/db/models/incompleteFile';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
@@ -13,23 +13,43 @@ const logger = log('api').c('user').c('files').c('incomplete');
|
||||
export const PATH = '/api/user/files/incomplete';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const incompleteFiles = await prisma.incompleteFile.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'List incomplete or still-processing file uploads for the authenticated user.',
|
||||
response: {
|
||||
200: z.array(incompleteFileSchema),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const incompleteFiles = await prisma.incompleteFile.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(incompleteFiles);
|
||||
});
|
||||
return res.send(incompleteFiles);
|
||||
},
|
||||
);
|
||||
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Delete one or more incomplete file records owned by the authenticated user.',
|
||||
body: z.object({
|
||||
id: z.array(z.string()),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
count: z.number(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
|
||||
import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zQsBoolean } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
@@ -26,6 +27,8 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'List, filter, and search files for the authenticated user (or another user if permitted).',
|
||||
querystring: z.object({
|
||||
page: z.coerce.number(),
|
||||
perpage: z.coerce.number().default(15),
|
||||
@@ -52,6 +55,20 @@ export default typedPlugin(
|
||||
id: z.string().optional(),
|
||||
folder: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
page: z.array(fileSchema),
|
||||
search: z
|
||||
.object({
|
||||
field: z.enum(['name', 'originalName', 'type', 'tags', 'id']),
|
||||
query: z.union([z.string(), z.array(z.string())]),
|
||||
})
|
||||
.optional(),
|
||||
total: z.number().optional(),
|
||||
pages: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -62,8 +79,9 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role)) return res.notFound();
|
||||
if (!user) return res.notFound();
|
||||
if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role))
|
||||
throw new ApiError(9002);
|
||||
if (!user) throw new ApiError(9002);
|
||||
|
||||
const { perpage, searchQuery, searchField, page, filter, favorite, sortBy, order, folder } =
|
||||
req.query;
|
||||
@@ -78,8 +96,8 @@ export default typedPlugin(
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!f) return res.notFound();
|
||||
if (!checkInteraction(req.user, f?.User)) return res.notFound();
|
||||
if (!f) throw new ApiError(9002);
|
||||
if (!checkInteraction(req.user, f?.User)) throw new ApiError(9002);
|
||||
|
||||
folderId = f.id;
|
||||
}
|
||||
@@ -121,7 +139,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere');
|
||||
if (foundTags.length !== parsedTags.length) throw new ApiError(1032);
|
||||
|
||||
tagFiles = foundTags
|
||||
.map((tag) => tag.files.map((file) => file.id))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
@@ -39,11 +40,19 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Bulk update files owned by the user: favorite/unfavorite or move them into a folder.',
|
||||
body: z.object({
|
||||
files: z.array(z.string()).min(1),
|
||||
favorite: z.boolean().optional(),
|
||||
folder: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
count: z.number(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
...secondlyRatelimit(2),
|
||||
@@ -66,7 +75,7 @@ export default typedPlugin(
|
||||
toFavoriteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
|
||||
);
|
||||
if (invalids.length > 0)
|
||||
return res.forbidden(`You don't have the permission to modify files[${invalids.join(', ')}]`);
|
||||
throw new ApiError(3014, `You don't have the permission to modify files[${invalids.join(', ')}]`);
|
||||
|
||||
const resp = await prisma.file.updateMany({
|
||||
where: {
|
||||
@@ -79,7 +88,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.count === 0) return res.badRequest('No files were updated.');
|
||||
if (resp.count === 0) throw new ApiError(1028);
|
||||
|
||||
logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, {
|
||||
user: req.user.id,
|
||||
@@ -89,7 +98,7 @@ export default typedPlugin(
|
||||
return res.send(resp);
|
||||
}
|
||||
|
||||
if (!folder) return res.badRequest("can't PATCH without an action");
|
||||
if (!folder) throw new ApiError(1020);
|
||||
|
||||
const f = await prisma.folder.findUnique({
|
||||
where: {
|
||||
@@ -97,7 +106,7 @@ export default typedPlugin(
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
if (!f) return res.notFound('folder not found');
|
||||
if (!f) throw new ApiError(4001);
|
||||
|
||||
const resp = await prisma.file.updateMany({
|
||||
where: {
|
||||
@@ -112,7 +121,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.count === 0) return res.notFound('No files were moved.');
|
||||
if (resp.count === 0) throw new ApiError(4006);
|
||||
|
||||
logger.info(`${req.user.username} moved ${resp.count} files to ${f.name}`, {
|
||||
user: req.user.id,
|
||||
@@ -130,10 +139,16 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Bulk delete files (and optionally delete the underlying datasource objects).',
|
||||
body: z.object({
|
||||
files: z.array(z.string()).min(1),
|
||||
delete_datasourceFiles: z.boolean().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
count: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
...secondlyRatelimit(2),
|
||||
@@ -162,7 +177,7 @@ export default typedPlugin(
|
||||
toDeleteFiles.map((f) => ({ id: f.userId ?? '', role: f.User?.role ?? 'USER' })),
|
||||
);
|
||||
if (invalids.length > 0)
|
||||
return res.forbidden(`You don't have the permission to delete files[${invalids.join(', ')}]`);
|
||||
throw new ApiError(3013, `You don't have the permission to delete files[${invalids.join(', ')}]`);
|
||||
|
||||
if (delete_datasourceFiles) {
|
||||
for (let i = 0; i !== toDeleteFiles.length; ++i) {
|
||||
@@ -182,7 +197,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.count === 0) return res.badRequest('No files were deleted.');
|
||||
if (resp.count === 0) throw new ApiError(1027);
|
||||
|
||||
logger.info(`${req.user.username} deleted ${resp.count} files`, {
|
||||
user: req.user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
@@ -77,7 +78,14 @@ export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{ schema: { params: z.object({ id: z.string() }) }, preHandler: [userMiddleware] },
|
||||
{
|
||||
schema: {
|
||||
description: 'Download a ZIP archive of all files contained in a folder and its subfolders.',
|
||||
params: z.object({ id: z.string() }),
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -86,11 +94,11 @@ export default typedPlugin(
|
||||
select: { id: true, name: true, userId: true },
|
||||
});
|
||||
|
||||
if (!folder) return res.notFound('Folder not found');
|
||||
if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder');
|
||||
if (!folder) throw new ApiError(4001);
|
||||
if (req.user.id !== folder.userId) throw new ApiError(3011);
|
||||
|
||||
const folderTree = await getFolderTree(id, req.user.id);
|
||||
if (!folderTree) return res.notFound('Folder not found');
|
||||
if (!folderTree) throw new ApiError(4001);
|
||||
|
||||
logger.info(`folder export requested: ${folder.name}`, { user: req.user.id, folder: folder.id });
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { buildParentChain, Folder, cleanFolder } from '@/lib/db/models/folder';
|
||||
import { buildParentChain, Folder, cleanFolder, folderSchema } from '@/lib/db/models/folder';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserFoldersIdResponse = Folder;
|
||||
@@ -28,7 +29,7 @@ const paramsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const folderExistsAndEditable = async (req: FastifyRequest, res: FastifyReply) => {
|
||||
const folderExistsAndEditable = async (req: FastifyRequest) => {
|
||||
const { id } = req.params as z.infer<typeof paramsSchema>;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
@@ -40,8 +41,8 @@ const folderExistsAndEditable = async (req: FastifyRequest, res: FastifyReply) =
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) return res.notFound('Folder not found');
|
||||
if (!checkInteraction(req.user, folder.User)) return res.notFound('Folder not found');
|
||||
if (!folder) throw new ApiError(4001);
|
||||
if (!checkInteraction(req.user, folder.User)) throw new ApiError(4001);
|
||||
};
|
||||
|
||||
export const PATH = '/api/user/folders/:id';
|
||||
@@ -49,7 +50,17 @@ export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{ schema: { params: paramsSchema }, preHandler: [userMiddleware, folderExistsAndEditable] },
|
||||
{
|
||||
schema: {
|
||||
description: 'Fetch a specific folder by ID, including files, children, and its parent chain.',
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: folderSchema.partial(),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, folderExistsAndEditable],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -81,7 +92,7 @@ export default typedPlugin(
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!folder) return res.notFound('Folder not found');
|
||||
if (!folder) throw new ApiError(4001);
|
||||
|
||||
if (folder.parentId) {
|
||||
(folder as any).parent = await buildParentChain(folder.parentId);
|
||||
@@ -95,10 +106,15 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Add a file to a specific folder owned by the user.',
|
||||
body: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: folderSchema.partial(),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, folderExistsAndEditable],
|
||||
},
|
||||
@@ -114,8 +130,8 @@ export default typedPlugin(
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
if (!file) return res.notFound('File not found');
|
||||
if (!checkInteraction(req.user, file.User)) return res.notFound('File not found');
|
||||
if (!file) throw new ApiError(4000);
|
||||
if (!checkInteraction(req.user, file.User)) throw new ApiError(4000);
|
||||
|
||||
const fileInFolder = await prisma.file.findFirst({
|
||||
where: {
|
||||
@@ -125,7 +141,7 @@ export default typedPlugin(
|
||||
},
|
||||
},
|
||||
});
|
||||
if (fileInFolder) return res.badRequest('File already in folder');
|
||||
if (fileInFolder) throw new ApiError(1011);
|
||||
|
||||
try {
|
||||
const nFolder = await prisma.folder.update({
|
||||
@@ -147,7 +163,7 @@ export default typedPlugin(
|
||||
logger.info('file added to folder', { folder: folderId, file: id });
|
||||
return res.send(cleanFolder(nFolder));
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2025') return res.notFound('Folder or File not found');
|
||||
if (error.code === 'P2025') throw new ApiError(4002);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -157,6 +173,7 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: "Update a folder's visibility, name, upload permissions, or parent.",
|
||||
body: z.object({
|
||||
isPublic: z.boolean().optional(),
|
||||
name: zStringTrimmed.optional(),
|
||||
@@ -164,6 +181,10 @@ export default typedPlugin(
|
||||
parentId: z.string().nullish(),
|
||||
}),
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: folderSchema.partial(),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, folderExistsAndEditable],
|
||||
},
|
||||
@@ -172,7 +193,7 @@ export default typedPlugin(
|
||||
const { isPublic, name, allowUploads, parentId } = req.body;
|
||||
|
||||
if (parentId !== undefined) {
|
||||
if (parentId === folderId) return res.badRequest('A folder cannot be its own parent');
|
||||
if (parentId === folderId) throw new ApiError(1015);
|
||||
|
||||
if (parentId !== null) {
|
||||
const newParent = await prisma.folder.findUnique({
|
||||
@@ -180,14 +201,13 @@ export default typedPlugin(
|
||||
select: { id: true, userId: true, parentId: true },
|
||||
});
|
||||
|
||||
if (!newParent) return res.notFound('Parent folder not found');
|
||||
if (newParent.userId !== req.user.id)
|
||||
return res.forbidden('Parent folder does not belong to you');
|
||||
if (!newParent) throw new ApiError(4007);
|
||||
if (newParent.userId !== req.user.id) throw new ApiError(3003);
|
||||
|
||||
let currentParentId: string | null = newParent.parentId;
|
||||
while (currentParentId) {
|
||||
if (currentParentId === folderId) {
|
||||
return res.badRequest('Cannot move folder into one of its descendants');
|
||||
throw new ApiError(1016);
|
||||
}
|
||||
const parent = await prisma.folder.findUnique({
|
||||
where: { id: currentParentId },
|
||||
@@ -233,7 +253,7 @@ export default typedPlugin(
|
||||
|
||||
return res.send(cleanFolder(nFolder));
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2025') return res.notFound('Folder not found');
|
||||
if (error.code === 'P2025') throw new ApiError(4001);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -251,6 +271,16 @@ export default typedPlugin(
|
||||
targetFolderId: z.string().optional(),
|
||||
}),
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean().nullish().describe('if deleting the folder, return success status'),
|
||||
folder: folderSchema
|
||||
.partial()
|
||||
.nullish()
|
||||
.describe('if deleting a file from the folder, returns the updated folder'),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, folderExistsAndEditable],
|
||||
},
|
||||
@@ -264,17 +294,15 @@ export default typedPlugin(
|
||||
where: { id: targetFolderId },
|
||||
select: { id: true, User: true },
|
||||
});
|
||||
if (!targetFolder) return res.notFound('Target folder not found');
|
||||
if (!checkInteraction(req.user, targetFolder.User))
|
||||
return res.forbidden('Target folder not found');
|
||||
if (!targetFolder) throw new ApiError(4008);
|
||||
if (!checkInteraction(req.user, targetFolder.User)) throw new ApiError(4008, undefined, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
if (!childrenAction)
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
if (!childrenAction) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (childrenAction === 'root') {
|
||||
await tx.folder.updateMany({ where: { parentId: folderId }, data: { parentId: null } });
|
||||
@@ -310,7 +338,7 @@ export default typedPlugin(
|
||||
}
|
||||
});
|
||||
|
||||
if (!result?.success) return res.badRequest('Invalid action');
|
||||
if (!result?.success) throw new ApiError(1019);
|
||||
|
||||
if (result?.isCascade) {
|
||||
logger.info('folder cascade deleted', { folder: folderId });
|
||||
@@ -322,21 +350,20 @@ export default typedPlugin(
|
||||
logger.info('folder deleted', { folder: folderId, childrenAction, targetFolderId });
|
||||
return res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2025')
|
||||
return res.notFound('Folder or related records not found during deletion');
|
||||
if (error.code === 'P2025') throw new ApiError(4003);
|
||||
throw error;
|
||||
}
|
||||
} else if (del === 'file') {
|
||||
const { id } = req.body;
|
||||
if (!id) return res.badRequest('File id is required');
|
||||
if (!id) throw new ApiError(1013);
|
||||
|
||||
const file = await prisma.file.findUnique({
|
||||
where: { id },
|
||||
include: { User: true },
|
||||
});
|
||||
|
||||
if (!file) return res.notFound('File not found');
|
||||
if (!checkInteraction(req.user, file.User)) return res.notFound('File not found');
|
||||
if (!file) throw new ApiError(4000);
|
||||
if (!checkInteraction(req.user, file.User)) throw new ApiError(4000);
|
||||
|
||||
const fileInFolder = await prisma.file.findFirst({
|
||||
where: {
|
||||
@@ -344,7 +371,7 @@ export default typedPlugin(
|
||||
Folder: { id: folderId },
|
||||
},
|
||||
});
|
||||
if (!fileInFolder) return res.badRequest('File not in folder');
|
||||
if (!fileInFolder) throw new ApiError(1012);
|
||||
|
||||
try {
|
||||
const nFolder = await prisma.folder.update({
|
||||
@@ -363,9 +390,9 @@ export default typedPlugin(
|
||||
});
|
||||
|
||||
logger.info('file removed from folder', { folder: nFolder.id, file: id });
|
||||
return res.send(cleanFolder(nFolder));
|
||||
return res.send({ folder: cleanFolder(nFolder) });
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2025') return res.notFound('Folder or file not found');
|
||||
if (error.code === 'P2025') throw new ApiError(4002);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { Folder, cleanFolder, cleanFolders } from '@/lib/db/models/folder';
|
||||
import { Folder, cleanFolder, cleanFolders, folderSchema } from '@/lib/db/models/folder';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { canInteract } from '@/lib/role';
|
||||
@@ -20,12 +21,18 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'List folders for the authenticated user, optionally including files or filtering by parent/root.',
|
||||
querystring: z.object({
|
||||
noincl: zQsBoolean.optional(),
|
||||
user: z.string().optional(),
|
||||
parentId: z.string().optional(),
|
||||
root: zQsBoolean.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(folderSchema),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -39,9 +46,9 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return res.notFound();
|
||||
if (!user) throw new ApiError(4009);
|
||||
if (req.user.id !== user.id) {
|
||||
if (!canInteract(req.user.role, user.role)) return res.notFound();
|
||||
if (!canInteract(req.user.role, user.role)) throw new ApiError(4009);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +89,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(cleanFolders(folders as unknown as Partial<Folder>[]));
|
||||
return res.send(cleanFolders(folders as unknown as Folder[]));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -90,12 +97,18 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'Create a new folder for the authenticated user, optionally public and/or seeded with files.',
|
||||
body: z.object({
|
||||
name: z.string().trim().min(1),
|
||||
isPublic: z.boolean().optional(),
|
||||
files: z.array(z.string()).optional(),
|
||||
parentId: z.string().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: folderSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
...secondlyRatelimit(2),
|
||||
@@ -110,9 +123,8 @@ export default typedPlugin(
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (!parentFolder) return res.notFound('Parent folder not found');
|
||||
if (parentFolder.userId !== req.user.id)
|
||||
return res.forbidden('Parent folder does not belong to you');
|
||||
if (!parentFolder) throw new ApiError(4007);
|
||||
if (parentFolder.userId !== req.user.id) throw new ApiError(3003);
|
||||
}
|
||||
|
||||
if (files) {
|
||||
@@ -127,7 +139,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (!filesAdd.length) return res.badRequest('No files found, with given request');
|
||||
if (!filesAdd.length) throw new ApiError(1026);
|
||||
|
||||
files = filesAdd.map((f) => f.id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
@@ -18,14 +19,31 @@ const logger = log('api').c('user');
|
||||
export const PATH = '/api/user';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
return res.send({ user: req.user, token: req.cookies.zipline_token });
|
||||
});
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Get the currently authenticated user and their token.',
|
||||
response: {
|
||||
200: z.object({
|
||||
user: userSchema.optional(),
|
||||
token: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
return res.send({ user: req.user, token: req.cookies.zipline_token });
|
||||
},
|
||||
);
|
||||
|
||||
server.patch(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: "Update the current user's profile, credentials, avatar, and view settings.",
|
||||
body: z.object({
|
||||
username: zStringTrimmed.optional(),
|
||||
password: zStringTrimmed.optional(),
|
||||
@@ -47,6 +65,13 @@ export default typedPlugin(
|
||||
.partial()
|
||||
.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
user: userSchema.optional(),
|
||||
token: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
...secondlyRatelimit(1),
|
||||
@@ -59,7 +84,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return res.badRequest('Username already exists');
|
||||
if (existing) throw new ApiError(1038);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { User, userPasskeySchema, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { isTruthy } from '@/lib/primitive';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
@@ -27,8 +28,8 @@ const logger = log('api').c('user').c('mfa').c('passkey');
|
||||
const passkeysEnabled = (): boolean =>
|
||||
isTruthy(config.mfa.passkeys.enabled, config.mfa.passkeys.rpID, config.mfa.passkeys.origin);
|
||||
|
||||
export const passkeysEnabledHandler = async (_: FastifyRequest, res: FastifyReply) => {
|
||||
if (!passkeysEnabled()) return res.notFound();
|
||||
export const passkeysEnabledHandler = async (_: FastifyRequest, __: FastifyReply) => {
|
||||
if (!passkeysEnabled()) throw new ApiError(9002);
|
||||
};
|
||||
|
||||
export type PasskeyReg = {
|
||||
@@ -48,22 +49,42 @@ const OPTIONS_CACHE = new TimedCache<string, PublicKeyCredentialCreationOptionsJ
|
||||
export const PATH = '/api/user/mfa/passkey';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware, passkeysEnabledHandler] }, async (req, res) => {
|
||||
const passkeys = await prisma.userPasskey.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'List all registered passkey credentials for the authenticated user.',
|
||||
response: {
|
||||
200: z.array(userPasskeySchema.omit({ reg: true })),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
omit: {
|
||||
reg: true,
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware, passkeysEnabledHandler],
|
||||
},
|
||||
async (req, res) => {
|
||||
const passkeys = await prisma.userPasskey.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
omit: {
|
||||
reg: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(passkeys);
|
||||
});
|
||||
return res.send(passkeys);
|
||||
},
|
||||
);
|
||||
|
||||
server.get(
|
||||
PATH + '/options',
|
||||
{ preHandler: [userMiddleware, passkeysEnabledHandler], ...secondlyRatelimit(1) },
|
||||
{
|
||||
schema: {
|
||||
description: 'Generate WebAuthn registration options for creating a new passkey.',
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, passkeysEnabledHandler],
|
||||
...secondlyRatelimit(1),
|
||||
},
|
||||
async (req, res) => {
|
||||
if (OPTIONS_CACHE.has(req.user.id)) return res.send(OPTIONS_CACHE.get(req.user.id)!);
|
||||
|
||||
@@ -108,10 +129,17 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Register a new WebAuthn passkey for the authenticated user.',
|
||||
body: z.object({
|
||||
response: z.custom<RegistrationResponseJSON>(),
|
||||
response: z
|
||||
.custom<RegistrationResponseJSON>()
|
||||
.describe('The registration response from the client, containing the new passkey credential.'),
|
||||
name: zStringTrimmed,
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, passkeysEnabledHandler],
|
||||
...secondlyRatelimit(1),
|
||||
@@ -120,7 +148,7 @@ export default typedPlugin(
|
||||
const { response, name } = req.body;
|
||||
|
||||
const optionsCached = OPTIONS_CACHE.get(req.user.id);
|
||||
if (!optionsCached) return res.badRequest('passkey registration timed out, try again later');
|
||||
if (!optionsCached) throw new ApiError(1048);
|
||||
|
||||
OPTIONS_CACHE.delete(req.user.id);
|
||||
|
||||
@@ -135,10 +163,10 @@ export default typedPlugin(
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logger.warn('error verifying passkey registration');
|
||||
return res.badRequest('Error verifying passkey registration');
|
||||
throw new ApiError(1049);
|
||||
}
|
||||
|
||||
if (!verification.verified) return res.badRequest('Could not verify passkey registration');
|
||||
if (!verification.verified) throw new ApiError(1050);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
@@ -161,6 +189,7 @@ export default typedPlugin(
|
||||
},
|
||||
},
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
logger.info('user created a new passkey', {
|
||||
@@ -176,9 +205,14 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Remove an existing passkey credential from your account.',
|
||||
body: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, passkeysEnabledHandler],
|
||||
},
|
||||
@@ -192,6 +226,7 @@ export default typedPlugin(
|
||||
delete: { id },
|
||||
},
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
logger.info('user deleted a passkey', {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
@@ -12,8 +14,8 @@ export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: strin
|
||||
|
||||
const logger = log('api').c('user').c('mfa').c('totp');
|
||||
|
||||
const totpEnabledMiddleware = (_: FastifyRequest, res: FastifyReply, next: () => void) => {
|
||||
if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled');
|
||||
const totpEnabledMiddleware = (_: FastifyRequest, __: FastifyReply, next: () => void) => {
|
||||
if (!config.mfa.totp.enabled) throw new ApiError(1054);
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -21,38 +23,67 @@ const totpEnabledMiddleware = (_: FastifyRequest, res: FastifyReply, next: () =>
|
||||
export const PATH = '/api/user/mfa/totp';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware, totpEnabledMiddleware] }, async (req, res) => {
|
||||
if (!req.user.totpSecret) {
|
||||
const secret = generateKey();
|
||||
const qrcode = await totpQrcode({
|
||||
issuer: config.mfa.totp.issuer,
|
||||
username: req.user.username,
|
||||
secret,
|
||||
});
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Get your current TOTP secret, generating one (and a QR code) if not yet enabled.',
|
||||
response: {
|
||||
200: z.object({
|
||||
secret: z
|
||||
.string()
|
||||
.describe('the TOTP secret key, used to generate codes in an authenticator app'),
|
||||
qrcode: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
"if the user hasn't enabled TOTP yet, a data URL for a QR code encoding the secret and account info",
|
||||
),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
...secondlyRatelimit(5),
|
||||
preHandler: [userMiddleware, totpEnabledMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!req.user.totpSecret) {
|
||||
const secret = generateKey();
|
||||
const qrcode = await totpQrcode({
|
||||
issuer: config.mfa.totp.issuer,
|
||||
username: req.user.username,
|
||||
secret,
|
||||
});
|
||||
|
||||
logger.info('user generated TOTP secret', {
|
||||
user: req.user.username,
|
||||
});
|
||||
logger.info('user generated TOTP secret', {
|
||||
user: req.user.username,
|
||||
});
|
||||
|
||||
return res.send({
|
||||
secret,
|
||||
qrcode,
|
||||
});
|
||||
}
|
||||
|
||||
return res.send({
|
||||
secret,
|
||||
qrcode,
|
||||
secret: req.user.totpSecret,
|
||||
});
|
||||
}
|
||||
|
||||
return res.send({
|
||||
secret: req.user.totpSecret,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Enable TOTP for your account by verifying a code for the provided secret.',
|
||||
body: z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
secret: z.string(),
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware, totpEnabledMiddleware],
|
||||
},
|
||||
@@ -60,7 +91,7 @@ export default typedPlugin(
|
||||
const { code, secret } = req.body;
|
||||
|
||||
const valid = verifyTotpCode(code, secret);
|
||||
if (!valid) return res.badRequest('Invalid code');
|
||||
if (!valid) throw new ApiError(1045);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
@@ -80,19 +111,23 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Disable TOTP for your account after confirming a valid TOTP code.',
|
||||
body: z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
},
|
||||
},
|
||||
preHandler: [userMiddleware, totpEnabledMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled");
|
||||
if (!req.user.totpSecret) throw new ApiError(1053);
|
||||
|
||||
const { code } = req.body;
|
||||
|
||||
const valid = verifyTotpCode(code, req.user.totpSecret);
|
||||
if (!valid) return res.badRequest('Invalid code');
|
||||
if (!valid) throw new ApiError(1045);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
|
||||
import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
@@ -13,9 +13,14 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Get the most recently uploaded files for the authenticated user.',
|
||||
querystring: z.object({
|
||||
take: z.coerce.number().min(1).max(100).default(3),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(fileSchema),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import type { UserSession } from '@/prisma/client';
|
||||
import { UserSession, userSessionSchema } from '@/lib/db/models/user';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import { getSession } from '@/server/session';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
@@ -15,27 +16,52 @@ const logger = log('api').c('user').c('sessions');
|
||||
export const PATH = '/api/user/sessions';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const currentSession = await getSession(req, res);
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description:
|
||||
'List the current browser session and other active sessions for the authenticated user.',
|
||||
response: {
|
||||
200: z.object({
|
||||
current: userSessionSchema,
|
||||
other: z.array(userSessionSchema),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const currentSession = await getSession(req, res);
|
||||
|
||||
const currentDbSession = req.user.sessions.find((session) => session.id === currentSession.sessionId);
|
||||
const currentDbSession = req.user.sessions.find((session) => session.id === currentSession.sessionId);
|
||||
|
||||
if (!currentDbSession) return res.unauthorized('invalid login session');
|
||||
if (!currentDbSession) throw new ApiError(2000);
|
||||
|
||||
return res.send({
|
||||
current: currentDbSession,
|
||||
other: req.user.sessions.filter((session) => session.id !== currentSession.sessionId),
|
||||
});
|
||||
});
|
||||
return res.send({
|
||||
current: currentDbSession,
|
||||
other: req.user.sessions.filter((session) => session.id !== currentSession.sessionId),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Invalidate one or all other sessions for the authenticated user.',
|
||||
body: z.object({
|
||||
sessionId: z.string().optional(),
|
||||
all: z.boolean().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
current: userSessionSchema,
|
||||
other: z.array(userSessionSchema),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -71,10 +97,8 @@ export default typedPlugin(
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.sessionId === currentSession.sessionId)
|
||||
return res.badRequest('Cannot delete current session, use log out instead.');
|
||||
if (!req.user.sessions.find((session) => session.id === req.body.sessionId))
|
||||
return res.badRequest('Session not found in logged in sessions');
|
||||
if (req.body.sessionId === currentSession.sessionId) throw new ApiError(1021);
|
||||
if (!req.user.sessions.find((session) => session.id === req.body.sessionId)) throw new ApiError(1031);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export type ApiUserStatsResponse = {
|
||||
filesUploaded: number;
|
||||
@@ -19,78 +20,101 @@ export const PATH = '/api/user/stats';
|
||||
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const aggFile = await prisma.file.aggregate({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: "View aggregate statistics for the authenticated user's files and URLs.",
|
||||
response: {
|
||||
200: z.object({
|
||||
filesUploaded: z.number(),
|
||||
favoriteFiles: z.number(),
|
||||
views: z.number(),
|
||||
avgViews: z.number(),
|
||||
storageUsed: z.number(),
|
||||
avgStorageUsed: z.number(),
|
||||
urlsCreated: z.number(),
|
||||
urlViews: z.number(),
|
||||
sortTypeCount: z.record(z.string(), z.number()),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
_sum: {
|
||||
views: true,
|
||||
size: true,
|
||||
},
|
||||
_avg: {
|
||||
views: true,
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const aggFile = await prisma.file.aggregate({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
_sum: {
|
||||
views: true,
|
||||
size: true,
|
||||
},
|
||||
_avg: {
|
||||
views: true,
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
|
||||
const favCount = await prisma.file.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
favorite: true,
|
||||
},
|
||||
});
|
||||
const favCount = await prisma.file.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
favorite: true,
|
||||
},
|
||||
});
|
||||
|
||||
const aggUrl = await prisma.url.aggregate({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
_avg: {
|
||||
views: true,
|
||||
},
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
const aggUrl = await prisma.url.aggregate({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
_avg: {
|
||||
views: true,
|
||||
},
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sortType = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
const sortType = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sortTypeCount = sortType.reduce(
|
||||
(acc, cur) => {
|
||||
if (acc[cur.type]) acc[cur.type] += 1;
|
||||
else acc[cur.type] = 1;
|
||||
const sortTypeCount = sortType.reduce(
|
||||
(acc, cur) => {
|
||||
if (acc[cur.type]) acc[cur.type] += 1;
|
||||
else acc[cur.type] = 1;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as { [type: string]: number },
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as { [type: string]: number },
|
||||
);
|
||||
|
||||
return res.send({
|
||||
filesUploaded: aggFile._count._all ?? 0,
|
||||
favoriteFiles: favCount ?? 0,
|
||||
views: aggFile._sum.views ?? 0,
|
||||
avgViews: aggFile._avg.views ?? 0,
|
||||
storageUsed: Number(aggFile._sum.size ?? 0),
|
||||
avgStorageUsed: Number(aggFile._avg.size ?? 0),
|
||||
urlsCreated: aggUrl._count._all ?? 0,
|
||||
urlViews: aggUrl._sum.views ?? 0,
|
||||
return res.send({
|
||||
filesUploaded: aggFile._count._all ?? 0,
|
||||
favoriteFiles: favCount ?? 0,
|
||||
views: aggFile._sum.views ?? 0,
|
||||
avgViews: aggFile._avg.views ?? 0,
|
||||
storageUsed: Number(aggFile._sum.size ?? 0),
|
||||
avgStorageUsed: Number(aggFile._avg.size ?? 0),
|
||||
urlsCreated: aggUrl._count._all ?? 0,
|
||||
urlViews: aggUrl._sum.views ?? 0,
|
||||
|
||||
sortTypeCount,
|
||||
});
|
||||
});
|
||||
sortTypeCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Tag, tagSelect } from '@/lib/db/models/tag';
|
||||
import { Tag, tagSchema, tagSelect } from '@/lib/db/models/tag';
|
||||
import { log } from '@/lib/logger';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
@@ -17,25 +18,48 @@ const paramsSchema = z.object({
|
||||
export const PATH = '/api/user/tags/:id';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(PATH, { schema: { params: paramsSchema }, preHandler: [userMiddleware] }, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id,
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Fetch a specific tag by ID, ensuring it is owned by the authenticated user.',
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: tagSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
if (!tag) return res.notFound();
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
return res.send(tag);
|
||||
});
|
||||
const tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id,
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
if (!tag) throw new ApiError(9002);
|
||||
|
||||
return res.send(tag);
|
||||
},
|
||||
);
|
||||
|
||||
server.delete(
|
||||
PATH,
|
||||
{
|
||||
schema: { params: paramsSchema },
|
||||
schema: {
|
||||
description: 'Delete a specific tag owned by the authenticated user.',
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
}),
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
@@ -48,7 +72,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (tag.count === 0) return res.notFound();
|
||||
if (tag.count === 0) throw new ApiError(9002);
|
||||
|
||||
logger.info('tag deleted', {
|
||||
id,
|
||||
@@ -63,6 +87,7 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Update the name and/or color of a specific tag.',
|
||||
params: paramsSchema,
|
||||
body: z.object({
|
||||
name: zStringTrimmed.optional(),
|
||||
@@ -71,6 +96,10 @@ export default typedPlugin(
|
||||
.regex(/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/)
|
||||
.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: tagSchema,
|
||||
},
|
||||
tags: ['auth'],
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
@@ -84,7 +113,7 @@ export default typedPlugin(
|
||||
id,
|
||||
},
|
||||
});
|
||||
if (!existingTag) return res.notFound();
|
||||
if (!existingTag) throw new ApiError(9002);
|
||||
|
||||
if (name) {
|
||||
const existing = await prisma.tag.findFirst({
|
||||
@@ -93,7 +122,7 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return res.badRequest('tag name already exists');
|
||||
if (existing) throw new ApiError(1034);
|
||||
}
|
||||
|
||||
const tag = await prisma.tag.update({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user