Initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# RustFS (S3-compatible) configuration
|
||||
RUSTFS_ENDPOINT=http://localhost:9000
|
||||
RUSTFS_ACCESS_KEY=minioadmin
|
||||
RUSTFS_SECRET_KEY=minioadmin
|
||||
RUSTFS_BUCKET=surf
|
||||
RUSTFS_PUBLIC=true
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
DB_PATH=./data/surf.db
|
||||
|
||||
# Auth - required, protects all upload/delete endpoints
|
||||
ADMIN_TOKEN=change-me-to-a-real-secret
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "surfnathanrip-backend",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@elysiajs/cors": "latest",
|
||||
"@elysiajs/static": "latest",
|
||||
"elysia": "latest",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||
|
||||
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1058.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/middleware-bucket-endpoint": "^3.972.17", "@aws-sdk/middleware-expect-continue": "^3.972.14", "@aws-sdk/middleware-flexible-checksums": "^3.974.23", "@aws-sdk/middleware-location-constraint": "^3.972.11", "@aws-sdk/middleware-sdk-s3": "^3.972.44", "@aws-sdk/middleware-ssec": "^3.972.11", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-AfED3hhaBZ121NuiBImgnlF98kQRMk6hGPMGfj/Oo1hSaoMFRzM+N4nlICCasUSM2R8QaIRZRYGpZ3fy0ilGZQ=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
|
||||
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-lbDmWuHenc+kiwCNrxz4MyN6nkxCWyTXPIWuspJN0ibziu+8CXci7vI1bK9MAkwy8cwJOEXNu0gBM5S0uTGRIg=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3TNFEVGO4sWZj9TEXOCZLzGEctXHnaO4fk2EQ8KVaboTbwHmEPEQrm17Xb9koImUIXEw0sgi2xtHjg7LuTS3rA=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.23", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/crc64-nvme": "^3.972.9", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-4nPKARo2lfKvQGUt2fPA5NlS/mEohckdxpuC9ecbjVfj7B7NFFYHeTg+Bf5BEQwdn3yRfUIzFiEkPp8Yuaw3wA=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-8HQsRg1NpX8vR4vNl1E8pyLnqZroq9VSL2vZQVSgBqp6wv6365LzYD08/c9FFh/9FTg7YRc7aTtEmXF0ir/pqg=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
|
||||
|
||||
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1058.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||
|
||||
"@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="],
|
||||
|
||||
"@elysiajs/static": ["@elysiajs/static@1.4.10", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-BN/3PZVWjqkL/6TM7RH69Pae89CfU0OqfEYPeBp2hB/GR5Euds39Wy+0ug9Vbvu++GG4tRRANf2QjaLnriJveg=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||
|
||||
"file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "surfnathanrip-backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"start": "bun run dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"elysia": "latest",
|
||||
"@elysiajs/cors": "latest",
|
||||
"@elysiajs/static": "latest",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Elysia, status } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { videoRoutes } from "./routes/videos";
|
||||
import { initDb } from "./services/db";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
initDb();
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3001");
|
||||
const STATIC_DIR = process.env.STATIC_DIR || "./public";
|
||||
|
||||
const app = new Elysia()
|
||||
.use(cors())
|
||||
.onError(({ error }) => {
|
||||
console.error("Unhandled error:", error);
|
||||
return status(500, { error: "Internal server error" });
|
||||
})
|
||||
.use(videoRoutes)
|
||||
.get("/api/health", () => ({ status: "ok" }))
|
||||
.get("*", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname.startsWith("/api")) return status(404, "Not found");
|
||||
|
||||
const filePath = join(STATIC_DIR, pathname);
|
||||
if (existsSync(filePath)) return Bun.file(filePath);
|
||||
|
||||
const indexPath = join(STATIC_DIR, "index.html");
|
||||
if (existsSync(indexPath)) return Bun.file(indexPath);
|
||||
|
||||
return status(404, "Not found");
|
||||
})
|
||||
.listen(PORT);
|
||||
|
||||
console.log(`surf.nathan.rip running on port ${PORT}`);
|
||||
console.log(`Serving static files from ${STATIC_DIR}`);
|
||||
@@ -0,0 +1,367 @@
|
||||
import { Elysia, status } from "elysia";
|
||||
import {
|
||||
getAllVideos,
|
||||
getVideoById,
|
||||
deleteVideoById,
|
||||
insertVideo,
|
||||
updateThumbnailKey,
|
||||
updateTier,
|
||||
getVideoByMapAndPlayer,
|
||||
updateVideoPB,
|
||||
} from "../services/db";
|
||||
import {
|
||||
uploadObject,
|
||||
deleteObject,
|
||||
getObjectUrl,
|
||||
getPresignedUploadUrl,
|
||||
} from "../services/rustfs";
|
||||
import { parseMtvFile } from "../services/mtv-parser";
|
||||
import { getMapInfo } from "../services/momentum-api";
|
||||
import type { VideoEntry, PreviousPB } from "../types/video";
|
||||
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
||||
|
||||
if (!ADMIN_TOKEN) {
|
||||
console.error(
|
||||
"ADMIN_TOKEN environment variable is required. Set it to secure upload/delete endpoints.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^a-zA-Z0-9_.\-]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export const videoRoutes = new Elysia({ prefix: "/api/videos" })
|
||||
.guard({
|
||||
beforeHandle: ({ request }) => {
|
||||
const isWrite =
|
||||
request.method !== "GET" &&
|
||||
request.method !== "HEAD" &&
|
||||
request.method !== "OPTIONS";
|
||||
if (isWrite) {
|
||||
const auth = request.headers.get("authorization");
|
||||
const token = auth?.startsWith("Bearer ")
|
||||
? auth.slice(7)
|
||||
: null;
|
||||
if (!token || token !== ADMIN_TOKEN)
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
.get("/", () => {
|
||||
const videos = getAllVideos();
|
||||
return videos.map((v) => ({
|
||||
...v,
|
||||
thumbnailUrl: v.thumbnailUrl
|
||||
? getObjectUrl(v.thumbnailUrl)
|
||||
: undefined,
|
||||
}));
|
||||
})
|
||||
|
||||
.post("/upload-url", async ({ request }) => {
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return status(400, { error: "Failed to parse form data" });
|
||||
}
|
||||
|
||||
const mtv = formData.get("mtv") as File | null;
|
||||
const videoFileName = formData.get("videoFileName") as string | null;
|
||||
const videoContentType =
|
||||
(formData.get("videoContentType") as string | null) || "video/webm";
|
||||
const runDate = formData.get("runDate") as string | null;
|
||||
|
||||
if (!mtv || !videoFileName) {
|
||||
return status(400, {
|
||||
error: "mtv file and videoFileName are required",
|
||||
});
|
||||
}
|
||||
|
||||
const mtvBuffer = await mtv.arrayBuffer();
|
||||
const metadata = parseMtvFile(mtvBuffer);
|
||||
if (!metadata) {
|
||||
return status(400, {
|
||||
error: "Invalid .mtv file: could not parse header",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = getVideoByMapAndPlayer(
|
||||
metadata.mapName,
|
||||
metadata.steamId,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.runTime <= metadata.runTime) {
|
||||
return status(409, {
|
||||
error: `PB not improved. Your current PB on ${metadata.mapName} is ${existing.runTime}, this run is ${metadata.runTime}`,
|
||||
});
|
||||
}
|
||||
|
||||
const deletes = [
|
||||
deleteObject(existing.videoKey),
|
||||
deleteObject(existing.mtvKey),
|
||||
];
|
||||
if (existing.thumbnailKey)
|
||||
deletes.push(deleteObject(existing.thumbnailKey));
|
||||
await Promise.all(deletes);
|
||||
|
||||
const previousPbs: PreviousPB[] = [
|
||||
{ runTime: existing.runTime, createdAt: existing.createdAt },
|
||||
...(existing.previousPbs ?? []),
|
||||
];
|
||||
|
||||
const id = existing.id;
|
||||
const createdAt =
|
||||
runDate && !isNaN(Date.parse(runDate))
|
||||
? new Date(runDate).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
const sanitizedVideoName = sanitizeFileName(videoFileName);
|
||||
const videoKey = `videos/${id}/${sanitizedVideoName}`;
|
||||
const mtvKey = `videos/${id}/${sanitizeFileName(mtv.name)}`;
|
||||
|
||||
await uploadObject(
|
||||
mtvKey,
|
||||
Buffer.from(mtvBuffer),
|
||||
"application/octet-stream",
|
||||
);
|
||||
|
||||
let tier: number | null | undefined;
|
||||
let mapId: number | null | undefined;
|
||||
let thumbnailKey: string | undefined;
|
||||
const mapInfo = await getMapInfo(metadata.mapName);
|
||||
mapId = mapInfo.mapId ?? undefined;
|
||||
tier = mapInfo.tier;
|
||||
|
||||
if (mapInfo.thumbnailUrl) {
|
||||
thumbnailKey = `videos/${id}/thumbnail.jpg`;
|
||||
try {
|
||||
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
||||
if (imgRes.ok) {
|
||||
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
||||
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
||||
} else {
|
||||
thumbnailKey = undefined;
|
||||
}
|
||||
} catch {
|
||||
thumbnailKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const title = `${metadata.mapName} - ${metadata.playerName}`;
|
||||
const entry: VideoEntry = {
|
||||
id,
|
||||
title,
|
||||
description: "",
|
||||
mapName: metadata.mapName,
|
||||
playerName: metadata.playerName,
|
||||
steamId: metadata.steamId,
|
||||
runTime: metadata.runTime,
|
||||
totalTicks: metadata.totalTicks,
|
||||
tickInterval: metadata.tickInterval,
|
||||
videoKey,
|
||||
mtvKey,
|
||||
thumbnailKey,
|
||||
tier,
|
||||
mapId,
|
||||
jsonStats: metadata.jsonStats,
|
||||
createdAt,
|
||||
previousPbs,
|
||||
};
|
||||
|
||||
updateVideoPB(id, entry, previousPbs);
|
||||
|
||||
const presignedUrls = {
|
||||
video: await getPresignedUploadUrl(videoKey, videoContentType),
|
||||
};
|
||||
|
||||
return {
|
||||
...entry,
|
||||
presignedUrls,
|
||||
videoUrl: getObjectUrl(videoKey),
|
||||
mtvUrl: getObjectUrl(mtvKey),
|
||||
thumbnailUrl: thumbnailKey
|
||||
? getObjectUrl(thumbnailKey)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt =
|
||||
runDate && !isNaN(Date.parse(runDate))
|
||||
? new Date(runDate).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
const sanitizedVideoName = sanitizeFileName(videoFileName);
|
||||
const videoKey = `videos/${id}/${sanitizedVideoName}`;
|
||||
const mtvKey = `videos/${id}/${sanitizeFileName(mtv.name)}`;
|
||||
|
||||
await uploadObject(
|
||||
mtvKey,
|
||||
Buffer.from(mtvBuffer),
|
||||
"application/octet-stream",
|
||||
);
|
||||
|
||||
let tier: number | null | undefined;
|
||||
let mapId: number | null | undefined;
|
||||
let thumbnailKey: string | undefined;
|
||||
const mapInfo = await getMapInfo(metadata.mapName);
|
||||
mapId = mapInfo.mapId ?? undefined;
|
||||
tier = mapInfo.tier;
|
||||
|
||||
if (mapInfo.thumbnailUrl) {
|
||||
thumbnailKey = `videos/${id}/thumbnail.jpg`;
|
||||
try {
|
||||
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
||||
if (imgRes.ok) {
|
||||
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
||||
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
||||
} else {
|
||||
thumbnailKey = undefined;
|
||||
}
|
||||
} catch {
|
||||
thumbnailKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const title = `${metadata.mapName} - ${metadata.playerName}`;
|
||||
const entry: VideoEntry = {
|
||||
id,
|
||||
title,
|
||||
description: "",
|
||||
mapName: metadata.mapName,
|
||||
playerName: metadata.playerName,
|
||||
steamId: metadata.steamId,
|
||||
runTime: metadata.runTime,
|
||||
totalTicks: metadata.totalTicks,
|
||||
tickInterval: metadata.tickInterval,
|
||||
videoKey,
|
||||
mtvKey,
|
||||
thumbnailKey,
|
||||
tier,
|
||||
mapId,
|
||||
jsonStats: metadata.jsonStats,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
insertVideo(entry);
|
||||
|
||||
const presignedUrls = {
|
||||
video: await getPresignedUploadUrl(videoKey, videoContentType),
|
||||
};
|
||||
|
||||
return {
|
||||
...entry,
|
||||
presignedUrls,
|
||||
videoUrl: getObjectUrl(videoKey),
|
||||
mtvUrl: getObjectUrl(mtvKey),
|
||||
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
|
||||
};
|
||||
})
|
||||
|
||||
.post("/:id/complete", async ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
return {
|
||||
...video,
|
||||
videoUrl: getObjectUrl(video.videoKey),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
|
||||
.get("/:id", ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
return {
|
||||
...video,
|
||||
videoUrl: getObjectUrl(video.videoKey),
|
||||
mtvUrl: getObjectUrl(video.mtvKey),
|
||||
thumbnailUrl: video.thumbnailKey
|
||||
? getObjectUrl(video.thumbnailKey)
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
|
||||
.post("/:id/refresh-info", async ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
const mapInfo = await getMapInfo(video.mapName);
|
||||
|
||||
let thumbnailKey = video.thumbnailKey;
|
||||
if (!thumbnailKey && mapInfo.thumbnailUrl) {
|
||||
thumbnailKey = `videos/${video.id}/thumbnail.jpg`;
|
||||
try {
|
||||
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
||||
if (imgRes.ok) {
|
||||
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
||||
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
||||
updateThumbnailKey(id, thumbnailKey);
|
||||
} else {
|
||||
thumbnailKey = undefined;
|
||||
}
|
||||
} catch {
|
||||
thumbnailKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
updateTier(id, mapInfo.tier);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tier: mapInfo.tier,
|
||||
thumbnailUrl: thumbnailKey ? getObjectUrl(thumbnailKey) : undefined,
|
||||
};
|
||||
})
|
||||
|
||||
.post("/:id/refresh-thumbnail", async ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
const mapInfo = await getMapInfo(video.mapName);
|
||||
if (!mapInfo.thumbnailUrl) {
|
||||
return status(404, {
|
||||
error: `No thumbnail found on Momentum Mod for ${video.mapName}`,
|
||||
});
|
||||
}
|
||||
|
||||
const thumbnailKey = `videos/${video.id}/thumbnail.jpg`;
|
||||
try {
|
||||
const imgRes = await fetch(mapInfo.thumbnailUrl);
|
||||
if (!imgRes.ok)
|
||||
return status(502, { error: "Failed to download thumbnail" });
|
||||
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
||||
await uploadObject(thumbnailKey, imgBuf, "image/jpeg");
|
||||
} catch {
|
||||
return status(502, { error: "Failed to download thumbnail" });
|
||||
}
|
||||
|
||||
updateThumbnailKey(id, thumbnailKey);
|
||||
updateTier(id, mapInfo.tier);
|
||||
|
||||
return { success: true, thumbnailUrl: getObjectUrl(thumbnailKey) };
|
||||
})
|
||||
|
||||
.delete("/:id", async ({ params: { id } }) => {
|
||||
const video = getVideoById(id);
|
||||
if (!video) return status(404, { error: "Not found" });
|
||||
|
||||
const deletes = [
|
||||
deleteObject(video.videoKey),
|
||||
deleteObject(video.mtvKey),
|
||||
];
|
||||
if (video.thumbnailKey) deletes.push(deleteObject(video.thumbnailKey));
|
||||
await Promise.all(deletes);
|
||||
|
||||
deleteVideoById(id);
|
||||
return { success: true };
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import type { VideoEntry, VideoListItem, PreviousPB } from "../types/video";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || "./data/surf.db";
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
export function initDb(): void {
|
||||
if (db) return;
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
db = new Database(DB_PATH, { create: true });
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA strict_mode = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
map_name TEXT NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
steam_id TEXT NOT NULL,
|
||||
run_time REAL NOT NULL,
|
||||
total_ticks INTEGER NOT NULL,
|
||||
tick_interval REAL NOT NULL,
|
||||
video_key TEXT NOT NULL,
|
||||
mtv_key TEXT NOT NULL,
|
||||
thumbnail_key TEXT,
|
||||
tier INTEGER,
|
||||
json_stats TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_map ON videos(map_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_created ON videos(created_at DESC);
|
||||
`);
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(videos)").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!columns.some((col) => col.name === "tier")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN tier INTEGER");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "map_id")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN map_id INTEGER");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "rank")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN rank INTEGER");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "rank_updated_at")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN rank_updated_at TEXT");
|
||||
}
|
||||
if (!columns.some((col) => col.name === "previous_pbs")) {
|
||||
db.exec("ALTER TABLE videos ADD COLUMN previous_pbs TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
function getDb(): Database {
|
||||
if (!db) initDb();
|
||||
return db!;
|
||||
}
|
||||
|
||||
export function insertVideo(entry: VideoEntry): void {
|
||||
const d = getDb();
|
||||
d.prepare(
|
||||
`INSERT INTO videos (id, title, description, map_name, player_name, steam_id, run_time, total_ticks, tick_interval, video_key, mtv_key, thumbnail_key, tier, map_id, json_stats, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
entry.id,
|
||||
entry.title,
|
||||
entry.description,
|
||||
entry.mapName,
|
||||
entry.playerName,
|
||||
entry.steamId,
|
||||
entry.runTime,
|
||||
entry.totalTicks,
|
||||
entry.tickInterval,
|
||||
entry.videoKey,
|
||||
entry.mtvKey,
|
||||
entry.thumbnailKey ?? null,
|
||||
entry.tier ?? null,
|
||||
entry.mapId ?? null,
|
||||
entry.jsonStats ?? null,
|
||||
entry.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllVideos(): VideoListItem[] {
|
||||
const d = getDb();
|
||||
return d
|
||||
.prepare(
|
||||
`SELECT id, title, map_name, player_name, run_time, tier, thumbnail_key, created_at
|
||||
FROM videos ORDER BY created_at DESC`,
|
||||
)
|
||||
.all()
|
||||
.map(rowToListItem);
|
||||
}
|
||||
|
||||
export function getVideoById(id: string): VideoEntry | null {
|
||||
const d = getDb();
|
||||
const row = d.prepare(`SELECT * FROM videos WHERE id = ?`).get(id);
|
||||
return row ? rowToEntry(row as any) : null;
|
||||
}
|
||||
|
||||
export function deleteVideoById(id: string): boolean {
|
||||
const d = getDb();
|
||||
const result = d.prepare(`DELETE FROM videos WHERE id = ?`).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
function rowToListItem(row: any): VideoListItem {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
mapName: row.map_name,
|
||||
playerName: row.player_name,
|
||||
runTime: row.run_time,
|
||||
tier: row.tier ?? undefined,
|
||||
thumbnailUrl: row.thumbnail_key || undefined,
|
||||
createdAt: row.created_at,
|
||||
previousPbs: row.previous_pbs
|
||||
? JSON.parse(row.previous_pbs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToEntry(row: any): VideoEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
mapName: row.map_name,
|
||||
playerName: row.player_name,
|
||||
steamId: row.steam_id,
|
||||
runTime: row.run_time,
|
||||
totalTicks: row.total_ticks,
|
||||
tickInterval: row.tick_interval,
|
||||
videoKey: row.video_key,
|
||||
mtvKey: row.mtv_key,
|
||||
thumbnailKey: row.thumbnail_key ?? undefined,
|
||||
tier: row.tier ?? undefined,
|
||||
mapId: row.map_id ?? undefined,
|
||||
jsonStats: row.json_stats ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
previousPbs: row.previous_pbs
|
||||
? JSON.parse(row.previous_pbs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateThumbnailKey(
|
||||
id: string,
|
||||
thumbnailKey: string | null,
|
||||
): boolean {
|
||||
const d = getDb();
|
||||
const result = d
|
||||
.prepare("UPDATE videos SET thumbnail_key = ? WHERE id = ?")
|
||||
.run(thumbnailKey, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getVideoByMapAndPlayer(
|
||||
mapName: string,
|
||||
steamId: string,
|
||||
): VideoEntry | null {
|
||||
const d = getDb();
|
||||
const row = d
|
||||
.prepare(`SELECT * FROM videos WHERE map_name = ? AND steam_id = ?`)
|
||||
.get(mapName, steamId);
|
||||
return row ? rowToEntry(row as any) : null;
|
||||
}
|
||||
|
||||
export function updateVideoPB(
|
||||
id: string,
|
||||
entry: VideoEntry,
|
||||
previousPbs: PreviousPB[],
|
||||
): void {
|
||||
const d = getDb();
|
||||
d.prepare(
|
||||
`UPDATE videos SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
map_name = ?,
|
||||
player_name = ?,
|
||||
steam_id = ?,
|
||||
run_time = ?,
|
||||
total_ticks = ?,
|
||||
tick_interval = ?,
|
||||
video_key = ?,
|
||||
mtv_key = ?,
|
||||
thumbnail_key = ?,
|
||||
tier = ?,
|
||||
map_id = ?,
|
||||
json_stats = ?,
|
||||
created_at = ?,
|
||||
previous_pbs = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
entry.title,
|
||||
entry.description,
|
||||
entry.mapName,
|
||||
entry.playerName,
|
||||
entry.steamId,
|
||||
entry.runTime,
|
||||
entry.totalTicks,
|
||||
entry.tickInterval,
|
||||
entry.videoKey,
|
||||
entry.mtvKey,
|
||||
entry.thumbnailKey ?? null,
|
||||
entry.tier ?? null,
|
||||
entry.mapId ?? null,
|
||||
entry.jsonStats ?? null,
|
||||
entry.createdAt,
|
||||
JSON.stringify(previousPbs),
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateTier(id: string, tier: number | null): boolean {
|
||||
const d = getDb();
|
||||
const result = d
|
||||
.prepare("UPDATE videos SET tier = ? WHERE id = ?")
|
||||
.run(tier, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
const MOMENTUM_API = "https://api.momentum-mod.org/v1";
|
||||
|
||||
interface MomentumMapThumbnail {
|
||||
id: string;
|
||||
small: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
xl: string;
|
||||
}
|
||||
|
||||
interface MomentumMapLeaderboard {
|
||||
gamemode: number;
|
||||
trackType: number;
|
||||
trackNum: number;
|
||||
style: number;
|
||||
tier: number | null;
|
||||
tags: number[];
|
||||
type: number;
|
||||
linear: boolean | null;
|
||||
}
|
||||
|
||||
interface MomentumMapInfo {
|
||||
description: string;
|
||||
youtubeID: string;
|
||||
creationDate: string;
|
||||
approvedDate: string;
|
||||
requiredGames: number[];
|
||||
}
|
||||
|
||||
interface MomentumMap {
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail: MomentumMapThumbnail | null;
|
||||
images: MomentumMapThumbnail[];
|
||||
info: MomentumMapInfo | null;
|
||||
leaderboards: MomentumMapLeaderboard[];
|
||||
}
|
||||
|
||||
interface MomentumMapsResponse {
|
||||
totalCount: number;
|
||||
returnCount: number;
|
||||
data: MomentumMap[];
|
||||
}
|
||||
|
||||
export interface MapInfo {
|
||||
mapId: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
tier: number | null;
|
||||
mapDate: string | null;
|
||||
}
|
||||
|
||||
const mapInfoCache = new Map<string, MapInfo>();
|
||||
|
||||
function extractSurfTier(
|
||||
leaderboards: MomentumMapLeaderboard[],
|
||||
): number | null {
|
||||
const mainLeaderboard = leaderboards.find(
|
||||
(lb) => lb.gamemode === 1 && lb.trackType === 0 && lb.style === 0,
|
||||
);
|
||||
return mainLeaderboard?.tier ?? null;
|
||||
}
|
||||
|
||||
export async function getMapInfo(mapName: string): Promise<MapInfo> {
|
||||
const cached = mapInfoCache.get(mapName);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const url = `${MOMENTUM_API}/maps?search=${encodeURIComponent(mapName)}&take=1&expand=info,leaderboards`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
const json: MomentumMapsResponse = await res.json();
|
||||
const map = json.data?.[0];
|
||||
if (!map || map.name !== mapName) {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
const thumbnailUrl =
|
||||
map.thumbnail?.small ?? map.images?.[0]?.small ?? null;
|
||||
const tier = extractSurfTier(map.leaderboards ?? []);
|
||||
const mapDate = map.info?.creationDate ?? null;
|
||||
const info: MapInfo = { mapId: map.id, thumbnailUrl, tier, mapDate };
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
} catch {
|
||||
const info: MapInfo = {
|
||||
mapId: null,
|
||||
thumbnailUrl: null,
|
||||
tier: null,
|
||||
mapDate: null,
|
||||
};
|
||||
mapInfoCache.set(mapName, info);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMapThumbnail(mapName: string): Promise<string | null> {
|
||||
const info = await getMapInfo(mapName);
|
||||
return info.thumbnailUrl;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { MtvHeader, MtvMetadata } from "../types/video";
|
||||
|
||||
const MTV_MAGIC = 0x56544d4d;
|
||||
const HEADER_SIZE = 0xc3;
|
||||
|
||||
function readNullTerminatedString(buffer: ArrayBuffer, offset: number, maxLen: number): string {
|
||||
const view = new Uint8Array(buffer, offset, maxLen);
|
||||
let end = 0;
|
||||
while (end < maxLen && view[end] !== 0) end++;
|
||||
return new TextDecoder().decode(view.slice(0, end));
|
||||
}
|
||||
|
||||
export function parseMtvHeader(buffer: ArrayBuffer): MtvHeader | null {
|
||||
if (buffer.byteLength < HEADER_SIZE) return null;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const magic = view.getUint32(0, true);
|
||||
if (magic !== MTV_MAGIC) return null;
|
||||
|
||||
const version = view.getUint32(4, true);
|
||||
const unknown08 = view.getFloat32(8, true);
|
||||
const unknown0c = view.getUint32(0x0c, true);
|
||||
const mapName = readNullTerminatedString(buffer, 0x10, 64);
|
||||
const mapHash = readNullTerminatedString(buffer, 0x50, 41);
|
||||
const unknown79 = view.getUint8(0x79);
|
||||
const compression = view.getUint8(0x7a);
|
||||
const tickInterval = view.getFloat32(0x7b, true);
|
||||
const steamId = view.getBigUint64(0x7f, true);
|
||||
const playerName = readNullTerminatedString(buffer, 0x87, 32);
|
||||
const unknownA7 = view.getUint8(0xa7);
|
||||
const unknownA8 = view.getUint8(0xa8);
|
||||
const runTime = view.getFloat64(0xa9, true);
|
||||
const totalTicks = view.getUint32(0xb1, true);
|
||||
const seekEntryCount = view.getUint32(0xb5, true);
|
||||
const baselineCount = view.getUint32(0xb9, true);
|
||||
const entityTypeCount = view.getUint8(0xbd);
|
||||
const tempEntityCount = view.getUint8(0xbe);
|
||||
const stringTableCount = view.getUint8(0xbf);
|
||||
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
unknown08,
|
||||
unknown0c,
|
||||
mapName,
|
||||
mapHash,
|
||||
unknown79,
|
||||
compression,
|
||||
tickInterval,
|
||||
steamId,
|
||||
playerName,
|
||||
unknownA7,
|
||||
unknownA8,
|
||||
runTime,
|
||||
totalTicks,
|
||||
seekEntryCount,
|
||||
baselineCount,
|
||||
entityTypeCount,
|
||||
tempEntityCount,
|
||||
stringTableCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMtvFile(buffer: ArrayBuffer): MtvMetadata | null {
|
||||
const header = parseMtvHeader(buffer);
|
||||
if (!header) return null;
|
||||
|
||||
let jsonStats: string | undefined;
|
||||
|
||||
if (buffer.byteLength > HEADER_SIZE + 4) {
|
||||
const view = new DataView(buffer);
|
||||
const jsonLen = view.getUint32(HEADER_SIZE, true);
|
||||
if (jsonLen > 0 && buffer.byteLength >= HEADER_SIZE + 4 + jsonLen) {
|
||||
const jsonBytes = new Uint8Array(buffer, HEADER_SIZE + 4, jsonLen - 1);
|
||||
jsonStats = new TextDecoder().decode(jsonBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mapName: header.mapName,
|
||||
mapHash: header.mapHash,
|
||||
playerName: header.playerName,
|
||||
steamId: header.steamId.toString(),
|
||||
runTime: header.runTime,
|
||||
totalTicks: header.totalTicks,
|
||||
tickInterval: header.tickInterval,
|
||||
version: header.version,
|
||||
jsonStats,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRunTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const RUSTFS_ENDPOINT = process.env.RUSTFS_ENDPOINT || "http://localhost:9000";
|
||||
const RUSTFS_ACCESS_KEY = process.env.RUSTFS_ACCESS_KEY || "minioadmin";
|
||||
const RUSTFS_SECRET_KEY = process.env.RUSTFS_SECRET_KEY || "minioadmin";
|
||||
const RUSTFS_BUCKET = process.env.RUSTFS_BUCKET || "surf";
|
||||
const RUSTFS_PUBLIC = (process.env.RUSTFS_PUBLIC || "true") === "true";
|
||||
|
||||
let client: S3Client | null = null;
|
||||
|
||||
function getRustFsClient(): S3Client {
|
||||
if (!client) {
|
||||
client = new S3Client({
|
||||
endpoint: RUSTFS_ENDPOINT,
|
||||
region: "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: RUSTFS_ACCESS_KEY,
|
||||
secretAccessKey: RUSTFS_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function getPublicUrl(key: string): string {
|
||||
if (RUSTFS_PUBLIC) {
|
||||
return `${RUSTFS_ENDPOINT}/${RUSTFS_BUCKET}/${key}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getObjectUrl(key: string): string {
|
||||
const publicUrl = getPublicUrl(key);
|
||||
if (publicUrl) return publicUrl;
|
||||
return `/api/videos/file/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
export async function uploadObject(
|
||||
key: string,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const s3 = getRustFsClient();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const s3 = getRustFsClient();
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresInSeconds = 3600,
|
||||
): Promise<string> {
|
||||
const s3 = getRustFsClient();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: RUSTFS_BUCKET,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface MtvHeader {
|
||||
magic: number;
|
||||
version: number;
|
||||
unknown08: number;
|
||||
unknown0c: number;
|
||||
mapName: string;
|
||||
mapHash: string;
|
||||
unknown79: number;
|
||||
compression: number;
|
||||
tickInterval: number;
|
||||
steamId: bigint;
|
||||
playerName: string;
|
||||
unknownA7: number;
|
||||
unknownA8: number;
|
||||
runTime: number;
|
||||
totalTicks: number;
|
||||
seekEntryCount: number;
|
||||
baselineCount: number;
|
||||
entityTypeCount: number;
|
||||
tempEntityCount: number;
|
||||
stringTableCount: number;
|
||||
}
|
||||
|
||||
export interface MtvMetadata {
|
||||
mapName: string;
|
||||
mapHash: string;
|
||||
playerName: string;
|
||||
steamId: string;
|
||||
runTime: number;
|
||||
totalTicks: number;
|
||||
tickInterval: number;
|
||||
version: number;
|
||||
jsonStats?: string;
|
||||
}
|
||||
|
||||
export interface PreviousPB {
|
||||
runTime: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VideoEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mapName: string;
|
||||
playerName: string;
|
||||
steamId: string;
|
||||
runTime: number;
|
||||
totalTicks: number;
|
||||
tickInterval: number;
|
||||
videoKey: string;
|
||||
mtvKey: string;
|
||||
thumbnailKey?: string;
|
||||
tier?: number | null;
|
||||
mapId?: number | null;
|
||||
jsonStats?: string;
|
||||
createdAt: string;
|
||||
previousPbs?: PreviousPB[];
|
||||
}
|
||||
|
||||
export interface VideoListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
mapName: string;
|
||||
playerName: string;
|
||||
runTime: number;
|
||||
tier?: number | null;
|
||||
thumbnailUrl?: string;
|
||||
createdAt: string;
|
||||
previousPbs?: PreviousPB[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"types": ["bun-types"],
|
||||
},
|
||||
"include": ["src"],
|
||||
}
|
||||
Reference in New Issue
Block a user