diff --git a/package.json b/package.json index c323ca3a..c01d867c 100755 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@fastify/multipart": "^8.2.0", "@fastify/rate-limit": "^9.1.0", "@fastify/sensible": "^5.5.0", + "@fastify/static": "^7.0.4", "@github/webauthn-json": "^2.1.1", "@mantine/code-highlight": "^7.2.2", "@mantine/core": "^7.2.2", @@ -52,6 +53,7 @@ "fast-glob": "^3.3.2", "fastify": "^4.26.2", "fastify-plugin": "^4.5.1", + "fflate": "^0.8.2", "fluent-ffmpeg": "^2.1.3", "highlight.js": "^11.9.0", "isomorphic-dompurify": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfd2d0d3..9130bf18 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@fastify/sensible': specifier: ^5.5.0 version: 5.5.0 + '@fastify/static': + specifier: ^7.0.4 + version: 7.0.4 '@github/webauthn-json': specifier: ^2.1.1 version: 2.1.1 @@ -104,6 +107,9 @@ importers: fastify-plugin: specifier: ^4.5.1 version: 4.5.1 + fflate: + specifier: ^0.8.2 + version: 0.8.2 fluent-ffmpeg: specifier: ^2.1.3 version: 2.1.3 @@ -960,6 +966,10 @@ packages: resolution: {integrity: sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fastify/accept-negotiator@1.1.0': + resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} + engines: {node: '>=14'} + '@fastify/ajv-compiler@3.5.0': resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} @@ -991,9 +1001,15 @@ packages: '@fastify/rate-limit@9.1.0': resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + '@fastify/send@2.1.0': + resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} + '@fastify/sensible@5.5.0': resolution: {integrity: sha512-D0zpl+nocsRXLceSbc4gasQaO3ZNQR4dy9Uu8Ym0mh8VUdrjpZ4g8Ca9O3pGXbBVOnPIGHUJNTV7Yf9dg/OYdg==} + '@fastify/static@7.0.4': + resolution: {integrity: sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==} + '@floating-ui/core@1.5.1': resolution: {integrity: sha512-QgcKYwzcc8vvZ4n/5uklchy8KVdjJwcOeI+HnnTNclJjs2nYsy23DOCf+sSV1kBwD9yDAoVKCkv/gEPzgQU3Pw==} @@ -1030,6 +1046,10 @@ packages: '@humanwhocodes/object-schema@2.0.1': resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.3': resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} engines: {node: '>=6.0.0'} @@ -1228,6 +1248,10 @@ packages: resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/utils@2.4.2': resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1765,6 +1789,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + ansi-styles@2.2.1: resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} engines: {node: '>=0.10.0'} @@ -1777,6 +1805,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2396,6 +2428,9 @@ packages: resolution: {integrity: sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==} hasBin: true + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -2714,6 +2749,9 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2763,6 +2801,10 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -2861,6 +2903,10 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} @@ -2869,10 +2915,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -3233,6 +3281,9 @@ packages: iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jdataview@2.5.0: resolution: {integrity: sha512-ZJop3D5nyDcWPBPv4NPnhCvx3HgQNsCXMfw8gpNKY16BobgxmVF+kJ08aHuqk6bJQVeL2mkf6nDCcZPMompalw==} @@ -3452,6 +3503,9 @@ packages: resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==} engines: {node: 14 || >=16.14} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3643,6 +3697,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3666,6 +3725,10 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3677,6 +3740,10 @@ packages: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} @@ -4002,6 +4069,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + packet-reader@1.0.0: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} @@ -4043,6 +4113,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} @@ -4596,6 +4670,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -4693,6 +4771,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.10: resolution: {integrity: sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==} @@ -4720,6 +4802,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -5259,6 +5345,14 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6450,6 +6544,8 @@ snapshots: '@eslint/js@8.54.0': {} + '@fastify/accept-negotiator@1.1.0': {} + '@fastify/ajv-compiler@3.5.0': dependencies: ajv: 8.12.0 @@ -6495,6 +6591,14 @@ snapshots: fastify-plugin: 4.5.1 toad-cache: 3.7.0 + '@fastify/send@2.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.0 + mime: 3.0.0 + '@fastify/sensible@5.5.0': dependencies: '@lukeed/ms': 2.0.2 @@ -6505,6 +6609,15 @@ snapshots: type-is: 1.6.18 vary: 1.1.2 + '@fastify/static@7.0.4': + dependencies: + '@fastify/accept-negotiator': 1.1.0 + '@fastify/send': 2.1.0 + content-disposition: 0.5.4 + fastify-plugin: 4.5.1 + fastq: 1.17.1 + glob: 10.4.5 + '@floating-ui/core@1.5.1': dependencies: '@floating-ui/utils': 0.1.6 @@ -6544,6 +6657,15 @@ snapshots: '@humanwhocodes/object-schema@2.0.1': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.3': dependencies: '@jridgewell/set-array': 1.1.2 @@ -6734,6 +6856,9 @@ snapshots: '@phc/format@1.0.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/utils@2.4.2': dependencies: cross-spawn: 7.0.3 @@ -7550,6 +7675,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.0.1: {} + ansi-styles@2.2.1: {} ansi-styles@3.2.1: @@ -7560,6 +7687,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.1: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -8173,6 +8302,8 @@ snapshots: dependencies: minimatch: 3.1.2 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -8717,6 +8848,8 @@ snapshots: fecha@4.2.3: {} + fflate@0.8.2: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -8788,6 +8921,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + form-data@4.0.0: dependencies: asynckit: 0.4.0 @@ -8884,6 +9022,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + glob@7.1.6: dependencies: fs.realpath: 1.0.0 @@ -9249,6 +9396,12 @@ snapshots: reflect.getprototypeof: 1.0.4 set-function-name: 2.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jdataview@2.5.0: {} joycon@3.1.1: {} @@ -9473,6 +9626,8 @@ snapshots: lru-cache@10.1.0: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9827,6 +9982,8 @@ snapshots: mime@1.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -9843,6 +10000,10 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + minimist@1.2.8: {} minipass@3.3.6: @@ -9851,6 +10012,8 @@ snapshots: minipass@5.0.0: {} + minipass@7.1.2: {} + minizlib@2.1.2: dependencies: minipass: 3.3.6 @@ -10203,6 +10366,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.0: {} + packet-reader@1.0.0: {} parent-module@1.0.1: @@ -10234,6 +10399,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@0.1.7: {} path-type@4.0.0: {} @@ -10845,6 +11015,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -10938,6 +11110,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + string.prototype.matchall@4.0.10: dependencies: call-bind: 1.0.5 @@ -10984,6 +11162,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + strip-bom@3.0.0: {} strip-final-newline@2.0.0: {} @@ -11572,6 +11754,18 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} ws@8.16.0: {} diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index be039124..507cfbfc 100755 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -11,6 +11,7 @@ import SettingsMfa from './parts/SettingsMfa'; import SettingsOAuth from './parts/SettingsOAuth'; import SettingsServerActions from './parts/SettingsServerUtil'; import SettingsUser from './parts/SettingsUser'; +import SettingsExports from './parts/SettingsExports'; export default function DashboardSettings() { const config = useConfig(); @@ -37,6 +38,8 @@ export default function DashboardSettings() { {isAdministrator(user?.role) && } + + ); diff --git a/src/components/pages/settings/parts/SettingsExports.tsx b/src/components/pages/settings/parts/SettingsExports.tsx new file mode 100644 index 00000000..cab08855 --- /dev/null +++ b/src/components/pages/settings/parts/SettingsExports.tsx @@ -0,0 +1,123 @@ +import { Response } from '@/lib/api/response'; +import { ActionIcon, Button, Paper, ScrollArea, Table, Title } from '@mantine/core'; +import { modals } from '@mantine/modals'; +import { showNotification } from '@mantine/notifications'; +import { IconPlus, IconTrashFilled } from '@tabler/icons-react'; +import useSWR from 'swr'; + +export default function SettingsExports() { + const { data, isLoading, mutate } = useSWR('/api/user/export', { + refreshInterval: 5000, + }); + + const handleNewExport = async () => { + modals.openConfirmModal({ + title: New Export?, + children: + 'Are you sure you want to start a new export? If you have a lot of files, this may take a while.', + onConfirm: async () => { + await fetch('/api/user/export', { + method: 'POST', + }); + + showNotification({ + title: 'Export started', + message: 'Export has been started, you can check its status in the table below', + color: 'blue', + loading: true, + }); + mutate(); + }, + labels: { + cancel: 'Cancel', + confirm: 'Start export', + }, + }); + }; + + const handleDelete = async (name: string) => { + await fetch(`/api/user/export?name=${name}`, { + method: 'DELETE', + }); + + showNotification({ + title: 'Export deleted', + message: 'Export has been deleted', + color: 'red', + }); + + mutate(); + }; + + return ( + + Export Files + + + + + Completed Exports + + + + + + Name + Started On + Files + + + + {isLoading && Loading...} + {data?.complete.map((file) => ( + + {file.name} + {new Date(file.date).toLocaleString()} + {file.files} + + handleDelete(file.name)}> + + + + + ))} + +
+
+ + + Running Exports + + + + + + Name + Started On + Files + + + + {isLoading && Loading...} + {data?.running.map((file) => ( + + {file.name} + {new Date(file.date).toLocaleString()} + {file.files} + + ))} + +
+
+
+ ); +} diff --git a/src/lib/api/response.ts b/src/lib/api/response.ts index 904a5492..d0ec9e97 100755 --- a/src/lib/api/response.ts +++ b/src/lib/api/response.ts @@ -15,6 +15,7 @@ import { ApiSetupResponse } from '@/server/routes/api/setup'; import { ApiStatsResponse } from '@/server/routes/api/stats'; import { ApiUploadResponse } from '@/server/routes/api/upload'; import { ApiUserResponse } from '@/server/routes/api/user'; +import { ApiUserExportResponse } from '@/server/routes/api/user/export'; 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'; @@ -60,6 +61,7 @@ export type Response = { '/api/user/stats': ApiUserStatsResponse; '/api/user/recent': ApiUserRecentResponse; '/api/user/token': ApiUserTokenResponse; + '/api/user/export': ApiUserExportResponse; '/api/users': ApiUsersResponse; '/api/users/[id]': ApiUsersIdResponse; '/api/server/clear_temp': ApiServerClearTempResponse; diff --git a/src/server/index.ts b/src/server/index.ts index 88f95964..e41a90f9 100755 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,6 +14,7 @@ import { fastifyCors } from '@fastify/cors'; import { fastifyMultipart } from '@fastify/multipart'; import { fastifyRateLimit } from '@fastify/rate-limit'; import { fastifySensible } from '@fastify/sensible'; +import { fastifyStatic } from '@fastify/static'; import fastify from 'fastify'; import { mkdir } from 'fs/promises'; import { parse } from 'url'; @@ -78,6 +79,11 @@ async function main() { }, }); + await server.register(fastifyStatic, { + serve: false, + root: '/', + }); + if (config.ratelimit.enabled) { try { checkRateLimit(config); diff --git a/src/server/routes/api/user/export.ts b/src/server/routes/api/user/export.ts new file mode 100644 index 00000000..947cc619 --- /dev/null +++ b/src/server/routes/api/user/export.ts @@ -0,0 +1,204 @@ +import { config } from '@/lib/config'; +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'; +import { Zip, ZipPassThrough } from 'fflate'; +import { createWriteStream } from 'fs'; +import { readdir, rename, rm } from 'fs/promises'; +import { join } from 'path'; + +export type ApiUserExportResponse = { + running?: boolean; + deleted?: boolean; +} & { + [key in 'running' | 'complete']: { + date: number; + files: number; + name: string; + }[]; +}; + +type Query = { + name?: string; +}; + +export const PATH = '/api/user/export'; + +const logger = log('api').c('user').c('export'); + +export default fastifyPlugin( + (server, _, done) => { + server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { + const tmpFiles = await readdir(config.core.tempDirectory); + const userExports = tmpFiles + .filter((file) => file.startsWith(`zexport_${req.user.id}`) && file.endsWith('.zip')) + .map((file) => file.split('_')) + .filter((file) => file.length === 5); + + const incompleteExports = userExports + .filter((file) => file[file.length - 1] === 'incomplete.zip') + .map((file) => ({ + date: Number(file[2]), + files: Number(file[3]), + name: file.join('_'), + })); + const completeExports = userExports + .filter((file) => file[file.length - 1] === 'complete.zip') + .map((file) => ({ + date: Number(file[2]), + files: Number(file[3]), + name: file.join('_'), + })); + + if (req.query.name) { + const file = completeExports.find((file) => file.name === req.query.name); + if (!file) return res.notFound(); + + const path = join(config.core.tempDirectory, file.name); + return res.sendFile(path); + } + + return res.send({ + running: incompleteExports, + complete: completeExports, + }); + }); + + server.delete<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { + if (!req.query.name) return res.badRequest('No name provided'); + + const tmpFiles = await readdir(config.core.tempDirectory); + const userExports = tmpFiles + .filter((file) => file.startsWith(`zexport_${req.user.id}`) && file.endsWith('.zip')) + .map((file) => file.split('_')) + .filter((file) => file.length === 5 && file[file.length - 1] === 'complete.zip') + .map((file) => file.join('_')); + + if (!userExports.includes(req.query.name)) return res.notFound(); + + const path = join(config.core.tempDirectory, req.query.name); + await rm(path); + + logger.info(`deleted export ${req.query.name}`); + + return res.send({ deleted: true }); + }); + + server.post(PATH, { preHandler: [userMiddleware] }, async (req, res) => { + const files = await prisma.file.findMany({ + where: { userId: req.user.id }, + }); + + if (!files.length) return res.badRequest('No files to export'); + + const exportFileName = `zexport_${req.user.id}_${Date.now()}_${files.length}_incomplete.zip`; + const exportPath = join(config.core.tempDirectory, exportFileName); + + logger.debug(`exporting ${req.user.id}`, { exportPath, files: files.length }); + + const writeStream = createWriteStream(exportPath); + const zip = new Zip(); + + const onBackpressure = (stream: any, outputStream: any, cb: any) => { + const runCb = () => { + cb(applyOutputBackpressure || backpressureBytes > backpressureThreshold); + }; + + const backpressureThreshold = 65536; + const backpressure: number[] = []; + let backpressureBytes = 0; + const push = stream.push; + stream.push = (data: string | any[], final: any) => { + backpressure.push(data.length); + backpressureBytes += data.length; + runCb(); + push.call(stream, data, final); + }; + let ondata = stream.ondata; + const ondataPatched = (err: any, data: any, final: any) => { + ondata.call(stream, err, data, final); + backpressureBytes -= backpressure.shift()!; + runCb(); + }; + if (ondata) { + stream.ondata = ondataPatched; + } else { + Object.defineProperty(stream, 'ondata', { + get: () => ondataPatched, + set: (cb) => (ondata = cb), + }); + } + + let applyOutputBackpressure = false; + const write = outputStream.write; + outputStream.write = (data: any) => { + const outputNotFull = write.call(outputStream, data); + applyOutputBackpressure = !outputNotFull; + runCb(); + }; + outputStream.on('drain', () => { + applyOutputBackpressure = false; + runCb(); + }); + }; + + zip.ondata = async (err, data, final) => { + if (err) { + writeStream.close(); + logger.debug('error while writing to zip', { err }); + logger.error(`export for ${req.user.id} failed`); + + return; + } + + writeStream.write(data); + + if (!final) return; + + const newExportName = `zexport_${req.user.id}_${Date.now()}_${files.length}_complete.zip`; + const path = join(config.core.tempDirectory, newExportName); + + writeStream.end(); + logger.debug('exported', { path, bytes: data.length }); + logger.info(`export for ${req.user.id} finished at ${path}`); + + await rename(exportPath, path); + }; + + for (let i = 0; i !== files.length; ++i) { + const file = files[i]; + + const stream = await datasource.get(file.name); + if (!stream) { + logger.warn(`failed to get file ${file.name}`); + continue; + } + + const passThrough = new ZipPassThrough(file.name); + zip.add(passThrough); + + onBackpressure(passThrough, stream, (applyBackpressure: boolean) => { + if (applyBackpressure) { + stream.pause(); + } else if (stream.isPaused()) { + stream.resume(); + } + }); + stream.on('data', (c) => passThrough.push(c)); + stream.on('end', () => { + passThrough.push(new Uint8Array(0), true); + logger.debug(`file ${i + 1}/${files.length} added to zip`, { name: file.name }); + }); + } + + zip.end(); + + return res.send({ running: true }); + }); + + done(); + }, + { name: PATH }, +);