refactor: express -> fastify

This commit is contained in:
diced
2024-04-23 22:23:14 -07:00
parent aaef35ca1b
commit 3e79534e39
44 changed files with 1766 additions and 1127 deletions

View File

@@ -8,12 +8,11 @@
"build:prisma": "prisma generate",
"build:next": "next build",
"build:server": "tsup",
"dev": "pnpm run build:server && pnpm run dev:server",
"dev:server": "NODE_ENV=development DEBUG=zipline node --require dotenv/config --enable-source-maps ./build/server.js",
"dev:inspector": "NODE_ENV=development DEBUG=zipline node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server.js",
"dev": "NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"dev:ctl": "tsup --config tsup.ctl.config.ts --watch",
"start": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/server.js",
"start:inspector": "NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server.js",
"start": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/server",
"start:inspector": "NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl.js",
"validate": "pnpm run \"/^validate:.*/\"",
"validate:lint": "eslint --cache --ignore-path .gitignore --fix .",
@@ -22,6 +21,9 @@
},
"dependencies": {
"@ant-design/plots": "^1.2.6",
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/sensible": "^5.5.0",
"@github/webauthn-json": "^2.1.1",
"@mantine/code-highlight": "^7.2.2",
"@mantine/core": "^7.2.2",
@@ -44,6 +46,9 @@
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"fast-glob": "^3.3.2",
"fastify": "^4.26.2",
"fastify-plugin": "^4.5.1",
"ffmpeg-static": "^5.2.0",
"highlight.js": "^11.9.0",
"isomorphic-dompurify": "^1.11.0",
@@ -92,5 +97,5 @@
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@8.7.0"
"packageManager": "pnpm@8.15.6"
}

342
pnpm-lock.yaml generated
View File

@@ -8,6 +8,15 @@ dependencies:
'@ant-design/plots':
specifier: ^1.2.6
version: 1.2.6(react-dom@18.2.0)(react@18.2.0)
'@fastify/cookie':
specifier: ^9.3.1
version: 9.3.1
'@fastify/cors':
specifier: ^9.0.1
version: 9.0.1
'@fastify/sensible':
specifier: ^5.5.0
version: 5.5.0
'@github/webauthn-json':
specifier: ^2.1.1
version: 2.1.1
@@ -74,6 +83,15 @@ dependencies:
express:
specifier: ^4.18.2
version: 4.18.2
fast-glob:
specifier: ^3.3.2
version: 3.3.2
fastify:
specifier: ^4.26.2
version: 4.26.2
fastify-plugin:
specifier: ^4.5.1
version: 4.5.1
ffmpeg-static:
specifier: ^5.2.0
version: 5.2.0
@@ -1099,6 +1117,56 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@fastify/ajv-compiler@3.5.0:
resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==}
dependencies:
ajv: 8.12.0
ajv-formats: 2.1.1(ajv@8.12.0)
fast-uri: 2.3.0
dev: false
/@fastify/cookie@9.3.1:
resolution: {integrity: sha512-h1NAEhB266+ZbZ0e9qUE6NnNR07i7DnNXWG9VbbZ8uC6O/hxHpl+Zoe5sw1yfdZ2U6XhToUGDnzQtWJdCaPwfg==}
dependencies:
cookie-signature: 1.2.1
fastify-plugin: 4.5.1
dev: false
/@fastify/cors@9.0.1:
resolution: {integrity: sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==}
dependencies:
fastify-plugin: 4.5.1
mnemonist: 0.39.6
dev: false
/@fastify/error@3.4.1:
resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
dev: false
/@fastify/fast-json-stringify-compiler@4.3.0:
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
dependencies:
fast-json-stringify: 5.14.1
dev: false
/@fastify/merge-json-schemas@0.1.1:
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
dependencies:
fast-deep-equal: 3.1.3
dev: false
/@fastify/sensible@5.5.0:
resolution: {integrity: sha512-D0zpl+nocsRXLceSbc4gasQaO3ZNQR4dy9Uu8Ym0mh8VUdrjpZ4g8Ca9O3pGXbBVOnPIGHUJNTV7Yf9dg/OYdg==}
dependencies:
'@lukeed/ms': 2.0.2
fast-deep-equal: 3.1.3
fastify-plugin: 4.5.1
forwarded: 0.2.0
http-errors: 2.0.0
type-is: 1.6.18
vary: 1.1.2
dev: false
/@floating-ui/core@1.5.1:
resolution: {integrity: sha512-QgcKYwzcc8vvZ4n/5uklchy8KVdjJwcOeI+HnnTNclJjs2nYsy23DOCf+sSV1kBwD9yDAoVKCkv/gEPzgQU3Pw==}
dependencies:
@@ -1208,6 +1276,11 @@ packages:
call-bind: 1.0.5
dev: false
/@lukeed/ms@2.0.2:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
dev: false
/@mantine/code-highlight@7.2.2(@mantine/core@7.2.2)(@mantine/hooks@7.2.2)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-zR3J8TvHVZjXESTLNd49pZobhJIZcXUr0CGLs8F4TdnslIoN4SNWipQCPa0kDw7qO5CJhfMqgMeEYNh/slPpPg==}
peerDependencies:
@@ -2109,6 +2182,10 @@ packages:
event-target-shim: 5.0.1
dev: false
/abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
dev: false
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -2157,6 +2234,28 @@ packages:
indent-string: 4.0.0
dev: false
/ajv-formats@2.1.1(ajv@8.12.0):
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
dependencies:
ajv: 8.12.0
dev: false
/ajv-formats@3.0.1(ajv@8.12.0):
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
dependencies:
ajv: 8.12.0
dev: false
/ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
dependencies:
@@ -2166,6 +2265,15 @@ packages:
uri-js: 4.4.1
dev: true
/ajv@8.12.0:
resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js: 4.4.1
dev: false
/align-text@0.1.4:
resolution: {integrity: sha512-GrTZLRpmp6wIC2ztrWW9MjjTgSKccffgFagbNDOX95/dcjEcYZibYTeaOntySQLcdw1ztBoFkviiUvTMbb9MYg==}
engines: {node: '>=0.10.0'}
@@ -2259,6 +2367,10 @@ packages:
zip-stream: 5.0.1
dev: false
/archy@1.0.0:
resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==}
dev: false
/are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
engines: {node: '>=10'}
@@ -2402,10 +2514,26 @@ packages:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false
/atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
dev: false
/available-typed-arrays@1.0.5:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'}
/avvio@8.3.0:
resolution: {integrity: sha512-VBVH0jubFr9LdFASy/vNtm5giTrnbVquWBhT0fyizuNK2rQ7e7ONU2plZQWUNqtE1EmxFEb+kbSkFRkstiaS9Q==}
dependencies:
'@fastify/error': 3.4.1
archy: 1.0.0
debug: 4.3.4
fastq: 1.17.1
transitivePeerDependencies:
- supports-color
dev: false
/axe-core@4.7.0:
resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==}
engines: {node: '>=4'}
@@ -2898,11 +3026,21 @@ packages:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
dev: false
/cookie-signature@1.2.1:
resolution: {integrity: sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==}
engines: {node: '>=6.6.0'}
dev: false
/cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: false
/cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
dev: false
/core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: false
@@ -3871,6 +4009,14 @@ packages:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
dev: false
/fast-content-type-parse@1.1.0:
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
dev: false
/fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
dev: false
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -3896,15 +4042,75 @@ packages:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
dev: true
/fast-json-stringify@5.14.1:
resolution: {integrity: sha512-J1Grbf0oSXV3lKsBf3itz1AvRk43qVrx3Ac10sNvi3LZaz1by4oDdYKFrJycPhS8+Gb7y8rgV/Jqw1UZVjyNvw==}
dependencies:
'@fastify/merge-json-schemas': 0.1.1
ajv: 8.12.0
ajv-formats: 3.0.1(ajv@8.12.0)
fast-deep-equal: 3.1.3
fast-uri: 2.3.0
json-schema-ref-resolver: 1.0.1
rfdc: 1.3.0
dev: false
/fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
dev: true
/fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
dependencies:
fast-decode-uri-component: 1.0.1
dev: false
/fast-redact@3.5.0:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
engines: {node: '>=6'}
dev: false
/fast-uri@2.3.0:
resolution: {integrity: sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==}
dev: false
/fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
dev: false
/fastify@4.26.2:
resolution: {integrity: sha512-90pjTuPGrfVKtdpLeLzND5nyC4woXZN5VadiNQCicj/iJU4viNHKhsAnb7jmv1vu2IzkLXyBiCzdWuzeXgQ5Ug==}
dependencies:
'@fastify/ajv-compiler': 3.5.0
'@fastify/error': 3.4.1
'@fastify/fast-json-stringify-compiler': 4.3.0
abstract-logging: 2.0.1
avvio: 8.3.0
fast-content-type-parse: 1.1.0
fast-json-stringify: 5.14.1
find-my-way: 8.1.0
light-my-request: 5.13.0
pino: 8.20.0
process-warning: 3.0.0
proxy-addr: 2.0.7
rfdc: 1.3.0
secure-json-parse: 2.7.0
semver: 7.5.4
toad-cache: 3.7.0
transitivePeerDependencies:
- supports-color
dev: false
/fastq@1.15.0:
resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
dependencies:
reusify: 1.0.4
/fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
dependencies:
reusify: 1.0.4
dev: false
/fecha@4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
dev: false
@@ -3959,6 +4165,15 @@ packages:
pkg-dir: 4.2.0
dev: false
/find-my-way@8.1.0:
resolution: {integrity: sha512-41QwjCGcVTODUmLLqTMeoHeiozbMXYMAE1CKFiDyi9zVZ2Vjh0yz3MF0WQZoIb+cmzP/XlbFjlF2NtJmvZHznA==}
engines: {node: '>=14'}
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 2.0.0
dev: false
/find-up@3.0.0:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
@@ -4826,7 +5041,7 @@ packages:
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
ws: 8.14.2
ws: 8.16.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
@@ -4848,10 +5063,20 @@ packages:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
dev: false
/json-schema-ref-resolver@1.0.1:
resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==}
dependencies:
fast-deep-equal: 3.1.3
dev: false
/json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
dev: true
/json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
dev: false
/json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
dev: true
@@ -5011,6 +5236,14 @@ packages:
type-check: 0.4.0
dev: true
/light-my-request@5.13.0:
resolution: {integrity: sha512-9IjUN9ZyCS9pTG+KqTDEQo68Sui2lHsYBrfMyVUTTZ3XhH8PMZq7xO94Kr+eP9dhi/kcKsx4N41p2IXEBil1pQ==}
dependencies:
cookie: 0.6.0
process-warning: 3.0.0
set-cookie-parser: 2.6.0
dev: false
/lilconfig@3.0.0:
resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==}
engines: {node: '>=14'}
@@ -5686,6 +5919,12 @@ packages:
hasBin: true
dev: false
/mnemonist@0.39.6:
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
dependencies:
obliterator: 2.0.4
dev: false
/mock-property@1.0.3:
resolution: {integrity: sha512-2emPTb1reeLLYwHxyVx993iYyCHEiRRO+y8NFXFPL5kl5q14sgTK76cXyEKkeKCHeRw35SfdkUJ10Q1KfHuiIQ==}
engines: {node: '>= 0.4'}
@@ -6082,6 +6321,15 @@ packages:
es-abstract: 1.22.3
dev: true
/obliterator@2.0.4:
resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==}
dev: false
/on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
dev: false
/on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -6378,6 +6626,34 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
/pino-abstract-transport@1.1.0:
resolution: {integrity: sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==}
dependencies:
readable-stream: 4.4.2
split2: 4.2.0
dev: false
/pino-std-serializers@6.2.2:
resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==}
dev: false
/pino@8.20.0:
resolution: {integrity: sha512-uhIfMj5TVp+WynVASaVEJFTncTUe4dHBq6CWplu/vBgvGHhvBvQfxz+vcOrnnBQdORH3izaGEurLfNlq3YxdFQ==}
hasBin: true
dependencies:
atomic-sleep: 1.0.0
fast-redact: 3.5.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.1.0
pino-std-serializers: 6.2.2
process-warning: 3.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.4.3
sonic-boom: 3.8.1
thread-stream: 2.4.1
dev: false
/pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -6558,6 +6834,10 @@ packages:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: false
/process-warning@3.0.0:
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
dev: false
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -6639,6 +6919,10 @@ packages:
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
dev: false
/quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
dev: false
/range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -6889,6 +7173,11 @@ packages:
picomatch: 2.3.1
dev: true
/real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
dev: false
/reflect.getprototypeof@1.0.4:
resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==}
engines: {node: '>= 0.4'}
@@ -6957,6 +7246,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
/require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
dev: false
/require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
dev: false
@@ -7004,6 +7298,11 @@ packages:
signal-exit: 3.0.7
dev: false
/ret@0.2.2:
resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==}
engines: {node: '>=4'}
dev: false
/retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
@@ -7098,6 +7397,17 @@ packages:
get-intrinsic: 1.2.2
is-regex: 1.1.4
/safe-regex2@2.0.0:
resolution: {integrity: sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==}
dependencies:
ret: 0.2.2
dev: false
/safe-stable-stringify@2.4.3:
resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==}
engines: {node: '>=10'}
dev: false
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: false
@@ -7115,6 +7425,10 @@ packages:
loose-envify: 1.4.0
dev: false
/secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
dev: false
/semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -7168,6 +7482,10 @@ packages:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: false
/set-cookie-parser@2.6.0:
resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==}
dev: false
/set-function-length@1.1.1:
resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==}
engines: {node: '>= 0.4'}
@@ -7276,6 +7594,12 @@ packages:
is-fullwidth-code-point: 3.0.0
dev: false
/sonic-boom@3.8.1:
resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==}
dependencies:
atomic-sleep: 1.0.0
dev: false
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@@ -7718,6 +8042,12 @@ packages:
engines: {node: '>=0.2.6'}
dev: false
/thread-stream@2.4.1:
resolution: {integrity: sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==}
dependencies:
real-require: 0.2.0
dev: false
/titleize@3.0.0:
resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}
engines: {node: '>=12'}
@@ -7741,6 +8071,11 @@ packages:
dependencies:
is-number: 7.0.0
/toad-cache@3.7.0:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'}
dev: false
/toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -8078,7 +8413,6 @@ packages:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
dependencies:
punycode: 2.3.1
dev: true
/url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
@@ -8380,8 +8714,8 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
/ws@8.14.2:
resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==}
/ws@8.16.0:
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1

View File

@@ -64,7 +64,7 @@ export default function SettingsUser() {
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', send);
if (!data && error) {
if (error.field === 'username') {
if (error.message === 'Username already exists') {
form.setFieldError('username', error.message);
} else {
notifications.show({

View File

@@ -5,14 +5,7 @@ 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 { ApiHealthcheckResponse } from '@/pages/api/healthcheck';
import { ApiServerClearTempResponse } from '@/pages/api/server/clear_temp';
import { ApiServerClearZerosResponse } from '@/pages/api/server/clear_zeros';
import { ApiServerRequerySizeResponse } from '@/pages/api/server/requery_size';
import { ApiSetupResponse } from '@/pages/api/setup';
import { ApiStatsResponse } from '@/pages/api/stats';
import { ApiUploadResponse } from '@/pages/api/upload';
import { ApiUserResponse } from '@/pages/api/user';
import { ApiUserFilesResponse } from '@/pages/api/user/files';
import { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
import { ApiUserFilesIdPasswordResponse } from '@/pages/api/user/files/[id]/password';
@@ -22,16 +15,25 @@ 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 { ApiUserRecentResponse } from '@/pages/api/user/recent';
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
import { ApiUserTagsResponse } from '@/pages/api/user/tags';
import { ApiUserTagsIdResponse } from '@/pages/api/user/tags/[id]';
import { ApiUserTokenResponse } from '@/pages/api/user/token';
import { ApiUserUrlsResponse } from '@/pages/api/user/urls';
import { ApiUserUrlsIdResponse } from '@/pages/api/user/urls/[id]';
import { ApiUsersResponse } from '@/pages/api/users';
import { ApiUsersIdResponse } from '@/pages/api/users/[id]';
import { ApiVersionResponse } from '@/pages/api/version';
// migrated routes
import { ApiHealthcheckResponse } from '@/server/routes/api/healthcheck';
import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_temp';
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
import { ApiSetupResponse } from '@/server/routes/api/setup';
import { ApiStatsResponse } from '@/server/routes/api/stats';
import { ApiUserResponse } from '@/server/routes/api/user';
import { ApiUserRecentResponse } from '@/server/routes/api/user/recent';
import { ApiUserStatsResponse } from '@/server/routes/api/user/stats';
import { ApiUserTokenResponse } from '@/server/routes/api/user/token';
import { ApiUsersResponse } from '@/server/routes/api/users';
import { ApiUsersIdResponse } from '@/server/routes/api/users/[id]';
import { ApiVersionResponse } from '@/server/routes/api/version';
export type Response = {
'/api/auth/invites/[id]': ApiAuthInvitesIdResponse;

View File

@@ -1,30 +0,0 @@
import { config } from '@/lib/config';
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 ApiHealthcheckResponse = {
pass: boolean;
};
export async function handler(_: NextApiReq, res: NextApiRes<ApiHealthcheckResponse>) {
if (!config.features.healthcheck) return res.notFound();
const logger = log('api').c('healthcheck');
try {
await prisma.$queryRaw`SELECT 1;`;
return res.ok({ pass: true });
} catch (e) {
logger.error('there was an error during a healthcheck').error(e as Error);
return res.serverError('there was an error during a healthcheck', {
pass: false,
});
}
}
export default combine([method(['GET'])], handler);

View File

@@ -1,17 +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';
import { clearTemp } from '@/lib/server-util/clearTemp';
export type ApiServerClearTempResponse = {
status?: string;
};
export async function handler(_: NextApiReq, res: NextApiRes<ApiServerClearTempResponse>) {
const response = await clearTemp();
return res.ok({ status: response });
}
export default combine([method(['DELETE']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,24 +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';
import { clearZeros, clearZerosFiles } from '@/lib/server-util/clearZeros';
export type ApiServerClearZerosResponse = {
status?: string;
files?: Awaited<ReturnType<typeof clearZerosFiles>>;
};
export async function handler(req: NextApiReq, res: NextApiRes<ApiServerClearZerosResponse>) {
const filesToDelete = await clearZerosFiles();
if (req.method === 'GET') {
return res.ok({ files: filesToDelete });
}
const response = await clearZeros(filesToDelete);
return res.ok({ status: response });
}
export default combine([method(['GET', 'DELETE']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,25 +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';
import { requerySize } from '@/lib/server-util/requerySize';
export type ApiServerRequerySizeResponse = {
status?: string;
};
type Body = {
forceDelete?: boolean;
forceUpdate?: boolean;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiServerRequerySizeResponse>) {
const response = await requerySize({
forceDelete: req.body.forceDelete || false,
forceUpdate: req.body.forceUpdate || false,
});
return res.ok({ status: response });
}
export default combine([method(['POST']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,63 +0,0 @@
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { getZipline } from '@/lib/db/models/zipline';
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 ApiSetupResponse = {
firstSetup?: boolean;
user?: User;
};
type Body = {
username: string;
password: string;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiSetupResponse>) {
const logger = log('api').c('setup');
const { firstSetup, id } = await getZipline();
if (!firstSetup) return res.forbidden();
logger.info('first setup running');
if (req.method === 'GET') {
return res.ok({ firstSetup });
}
const { username, password } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const user = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
role: 'SUPERADMIN',
token: createToken(),
},
select: userSelect,
});
logger.info('first setup complete');
await prisma.zipline.update({
where: {
id,
},
data: {
firstSetup: false,
},
});
return res.ok({
firstSetup,
user,
});
}
export default combine([method(['GET', 'POST'])], handler);

View File

@@ -1,49 +0,0 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { Metric } from '@/lib/db/models/metric';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiStatsResponse = Metric[];
type Query = {
from?: string;
to?: string;
};
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiStatsResponse>) {
if (!config.features.metrics) return res.forbidden();
const { from, to } = req.query;
const fromDate = from ? new Date(from) : new Date(Date.now() - 86400000);
const toDate = to ? new Date(to) : new Date();
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return res.badRequest('invalid date');
const stats = await prisma.metric.findMany({
where: {
createdAt: {
gte: fromDate,
lte: toDate,
},
},
orderBy: {
createdAt: 'desc',
},
});
if (!config.features.metrics.showUserSpecific) {
for (let i = 0; i !== stats.length; ++i) {
const stat = stats[i].data;
stat.filesUsers = [];
stat.urlsUsers = [];
}
}
return res.ok(stats);
}
export default combine([method(['GET'])], handler);

View File

@@ -1,28 +0,0 @@
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';
export type ApiUserTokenResponse = {
user?: User;
token?: string;
};
export async function handler(req: NextApiReq, res: NextApiRes<ApiUserTokenResponse>) {
const u = await prisma.user.findFirstOrThrow({
where: {
id: req.user.id,
},
select: {
avatar: true,
},
});
if (!u.avatar) return res.notFound();
return res.status(200).send(u.avatar);
}
export default combine([method(['GET']), ziplineAuth()], handler);

View File

@@ -1,92 +0,0 @@
import { hashPassword } from '@/lib/crypto';
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';
export type ApiUserResponse = {
user?: User;
token?: string;
};
type EditBody = {
username?: string;
password?: string;
avatar?: string;
view?: {
content?: string;
embed?: boolean;
embedTitle?: string;
embedDescription?: string;
embedColor?: string;
embedSiteName?: string;
enabled?: boolean;
align?: 'left' | 'center' | 'right';
showMimetype?: boolean;
};
};
export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<ApiUserResponse>) {
if (req.method === 'GET') {
return res.ok({ user: req.user, token: req.cookies.zipline_token });
} else if (req.method === 'PATCH') {
if (req.body.username) {
const existing = await prisma.user.findUnique({
where: {
username: req.body.username,
},
});
if (existing) return res.badRequest('Username already taken', { field: 'username' });
}
const user = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
...(req.body.username && { username: req.body.username }),
...(req.body.password && { password: await hashPassword(req.body.password) }),
...(req.body.avatar !== undefined && { avatar: req.body.avatar || null }),
...(req.body.view && {
view: {
...req.user.view,
...(req.body.view.enabled !== undefined && { enabled: req.body.view.enabled || false }),
...(req.body.view.content !== undefined && { content: req.body.view.content || null }),
...(req.body.view.embed !== undefined && { embed: req.body.view.embed || false }),
...(req.body.view.embedTitle !== undefined && { embedTitle: req.body.view.embedTitle || null }),
...(req.body.view.embedDescription !== undefined && {
embedDescription: req.body.view.embedDescription || null,
}),
...(req.body.view.embedColor !== undefined && { embedColor: req.body.view.embedColor || null }),
...(req.body.view.embedSiteName !== undefined && {
embedSiteName: req.body.view.embedSiteName || null,
}),
...(req.body.view.align !== undefined && { align: req.body.view.align || 'center' }),
...(req.body.view.showMimetype !== undefined && {
showMimetype: req.body.view.showMimetype || false,
}),
},
}),
},
select: {
...userSelect,
},
});
return res.ok({ user, token: req.cookies.zipline_token });
}
}
export const config = {
api: {
responseLimit: false,
bodyParser: {
sizeLimit: '100gb',
},
},
};
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,34 +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';
export type ApiUserRecentResponse = File[];
type Query = {
page?: string;
};
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserRecentResponse>) {
const files = cleanFiles(
await prisma.file.findMany({
where: {
userId: req.user.id,
},
select: {
...fileSelect,
password: true,
},
orderBy: {
createdAt: 'desc',
},
take: 3,
}),
);
return res.ok(files);
}
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,92 +0,0 @@
import { prisma } from '@/lib/db';
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 ApiUserStatsResponse = {
filesUploaded: number;
favoriteFiles: number;
views: number;
avgViews: number;
storageUsed: number;
avgStorageUsed: number;
urlsCreated: number;
urlViews: number;
sortTypeCount: { [type: string]: number };
};
export async function handler(req: NextApiReq, res: NextApiRes<ApiUserStatsResponse>) {
const aggFile = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_count: {
_all: true,
},
_sum: {
views: true,
size: true,
},
_avg: {
views: true,
size: true,
},
});
const favCount = await prisma.file.count({
where: {
favorite: true,
},
});
const aggUrl = await prisma.url.aggregate({
where: {
userId: req.user.id,
},
_count: {
_all: true,
},
_avg: {
views: true,
},
_sum: {
views: true,
},
});
const sortType = await prisma.file.findMany({
where: {
userId: req.user.id,
},
select: {
type: true,
},
});
const sortTypeCount = sortType.reduce(
(acc, cur) => {
if (acc[cur.type]) acc[cur.type] += 1;
else acc[cur.type] = 1;
return acc;
},
{} as { [type: string]: number },
);
return res.ok({
filesUploaded: aggFile._count._all ?? 0,
favoriteFiles: favCount ?? 0,
views: aggFile._sum.views ?? 0,
avgViews: aggFile._avg.views ?? 0,
storageUsed: Number(aggFile._sum.size ?? 0),
avgStorageUsed: Number(aggFile._avg.size ?? 0),
urlsCreated: aggUrl._count._all ?? 0,
urlViews: aggUrl._sum.views ?? 0,
sortTypeCount,
});
}
export default combine([method(['GET']), ziplineAuth()], handler);

View File

@@ -1,57 +0,0 @@
import { config } from '@/lib/config';
import { createToken, encryptToken } 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 { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserTokenResponse = {
user?: User;
token?: string;
};
export async function handler(req: NextApiReq, res: NextApiRes<ApiUserTokenResponse>) {
if (req.method === 'GET') {
const user = await prisma.user.findUnique({
where: {
id: req.user.id,
},
select: {
token: true,
},
});
const token = encryptToken(user!.token, config.core.secret);
return res.ok({
token,
});
}
const user = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
token: createToken(),
},
select: {
...userSelect,
token: true,
},
});
const token = loginToken(res, user);
delete (user as any).token;
return res.ok({
user,
token,
});
}
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,210 +0,0 @@
import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
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 { canInteract } from '@/lib/role';
import { UserFilesQuota } from '@prisma/client';
import { z } from 'zod';
export type ApiUsersIdResponse = User;
type Body = {
username?: string;
password?: string;
avatar?: string;
role?: 'USER' | 'ADMIN' | 'SUPERADMIN';
quota?: {
filesType?: UserFilesQuota & 'NONE';
maxFiles?: number;
maxBytes?: string;
maxUrls?: number;
};
delete?: boolean;
};
type Query = {
id: string;
};
const logger = log('api').c('users').c('[id]');
const zNumber = z.number();
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUsersIdResponse>) {
const user = await prisma.user.findUnique({
where: {
id: req.query.id,
},
select: userSelect,
});
if (!user) return res.notFound('User not found');
if (req.method === 'PATCH') {
const { username, password, avatar, role, quota } = req.body;
if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success)
return res.badRequest('Invalid role (USER, ADMIN)');
if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role');
let finalQuota:
| {
filesQuota?: UserFilesQuota;
maxFiles?: number | null;
maxBytes?: string | null;
maxUrls?: number | null;
}
| undefined = undefined;
if (quota) {
if (quota.filesType && !z.enum(['BY_BYTES', 'BY_FILES', 'NONE']).safeParse(quota.filesType).success)
return res.badRequest('Invalid filesType (BY_BYTES, BY_FILES, NONE)');
if (quota.maxFiles && !zNumber.safeParse(quota.maxFiles).success)
return res.badRequest('Invalid maxFiles');
if (quota.maxUrls && !zNumber.safeParse(quota.maxUrls).success)
return res.badRequest('Invalid maxUrls');
if (quota.filesType === 'BY_BYTES' && quota.maxBytes === undefined)
return res.badRequest('maxBytes is required');
if (quota.filesType === 'BY_FILES' && quota.maxFiles === undefined)
return res.badRequest('maxFiles is required');
finalQuota = {
...(quota.filesType === 'BY_BYTES' && {
filesQuota: 'BY_BYTES',
maxBytes: bytes(quota.maxBytes || '0') > 0 ? quota.maxBytes : null,
maxFiles: null,
}),
...(quota.filesType === 'BY_FILES' && {
filesQuota: 'BY_FILES',
maxFiles: quota.maxFiles,
maxBytes: null,
}),
...(quota.filesType === 'NONE' && {
filesQuota: 'BY_BYTES',
maxFiles: null,
maxBytes: null,
}),
maxUrls: (quota.maxUrls || 0) > 0 ? quota.maxUrls : null,
};
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
...(username && { username }),
...(password && { password: await hashPassword(password) }),
...(role !== undefined && { role: 'USER' }),
...(avatar && { avatar }),
...(finalQuota && {
quota: {
upsert: {
where: {
userId: user.id,
},
create: {
filesQuota: finalQuota.filesQuota || 'BY_BYTES',
maxFiles: finalQuota.maxFiles ?? null,
maxBytes: finalQuota.maxBytes ?? null,
maxUrls: finalQuota.maxUrls ?? null,
},
update: finalQuota,
},
},
}),
},
select: {
...userSelect,
totpSecret: false,
passkeys: false,
},
});
logger.info(`${req.user.username} updated another user`, {
username: updatedUser.username,
role: updatedUser.role,
});
return res.ok(updatedUser);
} else if (req.method === 'DELETE') {
if (user.id === req.user.id) return res.forbidden('You cannot delete yourself');
if (!canInteract(req.user.role, user.role)) return res.forbidden('You cannot delete this user');
if (req.body.delete) {
const files = await prisma.file.findMany({
where: {
userId: user.id,
},
select: {
name: true,
},
});
const [{ count: filesDeleted }, { count: urlsDeleted }] = await prisma.$transaction([
prisma.file.deleteMany({
where: {
userId: user.id,
},
}),
prisma.url.deleteMany({
where: {
userId: user.id,
},
}),
]);
logger.debug(`preparing to delete ${files.length} files from datasource`, {
username: user.username,
});
for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].name);
}
logger.info(`${req.user.username} deleted another user's files & urls`, {
username: user.username,
deletedFiles: filesDeleted,
deletedUrls: urlsDeleted,
});
}
await prisma.oAuthProvider.deleteMany({
where: {
userId: user.id,
},
});
const deletedUser = await prisma.user.delete({
where: {
id: user.id,
},
select: {
...userSelect,
totpSecret: false,
},
});
logger.info(`${req.user.username} deleted another user`, {
username: deletedUser.username,
role: deletedUser.role,
});
return res.ok(deletedUser);
}
return res.ok(user);
}
export default combine(
[method(['GET', 'PATCH', 'DELETE']), ziplineAuth({ administratorOnly: true })],
handler,
);

View File

@@ -1,90 +0,0 @@
import { config } from '@/lib/config';
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { 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 { canInteract } from '@/lib/role';
import { Role } from '@prisma/client';
import { readFile } from 'fs/promises';
import z from 'zod';
export type ApiUsersResponse = User[] | User;
type Query = {
noincl?: 'true' | 'false';
};
type Body = {
username?: string;
password?: string;
avatar?: string;
role?: Role;
};
const logger = log('api').c('users');
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUsersResponse>) {
if (req.method === 'POST') {
const { username, password, avatar, role } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
let avatar64 = null;
try {
if (config.website.defaultAvatar) {
avatar64 = (await readFile(config.website.defaultAvatar)).toString('base64');
} else if (avatar) {
avatar64 = avatar;
}
} catch {
logger.debug('failed to read default avatar', { path: config.website.defaultAvatar });
}
if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success)
return res.badRequest('Invalid role (USER, ADMIN)');
if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role');
const user = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
role: role ?? 'USER',
avatar: avatar64 ?? null,
token: createToken(),
},
select: {
...userSelect,
totpSecret: false,
passkeys: false,
},
});
logger.info(`${req.user.username} created a new user`, {
username: user.username,
role: user.role,
});
return res.ok(user);
}
const users = await prisma.user.findMany({
select: {
...userSelect,
avatar: true,
},
where: {
...(req.query.noincl === 'true' && { id: { not: req.user.id } }),
},
});
return res.ok(users);
}
export default combine([method(['GET', 'POST']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,15 +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';
import packageJson from '../../../package.json';
export type ApiVersionResponse = {
version: string;
};
export async function handler(_: NextApiReq, res: NextApiRes<ApiVersionResponse>) {
return res.ok({ version: packageJson.version });
}
export default combine([method(['GET']), ziplineAuth({ administratorOnly: true })], handler);

View File

@@ -1,30 +1,28 @@
import { readEnv } from '@/lib/config/read';
import { validateEnv } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { runMigrations } from '@/lib/db/migration';
import { log } from '@/lib/logger';
import express from 'express';
import { mkdir } from 'fs/promises';
import next from 'next';
import { parse } from 'url';
import { version } from '../../package.json';
import { filesRoute } from './routes/files';
import { urlsRoute } from './routes/urls';
import { Scheduler } from '@/lib/scheduler';
import deleteFiles from '@/lib/scheduler/jobs/deleteFiles';
import clearInvites from '@/lib/scheduler/jobs/clearInvites';
import deleteFiles from '@/lib/scheduler/jobs/deleteFiles';
import maxViews from '@/lib/scheduler/jobs/maxViews';
import thumbnails from '@/lib/scheduler/jobs/thumbnails';
import metrics from '@/lib/scheduler/jobs/metrics';
import { parseRange } from '@/lib/api/range';
import thumbnails from '@/lib/scheduler/jobs/thumbnails';
import { fastifyCookie } from '@fastify/cookie';
import { fastifyCors } from '@fastify/cors';
import { fastifySensible } from '@fastify/sensible';
import fastify from 'fastify';
import { mkdir } from 'fs/promises';
import { parse } from 'url';
import { version } from '../../package.json';
import next, { ALL_METHODS } from './plugins/next';
import loadRoutes from './routes';
import { filesRoute } from './routes/files.dy';
import { urlsRoute } from './routes/urls.dy';
const MODE = process.env.NODE_ENV || 'production';
const logger = log('server');
const scheduler = new Scheduler();
declare global {
interface BigInt {
@@ -38,9 +36,6 @@ BigInt.prototype.toJSON = function () {
async function main() {
logger.info('starting zipline', { mode: MODE, version: version });
const server = express();
logger.info('reading environment for configuration');
const config = validateEnv(readEnv());
@@ -49,34 +44,30 @@ async function main() {
}
await mkdir(config.core.tempDirectory, { recursive: true });
process.env.DATABASE_URL = config.core.databaseUrl;
await runMigrations();
server.disable('x-powered-by');
server.use(express.static('public', { maxAge: '1h' }));
const server = fastify({ ignoreTrailingSlash: true });
const app = next({
dev: MODE === 'development',
quiet: MODE === 'production',
hostname: config.core.hostname,
port: config.core.port,
dir: '.',
await server.register(fastifyCookie, {
secret: config.core.secret,
hook: 'onRequest',
});
const handle = app.getRequestHandler();
await app.prepare();
await server.register(fastifyCors);
await server.register(fastifySensible);
if (config.files.route === '/' && config.urls.route === '/') {
logger.debug('files & urls route are both /, using catch-all route');
logger.debug('files & urls route = /, using catch-all route');
server.get('/:id', async (req, res) => {
server.get<{ Params: { id: string } }>('/:id', async (req, res) => {
const { id } = req.params;
const parsedUrl = parse(req.url!, true);
if (id === '') return app.render404(req, res, parsedUrl);
else if (id === 'dashboard') return app.render(req, res, '/dashboard');
if (id === '') return server.nextServer.render404(req.raw, res.raw, parsedUrl);
else if (id === 'dashboard') return server.nextServer.render(req.raw, res.raw, '/dashboard');
const url = await prisma.url.findFirst({
where: {
@@ -84,122 +75,78 @@ async function main() {
},
});
if (url) return urlsRoute.bind(server)(app, req, res);
else return filesRoute.bind(server)(app, req, res);
if (url) return urlsRoute(req as any, res);
else return filesRoute(req as any, res);
});
} else {
server.get(config.files.route === '/' ? '/:id' : `${config.files.route}/:id`, async (req, res) => {
filesRoute.bind(server)(app, req, res);
});
server.get(config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`, async (req, res) => {
urlsRoute.bind(server)(app, req, res);
});
server.get(config.files.route === '/' ? '/:id' : `${config.files.route}/:id`, filesRoute);
server.get(config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`, urlsRoute);
}
server.get('/raw/:id', async (req, res) => {
const { id } = req.params;
const { pw } = req.query;
await server.register(next, {
dev: MODE === 'development',
quiet: MODE === 'production',
hostname: config.core.hostname,
port: config.core.port,
dir: '.',
});
const parsedUrl = parse(req.url!, true);
const routes = await loadRoutes();
const routesOptions = Object.values(routes);
Promise.all(routesOptions.map((route) => server.register(route)));
const file = await prisma.file.findFirst({
where: {
name: id,
},
});
server.next('/*', ALL_METHODS);
server.get('/', (_, res) => res.redirect('/dashboard'));
if (file?.password) {
if (!pw) return res.status(403).json({ code: 403, message: 'Password protected.' });
const verified = await verifyPassword(pw as string, file.password!);
if (!verified) return res.status(403).json({ code: 403, message: 'Incorrect password.' });
}
const size = file?.size || (await datasource.size(file?.name ?? id));
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
res.writeHead(416, {
'Content-Length': size,
'Content-Type': file?.type || 'application/octet-stream',
...(file?.originalName && {
'Content-Disposition': `${req.query.download ? 'attachment; ' : ''}filename="${
file.originalName
}"`,
}),
});
const buf = await datasource.get(file?.name ?? id);
if (!buf) return app.render404(req, res, parsedUrl);
return buf.pipe(res);
}
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': file?.type || 'application/octet-stream',
...(file?.originalName && {
'Content-Disposition': `${req.query.download ? 'attachment; ' : ''}filename="${file.originalName}"`,
}),
// TODO: no longer need this when all the api routes are handled by fastify :)
const routeKeys = Object.keys(routes); // holds "currently migrated routes" so we can parse json through fastify
server.addContentTypeParser('application/json', (req, body, done) => {
if (routeKeys.includes(req.routeOptions.config.url)) {
let bodyString = '';
body.on('data', (chunk) => {
bodyString += chunk;
});
const buf = await datasource.range(file?.name ?? id, start || 0, end);
if (!buf) return app.render404(req, res, parsedUrl);
body.on('end', () => {
server.getDefaultJsonParser('error', 'ignore')(req, bodyString, done);
});
} else done(null, body);
});
return buf.pipe(res);
// TODO: no longer need this when /api/upload is handled by fastify
server.addContentTypeParser('multipart/form-data', (_, body, done) => {
done(null, body);
});
await server.listen({
port: config.core.port,
host: config.core.hostname,
});
logger.info('server started', { hostname: config.core.hostname, port: config.core.port });
const scheduler = new Scheduler();
scheduler.interval('deletefiles', config.scheduler.deleteInterval, deleteFiles(prisma));
scheduler.interval('maxviews', config.scheduler.maxViewsInterval, maxViews(prisma));
if (config.features.metrics)
scheduler.interval('metrics', config.scheduler.metricsInterval, metrics(prisma));
if (config.features.thumbnails.enabled) {
scheduler.interval('thumbnails', config.scheduler.thumbnailsInterval, thumbnails(prisma));
for (let i = 0; i !== config.features.thumbnails.num_threads; ++i) {
scheduler.worker(`thumbnail-${i}`, './build/offload/thumbnails.js', {
id: `thumbnail-${i}`,
enabled: config.features.thumbnails.enabled,
});
}
res.writeHead(200, {
'Content-Length': size,
'Accept-Ranges': 'bytes',
'Content-Type': file?.type || 'application/octet-stream',
...(file?.originalName && {
'Content-Disposition': `${req.query.download ? 'attachment; ' : ''}filename="${file.originalName}"`,
}),
});
scheduler.interval('clearinvites', config.scheduler.clearInvitesInterval, clearInvites(prisma));
}
const buf = await datasource.get(file?.name ?? id);
if (!buf) return app.render404(req, res, parsedUrl);
return buf.pipe(res);
});
server.all('*', (req, res) => {
const parsedUrl = parse(req.url!, true);
return handle(req, res, parsedUrl);
});
server.listen(config.core.port, config.core.hostname, () => {
logger.info('server listening', {
hostname: config.core.hostname,
port: config.core.port,
});
scheduler.interval('deletefiles', config.scheduler.deleteInterval, deleteFiles(prisma));
scheduler.interval('maxviews', config.scheduler.maxViewsInterval, maxViews(prisma));
if (config.features.metrics)
scheduler.interval('metrics', config.scheduler.metricsInterval, metrics(prisma));
if (config.features.thumbnails.enabled) {
scheduler.interval('thumbnails', config.scheduler.thumbnailsInterval, thumbnails(prisma));
for (let i = 0; i !== config.features.thumbnails.num_threads; ++i) {
scheduler.worker(`thumbnail-${i}`, './build/offload/thumbnails.js', {
id: `thumbnail-${i}`,
enabled: config.features.thumbnails.enabled,
});
}
scheduler.interval('clearinvites', config.scheduler.clearInvitesInterval, clearInvites(prisma));
}
scheduler.start();
});
logger.info('starting scheduler');
scheduler.start();
}
main();

21
src/server/loginToken.ts Normal file
View File

@@ -0,0 +1,21 @@
import { config } from '@/lib/config';
import { serializeCookie } from '@/lib/cookie';
import { encryptToken } from '@/lib/crypto';
import { User } from '@/lib/db/models/user';
import { FastifyReply } from 'fastify';
export function loginToken(res: FastifyReply, user: User) {
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.header('Set-Cookie', cookie);
return token;
}

View File

@@ -0,0 +1,8 @@
import { isAdministrator } from '@/lib/role';
import { FastifyReply, FastifyRequest } from 'fastify';
export async function administratorMiddleware(req: FastifyRequest, res: FastifyReply) {
if (!req.user) return res.forbidden('not logged in');
if (!isAdministrator(req.user.role)) return res.forbidden();
}

View File

@@ -0,0 +1,62 @@
import { config } from '@/lib/config';
import { decryptToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { FastifyReply } from 'fastify';
import { FastifyRequest } from 'fastify/types/request';
declare module 'fastify' {
export interface FastifyRequest {
user: User;
}
}
export function parseUserToken(encryptedToken: string | undefined | null): string;
export function parseUserToken(encryptedToken: string | undefined | null, noThrow: true): string | null;
export function parseUserToken(
encryptedToken: string | undefined | null,
noThrow: boolean = false,
): string | null {
if (!encryptedToken) {
if (noThrow) return null;
throw { error: 'no token' };
}
const decryptedToken = decryptToken(encryptedToken, config.core.secret);
if (!decryptedToken) {
if (noThrow) return null;
throw { error: 'could not decrypt token' };
}
const [date, token] = decryptedToken;
if (isNaN(new Date(date).getTime())) {
if (noThrow) return null;
throw { error: 'invalid token' };
}
return token;
}
export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
let rawToken: string | undefined;
if (req.cookies.zipline_token) rawToken = req.cookies.zipline_token;
else if (req.headers.authorization) rawToken = req.headers.authorization;
try {
// eslint-disable-next-line no-var
var token = parseUserToken(rawToken);
} catch (e) {
return res.unauthorized((e as { error: string }).error);
}
const user = await prisma.user.findFirst({
where: {
token,
},
select: userSelect,
});
if (!user) return res.unauthorized('invalid token');
req.user = user;
}

View File

@@ -0,0 +1,60 @@
import { FastifyInstance, FastifyReply, FastifyRequest, HTTPMethods } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import next from 'next';
import { NextServerOptions, RequestHandler } from 'next/dist/server/next';
export const ALL_METHODS: HTTPMethods[] = [
'DELETE',
'GET',
'HEAD',
'PATCH',
'POST',
'PUT',
// 'OPTIONS',
'COPY',
'MOVE',
'TRACE',
];
async function nextPlugin(fastify: FastifyInstance, options: NextServerOptions) {
const nextServer = next(options);
const handle = nextServer.getRequestHandler();
fastify
.decorate('nextServer', nextServer)
.decorate('nextHandle', handle)
.decorate('next', route.bind(fastify));
return nextServer.prepare();
function route(this: FastifyInstance, path: string, method: HTTPMethods | HTTPMethods[] = 'GET') {
this.route({
method,
url: path,
handler,
});
async function handler(req: FastifyRequest, reply: FastifyReply) {
for (const [key, value] of Object.entries(reply.getHeaders())) {
if (value !== undefined) reply.raw.setHeader(key, value);
}
await handle(req.raw, reply.raw);
reply.hijack();
}
}
}
export default fastifyPlugin(nextPlugin, {
name: 'next',
fastify: '4.x',
});
declare module 'fastify' {
interface FastifyInstance {
nextServer: ReturnType<typeof next>;
next: (path: string, method?: HTTPMethods | HTTPMethods[]) => void;
nextHandle: RequestHandler;
}
}

View File

@@ -0,0 +1,30 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import fastifyPlugin from 'fastify-plugin';
export type ApiHealthcheckResponse = {
pass: boolean;
};
const logger = log('api').c('healthcheck');
export const PATH = '/api/healthcheck';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, async (_, res) => {
if (!config.features.healthcheck) return res.notFound();
try {
await prisma.$queryRaw`SELECT 1;`;
return res.send({ pass: true });
} catch (e) {
logger.error('there was an error during a healthcheck').error(e as Error);
return res.internalServerError('there was an error during a healthcheck');
}
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,28 @@
import { clearTemp } from '@/lib/server-util/clearTemp';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiServerClearTempResponse = {
status?: string;
};
export const PATH = '/api/server/clear_temp';
export default fastifyPlugin(
(server, _, done) => {
server.delete(
PATH,
{
preHandler: [userMiddleware, administratorMiddleware],
},
async (_, res) => {
const status = await clearTemp();
return res.send({ status });
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,42 @@
import { clearZeros, clearZerosFiles } from '@/lib/server-util/clearZeros';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiServerClearZerosResponse = {
status?: string;
files?: Awaited<ReturnType<typeof clearZerosFiles>>;
};
export const PATH = '/api/server/clear_zeros';
export default fastifyPlugin(
(server, _, done) => {
server.get(
PATH,
{
preHandler: [userMiddleware, administratorMiddleware],
},
async (_, res) => {
const files = await clearZerosFiles();
return res.send({ files });
},
);
server.delete(
PATH,
{
preHandler: [userMiddleware, administratorMiddleware],
},
async (_, res) => {
const files = await clearZerosFiles();
const status = await clearZeros(files);
return res.send({ status });
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,36 @@
import { requerySize } from '@/lib/server-util/requerySize';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiServerRequerySizeResponse = {
status?: string;
};
type Body = {
forceDelete?: boolean;
forceUpdate?: boolean;
};
export const PATH = '/api/server/requery_size';
export default fastifyPlugin(
(server, _, done) => {
server.post<{ Body: Body }>(
PATH,
{
preHandler: [userMiddleware, administratorMiddleware],
},
async (req, res) => {
const status = await requerySize({
forceDelete: req.body.forceDelete || false,
forceUpdate: req.body.forceUpdate || false,
});
return res.send({ status });
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,71 @@
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { getZipline } from '@/lib/db/models/zipline';
import { log } from '@/lib/logger';
import fastifyPlugin from 'fastify-plugin';
export type ApiSetupResponse = {
firstSetup?: boolean;
user?: User;
};
type Body = {
username: string;
password: string;
};
const logger = log('api').c('setup');
export const PATH = '/api/setup';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, async (_, res) => {
const { firstSetup } = await getZipline();
if (!firstSetup) return res.forbidden();
return res.send({ firstSetup });
});
server.post<{ Body: Body }>(PATH, async (req, res) => {
const { firstSetup, id } = await getZipline();
if (!firstSetup) return res.forbidden();
logger.info('first setup running');
const { username, password } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const user = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
role: 'SUPERADMIN',
token: createToken(),
},
select: userSelect,
});
logger.info('first setup complete');
await prisma.zipline.update({
where: {
id,
},
data: {
firstSetup: false,
},
});
return res.send({
firstSetup,
user,
});
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,54 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { Metric } from '@/lib/db/models/metric';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiStatsResponse = Metric[];
type Query = {
from?: string;
to?: string;
};
export const PATH = '/api/stats';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
if (!config.features.metrics) return res.forbidden('metrics are disabled');
const { from, to } = req.query;
const fromDate = from ? new Date(from) : new Date(Date.now() - 86400000);
const toDate = to ? new Date(to) : new Date();
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return res.badRequest('invalid date(s)');
const stats = await prisma.metric.findMany({
where: {
createdAt: {
gte: fromDate,
lte: toDate,
},
},
orderBy: {
createdAt: 'desc',
},
});
if (!config.features.metrics.showUserSpecific) {
for (let i = 0; i !== stats.length; ++i) {
const stat = stats[i].data;
stat.filesUsers = [];
stat.urlsUsers = [];
}
}
return res.send(stats);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,32 @@
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 ApiUserTokenResponse = {
user?: User;
token?: string;
};
export const PATH = '/api/user/avatar';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const u = await prisma.user.findFirstOrThrow({
where: {
id: req.user.id,
},
select: {
avatar: true,
},
});
if (!u.avatar) return res.notFound();
return res.send(u.avatar);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,87 @@
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserResponse = {
user?: User;
token?: string;
};
type Body = {
username?: string;
password?: string;
avatar?: string;
view?: {
content?: string;
embed?: boolean;
embedTitle?: string;
embedDescription?: string;
embedColor?: string;
embedSiteName?: string;
enabled?: boolean;
align?: 'left' | 'center' | 'right';
showMimetype?: boolean;
};
};
export const PATH = '/api/user';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
return res.send({ user: req.user, token: req.cookies.zipline_token });
});
server.patch<{ Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
if (req.body.username) {
const existing = await prisma.user.findUnique({
where: {
username: req.body.username,
},
});
if (existing) return res.badRequest('Username already exists');
}
const user = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
...(req.body.username && { username: req.body.username }),
...(req.body.password && { password: await hashPassword(req.body.password) }),
...(req.body.avatar !== undefined && { avatar: req.body.avatar || null }),
...(req.body.view && {
view: {
...req.user.view,
...(req.body.view.enabled !== undefined && { enabled: req.body.view.enabled || false }),
...(req.body.view.content !== undefined && { content: req.body.view.content || null }),
...(req.body.view.embed !== undefined && { embed: req.body.view.embed || false }),
...(req.body.view.embedTitle !== undefined && { embedTitle: req.body.view.embedTitle || null }),
...(req.body.view.embedDescription !== undefined && {
embedDescription: req.body.view.embedDescription || null,
}),
...(req.body.view.embedColor !== undefined && { embedColor: req.body.view.embedColor || null }),
...(req.body.view.embedSiteName !== undefined && {
embedSiteName: req.body.view.embedSiteName || null,
}),
...(req.body.view.align !== undefined && { align: req.body.view.align || 'center' }),
...(req.body.view.showMimetype !== undefined && {
showMimetype: req.body.view.showMimetype || false,
}),
},
}),
},
select: {
...userSelect,
},
});
return res.send({ user, token: req.cookies.zipline_token });
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,38 @@
import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserRecentResponse = File[];
type Query = {
page?: string;
};
export const PATH = '/api/user/recent';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const files = cleanFiles(
await prisma.file.findMany({
where: {
userId: req.user.id,
},
select: {
...fileSelect,
password: true,
},
orderBy: {
createdAt: 'desc',
},
take: 3,
}),
);
return res.send(files);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,96 @@
import { prisma } from '@/lib/db';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserStatsResponse = {
filesUploaded: number;
favoriteFiles: number;
views: number;
avgViews: number;
storageUsed: number;
avgStorageUsed: number;
urlsCreated: number;
urlViews: number;
sortTypeCount: { [type: string]: number };
};
export const PATH = '/api/user/stats';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const aggFile = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_count: {
_all: true,
},
_sum: {
views: true,
size: true,
},
_avg: {
views: true,
size: true,
},
});
const favCount = await prisma.file.count({
where: {
favorite: true,
},
});
const aggUrl = await prisma.url.aggregate({
where: {
userId: req.user.id,
},
_count: {
_all: true,
},
_avg: {
views: true,
},
_sum: {
views: true,
},
});
const sortType = await prisma.file.findMany({
where: {
userId: req.user.id,
},
select: {
type: true,
},
});
const sortTypeCount = sortType.reduce(
(acc, cur) => {
if (acc[cur.type]) acc[cur.type] += 1;
else acc[cur.type] = 1;
return acc;
},
{} as { [type: string]: number },
);
return res.send({
filesUploaded: aggFile._count._all ?? 0,
favoriteFiles: favCount ?? 0,
views: aggFile._sum.views ?? 0,
avgViews: aggFile._avg.views ?? 0,
storageUsed: Number(aggFile._sum.size ?? 0),
avgStorageUsed: Number(aggFile._avg.size ?? 0),
urlsCreated: aggUrl._count._all ?? 0,
urlViews: aggUrl._sum.views ?? 0,
sortTypeCount,
});
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,61 @@
import { config } from '@/lib/config';
import { createToken, encryptToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/server/loginToken';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiUserTokenResponse = {
user?: User;
token?: string;
};
export const PATH = '/api/user/token';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.user.id,
},
select: {
token: true,
},
});
const token = encryptToken(user!.token, config.core.secret);
return res.send({
token,
});
});
server.patch(PATH, async (req, res) => {
const user = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
token: createToken(),
},
select: {
...userSelect,
token: true,
},
});
const token = loginToken(res, user);
delete (user as any).token;
return res.send({
user,
token,
});
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,244 @@
import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { log } from '@/lib/logger';
import { canInteract } from '@/lib/role';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import { UserFilesQuota } from '@prisma/client';
import fastifyPlugin from 'fastify-plugin';
import { z } from 'zod';
export type ApiUsersIdResponse = User;
type Body = {
username?: string;
password?: string;
avatar?: string;
role?: 'USER' | 'ADMIN' | 'SUPERADMIN';
quota?: {
filesType?: UserFilesQuota & 'NONE';
maxFiles?: number;
maxBytes?: string;
maxUrls?: number;
};
delete?: boolean;
};
type Params = {
id: string;
};
const logger = log('api').c('users').c('[id]');
const zNumber = z.number();
export const PATH = '/api/users/:id';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Params: Params }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.params.id,
},
select: userSelect,
});
if (!user) return res.notFound('User not found');
return res.send(req.user);
},
);
server.patch<{ Params: Params; Body: Body }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.params.id,
},
select: userSelect,
});
if (!user) return res.notFound('User not found');
const { username, password, avatar, role, quota } = req.body;
if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success)
return res.badRequest('Invalid role (USER, ADMIN)');
if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role');
let finalQuota:
| {
filesQuota?: UserFilesQuota;
maxFiles?: number | null;
maxBytes?: string | null;
maxUrls?: number | null;
}
| undefined = undefined;
if (quota) {
if (quota.filesType && !z.enum(['BY_BYTES', 'BY_FILES', 'NONE']).safeParse(quota.filesType).success)
return res.badRequest('Invalid filesType (BY_BYTES, BY_FILES, NONE)');
if (quota.maxFiles && !zNumber.safeParse(quota.maxFiles).success)
return res.badRequest('Invalid maxFiles');
if (quota.maxUrls && !zNumber.safeParse(quota.maxUrls).success)
return res.badRequest('Invalid maxUrls');
if (quota.filesType === 'BY_BYTES' && quota.maxBytes === undefined)
return res.badRequest('maxBytes is required');
if (quota.filesType === 'BY_FILES' && quota.maxFiles === undefined)
return res.badRequest('maxFiles is required');
finalQuota = {
...(quota.filesType === 'BY_BYTES' && {
filesQuota: 'BY_BYTES',
maxBytes: bytes(quota.maxBytes || '0') > 0 ? quota.maxBytes : null,
maxFiles: null,
}),
...(quota.filesType === 'BY_FILES' && {
filesQuota: 'BY_FILES',
maxFiles: quota.maxFiles,
maxBytes: null,
}),
...(quota.filesType === 'NONE' && {
filesQuota: 'BY_BYTES',
maxFiles: null,
maxBytes: null,
}),
maxUrls: (quota.maxUrls || 0) > 0 ? quota.maxUrls : null,
};
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
...(username && { username }),
...(password && { password: await hashPassword(password) }),
...(role !== undefined && { role: 'USER' }),
...(avatar && { avatar }),
...(finalQuota && {
quota: {
upsert: {
where: {
userId: user.id,
},
create: {
filesQuota: finalQuota.filesQuota || 'BY_BYTES',
maxFiles: finalQuota.maxFiles ?? null,
maxBytes: finalQuota.maxBytes ?? null,
maxUrls: finalQuota.maxUrls ?? null,
},
update: finalQuota,
},
},
}),
},
select: {
...userSelect,
totpSecret: false,
passkeys: false,
},
});
logger.info(`${req.user.username} updated another user`, {
username: updatedUser.username,
role: updatedUser.role,
});
return res.send(updatedUser);
},
);
server.delete<{ Params: Params; Body: Body }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.params.id,
},
select: userSelect,
});
if (!user) return res.notFound('User not found');
if (user.id === req.user.id) return res.forbidden('You cannot delete yourself');
if (!canInteract(req.user.role, user.role)) return res.forbidden('You cannot delete this user');
if (req.body.delete) {
const files = await prisma.file.findMany({
where: {
userId: user.id,
},
select: {
name: true,
},
});
const [{ count: filesDeleted }, { count: urlsDeleted }] = await prisma.$transaction([
prisma.file.deleteMany({
where: {
userId: user.id,
},
}),
prisma.url.deleteMany({
where: {
userId: user.id,
},
}),
]);
logger.debug(`preparing to delete ${files.length} files from datasource`, {
username: user.username,
});
for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].name);
}
logger.info(`${req.user.username} deleted another user's files & urls`, {
username: user.username,
deletedFiles: filesDeleted,
deletedUrls: urlsDeleted,
});
}
await prisma.oAuthProvider.deleteMany({
where: {
userId: user.id,
},
});
const deletedUser = await prisma.user.delete({
where: {
id: user.id,
},
select: {
...userSelect,
totpSecret: false,
},
});
logger.info(`${req.user.username} deleted another user`, {
username: deletedUser.username,
role: deletedUser.role,
});
return res.send(deletedUser);
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,103 @@
import { config } from '@/lib/config';
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { log } from '@/lib/logger';
import { canInteract } from '@/lib/role';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import { Role } from '@prisma/client';
import fastifyPlugin from 'fastify-plugin';
import { readFile } from 'fs/promises';
import { z } from 'zod';
export type ApiUsersResponse = User[] | User;
type Query = {
noincl?: 'true' | 'false';
};
type Body = {
username?: string;
password?: string;
avatar?: string;
role?: Role;
};
const logger = log('api').c('users');
export const PATH = '/api/users';
export default fastifyPlugin(
(server, _, done) => {
server.get<{ Querystring: Query }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const users = await prisma.user.findMany({
select: {
...userSelect,
avatar: true,
},
where: {
...(req.query.noincl === 'true' && { id: { not: req.user.id } }),
},
});
return res.send(users);
},
);
server.post<{ Querystring: Query; Body: Body }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const { username, password, avatar, role } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
let avatar64 = null;
try {
if (config.website.defaultAvatar) {
avatar64 = (await readFile(config.website.defaultAvatar)).toString('base64');
} else if (avatar) {
avatar64 = avatar;
}
} catch {
logger.debug('failed to read default avatar', { path: config.website.defaultAvatar });
}
if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success)
return res.badRequest('Invalid role (USER, ADMIN)');
if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role');
const user = await prisma.user.create({
data: {
username,
password: await hashPassword(password),
role: role ?? 'USER',
avatar: avatar64 ?? null,
token: createToken(),
},
select: {
...userSelect,
totpSecret: false,
passkeys: false,
},
});
logger.info(`${req.user.username} created a new user`, {
username: user.username,
role: user.role,
});
return res.send(user);
},
);
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,22 @@
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
import { version } from '../../../../package.json';
export type ApiVersionResponse = {
version: string;
};
export const PATH = '/api/version';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware, administratorMiddleware] }, async (_, res) => {
return res.send({
version,
});
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,67 @@
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { FastifyReply, FastifyRequest } from 'fastify';
import { parse } from 'url';
type Params = {
id: string;
};
type Query = {
pw?: string;
download?: string;
};
export async function filesRoute(
req: FastifyRequest<{ Params: Params; Querystring: Query }>,
res: FastifyReply,
) {
const { id } = req.params;
const { pw, download } = req.query;
const parsedUrl = parse(req.url!, true);
const file = await prisma.file.findFirst({
where: {
name: id,
},
include: {
User: true,
},
});
if (!file) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
if (file.User?.view.enabled) return res.redirect(`/view/${file.name}`);
const stream = await datasource.get(file.name);
if (!stream) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
if (file.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw as string, file.password!);
if (!verified) return res.forbidden('Incorrect password.');
}
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
return res
.headers({
'Content-Type': file.type || 'application/octet-stream',
'Content-Length': file.size,
...(file.originalName && {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${file.originalName}"`,
}),
})
.send(stream);
}

View File

@@ -1,82 +0,0 @@
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import express, { Request, Response } from 'express';
import { NextServer } from 'next/dist/server/next';
import { parse } from 'url';
const logger = log('server').c('files');
export async function filesRoute(
this: ReturnType<typeof express>,
app: NextServer,
req: Request,
res: Response,
) {
const { id } = req.params;
const { pw } = req.query;
const parsedUrl = parse(req.url!, true);
const file = await prisma.file.findFirst({
where: {
name: id,
},
include: {
User: true,
},
});
if (!file) return app.render404(req, res, parsedUrl);
if (file.maxViews && file.views >= file.maxViews) {
if (config.features.deleteOnMaxViews) {
await prisma.file.delete({
where: {
id: file.id,
},
});
logger.info(`${file.name} deleted due to reaching max views`, {
id: file.id,
views: file.views,
});
}
return app.render404(req, res, parsedUrl);
}
if (file.User?.view.enabled) return res.redirect(`/view/${file.name}`);
const stream = await datasource.get(file.name);
if (!stream) return app.render404(req, res, parsedUrl);
if (file.password) {
if (!pw) return res.status(403).json({ code: 403, message: 'Password protected.' });
const verified = await verifyPassword(pw as string, file.password!);
if (!verified) return res.status(403).json({ code: 403, message: 'Incorrect password.' });
}
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Length', file.size);
file.originalName &&
res.setHeader(
'Content-Disposition',
`${req.query.download ? 'attachment; ' : ''}filename="${file.originalName}"`,
);
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
stream.pipe(res);
}

View File

@@ -0,0 +1,25 @@
import fastifyPlugin from 'fastify-plugin';
import glob from 'fast-glob';
import { pathToFileURL } from 'url';
const DEV = process.env.NODE_ENV === 'development';
const loadRoutes = async (): Promise<Record<string, ReturnType<typeof fastifyPlugin>>> => {
const files = await glob(`./${DEV ? 'src' : 'build'}/server/routes/**/!(*.dy).@(ts|js)`, {
ignore: [`./${DEV ? 'src' : 'build'}/server/routes/index.(ts|js)`],
});
const routes: Record<string, ReturnType<typeof fastifyPlugin>> = {};
for (const file of files) {
const a = await import(pathToFileURL(file).href);
if (!a.default) throw new Error(`Route ${file} does not have a default export.`);
if (!a.PATH) throw new Error(`Route ${file} does not have a PATH export.`);
routes[a.PATH] = a.default;
}
return routes;
};
export default loadRoutes;

98
src/server/routes/raw.ts Normal file
View File

@@ -0,0 +1,98 @@
import { parseRange } from '@/lib/api/range';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import fastifyPlugin from 'fastify-plugin';
import { parse } from 'url';
type Params = {
id: string;
};
type Querystring = {
pw?: string;
download?: string;
};
export const PATH = '/raw/:id';
export default fastifyPlugin(
(server, _, done) => {
server.get<{
Querystring: Querystring;
Params: Params;
}>(PATH, async (req, res) => {
const { id } = req.params;
const { pw, download } = req.query;
const parsedUrl = parse(req.url!, true);
const file = await prisma.file.findFirst({
where: {
name: id,
},
});
if (file?.password) {
if (!pw) return res.forbidden('Password protected.');
const verified = await verifyPassword(pw, file.password!);
if (!verified) return res.forbidden('Incorrect password.');
}
const size = file?.size || (await datasource.size(file?.name ?? id));
if (req.headers.range) {
const [start, end] = parseRange(req.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(file?.name ?? id);
if (!buf) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
...(file?.originalName && {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${file.originalName}"`,
}),
})
.status(416)
.send(buf);
}
const buf = await datasource.range(file?.name ?? id, start || 0, end);
if (!buf) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
...(file?.originalName && {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${file.originalName}"`,
}),
})
.status(206)
.send(buf);
}
const buf = await datasource.get(file?.name ?? id);
if (!buf) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
return res
.type(file?.type || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
...(file?.originalName && {
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${file.originalName}"`,
}),
})
.status(200)
.send(buf);
});
done();
},
{ name: PATH },
);

View File

@@ -1,18 +1,23 @@
import { NextServer } from 'next/dist/server/next';
import express, { Request, Response } from 'express';
import { parse } from 'url';
import { prisma } from '@/lib/db';
import { config } from '@/lib/config';
import { log } from '@/lib/logger';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { FastifyReply, FastifyRequest } from 'fastify';
import { parse } from 'url';
type Params = {
id: string;
};
type Query = {
pw?: string;
};
const logger = log('server').c('urls');
export async function urlsRoute(
this: ReturnType<typeof express>,
app: NextServer,
req: Request,
res: Response,
req: FastifyRequest<{ Params: Params; Querystring: Query }>,
res: FastifyReply,
) {
const { id } = req.params;
const { pw } = req.query;
@@ -24,7 +29,7 @@ export async function urlsRoute(
OR: [{ code: id }, { vanity: id }, { id }],
},
});
if (!url) return app.render404(req, res, parsedUrl);
if (!url) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
if (url.maxViews && url.views >= url.maxViews) {
if (config.features.deleteOnMaxViews) {
@@ -41,7 +46,7 @@ export async function urlsRoute(
});
}
return app.render404(req, res, parsedUrl);
return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
}
if (url.password) {

View File

@@ -18,6 +18,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/client/mount.js"],
"exclude": ["node_modules", "uploads"]
}

View File

@@ -1,50 +1,19 @@
import glob from 'fast-glob';
import { defineConfig } from 'tsup';
export default defineConfig([
{
platform: 'node',
format: 'cjs',
treeshake: true,
clean: false,
sourcemap: true,
entryPoints: {
server: 'src/server/index.ts',
export default defineConfig(async (_) => {
return [
{
platform: 'node',
format: 'cjs',
treeshake: true,
clean: false,
sourcemap: true,
entryPoints: await glob('./src/**/*.ts', {
ignore: ['./src/components/**/*.ts', './src/pages/**/*.ts'],
}),
outDir: 'build',
external: ['argon2'],
},
outDir: 'build',
},
{
platform: 'node',
format: 'cjs',
treeshake: true,
clean: false,
sourcemap: true,
entryPoints: {
ctl: 'src/ctl/index.ts',
},
outDir: 'build',
bundle: true,
minify: true,
},
{
platform: 'node',
format: 'cjs',
treeshake: true,
clean: false,
sourcemap: true,
entryPoints: {
thumbnails: 'src/offload/thumbnails.ts',
},
outDir: 'build/offload',
},
{
platform: 'node',
format: 'cjs',
treeshake: true,
clean: false,
sourcemap: true,
entryPoints: {
partial: 'src/offload/partial.ts',
},
outDir: 'build/offload',
},
]);
];
});