mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
refactor: move other api routes
This commit is contained in:
@@ -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
252
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
64
src/server/routes/api/auth/invites/[id].ts
Normal file
64
src/server/routes/api/auth/invites/[id].ts
Normal 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 },
|
||||
);
|
||||
72
src/server/routes/api/auth/invites/index.ts
Normal file
72
src/server/routes/api/auth/invites/index.ts
Normal 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 },
|
||||
);
|
||||
75
src/server/routes/api/auth/login.ts
Normal file
75
src/server/routes/api/auth/login.ts
Normal 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 },
|
||||
);
|
||||
27
src/server/routes/api/auth/logout.ts
Normal file
27
src/server/routes/api/auth/logout.ts
Normal 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 },
|
||||
);
|
||||
92
src/server/routes/api/auth/register.ts
Normal file
92
src/server/routes/api/auth/register.ts
Normal 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 },
|
||||
);
|
||||
77
src/server/routes/api/auth/webauthn.ts
Normal file
77
src/server/routes/api/auth/webauthn.ts
Normal 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 },
|
||||
);
|
||||
120
src/server/routes/api/user/files/[id]/index.ts
Normal file
120
src/server/routes/api/user/files/[id]/index.ts
Normal 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 },
|
||||
);
|
||||
56
src/server/routes/api/user/files/[id]/password.ts
Normal file
56
src/server/routes/api/user/files/[id]/password.ts
Normal 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 },
|
||||
);
|
||||
58
src/server/routes/api/user/files/incomplete.ts
Normal file
58
src/server/routes/api/user/files/incomplete.ts
Normal 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 },
|
||||
);
|
||||
239
src/server/routes/api/user/files/index.ts
Normal file
239
src/server/routes/api/user/files/index.ts
Normal 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 },
|
||||
);
|
||||
136
src/server/routes/api/user/files/transaction.ts
Normal file
136
src/server/routes/api/user/files/transaction.ts
Normal 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 },
|
||||
);
|
||||
190
src/server/routes/api/user/folders/[id].ts
Normal file
190
src/server/routes/api/user/folders/[id].ts
Normal 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 },
|
||||
);
|
||||
104
src/server/routes/api/user/folders/index.ts
Normal file
104
src/server/routes/api/user/folders/index.ts
Normal 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 },
|
||||
);
|
||||
72
src/server/routes/api/user/mfa/passkey.ts
Normal file
72
src/server/routes/api/user/mfa/passkey.ts
Normal 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 },
|
||||
);
|
||||
86
src/server/routes/api/user/mfa/totp.ts
Normal file
86
src/server/routes/api/user/mfa/totp.ts
Normal 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 },
|
||||
);
|
||||
84
src/server/routes/api/user/tags/[id].ts
Normal file
84
src/server/routes/api/user/tags/[id].ts
Normal 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 },
|
||||
);
|
||||
45
src/server/routes/api/user/tags/index.ts
Normal file
45
src/server/routes/api/user/tags/index.ts
Normal 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 },
|
||||
);
|
||||
54
src/server/routes/api/user/urls/[id]/index.ts
Normal file
54
src/server/routes/api/user/urls/[id]/index.ts
Normal 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 },
|
||||
);
|
||||
48
src/server/routes/api/user/urls/[id]/password.ts
Normal file
48
src/server/routes/api/user/urls/[id]/password.ts
Normal 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 },
|
||||
);
|
||||
173
src/server/routes/api/user/urls/index.ts
Normal file
173
src/server/routes/api/user/urls/index.ts
Normal 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 },
|
||||
);
|
||||
Reference in New Issue
Block a user