refactor: move other api routes

This commit is contained in:
diced
2024-04-24 20:44:06 -07:00
parent 3e79534e39
commit 9ec6f2bfcb
45 changed files with 2148 additions and 1663 deletions

View File

@@ -92,6 +92,7 @@
"prettier": "^3.1.0",
"prisma": "^5.6.0",
"tsup": "^7.2.0",
"tsx": "^4.7.2",
"typescript": "^5.3.2"
},
"engines": {

252
pnpm-lock.yaml generated
View File

@@ -217,6 +217,9 @@ devDependencies:
tsup:
specifier: ^7.2.0
version: 7.2.0(postcss@8.4.31)(typescript@5.3.2)
tsx:
specifier: ^4.7.2
version: 4.7.2
typescript:
specifier: ^5.3.2
version: 5.3.2
@@ -882,6 +885,15 @@ packages:
parse-cache-control: 1.0.1
dev: false
/@esbuild/aix-ppc64@0.19.12:
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm64@0.18.20:
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
@@ -891,6 +903,15 @@ packages:
dev: true
optional: true
/@esbuild/android-arm64@0.19.12:
resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.18.20:
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
@@ -900,6 +921,15 @@ packages:
dev: true
optional: true
/@esbuild/android-arm@0.19.12:
resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.18.20:
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
@@ -909,6 +939,15 @@ packages:
dev: true
optional: true
/@esbuild/android-x64@0.19.12:
resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.18.20:
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
@@ -918,6 +957,15 @@ packages:
dev: true
optional: true
/@esbuild/darwin-arm64@0.19.12:
resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.18.20:
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
@@ -927,6 +975,15 @@ packages:
dev: true
optional: true
/@esbuild/darwin-x64@0.19.12:
resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.18.20:
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
@@ -936,6 +993,15 @@ packages:
dev: true
optional: true
/@esbuild/freebsd-arm64@0.19.12:
resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.18.20:
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
@@ -945,6 +1011,15 @@ packages:
dev: true
optional: true
/@esbuild/freebsd-x64@0.19.12:
resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.18.20:
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
@@ -954,6 +1029,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-arm64@0.19.12:
resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.18.20:
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
@@ -963,6 +1047,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-arm@0.19.12:
resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.18.20:
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
@@ -972,6 +1065,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-ia32@0.19.12:
resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.18.20:
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
@@ -981,6 +1083,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-loong64@0.19.12:
resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.18.20:
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
@@ -990,6 +1101,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-mips64el@0.19.12:
resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.18.20:
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
@@ -999,6 +1119,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-ppc64@0.19.12:
resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.18.20:
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
@@ -1008,6 +1137,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-riscv64@0.19.12:
resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.18.20:
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
@@ -1017,6 +1155,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-s390x@0.19.12:
resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.18.20:
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
@@ -1026,6 +1173,15 @@ packages:
dev: true
optional: true
/@esbuild/linux-x64@0.19.12:
resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.18.20:
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
@@ -1035,6 +1191,15 @@ packages:
dev: true
optional: true
/@esbuild/netbsd-x64@0.19.12:
resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.18.20:
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
@@ -1044,6 +1209,15 @@ packages:
dev: true
optional: true
/@esbuild/openbsd-x64@0.19.12:
resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.18.20:
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
@@ -1053,6 +1227,15 @@ packages:
dev: true
optional: true
/@esbuild/sunos-x64@0.19.12:
resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.18.20:
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
@@ -1062,6 +1245,15 @@ packages:
dev: true
optional: true
/@esbuild/win32-arm64@0.19.12:
resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.18.20:
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
@@ -1071,6 +1263,15 @@ packages:
dev: true
optional: true
/@esbuild/win32-ia32@0.19.12:
resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.18.20:
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
@@ -1080,6 +1281,15 @@ packages:
dev: true
optional: true
/@esbuild/win32-x64@0.19.12:
resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@eslint-community/eslint-utils@4.4.0(eslint@8.54.0):
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -3564,6 +3774,37 @@ packages:
'@esbuild/win32-x64': 0.18.20
dev: true
/esbuild@0.19.12:
resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/aix-ppc64': 0.19.12
'@esbuild/android-arm': 0.19.12
'@esbuild/android-arm64': 0.19.12
'@esbuild/android-x64': 0.19.12
'@esbuild/darwin-arm64': 0.19.12
'@esbuild/darwin-x64': 0.19.12
'@esbuild/freebsd-arm64': 0.19.12
'@esbuild/freebsd-x64': 0.19.12
'@esbuild/linux-arm': 0.19.12
'@esbuild/linux-arm64': 0.19.12
'@esbuild/linux-ia32': 0.19.12
'@esbuild/linux-loong64': 0.19.12
'@esbuild/linux-mips64el': 0.19.12
'@esbuild/linux-ppc64': 0.19.12
'@esbuild/linux-riscv64': 0.19.12
'@esbuild/linux-s390x': 0.19.12
'@esbuild/linux-x64': 0.19.12
'@esbuild/netbsd-x64': 0.19.12
'@esbuild/openbsd-x64': 0.19.12
'@esbuild/sunos-x64': 0.19.12
'@esbuild/win32-arm64': 0.19.12
'@esbuild/win32-ia32': 0.19.12
'@esbuild/win32-x64': 0.19.12
dev: true
/escalade@3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
engines: {node: '>=6'}
@@ -8199,6 +8440,17 @@ packages:
- ts-node
dev: true
/tsx@4.7.2:
resolution: {integrity: sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==}
engines: {node: '>=18.0.0'}
hasBin: true
dependencies:
esbuild: 0.19.12
get-tsconfig: 4.7.2
optionalDependencies:
fsevents: 2.3.3
dev: true
/tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
dependencies:

View File

@@ -1,26 +1,13 @@
import { ApiAuthInvitesResponse } from '@/pages/api/auth/invites';
import { ApiAuthInvitesIdResponse } from '@/pages/api/auth/invites/[id]';
import { ApiLoginResponse } from '@/pages/api/auth/login';
import { ApiLogoutResponse } from '@/pages/api/auth/logout';
import { ApiAuthOauthResponse } from '@/pages/api/auth/oauth';
import { ApiAuthRegisterResponse } from '@/pages/api/auth/register';
import { ApiAuthWebauthnResponse } from '@/pages/api/auth/webauthn';
import { ApiUploadResponse } from '@/pages/api/upload';
import { ApiUserFilesResponse } from '@/pages/api/user/files';
import { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
import { ApiUserFilesIdPasswordResponse } from '@/pages/api/user/files/[id]/password';
import { ApiUserFilesIncompleteResponse } from '@/pages/api/user/files/incomplete';
import { ApiUserFilesTransactionResponse } from '@/pages/api/user/files/transaction';
import { ApiUserFoldersResponse } from '@/pages/api/user/folders';
import { ApiUserFoldersIdResponse } from '@/pages/api/user/folders/[id]';
import { ApiUserMfaPasskeyResponse } from '@/pages/api/user/mfa/passkey';
import { ApiUserMfaTotpResponse } from '@/pages/api/user/mfa/totp';
import { ApiUserTagsResponse } from '@/pages/api/user/tags';
import { ApiUserTagsIdResponse } from '@/pages/api/user/tags/[id]';
import { ApiUserUrlsResponse } from '@/pages/api/user/urls';
import { ApiUserUrlsIdResponse } from '@/pages/api/user/urls/[id]';
// migrated routes
import { ApiAuthInvitesResponse } from '@/server/routes/api/auth/invites';
import { ApiAuthInvitesIdResponse } from '@/server/routes/api/auth/invites/[id]';
import { ApiLoginResponse } from '@/server/routes/api/auth/login';
import { ApiLogoutResponse } from '@/server/routes/api/auth/logout';
import { ApiAuthRegisterResponse } from '@/server/routes/api/auth/register';
import { ApiAuthWebauthnResponse } from '@/server/routes/api/auth/webauthn';
import { ApiHealthcheckResponse } from '@/server/routes/api/healthcheck';
import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_temp';
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
@@ -28,9 +15,22 @@ import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery
import { ApiSetupResponse } from '@/server/routes/api/setup';
import { ApiStatsResponse } from '@/server/routes/api/stats';
import { ApiUserResponse } from '@/server/routes/api/user';
import { ApiUserFilesResponse } from '@/server/routes/api/user/files';
import { ApiUserFilesIdResponse } from '@/server/routes/api/user/files/[id]';
import { ApiUserFilesIdPasswordResponse } from '@/server/routes/api/user/files/[id]/password';
import { ApiUserFilesIncompleteResponse } from '@/server/routes/api/user/files/incomplete';
import { ApiUserFilesTransactionResponse } from '@/server/routes/api/user/files/transaction';
import { ApiUserFoldersResponse } from '@/server/routes/api/user/folders';
import { ApiUserFoldersIdResponse } from '@/server/routes/api/user/folders/[id]';
import { ApiUserMfaPasskeyResponse } from '@/server/routes/api/user/mfa/passkey';
import { ApiUserMfaTotpResponse } from '@/server/routes/api/user/mfa/totp';
import { ApiUserRecentResponse } from '@/server/routes/api/user/recent';
import { ApiUserStatsResponse } from '@/server/routes/api/user/stats';
import { ApiUserTagsResponse } from '@/server/routes/api/user/tags';
import { ApiUserTagsIdResponse } from '@/server/routes/api/user/tags/[id]';
import { ApiUserTokenResponse } from '@/server/routes/api/user/token';
import { ApiUserUrlsResponse } from '@/server/routes/api/user/urls';
import { ApiUserUrlsIdResponse } from '@/server/routes/api/user/urls/[id]';
import { ApiUsersResponse } from '@/server/routes/api/users';
import { ApiUsersIdResponse } from '@/server/routes/api/users/[id]';
import { ApiVersionResponse } from '@/server/routes/api/version';

View File

@@ -1,50 +0,0 @@
import { prisma } from '@/lib/db';
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiAuthInvitesIdResponse = Invite;
type Query = {
id: string;
};
const logger = log('api').c('auth').c('invites').c('[id]');
async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiAuthInvitesIdResponse>) {
const { id } = req.query;
const invite = await prisma.invite.findFirst({
where: {
OR: [{ id }, { code: id }],
},
include: {
inviter: inviteInviterSelect,
},
});
if (!invite) return res.notFound('Invite not found through id or code');
if (req.method === 'DELETE') {
const nInvite = await prisma.invite.delete({
where: {
id: invite.id,
},
include: {
inviter: inviteInviterSelect,
},
});
logger.info(`${req.user.username} deleted an invite`, {
id: invite.id,
code: invite.code,
});
return res.ok(nInvite);
}
return res.ok(invite);
}
export default combine([method(['GET', 'DELETE']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,59 +0,0 @@
import { config } from '@/lib/config';
import { randomCharacters } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { parseExpiry } from '@/lib/uploader/parseHeaders';
export type ApiAuthInvitesResponse = Invite | Invite[];
type Body = {
expiresAt: string;
maxUses?: number;
};
const logger = log('api').c('auth').c('invites');
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiAuthInvitesResponse>) {
if (req.method === 'POST') {
const { expiresAt, maxUses } = req.body;
if (!expiresAt) return res.badRequest('expiresAt is required');
let expires = null;
if (expiresAt !== 'never') expires = parseExpiry(expiresAt);
const invite = await prisma.invite.create({
data: {
code: randomCharacters(config.invites.length),
expiresAt: expires,
maxUses: maxUses ?? null,
inviterId: req.user.id,
},
include: {
inviter: inviteInviterSelect,
},
});
logger.info(`${req.user.username} created an invite`, {
maxUses,
expiresAt,
code: invite.code,
});
return res.ok(invite);
}
const invites = await prisma.invite.findMany({
include: {
inviter: inviteInviterSelect,
},
});
return res.ok(invites);
}
export default combine([method(['GET', 'POST']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,65 +0,0 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/lib/login';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { verifyTotpCode } from '@/lib/totp';
export type ApiLoginResponse = {
user?: User;
token?: string;
totp?: true;
};
type Body = {
username: string;
password: string;
code?: string;
};
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>) {
const { username, password, code } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
...userSelect,
password: true,
token: true,
},
});
if (!user) return res.badRequest('Invalid username', { username: true });
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
const valid = await verifyPassword(password, user.password);
if (!valid) return res.badRequest('Invalid password', { password: true });
if (user.totpSecret && code) {
const valid = verifyTotpCode(code, user.totpSecret);
if (!valid) return res.badRequest('Invalid code', { code: true });
}
if (user.totpSecret && !code)
return res.ok({
totp: true,
});
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;
return res.ok({
token,
user,
});
}
export default combine([method(['POST'])], handler);

View File

@@ -1,16 +0,0 @@
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiLogoutResponse = {
loggedOut?: boolean;
};
async function handler(_: NextApiReq, res: NextApiRes<ApiLogoutResponse>) {
res.setHeader('Set-Cookie', 'zipline_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT');
return res.ok({ loggedOut: true });
}
export default combine([method(['GET']), ziplineAuth()], handler);

View File

@@ -1,85 +0,0 @@
import { config } from '@/lib/config';
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/lib/login';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { ApiLoginResponse } from './login';
import { log } from '@/lib/logger';
export type ApiAuthRegisterResponse = ApiLoginResponse;
type Body = {
username: string;
password: string;
code?: string;
};
const _logger = log('api').c('auth').c('register');
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiAuthRegisterResponse>) {
const { username, password, code } = req.body;
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
if (!code && !config.features.userRegistration) return res.badRequest('User registration is disabled');
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const oUser = await prisma.user.findUnique({
where: {
username,
},
});
if (oUser) return res.badRequest('Username is taken', { username: true });
if (code) {
const invite = await prisma.invite.findFirst({
where: {
OR: [{ id: code }, { code }],
},
});
if (!invite) return res.badRequest('Invalid invite code');
if (invite.expiresAt && new Date(invite.expiresAt) < new Date())
return res.badRequest('Invalid invite code', { expired: true });
if (invite.maxUses && invite.uses >= invite.maxUses)
return res.badRequest('Invalid invite code', { uses: true });
await prisma.invite.update({
where: {
id: invite.id,
},
data: {
uses: invite.uses + 1,
},
});
}
const user = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
role: 'USER',
token: createToken(),
},
select: {
...userSelect,
password: true,
token: true,
},
});
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;
return res.ok({
token,
user,
});
}
export default combine([method(['POST'])], handler);

View File

@@ -1,67 +0,0 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/lib/login';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { AuthenticationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
export type ApiAuthWebauthnResponse = {
user: User;
token: string;
};
type Body = {
auth: AuthenticationResponseJSON;
};
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiAuthWebauthnResponse>) {
if (!config.mfa.passkeys) return res.badRequest('Passkeys are not enabled');
const { auth } = req.body;
if (!auth) return res.badRequest('Missing webauthn payload');
const user = await prisma.user.findFirst({
where: {
passkeys: {
some: {
reg: {
path: ['id'],
equals: auth.id,
},
},
},
},
select: {
...userSelect,
password: true,
token: true,
},
});
if (!user) return res.badRequest('Invalid passkey');
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;
await prisma.userPasskey.updateMany({
where: {
reg: {
path: ['id'],
equals: auth.id,
},
},
data: {
lastUsed: new Date(),
},
});
return res.ok({
token,
user,
});
}
export default combine([method(['POST'])], handler);

View File

@@ -1,106 +0,0 @@
import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFilesIdResponse = File;
type Body = {
favorite?: boolean;
maxViews?: number;
password?: string | null;
originalName?: string;
type?: string;
tags?: string[];
};
type Query = {
id: string;
};
const logger = log('api').c('user').c('files').c('[id]');
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFilesIdResponse>) {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.query.id }, { name: req.query.id }],
},
select: fileSelect,
});
if (!file) return res.notFound();
if (req.method === 'PATCH') {
if (req.body.maxViews !== undefined && req.body.maxViews < 0)
return res.badRequest('maxViews must be >= 0');
let password: string | null | undefined = undefined;
if (req.body.password !== undefined) {
if (req.body.password === null) {
password = null;
} else if (typeof req.body.password === 'string') {
password = await hashPassword(req.body.password);
} else {
return res.badRequest('password must be a string');
}
}
if (req.body.tags !== undefined) {
const tags = await prisma.tag.findMany({
where: {
userId: req.user.id,
id: {
in: req.body.tags,
},
},
});
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
}
const newFile = await prisma.file.update({
where: {
id: req.query.id,
},
data: {
...(req.body.favorite !== undefined && { favorite: req.body.favorite }),
...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }),
...(req.body.originalName !== undefined && { originalName: req.body.originalName }),
...(req.body.type !== undefined && { type: req.body.type }),
...(password !== undefined && { password }),
...(req.body.tags !== undefined && {
tags: {
set: req.body.tags.map((tag) => ({ id: tag })),
},
}),
},
select: fileSelect,
});
logger.info(`${req.user.username} updated file ${newFile.name}`, { favorite: newFile.favorite });
return res.ok(newFile);
} else if (req.method === 'DELETE') {
const deletedFile = await prisma.file.delete({
where: {
id: req.query.id,
},
select: fileSelect,
});
await datasource.delete(deletedFile.name);
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, { size: bytes(deletedFile.size) });
return res.ok(deletedFile);
}
return res.ok(file);
}
export default combine([method(['GET', 'PATCH', 'DELETE']), ziplineAuth()], handler);

View File

@@ -1,39 +0,0 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFilesIdPasswordResponse = {
success: boolean;
};
type Body = {
password: string;
};
const logger = log('api').c('user').c('files').c('$id').c('password');
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserFilesIdPasswordResponse>) {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.query.id }, { name: req.query.id }],
},
select: {
name: true,
password: true,
},
});
if (!file) return res.notFound();
if (!file.password) return res.notFound();
const verified = await verifyPassword(req.body.password, file.password);
if (!verified) return res.forbidden('Incorrect password');
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
return res.ok({ success: true });
}
export default combine([method(['POST'])], handler);

View File

@@ -1,47 +0,0 @@
import { prisma } from '@/lib/db';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFilesIncompleteResponse = IncompleteFile[] | { count: number };
type Body = {
id: string[];
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserFilesIncompleteResponse>) {
if (req.method === 'DELETE') {
if (!req.body.id) return res.badRequest('no id array provided');
const existingFiles = await prisma.incompleteFile.findMany({
where: {
id: {
in: req.body.id,
},
userId: req.user.id,
},
});
const incompleteFiles = await prisma.incompleteFile.deleteMany({
where: {
id: {
in: existingFiles.map((x) => x.id),
},
},
});
return res.json({ incompletefiles: incompleteFiles });
}
const incompleteFiles = await prisma.incompleteFile.findMany({
where: {
userId: req.user.id,
},
});
return res.json(incompleteFiles);
}
export default combine([method(['GET', 'DELETE']), ziplineAuth()], handler);

View File

@@ -1,226 +0,0 @@
import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { canInteract } from '@/lib/role';
import { Prisma } from '@prisma/client';
import { z } from 'zod';
export type ApiUserFilesResponse = {
page: File[];
search?: {
field: 'name' | 'originalName' | 'type' | 'tags';
query: string | string[];
};
total?: number;
pages?: number;
};
type Query = {
page?: string;
perpage?: string;
filter?: 'dashboard' | 'none' | 'all';
favorite?: 'true' | 'false';
sortBy: keyof Prisma.FileOrderByWithAggregationInput;
order: 'asc' | 'desc';
searchField?: 'name' | 'originalName' | 'type' | 'tags';
searchQuery?: string;
id?: string;
};
const validateSearchField = z.enum(['name', 'originalName', 'type', 'tags']).default('name');
const validateSortBy = z
.enum([
'id',
'createdAt',
'updatedAt',
'deletesAt',
'name',
'originalName',
'size',
'type',
'views',
'favorite',
])
.default('createdAt');
const validateOrder = z.enum(['asc', 'desc']).default('desc');
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserFilesResponse>) {
const user = await prisma.user.findUnique({
where: {
id: req.query.id ?? req.user.id,
},
});
if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role))
return res.forbidden("You can't view this user's files.");
if (!user) return res.notFound('User not found');
const perpage = Number(req.query.perpage || '9');
if (isNaN(Number(perpage))) return res.badRequest('Perpage must be a number');
const searchQuery = req.query.searchQuery ? decodeURIComponent(req.query.searchQuery.trim()) ?? null : null;
const { page, filter, favorite } = req.query;
if (!page && !searchQuery) return res.badRequest('Page is required');
if (isNaN(Number(page)) && !searchQuery) return res.badRequest('Page must be a number');
const sortBy = validateSortBy.safeParse(req.query.sortBy || 'createdAt');
if (!sortBy.success) return res.badRequest('Invalid sortBy value');
const order = validateOrder.safeParse(req.query.order || 'desc');
if (!order.success) return res.badRequest('Invalid order value');
const searchField = validateSearchField.safeParse(req.query.searchField || 'name');
if (!searchField.success) return res.badRequest('Invalid searchField value');
if (searchQuery) {
let tagFiles: string[] = [];
if (searchField.data === 'tags') {
const parsedTags = searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
const foundTags = await prisma.tag.findMany({
where: {
userId: user.id,
id: {
in: searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag),
},
},
include: {
files: {
select: {
id: true,
},
},
},
});
if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere');
tagFiles = foundTags
.map((tag) => tag.files.map((file) => file.id))
.reduce((a, b) => a.filter((c) => b.includes(c)));
}
const similarityResult = await prisma.file.findMany({
where: {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
...(searchField.data === 'tags'
? {
id: {
in: tagFiles,
},
}
: {
[searchField.data]: {
contains: searchQuery,
mode: 'insensitive',
},
}),
},
select: fileSelect,
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
});
return res.ok({
page: cleanFiles(similarityResult),
search: {
field: searchField.data,
query:
searchField.data === 'tags'
? searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag)
: searchQuery,
},
});
}
const where = {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
};
const count = await prisma.file.count({
where,
});
const files = cleanFiles(
await prisma.file.findMany({
where,
select: {
...fileSelect,
password: true,
},
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
}),
);
return res.ok({
page: files,
total: count,
pages: Math.ceil(count / perpage),
});
}
export default combine([method(['GET']), ziplineAuth()], handler);

View File

@@ -1,125 +0,0 @@
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFilesTransactionResponse = {
count: number;
name?: string;
};
type Body = {
files: string[];
favorite?: boolean;
folder?: string;
delete_datasourceFiles?: boolean;
};
const logger = log('api').c('user').c('files').c('transaction');
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserFilesTransactionResponse>) {
const { files, favorite, folder } = req.body;
if (!files || !files.length) return res.badRequest('Cannot process transaction without files');
if (req.method === 'DELETE') {
const { delete_datasourceFiles } = req.body;
logger.debug('preparing transaction', {
action: 'delete',
files: files.length,
});
if (delete_datasourceFiles) {
const dFiles = await prisma.file.findMany({
where: {
id: {
in: files,
},
},
});
for (let i = 0; i !== dFiles.length; ++i) {
await datasource.delete(dFiles[i].name);
}
logger.info(`${req.user.username} deleted ${dFiles.length} files from datasource`, {
user: req.user.id,
});
}
const resp = await prisma.file.deleteMany({
where: {
id: {
in: files,
},
},
});
logger.info(`${req.user.username} deleted ${resp.count} files`, {
user: req.user.id,
});
return res.ok(resp);
}
if (favorite) {
const resp = await prisma.file.updateMany({
where: {
id: {
in: files,
},
},
data: {
favorite: favorite ?? false,
},
});
logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, {
user: req.user.id,
});
return res.ok(resp);
}
if (!folder) return res.badRequest("can't PATCH without an action");
const f = await prisma.folder.findUnique({
where: {
id: folder,
userId: req.user.id,
},
});
if (!f) return res.notFound('folder not found');
const resp = await prisma.file.updateMany({
where: {
id: {
in: files,
},
},
data: {
folderId: folder,
},
});
logger.info(`${req.user.username} moved ${resp.count} files to ${f.name}`, {
user: req.user.id,
folderId: f.id,
});
return res.ok({
...resp,
name: f.name,
});
}
export default combine([method(['DELETE', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,178 +0,0 @@
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { Folder, cleanFolder } from '@/lib/db/models/folder';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFoldersIdResponse = Folder;
type Query = {
id: string;
};
type Body = {
id?: string;
isPublic?: boolean;
delete?: 'file' | 'folder';
};
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFoldersIdResponse>) {
const { id } = req.query;
const folder = await prisma.folder.findUnique({
where: {
id,
},
include: {
files: {
select: {
...fileSelect,
password: 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 (req.method === 'POST') {
const { id } = req.body;
if (!id) return res.badRequest('File id is required');
const file = await prisma.file.findUnique({
where: {
id,
},
});
if (!file) return res.notFound('File not found');
if (file.userId !== req.user.id) return res.forbidden('You do not own this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id,
Folder: {
id: folder.id,
},
},
});
if (fileInFolder) return res.badRequest('File already in folder');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
files: {
connect: {
id,
},
},
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
} else if (req.method === 'PATCH') {
const { isPublic } = req.body;
if (isPublic === undefined) return res.badRequest('isPublic is required');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
public: isPublic,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
} else if (req.method === 'DELETE') {
const { delete: del } = req.body;
if (del === 'folder') {
const nFolder = await prisma.folder.delete({
where: {
id: folder.id,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
} else if (del === 'file') {
const { id } = req.body;
if (!id) return res.badRequest('File id is required');
const file = await prisma.file.findUnique({
where: {
id,
},
});
if (!file) return res.notFound('File not found');
if (file.userId !== req.user.id) return res.forbidden('You do not own this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id,
Folder: {
id: folder.id,
},
},
});
if (!fileInFolder) return res.badRequest('File not in folder');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
files: {
disconnect: {
id,
},
},
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
}
return res.badRequest('Invalid delete type');
}
return res.ok(cleanFolder(folder));
}
export default combine([method(['GET', 'POST', 'PATCH', 'DELETE']), ziplineAuth()], handler);

View File

@@ -1,92 +0,0 @@
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { Folder, cleanFolder, cleanFolders } from '@/lib/db/models/folder';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFoldersResponse = Folder | Folder[];
type Body = {
files?: string[];
name?: string;
isPublic?: boolean;
};
type Query = {
noincl?: boolean;
};
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFoldersResponse>) {
const { noincl } = req.query;
if (req.method === 'POST') {
const { name, isPublic } = req.body;
let files = req.body.files;
if (!name) return res.badRequest('Name is required');
if (files) {
const filesAdd = await prisma.file.findMany({
where: {
id: {
in: files,
},
},
select: {
id: true,
},
});
if (!filesAdd.length) return res.badRequest('No files found, with given request');
files = filesAdd.map((f) => f.id);
}
const folder = await prisma.folder.create({
data: {
name,
userId: req.user.id,
...(files?.length && {
files: {
connect: files!.map((f) => ({ id: f })),
},
}),
public: isPublic ?? false,
},
...(!noincl && {
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
});
return res.ok(cleanFolder(folder));
}
const folders = await prisma.folder.findMany({
where: {
userId: req.user.id,
},
...(!noincl && {
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
});
return res.ok(cleanFolders(folders));
}
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);

View File

@@ -1,60 +0,0 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User } from '@/lib/db/models/user';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
import { Prisma } from '@prisma/client';
export type ApiUserMfaPasskeyResponse = User | User['passkeys'];
type Body = {
reg?: RegistrationResponseJSON;
name?: string;
id?: string;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserMfaPasskeyResponse>) {
if (!config.mfa.passkeys) return res.badRequest('Passkeys are not enabled');
if (req.method === 'POST') {
const { reg, name } = req.body;
if (!reg) return res.badRequest('Missing webauthn response');
if (!name) return res.badRequest('Missing name');
const user = await prisma.user.update({
where: { id: req.user.id },
data: {
passkeys: {
create: {
name,
reg: reg as unknown as Prisma.InputJsonValue,
lastUsed: new Date(),
},
},
},
});
return res.json(user);
} else if (req.method === 'DELETE') {
const { id } = req.body;
if (!id) return res.badRequest('Missing id');
const user = await prisma.user.update({
where: { id: req.user.id },
data: {
passkeys: {
delete: { id },
},
},
});
return res.json(user);
}
return res.json(req.user.passkeys);
}
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);

View File

@@ -1,75 +0,0 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp';
export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: string; qrcode: string };
type Body = {
code?: string;
secret?: string;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserMfaTotpResponse>) {
if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled');
if (req.method === 'DELETE') {
if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled");
const { code } = req.body;
if (!code) return res.badRequest('Missing code');
if (code.length !== 6) return res.badRequest('Invalid code');
const valid = verifyTotpCode(code, req.user.totpSecret);
if (!valid) return res.badRequest('Invalid code');
const user = await prisma.user.update({
where: { id: req.user.id },
data: { totpSecret: null },
select: userSelect,
});
return res.json(user);
} else if (req.method === 'POST') {
const { code, secret } = req.body;
if (!code) return res.badRequest('Missing code');
if (code.length !== 6) return res.badRequest('Invalid code');
if (!secret) return res.badRequest('Missing secret');
const valid = verifyTotpCode(code, secret);
if (!valid) return res.badRequest('Invalid code');
const user = await prisma.user.update({
where: { id: req.user.id },
data: { totpSecret: secret },
select: userSelect,
});
return res.json(user);
}
if (!req.user.totpSecret) {
const secret = generateKey();
const qrcode = await totpQrcode({
issuer: config.mfa.totp.issuer,
username: req.user.username,
secret,
});
return res.json({
secret,
qrcode,
});
}
return res.json({
secret: req.user.totpSecret,
});
}
export default combine([method(['GET', 'POST', 'DELETE']), ziplineAuth()], handler);

View File

@@ -1,72 +0,0 @@
import { prisma } from '@/lib/db';
import { Tag, tagSelect } from '@/lib/db/models/tag';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserTagsIdResponse = Tag;
type Body = {
name?: string;
color?: string;
};
type Query = {
id: string;
};
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserTagsIdResponse>) {
const { id } = req.query;
const tag = await prisma.tag.findFirst({
where: {
userId: req.user.id,
id,
},
select: tagSelect,
});
if (!tag) return res.notFound();
if (req.method === 'DELETE') {
const tag = await prisma.tag.delete({
where: {
id,
},
select: tagSelect,
});
return res.ok(tag);
}
if (req.method === 'PATCH') {
const { name, color } = req.body;
if (name) {
const existing = await prisma.tag.findFirst({
where: {
name,
},
});
if (existing) return res.badRequest('tag name already exists');
}
const tag = await prisma.tag.update({
where: {
id,
},
data: {
...(name && { name }),
...(color && { color }),
},
select: tagSelect,
});
return res.ok(tag);
}
return res.ok(tag);
}
export default combine([method(['GET', 'DELETE', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,41 +0,0 @@
import { prisma } from '@/lib/db';
import { Tag, tagSelect } from '@/lib/db/models/tag';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserTagsResponse = Tag | Tag[];
type Body = {
name: string;
color: string;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserTagsResponse>) {
if (req.method === 'POST') {
const { name, color } = req.body;
const tag = await prisma.tag.create({
data: {
name,
color,
userId: req.user.id,
},
select: tagSelect,
});
return res.ok(tag);
}
const tags = await prisma.tag.findMany({
where: {
userId: req.user.id,
},
select: tagSelect,
});
return res.ok(tags);
}
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);

View File

@@ -1,39 +0,0 @@
import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserUrlsIdResponse = Url;
type Query = {
id: string;
};
export async function handler(req: NextApiReq<unknown, Query>, res: NextApiRes<ApiUserUrlsIdResponse>) {
const { id } = req.query;
const url = await prisma.url.findFirst({
where: {
id: id,
},
});
if (!url) return res.notFound();
if (url.userId !== req.user.id) return res.forbidden('You do not own this URL');
if (req.method === 'DELETE') {
const url = await prisma.url.delete({
where: {
id: id,
},
});
return res.ok(url);
}
return res.ok(url);
}
export default combine([method(['GET', 'DELETE']), ziplineAuth()], handler);

View File

@@ -1,39 +0,0 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserUrlsIdPasswordResponse = {
success: boolean;
};
type Body = {
password: string;
};
const logger = log('api').c('user').c('urls').c('$id').c('password');
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserUrlsIdPasswordResponse>) {
const url = await prisma.url.findFirst({
where: {
OR: [{ id: req.query.id }, { code: req.query.id }, { vanity: req.query.id }],
},
select: {
password: true,
id: true,
},
});
if (!url) return res.notFound();
if (!url.password) return res.notFound();
const verified = await verifyPassword(req.body.password, url.password);
if (!verified) return res.forbidden('Incorrect password');
logger.info(`url ${url.id} was accessed with the correct password`, { ua: req.headers['user-agent'] });
return res.ok({ success: true });
}
export default combine([method(['POST'])], handler);

View File

@@ -1,159 +0,0 @@
import { config } from '@/lib/config';
import { hashPassword, randomCharacters } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { onShorten } from '@/lib/discord';
export type ApiUserUrlsResponse =
| Url[]
| {
url: string;
};
type Body = {
vanity?: string;
destination: string;
};
type Headers = {
'x-zipline-max-views': string;
'x-zipline-no-json': string;
'x-zipline-domain': string;
};
type Query = {
searchField?: 'destination' | 'vanity' | 'code';
searchQuery?: string;
searchThreshold?: string;
};
const validateSearchField = z.enum(['destination', 'vanity', 'code']).default('destination');
const validateThreshold = z.number().default(0.1);
const logger = log('api').c('user').c('urls');
export async function handler(req: NextApiReq<Body, Query, Headers>, res: NextApiRes<ApiUserUrlsResponse>) {
if (req.method === 'POST') {
const { vanity, destination } = req.body;
const noJson = !!req.headers['x-zipline-no-json'];
const countUrls = await prisma.url.count({
where: {
userId: req.user.id,
},
});
if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
return res.forbidden(`shortenning this url would exceed your quota of ${req.user.quota.maxUrls} urls`);
let maxViews: number | undefined;
const returnDomain = req.headers['x-zipline-domain'];
const maxViewsHeader = req.headers['x-zipline-max-views'];
if (maxViewsHeader) {
maxViews = Number(maxViewsHeader);
if (isNaN(maxViews)) return res.badRequest('Max views must be a number');
if (maxViews < 0) return res.badRequest('Max views must be greater than 0');
}
const password = req.headers['x-zipline-password']
? await hashPassword(req.headers['x-zipline-password'])
: undefined;
if (!destination) return res.badRequest('Destination is required');
if (vanity) {
const existingVanity = await prisma.url.findFirst({
where: {
vanity: vanity,
},
});
if (existingVanity) return res.badRequest('Vanity already taken');
}
const url = await prisma.url.create({
data: {
userId: req.user.id,
destination: destination,
code: randomCharacters(config.urls.length),
...(vanity && { vanity: vanity }),
...(maxViews && { maxViews: maxViews }),
...(password && { password: password }),
},
});
let domain;
if (returnDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${returnDomain}`;
} else if (config.core.defaultDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${config.core.defaultDomain}`;
} else {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
const responseUrl = `${domain}${
config.urls.route === '/' || config.urls.route === '' ? '' : `${config.urls.route}`
}/${url.vanity ?? url.code}`;
logger.info(`${req.user.username} shortened a URL`, {
from: destination,
to: responseUrl,
user: req.user.id,
});
onShorten({
user: req.user,
url,
link: {
returned: responseUrl,
},
});
if (noJson) return res.status(200).end(responseUrl);
return res.ok({
url: responseUrl,
});
}
const searchQuery = req.query.searchQuery ? decodeURIComponent(req.query.searchQuery.trim()) ?? null : null;
const searchField = validateSearchField.safeParse(req.query.searchField || 'destination');
if (!searchField.success) return res.badRequest('Invalid searchField value');
const searchThreshold = validateThreshold.safeParse(Number(req.query.searchThreshold) || 0.1);
if (!searchThreshold.success) return res.badRequest('Invalid searchThreshold value');
if (searchQuery) {
const similarityResult: Url[] = await prisma.$queryRaw`
SELECT
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) AS similarity,
*
FROM "Url"
WHERE
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) > ${Prisma.raw(
String(searchThreshold.data),
)} OR
"${Prisma.raw(searchField.data)}" ILIKE '${Prisma.sql`%${searchQuery}%`}' AND
"userId" = ${req.user.id};
`;
return res.ok(similarityResult);
}
const urls = await prisma.url.findMany({
where: {
userId: req.user.id,
},
});
return res.ok(urls);
}
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);

View File

@@ -91,9 +91,9 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
});
if (error) {
if (error.username) form.setFieldError('username', 'Invalid username');
else if (error.password) form.setFieldError('password', 'Invalid password');
else if (error.code) setPinError(error.message!);
if (error.message === 'Invalid username') form.setFieldError('username', 'Invalid username');
else if (error.message === 'Invalid password') form.setFieldError('password', 'Invalid password');
else if (error.message === 'Invalid code') setPinError(error.message!);
setPinDisabled(false);
} else {
if (data!.totp) {

View File

@@ -61,7 +61,7 @@ export default function Register({ config, invite }: InferGetServerSidePropsType
});
if (error) {
if (error.username) form.setFieldError('username', 'Username is taken');
if (error.message === 'Username is taken') form.setFieldError('username', 'Username is taken');
else {
notifications.show({
title: 'Failed to register',

View File

@@ -0,0 +1,64 @@
import { prisma } from '@/lib/db';
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
import { log } from '@/lib/logger';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiAuthInvitesIdResponse = Invite;
type Params = {
id: string;
};
const logger = log('api').c('auth').c('invites').c('[id]');
export const PATH = '/api/auth/invites/:id';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
Params: Params;
}>({
url: PATH,
method: ['GET', 'DELETE'],
preHandler: [userMiddleware, administratorMiddleware],
handler: async (req, res) => {
const { id } = req.params;
const invite = await prisma.invite.findFirst({
where: {
OR: [{ id }, { code: id }],
},
include: {
inviter: inviteInviterSelect,
},
});
if (!invite) return res.notFound('Invite not found through id or code');
if (req.method === 'DELETE') {
const nInvite = await prisma.invite.delete({
where: {
id: invite.id,
},
include: {
inviter: inviteInviterSelect,
},
});
logger.info(`${req.user.username} deleted an invite`, {
id: invite.id,
code: invite.code,
});
return res.send(nInvite);
}
return res.send(invite);
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,72 @@
import { config } from '@/lib/config';
import { randomCharacters } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
import { log } from '@/lib/logger';
import { parseExpiry } from '@/lib/uploader/parseHeaders';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiAuthInvitesResponse = Invite | Invite[];
type Body = {
expiresAt: string;
maxUses?: number;
};
const logger = log('api').c('auth').c('invites');
export const PATH = '/api/auth/invites';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['GET', 'POST'],
preHandler: [userMiddleware, administratorMiddleware],
handler: async (req, res) => {
if (req.method === 'POST') {
const { expiresAt, maxUses } = req.body;
if (!expiresAt) return res.badRequest('expiresAt is required');
let expires = null;
if (expiresAt !== 'never') expires = parseExpiry(expiresAt);
const invite = await prisma.invite.create({
data: {
code: randomCharacters(config.invites.length),
expiresAt: expires,
maxUses: maxUses ?? null,
inviterId: req.user.id,
},
include: {
inviter: inviteInviterSelect,
},
});
logger.info(`${req.user.username} created an invite`, {
maxUses,
expiresAt,
code: invite.code,
});
return res.send(invite);
}
const invites = await prisma.invite.findMany({
include: {
inviter: inviteInviterSelect,
},
});
return res.send(invites);
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,75 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { verifyTotpCode } from '@/lib/totp';
import { loginToken } from '@/server/loginToken';
import fastifyPlugin from 'fastify-plugin';
export type ApiLoginResponse = {
user?: User;
token?: string;
totp?: true;
};
type Body = {
username: string;
password: string;
code?: string;
};
export const PATH = '/api/auth/login';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['POST'],
handler: async (req, res) => {
const { username, password, code } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
...userSelect,
password: true,
token: true,
},
});
if (!user) return res.badRequest('Invalid username');
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
const valid = await verifyPassword(password, user.password);
if (!valid) return res.badRequest('Invalid password');
if (user.totpSecret && code) {
const valid = verifyTotpCode(code, user.totpSecret);
if (!valid) return res.badRequest('Invalid code');
}
if (user.totpSecret && !code)
return res.send({
totp: true,
});
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;
return res.send({
token,
user,
});
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,27 @@
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiLogoutResponse = {
loggedOut?: boolean;
};
export const PATH = '/api/auth/logout';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['GET'],
preHandler: [userMiddleware],
handler: async (_, res) => {
res.header('Set-Cookie', 'zipline_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT');
return res.send({ loggedOut: true });
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,92 @@
import { config } from '@/lib/config';
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { userSelect } from '@/lib/db/models/user';
import fastifyPlugin from 'fastify-plugin';
import { ApiLoginResponse } from './login';
import { loginToken } from '@/server/loginToken';
export type ApiAuthRegisterResponse = ApiLoginResponse;
type Body = {
username: string;
password: string;
code?: string;
};
export const PATH = '/api/auth/register';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['POST'],
handler: async (req, res) => {
const { username, password, code } = req.body;
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
if (!code && !config.features.userRegistration)
return res.badRequest('User registration is disabled');
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const oUser = await prisma.user.findUnique({
where: {
username,
},
});
if (oUser) return res.badRequest('Username is taken');
if (code) {
const invite = await prisma.invite.findFirst({
where: {
OR: [{ id: code }, { code }],
},
});
if (!invite) return res.badRequest('Invalid invite code');
if (invite.expiresAt && new Date(invite.expiresAt) < new Date())
return res.badRequest('Invalid invite code');
if (invite.maxUses && invite.uses >= invite.maxUses) return res.badRequest('Invalid invite code');
await prisma.invite.update({
where: {
id: invite.id,
},
data: {
uses: invite.uses + 1,
},
});
}
const user = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
role: 'USER',
token: createToken(),
},
select: {
...userSelect,
password: true,
token: true,
},
});
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;
return res.send({
token,
user,
});
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,77 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/server/loginToken';
import { AuthenticationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
import fastifyPlugin from 'fastify-plugin';
export type ApiAuthWebauthnResponse = {
user: User;
token: string;
};
type Body = {
auth: AuthenticationResponseJSON;
};
export const PATH = '/api/auth/webauthn';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['POST'],
handler: async (req, res) => {
if (!config.mfa.passkeys) return res.badRequest('Passkeys are not enabled');
const { auth } = req.body;
if (!auth) return res.badRequest('Missing webauthn payload');
const user = await prisma.user.findFirst({
where: {
passkeys: {
some: {
reg: {
path: ['id'],
equals: auth.id,
},
},
},
},
select: {
...userSelect,
password: true,
token: true,
},
});
if (!user) return res.badRequest('Invalid passkey');
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;
await prisma.userPasskey.updateMany({
where: {
reg: {
path: ['id'],
equals: auth.id,
},
},
data: {
lastUsed: new Date(),
},
});
return res.send({
token,
user,
});
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,120 @@
import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFilesIdResponse = File;
type Body = {
favorite?: boolean;
maxViews?: number;
password?: string | null;
originalName?: string;
type?: string;
tags?: string[];
};
type Params = {
id: string;
};
const logger = log('api').c('user').c('files').c('[id]');
export const PATH = '/api/user/files/:id';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
Params: Params;
}>({
url: PATH,
method: ['GET', 'PATCH', 'DELETE'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.params.id }, { name: req.params.id }],
},
select: fileSelect,
});
if (!file) return res.notFound();
if (req.method === 'PATCH') {
if (req.body.maxViews !== undefined && req.body.maxViews < 0)
return res.badRequest('maxViews must be >= 0');
let password: string | null | undefined = undefined;
if (req.body.password !== undefined) {
if (req.body.password === null) {
password = null;
} else if (typeof req.body.password === 'string') {
password = await hashPassword(req.body.password);
} else {
return res.badRequest('password must be a string');
}
}
if (req.body.tags !== undefined) {
const tags = await prisma.tag.findMany({
where: {
userId: req.user.id,
id: {
in: req.body.tags,
},
},
});
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
}
const newFile = await prisma.file.update({
where: {
id: req.params.id,
},
data: {
...(req.body.favorite !== undefined && { favorite: req.body.favorite }),
...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }),
...(req.body.originalName !== undefined && { originalName: req.body.originalName }),
...(req.body.type !== undefined && { type: req.body.type }),
...(password !== undefined && { password }),
...(req.body.tags !== undefined && {
tags: {
set: req.body.tags.map((tag) => ({ id: tag })),
},
}),
},
select: fileSelect,
});
logger.info(`${req.user.username} updated file ${newFile.name}`, { favorite: newFile.favorite });
return res.send(newFile);
} else if (req.method === 'DELETE') {
const deletedFile = await prisma.file.delete({
where: {
id: req.params.id,
},
select: fileSelect,
});
await datasource.delete(deletedFile.name);
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
size: bytes(deletedFile.size),
});
return res.send(deletedFile);
}
return res.send(file);
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,56 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFilesIdPasswordResponse = {
success: boolean;
};
type Body = {
password: string;
};
type Params = {
id: string;
};
const logger = log('api').c('user').c('files').c('[id]').c('password');
export const PATH = '/api/user/files/:id/password';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
Params: Params;
}>({
url: PATH,
method: ['POST'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.params.id }, { name: req.params.id }],
},
select: {
name: true,
password: true,
},
});
if (!file) return res.notFound();
if (!file.password) return res.notFound();
const verified = await verifyPassword(req.body.password, file.password);
if (!verified) return res.forbidden('Incorrect password');
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
return res.send({ success: true });
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,58 @@
import { prisma } from '@/lib/db';
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFilesIncompleteResponse = IncompleteFile[] | { count: number };
type Body = {
id: string[];
};
export const PATH = '/api/user/files/incomplete';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['GET', 'DELETE'],
preHandler: [userMiddleware],
handler: async (req, res) => {
if (req.method === 'DELETE') {
if (!req.body.id) return res.badRequest('no id array provided');
const existingFiles = await prisma.incompleteFile.findMany({
where: {
id: {
in: req.body.id,
},
userId: req.user.id,
},
});
const incompleteFiles = await prisma.incompleteFile.deleteMany({
where: {
id: {
in: existingFiles.map((x) => x.id),
},
},
});
return res.send(incompleteFiles);
}
const incompleteFiles = await prisma.incompleteFile.findMany({
where: {
userId: req.user.id,
},
});
return res.send(incompleteFiles);
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,239 @@
import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { canInteract } from '@/lib/role';
import { userMiddleware } from '@/server/middleware/user';
import { Prisma } from '@prisma/client';
import fastifyPlugin from 'fastify-plugin';
import { z } from 'zod';
export type ApiUserFilesResponse = {
page: File[];
search?: {
field: 'name' | 'originalName' | 'type' | 'tags';
query: string | string[];
};
total?: number;
pages?: number;
};
type Query = {
page?: string;
perpage?: string;
filter?: 'dashboard' | 'none' | 'all';
favorite?: 'true' | 'false';
sortBy: keyof Prisma.FileOrderByWithAggregationInput;
order: 'asc' | 'desc';
searchField?: 'name' | 'originalName' | 'type' | 'tags';
searchQuery?: string;
id?: string;
};
const validateSearchField = z.enum(['name', 'originalName', 'type', 'tags']).default('name');
const validateSortBy = z
.enum([
'id',
'createdAt',
'updatedAt',
'deletesAt',
'name',
'originalName',
'size',
'type',
'views',
'favorite',
])
.default('createdAt');
const validateOrder = z.enum(['asc', 'desc']).default('desc');
export const PATH = '/api/user/files';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Querystring: Query;
}>({
url: PATH,
method: ['GET'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.query.id ?? req.user.id,
},
});
if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role))
return res.forbidden("You can't view this user's files.");
if (!user) return res.notFound('User not found');
const perpage = Number(req.query.perpage || '9');
if (isNaN(Number(perpage))) return res.badRequest('Perpage must be a number');
const searchQuery = req.query.searchQuery
? decodeURIComponent(req.query.searchQuery.trim()) ?? null
: null;
const { page, filter, favorite } = req.query;
if (!page && !searchQuery) return res.badRequest('Page is required');
if (isNaN(Number(page)) && !searchQuery) return res.badRequest('Page must be a number');
const sortBy = validateSortBy.safeParse(req.query.sortBy || 'createdAt');
if (!sortBy.success) return res.badRequest('Invalid sortBy value');
const order = validateOrder.safeParse(req.query.order || 'desc');
if (!order.success) return res.badRequest('Invalid order value');
const searchField = validateSearchField.safeParse(req.query.searchField || 'name');
if (!searchField.success) return res.badRequest('Invalid searchField value');
if (searchQuery) {
let tagFiles: string[] = [];
if (searchField.data === 'tags') {
const parsedTags = searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
const foundTags = await prisma.tag.findMany({
where: {
userId: user.id,
id: {
in: searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag),
},
},
include: {
files: {
select: {
id: true,
},
},
},
});
if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere');
tagFiles = foundTags
.map((tag) => tag.files.map((file) => file.id))
.reduce((a, b) => a.filter((c) => b.includes(c)));
}
const similarityResult = await prisma.file.findMany({
where: {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
...(searchField.data === 'tags'
? {
id: {
in: tagFiles,
},
}
: {
[searchField.data]: {
contains: searchQuery,
mode: 'insensitive',
},
}),
},
select: fileSelect,
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
});
return res.send({
page: cleanFiles(similarityResult),
search: {
field: searchField.data,
query:
searchField.data === 'tags'
? searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag)
: searchQuery,
},
});
}
const where = {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
};
const count = await prisma.file.count({
where,
});
const files = cleanFiles(
await prisma.file.findMany({
where,
select: {
...fileSelect,
password: true,
},
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
}),
);
return res.send({
page: files,
total: count,
pages: Math.ceil(count / perpage),
});
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,136 @@
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFilesTransactionResponse = {
count: number;
name?: string;
};
type Body = {
files: string[];
favorite?: boolean;
folder?: string;
delete_datasourceFiles?: boolean;
};
const logger = log('api').c('user').c('files').c('transaction');
export const PATH = '/api/user/files/transaction';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['PATCH', 'DELETE'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const { files, favorite, folder } = req.body;
if (!files || !files.length) return res.badRequest('Cannot process transaction without files');
if (req.method === 'DELETE') {
const { delete_datasourceFiles } = req.body;
logger.debug('preparing transaction', {
action: 'delete',
files: files.length,
});
if (delete_datasourceFiles) {
const dFiles = await prisma.file.findMany({
where: {
id: {
in: files,
},
},
});
for (let i = 0; i !== dFiles.length; ++i) {
await datasource.delete(dFiles[i].name);
}
logger.info(`${req.user.username} deleted ${dFiles.length} files from datasource`, {
user: req.user.id,
});
}
const resp = await prisma.file.deleteMany({
where: {
id: {
in: files,
},
},
});
logger.info(`${req.user.username} deleted ${resp.count} files`, {
user: req.user.id,
});
return res.send(resp);
}
if (favorite) {
const resp = await prisma.file.updateMany({
where: {
id: {
in: files,
},
},
data: {
favorite: favorite ?? false,
},
});
logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, {
user: req.user.id,
});
return res.send(resp);
}
if (!folder) return res.badRequest("can't PATCH without an action");
const f = await prisma.folder.findUnique({
where: {
id: folder,
userId: req.user.id,
},
});
if (!f) return res.notFound('folder not found');
const resp = await prisma.file.updateMany({
where: {
id: {
in: files,
},
},
data: {
folderId: folder,
},
});
logger.info(`${req.user.username} moved ${resp.count} files to ${f.name}`, {
user: req.user.id,
folderId: f.id,
});
return res.send({
...resp,
name: f.name,
});
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,190 @@
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { Folder, cleanFolder } from '@/lib/db/models/folder';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFoldersIdResponse = Folder;
type Params = {
id: string;
};
type Body = {
id?: string;
isPublic?: boolean;
delete?: 'file' | 'folder';
};
export const PATH = '/api/user/folders/:id';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
Params: Params;
}>({
url: PATH,
method: ['GET', 'POST', 'PATCH', 'DELETE'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const { id } = req.params;
const folder = await prisma.folder.findUnique({
where: {
id,
},
include: {
files: {
select: {
...fileSelect,
password: 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 (req.method === 'POST') {
const { id } = req.body;
if (!id) return res.badRequest('File id is required');
const file = await prisma.file.findUnique({
where: {
id,
},
});
if (!file) return res.notFound('File not found');
if (file.userId !== req.user.id) return res.forbidden('You do not own this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id,
Folder: {
id: folder.id,
},
},
});
if (fileInFolder) return res.badRequest('File already in folder');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
files: {
connect: {
id,
},
},
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.send(cleanFolder(nFolder));
} else if (req.method === 'PATCH') {
const { isPublic } = req.body;
if (isPublic === undefined) return res.badRequest('isPublic is required');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
public: isPublic,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.send(cleanFolder(nFolder));
} else if (req.method === 'DELETE') {
const { delete: del } = req.body;
if (del === 'folder') {
const nFolder = await prisma.folder.delete({
where: {
id: folder.id,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.send(cleanFolder(nFolder));
} else if (del === 'file') {
const { id } = req.body;
if (!id) return res.badRequest('File id is required');
const file = await prisma.file.findUnique({
where: {
id,
},
});
if (!file) return res.notFound('File not found');
if (file.userId !== req.user.id) return res.forbidden('You do not own this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id,
Folder: {
id: folder.id,
},
},
});
if (!fileInFolder) return res.badRequest('File not in folder');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
files: {
disconnect: {
id,
},
},
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.send(cleanFolder(nFolder));
}
return res.badRequest('Invalid delete type');
}
return res.send(cleanFolder(folder));
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,104 @@
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { Folder, cleanFolder, cleanFolders } from '@/lib/db/models/folder';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserFoldersResponse = Folder | Folder[];
type Body = {
files?: string[];
name?: string;
isPublic?: boolean;
};
type Query = {
noincl?: boolean;
};
export const PATH = '/api/user/folders';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
Querystring: Query;
}>({
url: PATH,
method: ['GET', 'POST'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const { noincl } = req.query;
if (req.method === 'POST') {
const { name, isPublic } = req.body;
let files = req.body.files;
if (!name) return res.badRequest('Name is required');
if (files) {
const filesAdd = await prisma.file.findMany({
where: {
id: {
in: files,
},
},
select: {
id: true,
},
});
if (!filesAdd.length) return res.badRequest('No files found, with given request');
files = filesAdd.map((f) => f.id);
}
const folder = await prisma.folder.create({
data: {
name,
userId: req.user.id,
...(files?.length && {
files: {
connect: files!.map((f) => ({ id: f })),
},
}),
public: isPublic ?? false,
},
...(!noincl && {
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
});
return res.send(cleanFolder(folder));
}
const folders = await prisma.folder.findMany({
where: {
userId: req.user.id,
},
...(!noincl && {
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
});
return res.send(cleanFolders(folders));
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,72 @@
import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/browser-ponyfill';
import { Prisma } from '@prisma/client';
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User } from '@/lib/db/models/user';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserMfaPasskeyResponse = User | User['passkeys'];
type Body = {
reg?: RegistrationResponseJSON;
name?: string;
id?: string;
};
export const PATH = '/api/user/mfa/passkey';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['GET', 'POST'],
preHandler: [userMiddleware],
handler: async (req, res) => {
if (!config.mfa.passkeys) return res.badRequest('Passkeys are not enabled');
if (req.method === 'POST') {
const { reg, name } = req.body;
if (!reg) return res.badRequest('Missing webauthn response');
if (!name) return res.badRequest('Missing name');
const user = await prisma.user.update({
where: { id: req.user.id },
data: {
passkeys: {
create: {
name,
reg: reg as unknown as Prisma.InputJsonValue,
lastUsed: new Date(),
},
},
},
});
return res.send(user);
} else if (req.method === 'DELETE') {
const { id } = req.body;
if (!id) return res.badRequest('Missing id');
const user = await prisma.user.update({
where: { id: req.user.id },
data: {
passkeys: {
delete: { id },
},
},
});
return res.send(user);
}
return res.send(req.user.passkeys);
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,86 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: string; qrcode: string };
type Body = {
code?: string;
secret?: string;
};
export const PATH = '/api/user/mfa/totp';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
}>({
url: PATH,
method: ['GET', 'POST', 'DELETE'],
preHandler: [userMiddleware],
handler: async (req, res) => {
if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled');
if (req.method === 'DELETE') {
if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled");
const { code } = req.body;
if (!code) return res.badRequest('Missing code');
if (code.length !== 6) return res.badRequest('Invalid code');
const valid = verifyTotpCode(code, req.user.totpSecret);
if (!valid) return res.badRequest('Invalid code');
const user = await prisma.user.update({
where: { id: req.user.id },
data: { totpSecret: null },
select: userSelect,
});
return res.send(user);
} else if (req.method === 'POST') {
const { code, secret } = req.body;
if (!code) return res.badRequest('Missing code');
if (code.length !== 6) return res.badRequest('Invalid code');
if (!secret) return res.badRequest('Missing secret');
const valid = verifyTotpCode(code, secret);
if (!valid) return res.badRequest('Invalid code');
const user = await prisma.user.update({
where: { id: req.user.id },
data: { totpSecret: secret },
select: userSelect,
});
return res.send(user);
}
if (!req.user.totpSecret) {
const secret = generateKey();
const qrcode = await totpQrcode({
issuer: config.mfa.totp.issuer,
username: req.user.username,
secret,
});
return res.send({
secret,
qrcode,
});
}
return res.send({
secret: req.user.totpSecret,
});
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,84 @@
import { prisma } from '@/lib/db';
import { Tag, tagSelect } from '@/lib/db/models/tag';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserTagsIdResponse = Tag;
type Body = {
name?: string;
color?: string;
};
type Params = {
id: string;
};
export const PATH = '/api/user/tags/:id';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Params: Params;
Body: Body;
}>({
url: PATH,
method: ['GET', 'DELETE', 'PATCH'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const { id } = req.params;
const tag = await prisma.tag.findFirst({
where: {
userId: req.user.id,
id,
},
select: tagSelect,
});
if (!tag) return res.notFound();
if (req.method === 'DELETE') {
const tag = await prisma.tag.delete({
where: {
id,
},
select: tagSelect,
});
return res.send(tag);
}
if (req.method === 'PATCH') {
const { name, color } = req.body;
if (name) {
const existing = await prisma.tag.findFirst({
where: {
name,
},
});
if (existing) return res.badRequest('tag name already exists');
}
const tag = await prisma.tag.update({
where: {
id,
},
data: {
...(name && { name }),
...(color && { color }),
},
select: tagSelect,
});
return res.send(tag);
}
return res.send(tag);
},
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,45 @@
import { prisma } from '@/lib/db';
import { Tag, tagSelect } from '@/lib/db/models/tag';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserTagsResponse = Tag | Tag[];
type Body = {
name: string;
color: string;
};
export const PATH = '/api/user/tags';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const tags = await prisma.tag.findMany({
where: {
userId: req.user.id,
},
select: tagSelect,
});
return res.send(tags);
});
server.post<{ Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const { name, color } = req.body;
const tag = await prisma.tag.create({
data: {
name,
color,
userId: req.user.id,
},
select: tagSelect,
});
return res.send(tag);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,54 @@
import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserUrlsIdResponse = Url;
type Params = {
id: string;
};
export const PATH = '/api/user/urls/:id';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const { id } = req.params;
const url = await prisma.url.findFirst({
where: {
id: id,
},
});
if (!url) return res.notFound();
if (url.userId !== req.user.id) return res.forbidden("You don't own this URL");
return res.send(url);
});
server.delete<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const { id } = req.params;
const url = await prisma.url.findFirst({
where: {
id: id,
},
});
if (!url) return res.notFound();
if (url.userId !== req.user.id) return res.forbidden("You don't own this URL");
const deletedUrl = await prisma.url.delete({
where: {
id: id,
},
});
return res.send(deletedUrl);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,48 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserUrlsIdPasswordResponse = {
success: boolean;
};
type Body = {
password: string;
};
type Params = {
id: string;
};
const logger = log('api').c('user').c('urls').c('[id]').c('password');
export const PATH = '/api/user/urls/:id/password';
export default fastifyPlugin(
(server, _, done) => {
server.post<{ Params: Params; Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const url = await prisma.url.findFirst({
where: {
OR: [{ id: req.params.id }, { code: req.params.id }, { vanity: req.params.id }],
},
select: {
password: true,
id: true,
},
});
if (!url) return res.notFound();
if (!url.password) return res.notFound();
const verified = await verifyPassword(req.body.password, url.password);
if (!verified) return res.forbidden('Incorrect password');
logger.info(`url ${url.id} was accessed with the correct password`, { ua: req.headers['user-agent'] });
return res.send({ success: true });
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,173 @@
import { config } from '@/lib/config';
import { hashPassword, randomCharacters } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { log } from '@/lib/logger';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { onShorten } from '@/lib/discord';
import fastifyPlugin from 'fastify-plugin';
import { userMiddleware } from '@/server/middleware/user';
export type ApiUserUrlsResponse =
| Url[]
| {
url: string;
};
type Body = {
vanity?: string;
destination: string;
};
type Headers = {
'x-zipline-max-views': string;
'x-zipline-no-json': string;
'x-zipline-domain': string;
'x-zipline-password': string;
};
type Query = {
searchField?: 'destination' | 'vanity' | 'code';
searchQuery?: string;
searchThreshold?: string;
};
export const PATH = '/api/user/urls';
const validateSearchField = z.enum(['destination', 'vanity', 'code']).default('destination');
const validateThreshold = z.number().default(0.1);
const logger = log('api').c('user').c('urls');
export default fastifyPlugin(
(server, _, done) => {
server.post<{ Body: Body; Headers: Headers }>(
PATH,
{ preHandler: [userMiddleware] },
async (req, res) => {
const { vanity, destination } = req.body;
const noJson = !!req.headers['x-zipline-no-json'];
const countUrls = await prisma.url.count({
where: {
userId: req.user.id,
},
});
if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
return res.forbidden(
`shortenning this url would exceed your quota of ${req.user.quota.maxUrls} urls`,
);
let maxViews: number | undefined;
const returnDomain = req.headers['x-zipline-domain'];
const maxViewsHeader = req.headers['x-zipline-max-views'];
if (maxViewsHeader) {
maxViews = Number(maxViewsHeader);
if (isNaN(maxViews)) return res.badRequest('Max views must be a number');
if (maxViews < 0) return res.badRequest('Max views must be greater than 0');
}
const password = req.headers['x-zipline-password']
? await hashPassword(req.headers['x-zipline-password'])
: undefined;
if (!destination) return res.badRequest('Destination is required');
if (vanity) {
const existingVanity = await prisma.url.findFirst({
where: {
vanity: vanity,
},
});
if (existingVanity) return res.badRequest('Vanity already taken');
}
const url = await prisma.url.create({
data: {
userId: req.user.id,
destination: destination,
code: randomCharacters(config.urls.length),
...(vanity && { vanity: vanity }),
...(maxViews && { maxViews: maxViews }),
...(password && { password: password }),
},
});
let domain;
if (returnDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${returnDomain}`;
} else if (config.core.defaultDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${config.core.defaultDomain}`;
} else {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
const responseUrl = `${domain}${
config.urls.route === '/' || config.urls.route === '' ? '' : `${config.urls.route}`
}/${url.vanity ?? url.code}`;
logger.info(`${req.user.username} shortened a URL`, {
from: destination,
to: responseUrl,
user: req.user.id,
});
onShorten({
user: req.user,
url,
link: {
returned: responseUrl,
},
});
if (noJson) return res.type('text/plain').send(responseUrl);
return res.send({
url: responseUrl,
});
},
);
server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const searchQuery = req.query.searchQuery
? decodeURIComponent(req.query.searchQuery.trim()) ?? null
: null;
const searchField = validateSearchField.safeParse(req.query.searchField || 'destination');
if (!searchField.success) return res.badRequest('Invalid searchField value');
const searchThreshold = validateThreshold.safeParse(Number(req.query.searchThreshold) || 0.1);
if (!searchThreshold.success) return res.badRequest('Invalid searchThreshold value');
if (searchQuery) {
const similarityResult: Url[] = await prisma.$queryRaw`
SELECT
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) AS similarity,
*
FROM "Url"
WHERE
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) > ${Prisma.raw(
String(searchThreshold.data),
)} OR
"${Prisma.raw(searchField.data)}" ILIKE '${Prisma.sql`%${searchQuery}%`}' AND
"userId" = ${req.user.id};
`;
return res.send(similarityResult);
}
const urls = await prisma.url.findMany({
where: {
userId: req.user.id,
},
});
return res.send(urls);
});
done();
},
{ name: PATH },
);