mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
🔄 refactor(steam): Migrate to Steam OpenID authentication and official Web API (#282)
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for managing multiple Steam profiles per user, including a new profiles page with avatar selection and profile management. - Introduced a streamlined Steam authentication flow using a popup window, replacing the previous QR code and team-based login. - Added utilities for Steam image handling and metadata, including avatar preloading and static Steam metadata mappings. - Enhanced OpenID verification for Steam login. - Added new image-related events and expanded event handling for Steam account updates and image processing. - **Improvements** - Refactored the account structure from teams to profiles, updating related UI, context, and storage. - Updated API headers and authentication logic to use Steam IDs instead of team IDs. - Expanded game metadata with new fields for categories, franchises, and social links. - Improved library and category schemas for richer game and profile data. - Simplified and improved Steam API client methods for fetching user info, friends, and game libraries using Steam Web API. - Updated queue processing to handle individual game updates and publish image events. - Adjusted permissions and queue configurations for better message handling and dead-letter queue support. - Improved slug creation and rating estimation utilities. - **Bug Fixes** - Fixed avatar image loading to display higher quality images after initial load. - **Removals** - Removed all team, member, and credential management functionality and related database schemas. - Eliminated the QR code-based login and related UI components. - Deleted legacy team and member database tables and related code. - Removed encryption utilities and deprecated secret keys in favor of new secret management. - **Chores** - Updated dependencies and internal configuration for new features and schema changes. - Cleaned up unused code and updated database migrations for new data structures. - Adjusted import orders and removed unused imports across multiple modules. - Added new resource declarations and updated service link configurations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
57
bun.lock
57
bun.lock
@@ -98,11 +98,13 @@
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*",
|
||||
"xml2js": "^0.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"mqtt": "^5.10.3",
|
||||
@@ -224,6 +226,7 @@
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"body-scroll-lock-upgrade": "^1.1.0",
|
||||
"eventsource": "^3.0.5",
|
||||
"fast-average-color": "9.5.0",
|
||||
"focus-trap": "^7.6.4",
|
||||
"hono": "^4.7.4",
|
||||
"modern-normalize": "^3.0.1",
|
||||
@@ -1547,7 +1550,7 @@
|
||||
|
||||
"@types/basic-auth": ["@types/basic-auth@1.1.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-dKcUeixGuZn8pBjcUrf1N7x5K6lWuKuwHHitM2IZ4vwZUDWEhhNtwCWiba8jTA9zn0GQQ+fTFkWpKx8pOU/enw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
|
||||
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
|
||||
|
||||
"@types/bunyan": ["@types/bunyan@1.8.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="],
|
||||
|
||||
@@ -1651,21 +1654,27 @@
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.32.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/type-utils": "8.32.1", "@typescript-eslint/utils": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg=="],
|
||||
"@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.32.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/types": "8.32.1", "@typescript-eslint/typescript-estree": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.33.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/type-utils": "8.33.0", "@typescript-eslint/utils": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.33.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.32.1", "", { "dependencies": { "@typescript-eslint/types": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1" } }, "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.33.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.32.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.32.1", "@typescript-eslint/utils": "8.32.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.33.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.33.0", "@typescript-eslint/types": "^8.33.0", "debug": "^4.3.4" } }, "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.32.1", "", {}, "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.33.0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0" } }, "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.32.1", "", { "dependencies": { "@typescript-eslint/types": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.33.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.32.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/types": "8.32.1", "@typescript-eslint/typescript-estree": "8.32.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.33.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.33.0", "@typescript-eslint/utils": "8.33.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.32.1", "", { "dependencies": { "@typescript-eslint/types": "8.32.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.33.0", "", {}, "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.33.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.33.0", "@typescript-eslint/tsconfig-utils": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/visitor-keys": "8.33.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", "@typescript-eslint/typescript-estree": "8.33.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.33.0", "", { "dependencies": { "@typescript-eslint/types": "8.33.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ=="],
|
||||
|
||||
"@typescript/lib-dom": ["@types/web@0.0.115", "", {}, "sha512-IBtUgtxnITC7WTCg4tv6kCnSP0T+fM+3PzQPIzLzJY1DDlhBFKM/9+uMURw14YweWPDiFNIZ94Gc1bJtwow97g=="],
|
||||
|
||||
@@ -2389,7 +2398,7 @@
|
||||
|
||||
"eslint-plugin-jsdoc": ["eslint-plugin-jsdoc@50.6.3", "", { "dependencies": { "@es-joy/jsdoccomment": "~0.49.0", "are-docs-informative": "^0.0.2", "comment-parser": "1.4.1", "debug": "^4.3.6", "escape-string-regexp": "^4.0.0", "espree": "^10.1.0", "esquery": "^1.6.0", "parse-imports": "^2.1.1", "semver": "^7.6.3", "spdx-expression-parse": "^4.0.0", "synckit": "^0.9.1" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "sha512-NxbJyt1M5zffPcYZ8Nb53/8nnbIScmiLAMdoe0/FAszwb7lcSiX3iYBTsuF7RV84dZZJC8r3NghomrUXsmWvxQ=="],
|
||||
|
||||
"eslint-plugin-qwik": ["eslint-plugin-qwik@1.13.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.12.2", "jsx-ast-utils": "^3.3.5" } }, "sha512-7qF4Sq36KY0a8ktCzZac8Q2BBzjAqLb7Stcjwxh4hwO/AD2CcQIl3ZXGvRM57w9siYlmyegUfvxZBCcuvm/4gA=="],
|
||||
"eslint-plugin-qwik": ["eslint-plugin-qwik@1.14.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.31.0", "jsx-ast-utils": "^3.3.5" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-N0C8V/6F4EfRdC6EmE7o0VnUod9T16u94C+AD325xA0U4IQoC191uvkGeeJtJ7RXv6UPWz0Evh1aHTfaEILhkg=="],
|
||||
|
||||
"eslint-plugin-regexp": ["eslint-plugin-regexp@2.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "comment-parser": "^1.4.0", "jsdoc-type-pratt-parser": "^4.0.0", "refa": "^0.12.1", "regexp-ast-analysis": "^0.7.1", "scslre": "^0.3.0" }, "peerDependencies": { "eslint": ">=8.44.0" } }, "sha512-U8oZI77SBtH8U3ulZ05iu0qEzIizyEDXd+BWHvyVxTOjGwcDcvy/kEpgFG4DYca2ByRLiVPFZ2GeH7j1pdvZTA=="],
|
||||
|
||||
@@ -5333,7 +5342,7 @@
|
||||
|
||||
"@types/basic-auth/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||
|
||||
"@types/bun/bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
|
||||
"@types/bun/bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
|
||||
|
||||
"@types/bunyan/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||
|
||||
@@ -5369,6 +5378,8 @@
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||
|
||||
"@types/xml2js/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
@@ -5541,8 +5552,6 @@
|
||||
|
||||
"eslint-plugin-jsdoc/espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils": ["@typescript-eslint/utils@8.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ=="],
|
||||
|
||||
"eslint-plugin-unicorn/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
|
||||
|
||||
"espree/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
@@ -6639,6 +6648,8 @@
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/xml2js/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
"@vanilla-extract/integration/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
@@ -6761,12 +6772,6 @@
|
||||
|
||||
"eslint-plugin-jsdoc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="],
|
||||
|
||||
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"eslint/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -7795,14 +7800,6 @@
|
||||
|
||||
"eslint-plugin-import-x/@typescript-eslint/utils/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.0.1", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.0.1", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
|
||||
|
||||
"read-pkg-up/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
@@ -7969,12 +7966,6 @@
|
||||
|
||||
"eslint-plugin-import-x/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||
|
||||
"eslint-plugin-qwik/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
|
||||
|
||||
"read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { bus } from "./bus";
|
||||
import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
import { LibraryQueue } from "./steam";
|
||||
import { secret, steamEncryptionKey } from "./secret";
|
||||
import { libraryQueue } from "./steam";
|
||||
|
||||
export const apiService = new sst.aws.Service("Api", {
|
||||
cluster,
|
||||
@@ -14,8 +14,8 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
bus,
|
||||
auth,
|
||||
postgres,
|
||||
LibraryQueue,
|
||||
steamEncryptionKey,
|
||||
libraryQueue,
|
||||
secret.SteamApiKey,
|
||||
secret.PolarSecret,
|
||||
secret.PolarWebhookSecret,
|
||||
secret.NestriFamilyMonthly,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { bus } from "./bus";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
import { secret, steamEncryptionKey } from "./secret";
|
||||
|
||||
export const authService = new sst.aws.Service("Auth", {
|
||||
cluster,
|
||||
@@ -13,7 +13,6 @@ export const authService = new sst.aws.Service("Auth", {
|
||||
bus,
|
||||
postgres,
|
||||
secret.PolarSecret,
|
||||
steamEncryptionKey,
|
||||
secret.GithubClientID,
|
||||
secret.DiscordClientID,
|
||||
secret.GithubClientSecret,
|
||||
|
||||
19
infra/bus.ts
19
infra/bus.ts
@@ -1,8 +1,8 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { storage } from "./storage";
|
||||
import { secret } from "./secret";
|
||||
// import { email } from "./email";
|
||||
import { storage } from "./storage";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
@@ -11,16 +11,25 @@ bus.subscribe("Event", {
|
||||
handler: "packages/functions/src/events/index.handler",
|
||||
link: [
|
||||
// email,
|
||||
postgres,
|
||||
bus,
|
||||
storage,
|
||||
steamEncryptionKey
|
||||
postgres,
|
||||
secret.PolarSecret,
|
||||
secret.SteamApiKey
|
||||
],
|
||||
timeout: "10 minutes",
|
||||
memory: "3002 MB",// For faster processing of large(r) images
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
actions: ["ses:SendEmail","sqs:SendMessage"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
// transform: {
|
||||
// function: {
|
||||
// deadLetterConfig: {
|
||||
// targetArn: EventDlq.arn,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { isPermanentStage } from "./stage";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||
export const postgres = new sst.aws.Aurora("Database", {
|
||||
@@ -42,7 +41,7 @@ export const postgres = new sst.aws.Aurora("Database", {
|
||||
|
||||
|
||||
new sst.x.DevCommand("Studio", {
|
||||
link: [postgres, steamEncryptionKey],
|
||||
link: [postgres],
|
||||
dev: {
|
||||
command: "bun db:dev studio",
|
||||
directory: "packages/core",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const secret = {
|
||||
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
|
||||
SteamApiKey: new sst.Secret("SteamApiKey"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"),
|
||||
@@ -14,17 +15,4 @@ export const secret = {
|
||||
NestriFamilyYearly: new sst.Secret("NestriFamilyYearly"),
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
|
||||
export const steamEncryptionKey = new random.RandomString(
|
||||
"SteamEncryptionKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
export const allSecrets = Object.values(secret);
|
||||
@@ -1,19 +1,29 @@
|
||||
import { bus } from "./bus";
|
||||
import { vpc } from "./vpc";
|
||||
import { secret } from "./secret";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const LibraryQueue = new sst.aws.Queue("LibraryQueue", {
|
||||
fifo: true,
|
||||
visibilityTimeout: "10 minutes",
|
||||
export const libraryDlq = new sst.aws.Queue("LibraryDLQ");
|
||||
|
||||
export const libraryQueue = new sst.aws.Queue("LibraryQueue", {
|
||||
dlq: libraryDlq.arn,
|
||||
visibilityTimeout: "5 minutes",
|
||||
});
|
||||
|
||||
LibraryQueue.subscribe({
|
||||
libraryQueue.subscribe({
|
||||
vpc,
|
||||
timeout: "10 minutes",
|
||||
memory: "3002 MB",
|
||||
timeout: "5 minutes",
|
||||
handler: "packages/functions/src/queues/library.handler",
|
||||
link: [
|
||||
bus,
|
||||
postgres,
|
||||
steamEncryptionKey
|
||||
secret.SteamApiKey
|
||||
],
|
||||
permissions: [
|
||||
{
|
||||
actions: ["sqs:SendMessage"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
});
|
||||
23
packages/core/migrations/0020_vengeful_wallop.sql
Normal file
23
packages/core/migrations/0020_vengeful_wallop.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE "steam_account_credentials" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "steam_account_credentials" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "teams" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP CONSTRAINT "idx_steam_username";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_owner_id_users_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_slug_steam_accounts_username_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "idx_team_slug";--> statement-breakpoint
|
||||
DROP INDEX "idx_game_libraries_owner_id";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_base_game_id_owner_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ALTER COLUMN "last_played" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_owner_steam_id_pk" PRIMARY KEY("base_game_id","owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "time_acquired";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "is_family_shared";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP COLUMN "username";--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP COLUMN "slug";
|
||||
2
packages/core/migrations/0021_real_skreet.sql
Normal file
2
packages/core/migrations/0021_real_skreet.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'category';--> statement-breakpoint
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'franchise';
|
||||
6
packages/core/migrations/0022_clean_living_lightning.sql
Normal file
6
packages/core/migrations/0022_clean_living_lightning.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."category_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer', 'categorie', 'franchise');--> statement-breakpoint
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";
|
||||
2
packages/core/migrations/0023_flawless_steel_serpent.sql
Normal file
2
packages/core/migrations/0023_flawless_steel_serpent.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ADD COLUMN "links" text[];
|
||||
1
packages/core/migrations/0024_damp_cerise.sql
Normal file
1
packages/core/migrations/0024_damp_cerise.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "links" SET DATA TYPE json;
|
||||
1158
packages/core/migrations/meta/0020_snapshot.json
Normal file
1158
packages/core/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1160
packages/core/migrations/meta/0021_snapshot.json
Normal file
1160
packages/core/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1160
packages/core/migrations/meta/0022_snapshot.json
Normal file
1160
packages/core/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1166
packages/core/migrations/meta/0023_snapshot.json
Normal file
1166
packages/core/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1166
packages/core/migrations/meta/0024_snapshot.json
Normal file
1166
packages/core/migrations/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,41 @@
|
||||
"when": 1747202158003,
|
||||
"tag": "0019_charming_namorita",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1747795508868,
|
||||
"tag": "0020_vengeful_wallop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1747975397543,
|
||||
"tag": "0021_real_skreet",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1748099972605,
|
||||
"tag": "0022_clean_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1748411845939,
|
||||
"tag": "0023_flawless_steel_serpent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1748414049463,
|
||||
"tag": "0024_damp_cerise",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"mqtt": "^5.10.3",
|
||||
@@ -42,6 +43,7 @@
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*"
|
||||
"steam-session": "*",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
import { User } from "../user";
|
||||
import { Team } from "../team";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
@@ -9,26 +9,26 @@ export namespace Account {
|
||||
export const Info =
|
||||
User.Info
|
||||
.extend({
|
||||
teams: Team.Info
|
||||
profiles: Steam.Info
|
||||
.array()
|
||||
.openapi({
|
||||
description: "The teams that this user is part of",
|
||||
example: [Examples.Team]
|
||||
description: "The Steam accounts this user owns",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Account",
|
||||
description: "Represents an account's information stored on Nestri",
|
||||
example: { ...Examples.User, teams: [Examples.Team] },
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] },
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async (): Promise<Info> => {
|
||||
const [userResult, teamsResult] =
|
||||
const [userResult, steamResult] =
|
||||
await Promise.allSettled([
|
||||
User.fromID(Actor.userID()),
|
||||
Team.list()
|
||||
Steam.list()
|
||||
])
|
||||
|
||||
if (userResult.status === "rejected" || !userResult.value)
|
||||
@@ -40,7 +40,7 @@ export namespace Account {
|
||||
|
||||
return {
|
||||
...userResult.value,
|
||||
teams: teamsResult.status === "rejected" ? [] : teamsResult.value
|
||||
profiles: steamResult.status === "rejected" ? [] : steamResult.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ export namespace Actor {
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface System {
|
||||
type: "system";
|
||||
|
||||
export interface Steam {
|
||||
type: "steam";
|
||||
properties: {
|
||||
teamID: string;
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export namespace Actor {
|
||||
properties: {
|
||||
userID: string;
|
||||
steamID: string;
|
||||
teamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ export namespace Actor {
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export type Info = User | Public | Token | System | Machine;
|
||||
export type Info = User | Public | Token | Machine | Steam;
|
||||
|
||||
export const Context = createContext<Info>();
|
||||
|
||||
|
||||
@@ -3,15 +3,18 @@ import { timestamps, utc } from "../drizzle/types";
|
||||
import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CompatibilityEnum = pgEnum("compatibility", ["high", "mid", "low", "unknown"])
|
||||
export const ControllerEnum = pgEnum("controller_support", ["full","partial", "unknown"])
|
||||
export const ControllerEnum = pgEnum("controller_support", ["full", "partial", "unknown"])
|
||||
|
||||
export const Size =
|
||||
z.object({
|
||||
downloadSize: z.number().positive().int(),
|
||||
sizeOnDisk: z.number().positive().int()
|
||||
})
|
||||
});
|
||||
|
||||
export type Size = z.infer<typeof Size>
|
||||
export const Links = z.string().array();
|
||||
|
||||
export type Size = z.infer<typeof Size>;
|
||||
export type Links = z.infer<typeof Links>;
|
||||
|
||||
export const baseGamesTable = pgTable(
|
||||
"base_games",
|
||||
@@ -20,12 +23,13 @@ export const baseGamesTable = pgTable(
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
links: json("links").$type<Links>(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
releaseDate: utc("release_date").notNull(),
|
||||
size: json("size").$type<Size>().notNull(),
|
||||
description: text("description").notNull(),
|
||||
primaryGenre: text("primary_genre"),
|
||||
controllerSupport: ControllerEnum("controller_support").notNull(),
|
||||
compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"),
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum } from "./base-game.sql";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
|
||||
|
||||
export namespace BaseGame {
|
||||
export const Info = z.object({
|
||||
@@ -31,7 +30,7 @@ export namespace BaseGame {
|
||||
description: "The initial public release date of the game on Steam",
|
||||
example: Examples.BaseGame.releaseDate
|
||||
}),
|
||||
description: z.string().openapi({
|
||||
description: z.string().nullable().openapi({
|
||||
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
||||
example: Examples.BaseGame.description
|
||||
}),
|
||||
@@ -39,6 +38,12 @@ export namespace BaseGame {
|
||||
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
||||
example: Examples.BaseGame.score
|
||||
}),
|
||||
links: Links
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: "The social links of this game",
|
||||
example: Examples.BaseGame.links
|
||||
}),
|
||||
primaryGenre: z.string().nullable().openapi({
|
||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
||||
example: Examples.BaseGame.primaryGenre
|
||||
@@ -50,7 +55,7 @@ export namespace BaseGame {
|
||||
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
||||
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
|
||||
example: Examples.BaseGame.compatibility
|
||||
})
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "BaseGame",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
|
||||
@@ -61,9 +66,27 @@ export namespace BaseGame {
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_game.added",
|
||||
"new_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
url: z.string().url(),
|
||||
type: z.enum(ImageTypeEnum.enumValues)
|
||||
}),
|
||||
),
|
||||
NewBoxArt: createEvent(
|
||||
"new_box_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
logoUrl: z.string().url(),
|
||||
backgroundUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
NewHeroArt: createEvent(
|
||||
"new_hero_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
backdropUrl: z.string().url(),
|
||||
screenshots: z.string().url().array(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -72,6 +95,21 @@ export namespace BaseGame {
|
||||
Info,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(baseGamesTable.id, input.id),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.id
|
||||
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
@@ -82,10 +120,6 @@ export namespace BaseGame {
|
||||
}
|
||||
})
|
||||
|
||||
await afterTx(async () => {
|
||||
await bus.publish(Resource.Bus, Events.New, { appID: input.id })
|
||||
})
|
||||
|
||||
return input.id
|
||||
})
|
||||
)
|
||||
@@ -116,6 +150,7 @@ export namespace BaseGame {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
size: input.size,
|
||||
links: input.links,
|
||||
score: input.score,
|
||||
description: input.description,
|
||||
releaseDate: input.releaseDate,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer"])
|
||||
// Intentional grammatical error on category
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer", "categorie", "franchise"])
|
||||
|
||||
export const categoriesTable = pgTable(
|
||||
"categories",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
|
||||
export namespace Categories {
|
||||
|
||||
@@ -36,7 +36,16 @@ export namespace Categories {
|
||||
genres: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's style and type of gameplay",
|
||||
example: Examples.Categories.genres
|
||||
})
|
||||
}),
|
||||
categories: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's categorisation on Steam",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
franchises: Category.array().openapi({
|
||||
description: "The franchise this game belongs belongs to on Steam",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
|
||||
}).openapi({
|
||||
ref: "Categories",
|
||||
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
||||
@@ -111,7 +120,9 @@ export namespace Categories {
|
||||
tags: [],
|
||||
genres: [],
|
||||
publishers: [],
|
||||
developers: []
|
||||
developers: [],
|
||||
categories: [],
|
||||
franchises: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,172 @@
|
||||
import type {
|
||||
Shot,
|
||||
AppInfo,
|
||||
GameTagsResponse,
|
||||
SteamApiResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
Shot
|
||||
SteamAccount,
|
||||
GameTagsResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
SteamOwnedGamesResponse,
|
||||
SteamPlayerBansResponse,
|
||||
SteamFriendsListResponse,
|
||||
SteamPlayerSummaryResponse,
|
||||
SteamStoreResponse,
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import pLimit from 'p-limit';
|
||||
import SteamID from "steamid";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Steam } from "./steam";
|
||||
import { Utils } from "./utils";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { Credentials } from "../credentials";
|
||||
import type CSteamUser from "steamcommunity/classes/CSteamUser";
|
||||
|
||||
const requestLimit = pLimit(10); // max concurrent requests
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
|
||||
export namespace Client {
|
||||
export const getUserLibrary = fn(
|
||||
Credentials.Info.shape.accessToken,
|
||||
async (accessToken) =>
|
||||
await Utils.fetchApi<SteamApiResponse>(`https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${accessToken}&family_groupid=0&include_excluded=true&include_free=true&include_non_games=false&include_own=true`)
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamOwnedGamesResponse>(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&include_appinfo=1&format=json&include_played_free_games=1&skip_unvetted_apps=0`)
|
||||
)
|
||||
|
||||
export const getFriendsList = fn(
|
||||
Credentials.Info.shape.cookies,
|
||||
async (cookies): Promise<CSteamUser[]> => {
|
||||
const community = new SteamCommunity();
|
||||
community.setCookies(cookies);
|
||||
|
||||
const allFriends = await new Promise<Record<string, any>>((resolve, reject) => {
|
||||
community.getFriendsList((err, friends) => {
|
||||
if (err) {
|
||||
return reject(new Error(`Could not get friends list: ${err.message}`));
|
||||
}
|
||||
resolve(friends);
|
||||
});
|
||||
});
|
||||
|
||||
const friendIds = Object.keys(allFriends);
|
||||
|
||||
const userPromises: Promise<CSteamUser>[] = friendIds.map(id =>
|
||||
requestLimit(() => new Promise<CSteamUser>((resolve, reject) => {
|
||||
const sid = new SteamID(id);
|
||||
community.getSteamUser(sid, (err, user) => {
|
||||
if (err) {
|
||||
return reject(new Error(`Could not get steam user info for ${id}: ${err.message}`));
|
||||
}
|
||||
resolve(user);
|
||||
});
|
||||
}))
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(userPromises)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getFriendsList] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<CSteamUser>).value);
|
||||
}
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamFriendsListResponse>(`https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&relationship=friend`)
|
||||
);
|
||||
|
||||
export const getUserInfo = fn(
|
||||
Credentials.Info.pick({ cookies: true, steamID: true }),
|
||||
async (input) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const community = new SteamCommunity()
|
||||
community.setCookies(input.cookies);
|
||||
const steamID = new SteamID(input.steamID);
|
||||
community.getSteamUser(steamID, async (err, user) => {
|
||||
if (err) {
|
||||
reject(`Could not get steam user info: ${err.message}`)
|
||||
z.string().array(),
|
||||
async (steamIDs) => {
|
||||
const [userInfo, banInfo, profileInfo] = await Promise.all([
|
||||
Utils.fetchApi<SteamPlayerSummaryResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchApi<SteamPlayerBansResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchProfilesInfo(steamIDs)
|
||||
])
|
||||
|
||||
// Create a map of bans by steamID for fast lookup
|
||||
const bansBySteamID = new Map(
|
||||
banInfo.players.map((b) => [b.SteamId, b])
|
||||
);
|
||||
|
||||
// Map userInfo.players to your desired output using Promise.allSettled
|
||||
// to prevent one error from closing down the whole pipeline
|
||||
const steamAccounts = await Promise.allSettled(
|
||||
userInfo.response.players.map(async (player) => {
|
||||
const ban = bansBySteamID.get(player.steamid);
|
||||
const info = profileInfo.get(player.steamid);
|
||||
|
||||
if (!info) {
|
||||
throw new Error(`[userInfo] profile info missing for ${player.steamid}`)
|
||||
}
|
||||
|
||||
if ('error' in info) {
|
||||
throw new Error(`error handling profile info for: ${player.steamid}:${info.error}`)
|
||||
} else {
|
||||
resolve(user)
|
||||
return {
|
||||
id: player.steamid,
|
||||
name: player.personaname,
|
||||
realName: player.realname ?? null,
|
||||
steamMemberSince: new Date(player.timecreated * 1000),
|
||||
avatarHash: player.avatarhash,
|
||||
limitations: {
|
||||
isLimited: info.isLimited,
|
||||
privacyState: info.privacyState,
|
||||
isVacBanned: ban?.VACBanned ?? false,
|
||||
tradeBanState: ban?.EconomyBan ?? "none",
|
||||
visibilityState: player.communityvisibilitystate,
|
||||
},
|
||||
lastSyncedAt: new Date(),
|
||||
profileUrl: player.profileurl,
|
||||
};
|
||||
}
|
||||
})
|
||||
}) as Promise<CSteamUser>
|
||||
)
|
||||
);
|
||||
|
||||
steamAccounts
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[userInfo] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
return steamAccounts.filter(result => result.status === "fulfilled").map(result => (result as PromiseFulfilledResult<SteamAccount>).value)
|
||||
})
|
||||
|
||||
export const getAppInfo = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [infoData, tagsData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameTagsResponse>("https://store.steampowered.com/actions/ajaxgetstoretags"),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
try {
|
||||
const info = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<SteamStoreResponse>(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?key=${Resource.SteamApiKey.value}&input_json={"ids":[{"appid":"${appid}"}],"context":{"language":"english","country_code":"US","steam_realm":"1"},"data_request":{"include_assets":true,"include_release":true,"include_platforms":true,"include_all_purchase_options":true,"include_screenshots":true,"include_trailers":true,"include_ratings":true,"include_tag_count":"40","include_reviews":true,"include_basic_info":true,"include_supported_languages":true,"include_full_description":true,"include_included_items":true,"include_assets_without_overrides":true,"apply_user_filters":true,"include_links":true}}`),
|
||||
]);
|
||||
|
||||
const tags = tagsData.tags;
|
||||
const game = infoData.data[appid];
|
||||
// Guard against an empty string - When there are no genres, Steam returns an empty string
|
||||
const genres = details.strGenres ? Utils.parseGenres(details.strGenres) : [];
|
||||
const cmd = info[0].data[appid]
|
||||
const store = info[1].response.store_items[0]
|
||||
|
||||
const controllerTag = game.common.controller_support ?
|
||||
Utils.createTag(`${Utils.capitalise(game.common.controller_support)} Controller Support`) :
|
||||
Utils.createTag(`Unknown Controller Support`)
|
||||
if (!cmd) {
|
||||
throw new Error(`App data not found for appid: ${appid}`)
|
||||
}
|
||||
|
||||
const compatibilityTag = Utils.createTag(`${Utils.capitalise(Utils.compatibilityType(game.common.steam_deck_compatibility?.category))} Compatibility`)
|
||||
if (!store || store.success !== 1) {
|
||||
throw new Error(`Could not get store information or appid: ${appid}`)
|
||||
}
|
||||
|
||||
const controller = (game.common.controller_support === "partial" || game.common.controller_support === "full") ? game.common.controller_support : "unknown";
|
||||
const appInfo: AppInfo = {
|
||||
genres,
|
||||
gameid: game.appid,
|
||||
name: game.common.name.trim(),
|
||||
size: Utils.getPublicDepotSizes(game.depots!),
|
||||
slug: Utils.createSlug(game.common.name.trim()),
|
||||
description: Utils.cleanDescription(details.strDescription),
|
||||
controllerSupport: controller,
|
||||
releaseDate: new Date(Number(game.common.steam_release_date) * 1000),
|
||||
primaryGenre: (!!game?.common.genres && !!details.strGenres) ? Utils.getPrimaryGenre(
|
||||
genres,
|
||||
game.common.genres!,
|
||||
game.common.primary_genre!
|
||||
) : null,
|
||||
developers: game.common.associations ?
|
||||
Array.from(
|
||||
Utils.getAssociationsByTypeWithSlug(
|
||||
game.common.associations!,
|
||||
"developer"
|
||||
)
|
||||
) : [],
|
||||
publishers: game.common.associations ?
|
||||
Array.from(
|
||||
Utils.getAssociationsByTypeWithSlug(
|
||||
game.common.associations!,
|
||||
"publisher"
|
||||
)
|
||||
) : [],
|
||||
compatibility: Utils.compatibilityType(game.common.steam_deck_compatibility?.category),
|
||||
tags: [
|
||||
...(game?.common.store_tags ?
|
||||
Utils.mapGameTags(
|
||||
tags,
|
||||
game.common.store_tags!,
|
||||
) : []),
|
||||
controllerTag,
|
||||
compatibilityTag
|
||||
],
|
||||
score: Utils.getRating(
|
||||
details.ReviewSummary.cRecommendationsPositive,
|
||||
details.ReviewSummary.cRecommendationsNegative
|
||||
),
|
||||
};
|
||||
const tags = store.tagids
|
||||
.map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
return appInfo
|
||||
const publishers = store.basic_info.publishers
|
||||
.map(i => i.name)
|
||||
|
||||
const developers = store.basic_info.developers
|
||||
.map(i => i.name)
|
||||
|
||||
const franchises = store.basic_info.franchises
|
||||
?.map(i => i.name)
|
||||
|
||||
const genres = cmd?.common.genres &&
|
||||
Object.keys(cmd?.common.genres)
|
||||
.map(id => Steam.genres[id.toString() as keyof typeof Steam.genres])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const categories = [
|
||||
...(store.categories?.controller_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? []),
|
||||
...(store.categories?.supported_player_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? [])
|
||||
].filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const assetUrls = Utils.getAssetUrls(cmd?.common.library_assets_full, appid, cmd?.common.header_image.english);
|
||||
|
||||
const screenshots = store.screenshots.all_ages_screenshots?.map(i => `https://shared.cloudflare.steamstatic.com/store_item_assets/${i.filename}`) ?? [];
|
||||
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${cmd?.common.icon}.jpg`;
|
||||
|
||||
const data: AppInfo = {
|
||||
id: appid,
|
||||
name: cmd?.common.name.trim(),
|
||||
tags: Utils.createType(tags, "tag"),
|
||||
images: { screenshots, icon, ...assetUrls },
|
||||
size: Utils.getPublicDepotSizes(cmd?.depots!),
|
||||
slug: Utils.createSlug(cmd?.common.name.trim()),
|
||||
publishers: Utils.createType(publishers, "publisher"),
|
||||
developers: Utils.createType(developers, "developer"),
|
||||
categories: Utils.createType(categories, "categorie"),
|
||||
links: store.links ? store.links.map(i => i.url) : null,
|
||||
genres: genres ? Utils.createType(genres, "genre") : [],
|
||||
franchises: franchises ? Utils.createType(franchises, "franchise") : [],
|
||||
description: store.basic_info.short_description ? Utils.cleanDescription(store.basic_info.short_description) : null,
|
||||
controllerSupport: cmd?.common.controller_support ?? "unknown" as any,
|
||||
releaseDate: new Date(Number(cmd?.common.steam_release_date) * 1000),
|
||||
primaryGenre: !!cmd?.common.primary_genre && Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] ? Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] : null,
|
||||
compatibility: store?.platforms.steam_os_compat_category ? Utils.compatibilityType(store?.platforms.steam_os_compat_category.toString() as any).toLowerCase() : "unknown" as any,
|
||||
score: Utils.estimateRatingFromSummary(store.reviews.summary_filtered.review_count, store.reviews.summary_filtered.percent_positive)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.log(`Error handling: ${appid}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getImages = fn(
|
||||
export const getImageUrls = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
@@ -167,18 +180,49 @@ export namespace Client {
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshotUrls = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const screenshots = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
|
||||
const iconUrl = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
|
||||
//2.5 Get the backdrop buffer and use it to get the best screenshot
|
||||
const baselineBuffer = await Utils.fetchBuffer(assetUrls.backdrop);
|
||||
return { screenshots, icon, ...assetUrls }
|
||||
}
|
||||
)
|
||||
|
||||
// 3. Download screenshot buffers in parallel
|
||||
export const getImageInfo = fn(
|
||||
z.object({
|
||||
type: z.enum(ImageTypeEnum.enumValues),
|
||||
url: z.string()
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.fetchBuffer(input.url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: input.url, type: input.type } as ImageInfo))
|
||||
)
|
||||
|
||||
export const createBoxArt = fn(
|
||||
z.object({
|
||||
backgroundUrl: z.string(),
|
||||
logoUrl: z.string(),
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.createBoxArtBuffer(input.logoUrl, input.backgroundUrl)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
)
|
||||
|
||||
export const createHeroArt = fn(
|
||||
z.object({
|
||||
screenshots: z.string().array(),
|
||||
backdropUrl: z.string()
|
||||
}),
|
||||
async (input) => {
|
||||
// Download screenshot buffers in parallel
|
||||
const shots: Shot[] = await Promise.all(
|
||||
screenshotUrls.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
input.screenshots.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
);
|
||||
|
||||
const baselineBuffer = await Utils.fetchBuffer(input.backdropUrl);
|
||||
|
||||
// 4. Score screenshots (or pick single)
|
||||
const scores =
|
||||
shots.length === 1
|
||||
@@ -204,37 +248,69 @@ export namespace Client {
|
||||
);
|
||||
}
|
||||
|
||||
// 5b. Asset images
|
||||
for (const [type, url] of Object.entries({ ...assetUrls, icon: iconUrl })) {
|
||||
if (!url || type === "backdrop") continue;
|
||||
tasks.push(
|
||||
Utils.fetchBuffer(url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: url, type: type as ImageType } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
// 5c. Backdrop
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(baselineBuffer)
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: assetUrls.backdrop, type: "backdrop" as const } as ImageInfo))
|
||||
)
|
||||
|
||||
// 5d. Box art
|
||||
tasks.push(
|
||||
Utils.createBoxArtBuffer(game.library_assets_full, appid)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(tasks)
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
.forEach(r => console.warn("[getHeroArt] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// 6. Await all and return
|
||||
// Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies a Steam OpenID response by sending a request back to Steam
|
||||
* with mode=check_authentication
|
||||
*/
|
||||
export async function verifyOpenIDResponse(params: URLSearchParams): Promise<string | null> {
|
||||
try {
|
||||
// Create a new URLSearchParams with all the original parameters
|
||||
const verificationParams = new URLSearchParams();
|
||||
|
||||
// Copy all parameters from the original request
|
||||
for (const [key, value] of params.entries()) {
|
||||
verificationParams.append(key, value);
|
||||
}
|
||||
|
||||
// Change mode to check_authentication for verification
|
||||
verificationParams.set('openid.mode', 'check_authentication');
|
||||
|
||||
// Send verification request to Steam
|
||||
const verificationResponse = await fetch('https://steamcommunity.com/openid/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: verificationParams.toString()
|
||||
});
|
||||
|
||||
const responseText = await verificationResponse.text();
|
||||
|
||||
// Check if verification was successful
|
||||
if (!responseText.includes('is_valid:true')) {
|
||||
console.error('OpenID verification failed: Invalid response from Steam', responseText);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract steamID from the claimed_id
|
||||
const claimedId = params.get('openid.claimed_id');
|
||||
if (!claimedId) {
|
||||
console.error('OpenID verification failed: Missing claimed_id');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the Steam ID from the claimed_id
|
||||
const steamID = claimedId.split('/').pop();
|
||||
if (!steamID || !/^\d+$/.test(steamID)) {
|
||||
console.error('OpenID verification failed: Invalid steamID format', steamID);
|
||||
return null;
|
||||
}
|
||||
|
||||
return steamID;
|
||||
} catch (error) {
|
||||
console.error('OpenID verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
544
packages/core/src/client/steam.ts
Normal file
544
packages/core/src/client/steam.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
export namespace Steam {
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/genres.json
|
||||
export const genres = {
|
||||
"1": "Action",
|
||||
"2": "Strategy",
|
||||
"3": "RPG",
|
||||
"4": "Casual",
|
||||
"9": "Racing",
|
||||
"18": "Sports",
|
||||
"23": "Indie",
|
||||
"25": "Adventure",
|
||||
"28": "Simulation",
|
||||
"29": "Massively Multiplayer",
|
||||
"37": "Free to Play",
|
||||
"50": "Accounting",
|
||||
"51": "Animation & Modeling",
|
||||
"52": "Audio Production",
|
||||
"53": "Design & Illustration",
|
||||
"54": "Education",
|
||||
"55": "Photo Editing",
|
||||
"56": "Software Training",
|
||||
"57": "Utilities",
|
||||
"58": "Video Production",
|
||||
"59": "Web Publishing",
|
||||
"60": "Game Development",
|
||||
"70": "Early Access",
|
||||
"71": "Sexual Content",
|
||||
"72": "Nudity",
|
||||
"73": "Violent",
|
||||
"74": "Gore",
|
||||
"80": "Movie",
|
||||
"81": "Documentary",
|
||||
"82": "Episodic",
|
||||
"83": "Short",
|
||||
"84": "Tutorial",
|
||||
"85": "360 Video"
|
||||
}
|
||||
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/categories.json
|
||||
export const categories = {
|
||||
"1": "Multi-player",
|
||||
"2": "Single-player",
|
||||
"6": "Mods (require HL2)",
|
||||
"7": "Mods (require HL1)",
|
||||
"8": "Valve Anti-Cheat enabled",
|
||||
"9": "Co-op",
|
||||
"10": "Demos",
|
||||
"12": "HDR available",
|
||||
"13": "Captions available",
|
||||
"14": "Commentary available",
|
||||
"15": "Stats",
|
||||
"16": "Includes Source SDK",
|
||||
"17": "Includes level editor",
|
||||
"18": "Partial Controller Support",
|
||||
"19": "Mods",
|
||||
"20": "MMO",
|
||||
"21": "Downloadable Content",
|
||||
"22": "Steam Achievements",
|
||||
"23": "Steam Cloud",
|
||||
"24": "Shared/Split Screen",
|
||||
"25": "Steam Leaderboards",
|
||||
"27": "Cross-Platform Multiplayer",
|
||||
"28": "Full controller support",
|
||||
"29": "Steam Trading Cards",
|
||||
"30": "Steam Workshop",
|
||||
"31": "VR Support",
|
||||
"32": "Steam Turn Notifications",
|
||||
"33": "Native Steam Controller",
|
||||
"35": "In-App Purchases",
|
||||
"36": "Online PvP",
|
||||
"37": "Shared/Split Screen PvP",
|
||||
"38": "Online Co-op",
|
||||
"39": "Shared/Split Screen Co-op",
|
||||
"40": "SteamVR Collectibles",
|
||||
"41": "Remote Play on Phone",
|
||||
"42": "Remote Play on Tablet",
|
||||
"43": "Remote Play on TV",
|
||||
"44": "Remote Play Together",
|
||||
"45": "Cloud Gaming",
|
||||
"46": "Cloud Gaming (NVIDIA)",
|
||||
"47": "LAN PvP",
|
||||
"48": "LAN Co-op",
|
||||
"49": "PvP",
|
||||
"50": "Additional High-Quality Audio",
|
||||
"51": "Steam Workshop",
|
||||
"52": "Tracked Controller Support",
|
||||
"53": "VR Supported",
|
||||
"54": "VR Only"
|
||||
}
|
||||
|
||||
// Source: https://files.catbox.moe/96bty7.json
|
||||
export const tags = {
|
||||
"9": "Strategy",
|
||||
"19": "Action",
|
||||
"21": "Adventure",
|
||||
"84": "Design & Illustration",
|
||||
"87": "Utilities",
|
||||
"113": "Free to Play",
|
||||
"122": "RPG",
|
||||
"128": "Massively Multiplayer",
|
||||
"492": "Indie",
|
||||
"493": "Early Access",
|
||||
"597": "Casual",
|
||||
"599": "Simulation",
|
||||
"699": "Racing",
|
||||
"701": "Sports",
|
||||
"784": "Video Production",
|
||||
"809": "Photo Editing",
|
||||
"872": "Animation & Modeling",
|
||||
"1027": "Audio Production",
|
||||
"1036": "Education",
|
||||
"1038": "Web Publishing",
|
||||
"1445": "Software Training",
|
||||
"1616": "Trains",
|
||||
"1621": "Music",
|
||||
"1625": "Platformer",
|
||||
"1628": "Metroidvania",
|
||||
"1638": "Dog",
|
||||
"1643": "Building",
|
||||
"1644": "Driving",
|
||||
"1645": "Tower Defense",
|
||||
"1646": "Hack and Slash",
|
||||
"1647": "Western",
|
||||
"1649": "GameMaker",
|
||||
"1651": "Satire",
|
||||
"1654": "Relaxing",
|
||||
"1659": "Zombies",
|
||||
"1662": "Survival",
|
||||
"1663": "FPS",
|
||||
"1664": "Puzzle",
|
||||
"1665": "Match 3",
|
||||
"1666": "Card Game",
|
||||
"1667": "Horror",
|
||||
"1669": "Moddable",
|
||||
"1670": "4X",
|
||||
"1671": "Superhero",
|
||||
"1673": "Aliens",
|
||||
"1674": "Typing",
|
||||
"1676": "RTS",
|
||||
"1677": "Turn-Based",
|
||||
"1678": "War",
|
||||
"1680": "Heist",
|
||||
"1681": "Pirates",
|
||||
"1684": "Fantasy",
|
||||
"1685": "Co-op",
|
||||
"1687": "Stealth",
|
||||
"1688": "Ninja",
|
||||
"1693": "Classic",
|
||||
"1695": "Open World",
|
||||
"1697": "Third Person",
|
||||
"1698": "Point & Click",
|
||||
"1702": "Crafting",
|
||||
"1708": "Tactical",
|
||||
"1710": "Surreal",
|
||||
"1714": "Psychedelic",
|
||||
"1716": "Roguelike",
|
||||
"1717": "Hex Grid",
|
||||
"1718": "MOBA",
|
||||
"1719": "Comedy",
|
||||
"1720": "Dungeon Crawler",
|
||||
"1721": "Psychological Horror",
|
||||
"1723": "Action RTS",
|
||||
"1730": "Sokoban",
|
||||
"1732": "Voxel",
|
||||
"1733": "Unforgiving",
|
||||
"1734": "Fast-Paced",
|
||||
"1736": "LEGO",
|
||||
"1738": "Hidden Object",
|
||||
"1741": "Turn-Based Strategy",
|
||||
"1742": "Story Rich",
|
||||
"1743": "Fighting",
|
||||
"1746": "Basketball",
|
||||
"1751": "Comic Book",
|
||||
"1752": "Rhythm",
|
||||
"1753": "Skateboarding",
|
||||
"1754": "MMORPG",
|
||||
"1755": "Space",
|
||||
"1756": "Great Soundtrack",
|
||||
"1759": "Perma Death",
|
||||
"1770": "Board Game",
|
||||
"1773": "Arcade",
|
||||
"1774": "Shooter",
|
||||
"1775": "PvP",
|
||||
"1777": "Steampunk",
|
||||
"3796": "Based On A Novel",
|
||||
"3798": "Side Scroller",
|
||||
"3799": "Visual Novel",
|
||||
"3810": "Sandbox",
|
||||
"3813": "Real Time Tactics",
|
||||
"3814": "Third-Person Shooter",
|
||||
"3834": "Exploration",
|
||||
"3835": "Post-apocalyptic",
|
||||
"3839": "First-Person",
|
||||
"3841": "Local Co-Op",
|
||||
"3843": "Online Co-Op",
|
||||
"3854": "Lore-Rich",
|
||||
"3859": "Multiplayer",
|
||||
"3871": "2D",
|
||||
"3877": "Precision Platformer",
|
||||
"3878": "Competitive",
|
||||
"3916": "Old School",
|
||||
"3920": "Cooking",
|
||||
"3934": "Immersive",
|
||||
"3942": "Sci-fi",
|
||||
"3952": "Gothic",
|
||||
"3955": "Character Action Game",
|
||||
"3959": "Roguelite",
|
||||
"3964": "Pixel Graphics",
|
||||
"3965": "Epic",
|
||||
"3968": "Physics",
|
||||
"3978": "Survival Horror",
|
||||
"3987": "Historical",
|
||||
"3993": "Combat",
|
||||
"4004": "Retro",
|
||||
"4018": "Vampire",
|
||||
"4026": "Difficult",
|
||||
"4036": "Parkour",
|
||||
"4046": "Dragons",
|
||||
"4057": "Magic",
|
||||
"4064": "Thriller",
|
||||
"4085": "Anime",
|
||||
"4094": "Minimalist",
|
||||
"4102": "Combat Racing",
|
||||
"4106": "Action-Adventure",
|
||||
"4115": "Cyberpunk",
|
||||
"4136": "Funny",
|
||||
"4137": "Transhumanism",
|
||||
"4145": "Cinematic",
|
||||
"4150": "World War II",
|
||||
"4155": "Class-Based",
|
||||
"4158": "Beat 'em up",
|
||||
"4161": "Real-Time",
|
||||
"4166": "Atmospheric",
|
||||
"4168": "Military",
|
||||
"4172": "Medieval",
|
||||
"4175": "Realistic",
|
||||
"4182": "Singleplayer",
|
||||
"4184": "Chess",
|
||||
"4190": "Addictive",
|
||||
"4191": "3D",
|
||||
"4195": "Cartoony",
|
||||
"4202": "Trading",
|
||||
"4231": "Action RPG",
|
||||
"4234": "Short",
|
||||
"4236": "Loot",
|
||||
"4242": "Episodic",
|
||||
"4252": "Stylized",
|
||||
"4255": "Shoot 'Em Up",
|
||||
"4291": "Spaceships",
|
||||
"4295": "Futuristic",
|
||||
"4305": "Colorful",
|
||||
"4325": "Turn-Based Combat",
|
||||
"4328": "City Builder",
|
||||
"4342": "Dark",
|
||||
"4345": "Gore",
|
||||
"4364": "Grand Strategy",
|
||||
"4376": "Assassin",
|
||||
"4400": "Abstract",
|
||||
"4434": "JRPG",
|
||||
"4474": "CRPG",
|
||||
"4486": "Choose Your Own Adventure",
|
||||
"4508": "Co-op Campaign",
|
||||
"4520": "Farming",
|
||||
"4559": "Quick-Time Events",
|
||||
"4562": "Cartoon",
|
||||
"4598": "Alternate History",
|
||||
"4604": "Dark Fantasy",
|
||||
"4608": "Swordplay",
|
||||
"4637": "Top-Down Shooter",
|
||||
"4667": "Violent",
|
||||
"4684": "Wargame",
|
||||
"4695": "Economy",
|
||||
"4700": "Movie",
|
||||
"4711": "Replay Value",
|
||||
"4726": "Cute",
|
||||
"4736": "2D Fighter",
|
||||
"4747": "Character Customization",
|
||||
"4754": "Politics",
|
||||
"4758": "Twin Stick Shooter",
|
||||
"4777": "Spectacle fighter",
|
||||
"4791": "Top-Down",
|
||||
"4821": "Mechs",
|
||||
"4835": "6DOF",
|
||||
"4840": "4 Player Local",
|
||||
"4845": "Capitalism",
|
||||
"4853": "Political",
|
||||
"4878": "Parody",
|
||||
"4885": "Bullet Hell",
|
||||
"4947": "Romance",
|
||||
"4975": "2.5D",
|
||||
"4994": "Naval Combat",
|
||||
"5030": "Dystopian",
|
||||
"5055": "eSports",
|
||||
"5094": "Narration",
|
||||
"5125": "Procedural Generation",
|
||||
"5153": "Kickstarter",
|
||||
"5154": "Score Attack",
|
||||
"5160": "Dinosaurs",
|
||||
"5179": "Cold War",
|
||||
"5186": "Psychological",
|
||||
"5228": "Blood",
|
||||
"5230": "Sequel",
|
||||
"5300": "God Game",
|
||||
"5310": "Games Workshop",
|
||||
"5348": "Mod",
|
||||
"5350": "Family Friendly",
|
||||
"5363": "Destruction",
|
||||
"5372": "Conspiracy",
|
||||
"5379": "2D Platformer",
|
||||
"5382": "World War I",
|
||||
"5390": "Time Attack",
|
||||
"5395": "3D Platformer",
|
||||
"5407": "Benchmark",
|
||||
"5411": "Beautiful",
|
||||
"5432": "Programming",
|
||||
"5502": "Hacking",
|
||||
"5537": "Puzzle Platformer",
|
||||
"5547": "Arena Shooter",
|
||||
"5577": "RPGMaker",
|
||||
"5608": "Emotional",
|
||||
"5611": "Mature",
|
||||
"5613": "Detective",
|
||||
"5652": "Collectathon",
|
||||
"5673": "Modern",
|
||||
"5708": "Remake",
|
||||
"5711": "Team-Based",
|
||||
"5716": "Mystery",
|
||||
"5727": "Baseball",
|
||||
"5752": "Robots",
|
||||
"5765": "Gun Customization",
|
||||
"5794": "Science",
|
||||
"5796": "Bullet Time",
|
||||
"5851": "Isometric",
|
||||
"5900": "Walking Simulator",
|
||||
"5914": "Tennis",
|
||||
"5923": "Dark Humor",
|
||||
"5941": "Reboot",
|
||||
"5981": "Mining",
|
||||
"5984": "Drama",
|
||||
"6041": "Horses",
|
||||
"6052": "Noir",
|
||||
"6129": "Logic",
|
||||
"6214": "Birds",
|
||||
"6276": "Inventory Management",
|
||||
"6310": "Diplomacy",
|
||||
"6378": "Crime",
|
||||
"6426": "Choices Matter",
|
||||
"6506": "3D Fighter",
|
||||
"6621": "Pinball",
|
||||
"6625": "Time Manipulation",
|
||||
"6650": "Nudity",
|
||||
"6691": "1990's",
|
||||
"6702": "Mars",
|
||||
"6730": "PvE",
|
||||
"6815": "Hand-drawn",
|
||||
"6869": "Nonlinear",
|
||||
"6910": "Naval",
|
||||
"6915": "Martial Arts",
|
||||
"6948": "Rome",
|
||||
"6971": "Multiple Endings",
|
||||
"7038": "Golf",
|
||||
"7107": "Real-Time with Pause",
|
||||
"7108": "Party",
|
||||
"7113": "Crowdfunded",
|
||||
"7178": "Party Game",
|
||||
"7208": "Female Protagonist",
|
||||
"7250": "Linear",
|
||||
"7309": "Skiing",
|
||||
"7328": "Bowling",
|
||||
"7332": "Base Building",
|
||||
"7368": "Local Multiplayer",
|
||||
"7423": "Sniper",
|
||||
"7432": "Lovecraftian",
|
||||
"7478": "Illuminati",
|
||||
"7481": "Controller",
|
||||
"7556": "Dice",
|
||||
"7569": "Grid-Based Movement",
|
||||
"7622": "Offroad",
|
||||
"7702": "Narrative",
|
||||
"7743": "1980s",
|
||||
"7782": "Cult Classic",
|
||||
"7918": "Dwarf",
|
||||
"7926": "Artificial Intelligence",
|
||||
"7948": "Soundtrack",
|
||||
"8013": "Software",
|
||||
"8075": "TrackIR",
|
||||
"8093": "Minigames",
|
||||
"8122": "Level Editor",
|
||||
"8253": "Music-Based Procedural Generation",
|
||||
"8369": "Investigation",
|
||||
"8461": "Well-Written",
|
||||
"8666": "Runner",
|
||||
"8945": "Resource Management",
|
||||
"9130": "Hentai",
|
||||
"9157": "Underwater",
|
||||
"9204": "Immersive Sim",
|
||||
"9271": "Trading Card Game",
|
||||
"9541": "Demons",
|
||||
"9551": "Dating Sim",
|
||||
"9564": "Hunting",
|
||||
"9592": "Dynamic Narration",
|
||||
"9803": "Snow",
|
||||
"9994": "Experience",
|
||||
"10235": "Life Sim",
|
||||
"10383": "Transportation",
|
||||
"10397": "Memes",
|
||||
"10437": "Trivia",
|
||||
"10679": "Time Travel",
|
||||
"10695": "Party-Based RPG",
|
||||
"10808": "Supernatural",
|
||||
"10816": "Split Screen",
|
||||
"11014": "Interactive Fiction",
|
||||
"11095": "Boss Rush",
|
||||
"11104": "Vehicular Combat",
|
||||
"11123": "Mouse only",
|
||||
"11333": "Villain Protagonist",
|
||||
"11634": "Vikings",
|
||||
"12057": "Tutorial",
|
||||
"12095": "Sexual Content",
|
||||
"12190": "Boxing",
|
||||
"12286": "Warhammer 40K",
|
||||
"12472": "Management",
|
||||
"13070": "Solitaire",
|
||||
"13190": "America",
|
||||
"13276": "Tanks",
|
||||
"13382": "Archery",
|
||||
"13577": "Sailing",
|
||||
"13782": "Experimental",
|
||||
"13906": "Game Development",
|
||||
"14139": "Turn-Based Tactics",
|
||||
"14153": "Dungeons & Dragons",
|
||||
"14720": "Nostalgia",
|
||||
"14906": "Intentionally Awkward Controls",
|
||||
"15045": "Flight",
|
||||
"15172": "Conversation",
|
||||
"15277": "Philosophical",
|
||||
"15339": "Documentary",
|
||||
"15564": "Fishing",
|
||||
"15868": "Motocross",
|
||||
"15954": "Silent Protagonist",
|
||||
"16094": "Mythology",
|
||||
"16250": "Gambling",
|
||||
"16598": "Space Sim",
|
||||
"16689": "Time Management",
|
||||
"17015": "Werewolves",
|
||||
"17305": "Strategy RPG",
|
||||
"17337": "Lemmings",
|
||||
"17389": "Tabletop",
|
||||
"17770": "Asynchronous Multiplayer",
|
||||
"17894": "Cats",
|
||||
"17927": "Pool",
|
||||
"18594": "FMV",
|
||||
"19568": "Cycling",
|
||||
"19780": "Submarine",
|
||||
"19995": "Dark Comedy",
|
||||
"21006": "Underground",
|
||||
"21491": "Demo Available",
|
||||
"21725": "Tactical RPG",
|
||||
"21978": "VR",
|
||||
"22602": "Agriculture",
|
||||
"22955": "Mini Golf",
|
||||
"24003": "Word Game",
|
||||
"24904": "NSFW",
|
||||
"25085": "Touch-Friendly",
|
||||
"26921": "Political Sim",
|
||||
"27758": "Voice Control",
|
||||
"28444": "Snowboarding",
|
||||
"29363": "3D Vision",
|
||||
"29482": "Souls-like",
|
||||
"29855": "Ambient",
|
||||
"30358": "Nature",
|
||||
"30927": "Fox",
|
||||
"31275": "Text-Based",
|
||||
"31579": "Otome",
|
||||
"32322": "Deckbuilding",
|
||||
"33572": "Mahjong",
|
||||
"35079": "Job Simulator",
|
||||
"42089": "Jump Scare",
|
||||
"42329": "Coding",
|
||||
"42804": "Action Roguelike",
|
||||
"44868": "LGBTQ+",
|
||||
"47827": "Wrestling",
|
||||
"49213": "Rugby",
|
||||
"51306": "Foreign",
|
||||
"56690": "On-Rails Shooter",
|
||||
"61357": "Electronic Music",
|
||||
"65443": "Adult Content",
|
||||
"71389": "Spelling",
|
||||
"87918": "Farming Sim",
|
||||
"91114": "Shop Keeper",
|
||||
"92092": "Jet",
|
||||
"96359": "Skating",
|
||||
"97376": "Cozy",
|
||||
"102530": "Elf",
|
||||
"117648": "8-bit Music",
|
||||
"123332": "Bikes",
|
||||
"129761": "ATV",
|
||||
"143739": "Electronic",
|
||||
"150626": "Gaming",
|
||||
"158638": "Cricket",
|
||||
"176981": "Battle Royale",
|
||||
"180368": "Faith",
|
||||
"189941": "Instrumental Music",
|
||||
"198631": "Mystery Dungeon",
|
||||
"198913": "Motorbike",
|
||||
"220585": "Colony Sim",
|
||||
"233824": "Feature Film",
|
||||
"252854": "BMX",
|
||||
"255534": "Automation",
|
||||
"323922": "Musou",
|
||||
"324176": "Hockey",
|
||||
"337964": "Rock Music",
|
||||
"348922": "Steam Machine",
|
||||
"353880": "Looter Shooter",
|
||||
"363767": "Snooker",
|
||||
"379975": "Clicker",
|
||||
"454187": "Traditional Roguelike",
|
||||
"552282": "Wholesome",
|
||||
"603297": "Hardware",
|
||||
"615955": "Idler",
|
||||
"620519": "Hero Shooter",
|
||||
"745697": "Social Deduction",
|
||||
"769306": "Escape Room",
|
||||
"776177": "360 Video",
|
||||
"791774": "Card Battler",
|
||||
"847164": "Volleyball",
|
||||
"856791": "Asymmetric VR",
|
||||
"916648": "Creature Collector",
|
||||
"922563": "Roguevania",
|
||||
"1003823": "Profile Features Limited",
|
||||
"1023537": "Boomer Shooter",
|
||||
"1084988": "Auto Battler",
|
||||
"1091588": "Roguelike Deckbuilder",
|
||||
"1100686": "Outbreak Sim",
|
||||
"1100687": "Automobile Sim",
|
||||
"1100688": "Medical Sim",
|
||||
"1100689": "Open World Survival Craft",
|
||||
"1199779": "Extraction Shooter",
|
||||
"1220528": "Hobby Sim",
|
||||
"1254546": "Football (Soccer)",
|
||||
"1254552": "Football (American)",
|
||||
"1368160": "AI Content Disclosed",
|
||||
}
|
||||
}
|
||||
@@ -160,9 +160,9 @@ export interface AppConfig {
|
||||
export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
@@ -284,16 +284,27 @@ export type GenreType = {
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
images: {
|
||||
logo: string;
|
||||
backdrop: string;
|
||||
poster: string;
|
||||
banner: string;
|
||||
screenshots: string[];
|
||||
icon: string;
|
||||
}
|
||||
links: string[] | null;
|
||||
score: number;
|
||||
gameid: string;
|
||||
id: string;
|
||||
releaseDate: Date;
|
||||
description: string;
|
||||
description: string | null;
|
||||
compatibility: "low" | "mid" | "high" | "unknown";
|
||||
controllerSupport: "partial" | "full" | "unknown";
|
||||
primaryGenre: string | null;
|
||||
size: { downloadSize: number; sizeOnDisk: number };
|
||||
tags: Array<{ name: string; slug: string; type: "tag" }>;
|
||||
genres: Array<{ type: "genre"; name: string; slug: string }>;
|
||||
categories: Array<{ name: string; slug: string; type: "categorie" }>;
|
||||
franchises: Array<{ name: string; slug: string; type: "franchise" }>;
|
||||
developers: Array<{ name: string; slug: string; type: "developer" }>;
|
||||
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
|
||||
}
|
||||
@@ -341,4 +352,249 @@ export interface Shot {
|
||||
export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummaryResponse {
|
||||
response: {
|
||||
players: SteamPlayerSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummary {
|
||||
steamid: string;
|
||||
communityvisibilitystate: number;
|
||||
profilestate?: number;
|
||||
personaname: string;
|
||||
profileurl: string;
|
||||
avatar: string;
|
||||
avatarmedium: string;
|
||||
avatarfull: string;
|
||||
avatarhash: string;
|
||||
lastlogoff?: number;
|
||||
personastate: number;
|
||||
realname?: string;
|
||||
primaryclanid?: string;
|
||||
timecreated: number;
|
||||
personastateflags?: number;
|
||||
loccountrycode?: string;
|
||||
}
|
||||
|
||||
export interface SteamPlayerBansResponse {
|
||||
players: SteamPlayerBan[];
|
||||
}
|
||||
|
||||
export interface SteamPlayerBan {
|
||||
SteamId: string;
|
||||
CommunityBanned: boolean;
|
||||
VACBanned: boolean;
|
||||
NumberOfVACBans: number;
|
||||
DaysSinceLastBan: number;
|
||||
NumberOfGameBans: number;
|
||||
EconomyBan: 'none' | 'probation' | 'banned'; // Enum based on known possible values
|
||||
}
|
||||
|
||||
export type SteamAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string | null;
|
||||
steamMemberSince: Date;
|
||||
avatarHash: string;
|
||||
limitations: {
|
||||
isLimited: boolean;
|
||||
tradeBanState: 'none' | 'probation' | 'banned';
|
||||
isVacBanned: boolean;
|
||||
visibilityState: number;
|
||||
privacyState: 'public' | 'private' | 'friendsonly';
|
||||
};
|
||||
profileUrl: string;
|
||||
lastSyncedAt: Date;
|
||||
};
|
||||
|
||||
export interface SteamFriendsListResponse {
|
||||
friendslist: {
|
||||
friends: SteamFriend[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamFriend {
|
||||
steamid: string;
|
||||
relationship: 'friend'; // could expand this if Steam ever adds more types
|
||||
friend_since: number; // Unix timestamp (seconds)
|
||||
}
|
||||
|
||||
export interface SteamOwnedGamesResponse {
|
||||
response: {
|
||||
game_count: number;
|
||||
games: SteamOwnedGame[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamOwnedGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
|
||||
playtime_windows_forever?: number;
|
||||
playtime_mac_forever?: number;
|
||||
playtime_linux_forever?: number;
|
||||
playtime_deck_forever?: number;
|
||||
|
||||
rtime_last_played?: number; // Unix timestamp
|
||||
content_descriptorids?: number[];
|
||||
playtime_disconnected?: number;
|
||||
has_community_visible_stats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the parsed Steam profile information.
|
||||
*/
|
||||
export interface ProfileInfo {
|
||||
steamID64: string;
|
||||
isLimited: boolean;
|
||||
privacyState: 'public' | 'private' | 'friendsonly' | string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
export interface SteamStoreResponse {
|
||||
response: {
|
||||
store_items: SteamStoreItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamStoreItem {
|
||||
item_type: number;
|
||||
id: number;
|
||||
success: number;
|
||||
visible: boolean;
|
||||
name: string;
|
||||
store_url_path: string;
|
||||
appid: number;
|
||||
type: number;
|
||||
tagids: number[];
|
||||
categories: {
|
||||
supported_player_categoryids?: number[];
|
||||
feature_categoryids?: number[];
|
||||
controller_categoryids?: number[];
|
||||
};
|
||||
reviews: {
|
||||
summary_filtered: {
|
||||
review_count: number;
|
||||
percent_positive: number;
|
||||
review_score: number;
|
||||
review_score_label: string;
|
||||
};
|
||||
};
|
||||
basic_info: {
|
||||
short_description?: string;
|
||||
publishers: SteamCreator[];
|
||||
developers: SteamCreator[];
|
||||
franchises?: SteamCreator[];
|
||||
};
|
||||
tags: {
|
||||
tagid: number;
|
||||
weight: number;
|
||||
}[];
|
||||
assets: SteamAssets;
|
||||
assets_without_overrides: SteamAssets;
|
||||
release: {
|
||||
steam_release_date: number;
|
||||
};
|
||||
platforms: {
|
||||
windows: boolean;
|
||||
mac: boolean;
|
||||
steamos_linux: boolean;
|
||||
vr_support: Record<string, never>;
|
||||
steam_deck_compat_category?: number;
|
||||
steam_os_compat_category?: number;
|
||||
};
|
||||
best_purchase_option: PurchaseOption;
|
||||
purchase_options: PurchaseOption[];
|
||||
screenshots: {
|
||||
all_ages_screenshots: {
|
||||
filename: string;
|
||||
ordinal: number;
|
||||
}[];
|
||||
};
|
||||
trailers: {
|
||||
highlights: Trailer[];
|
||||
};
|
||||
supported_languages: SupportedLanguage[];
|
||||
full_description: string;
|
||||
links?: {
|
||||
link_type: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SteamCreator {
|
||||
name: string;
|
||||
creator_clan_account_id: number;
|
||||
}
|
||||
|
||||
export interface SteamAssets {
|
||||
asset_url_format: string;
|
||||
main_capsule: string;
|
||||
small_capsule: string;
|
||||
header: string;
|
||||
page_background: string;
|
||||
hero_capsule: string;
|
||||
hero_capsule_2x: string;
|
||||
library_capsule: string;
|
||||
library_capsule_2x: string;
|
||||
library_hero: string;
|
||||
library_hero_2x: string;
|
||||
community_icon: string;
|
||||
page_background_path: string;
|
||||
raw_page_background: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOption {
|
||||
packageid?: number;
|
||||
bundleid?: number;
|
||||
purchase_option_name: string;
|
||||
final_price_in_cents: string;
|
||||
original_price_in_cents: string;
|
||||
formatted_final_price: string;
|
||||
formatted_original_price: string;
|
||||
discount_pct: number;
|
||||
active_discounts: ActiveDiscount[];
|
||||
user_can_purchase_as_gift: boolean;
|
||||
hide_discount_pct_for_compliance: boolean;
|
||||
included_game_count: number;
|
||||
bundle_discount_pct?: number;
|
||||
price_before_bundle_discount?: string;
|
||||
formatted_price_before_bundle_discount?: string;
|
||||
}
|
||||
|
||||
export interface ActiveDiscount {
|
||||
discount_amount: string;
|
||||
discount_description: string;
|
||||
discount_end_date: number;
|
||||
}
|
||||
|
||||
export interface Trailer {
|
||||
trailer_name: string;
|
||||
trailer_url_format: string;
|
||||
trailer_category: number;
|
||||
trailer_480p: TrailerFile[];
|
||||
trailer_max: TrailerFile[];
|
||||
microtrailer: TrailerFile[];
|
||||
screenshot_medium: string;
|
||||
screenshot_full: string;
|
||||
trailer_base_id: number;
|
||||
all_ages: boolean;
|
||||
}
|
||||
|
||||
export interface TrailerFile {
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SupportedLanguage {
|
||||
elanguage: number;
|
||||
eadditionallanguage: number;
|
||||
supported: boolean;
|
||||
full_audio: boolean;
|
||||
subtitles: boolean;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
ProfileInfo,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
@@ -18,6 +19,7 @@ import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
@@ -90,29 +92,21 @@ export namespace Utils {
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
assets: LibraryAssetsFull,
|
||||
appid: number | string,
|
||||
logoUrl: string,
|
||||
backgroundUrl: string,
|
||||
logoPercent = 0.9
|
||||
): Promise<Buffer> {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
const pick = (key: string) => {
|
||||
const set = assets[key];
|
||||
const path = set?.image2x?.english || set?.image?.english;
|
||||
if (!path) throw new Error(`Missing asset for ${key}`);
|
||||
return `${base}/${path}`;
|
||||
};
|
||||
|
||||
const [bgBuf, logoBuf] = await Promise.all([
|
||||
downloadLimit(() =>
|
||||
fetchBuffer(pick('library_hero'))
|
||||
fetchBuffer(backgroundUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image for ${appid}:`, error);
|
||||
console.error(`Failed to download hero image from ${backgroundUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(pick('library_logo'))
|
||||
downloadLimit(() => fetchBuffer(logoUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image for ${appid}:`, error);
|
||||
console.error(`Failed to download logo image from ${logoUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
@@ -182,9 +176,11 @@ export namespace Utils {
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s -]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.normalize("NFKD") // Normalize to decompose accented characters
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '') // Keep Unicode letters, numbers, spaces, and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -328,16 +324,26 @@ export namespace Utils {
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "low";
|
||||
return "high";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
return "high";
|
||||
return "low";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function estimateRatingFromSummary(
|
||||
reviewCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
const positiveVotes = Math.round((percentPositive / 100) * reviewCount);
|
||||
const negativeVotes = reviewCount - positiveVotes;
|
||||
return getRating(positiveVotes, negativeVotes);
|
||||
}
|
||||
|
||||
export function mapGameTags<
|
||||
T extends string = "tag"
|
||||
>(
|
||||
@@ -353,6 +359,20 @@ export namespace Utils {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createType<
|
||||
T extends "developer" | "publisher" | "franchise" | "tag" | "categorie" | "genre"
|
||||
>(
|
||||
names: string[],
|
||||
type: T
|
||||
) {
|
||||
return names
|
||||
.map(name => ({
|
||||
type,
|
||||
name: name.trim(),
|
||||
slug: createSlug(name.trim())
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag object with name, slug, and type
|
||||
* @typeparam T Literal type of the `type` field (defaults to 'tag')
|
||||
@@ -380,17 +400,39 @@ export namespace Utils {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isDepotEntry(e: any): e is DepotEntry {
|
||||
return (
|
||||
e != null &&
|
||||
typeof e === 'object' &&
|
||||
'manifests' in e &&
|
||||
e.manifests != null &&
|
||||
typeof e.manifests.public?.download === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicDepotSizes(depots: AppDepots) {
|
||||
const sum = { download: 0, size: 0 };
|
||||
for (const key in depots) {
|
||||
let download = 0;
|
||||
let size = 0;
|
||||
|
||||
for (const key of Object.keys(depots)) {
|
||||
if (key === 'branches' || key === 'privatebranches') continue;
|
||||
const entry = depots[key] as DepotEntry;
|
||||
if ('manifests' in entry && entry.manifests.public) {
|
||||
sum.download += Number(entry.manifests.public.download);
|
||||
sum.size += Number(entry.manifests.public.size);
|
||||
if (!isDepotEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dl = Number(entry.manifests.public.download);
|
||||
const sz = Number(entry.manifests.public.size);
|
||||
if (!Number.isFinite(dl) || !Number.isFinite(sz)) {
|
||||
console.warn(`[getPublicDepotSizes] non-numeric size for depot ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
download += dl;
|
||||
size += sz;
|
||||
}
|
||||
return { downloadSize: sum.download, sizeOnDisk: sum.size };
|
||||
|
||||
return { downloadSize: download, sizeOnDisk: size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
@@ -419,4 +461,64 @@ export namespace Utils {
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses a single Steam community profile XML.
|
||||
* @param steamIdOrVanity - The 64-bit SteamID or vanity name.
|
||||
* @returns Promise resolving to ProfileInfo.
|
||||
*/
|
||||
export async function fetchProfileInfo(
|
||||
steamIdOrVanity: string
|
||||
): Promise<ProfileInfo> {
|
||||
const isNumericId = /^\d+$/.test(steamIdOrVanity);
|
||||
const path = isNumericId ? `profiles/${steamIdOrVanity}` : `id/${steamIdOrVanity}`;
|
||||
const url = `https://steamcommunity.com/${path}/?xml=1`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${steamIdOrVanity}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const { profile } = await parseStringPromise(xml, {
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
mergeAttrs: true
|
||||
}) as { profile: any };
|
||||
|
||||
// Extract fields (fall back to limitedAccount tag if needed)
|
||||
const limitedFlag = profile.isLimitedAccount ?? profile.limitedAccount;
|
||||
const isLimited = limitedFlag === '1';
|
||||
|
||||
return {
|
||||
isLimited,
|
||||
steamID64: profile.steamID64,
|
||||
privacyState: profile.privacyState,
|
||||
visibility: profile.visibilityState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple Steam profiles in parallel.
|
||||
* @param idsOrVanities - Array of SteamID64 strings or vanity names.
|
||||
* @returns Promise resolving to a record mapping each input to its ProfileInfo or an error.
|
||||
*/
|
||||
export async function fetchProfilesInfo(
|
||||
idsOrVanities: string[]
|
||||
): Promise<Map<string, ProfileInfo | { error: string }>> {
|
||||
const results = await Promise.all(
|
||||
idsOrVanities.map(async (input) => {
|
||||
try {
|
||||
const info = await fetchProfileInfo(input);
|
||||
return { input, result: info };
|
||||
} catch (err) {
|
||||
return { input, result: { error: (err as Error).message } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Map(
|
||||
results.map(({ input, result }) => [input, result] as [string, ProfileInfo | { error: string }])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import "zod-openapi/extend";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export namespace Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
import { encryptedText, ulid, timestamps, utc } from "../drizzle/types";
|
||||
|
||||
export const steamCredentialsTable = pgTable(
|
||||
"steam_account_credentials",
|
||||
{
|
||||
...timestamps,
|
||||
id: ulid("id").notNull(),
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
refreshToken: encryptedText("refresh_token")
|
||||
.notNull(),
|
||||
expiry: utc("expiry").notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.id]
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { createEvent } from "../event";
|
||||
import { createID, fn } from "../utils";
|
||||
import { eq, and, isNull, gt } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamCredentialsTable } from "./credentials.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Credentials {
|
||||
export const Info = createSelectSchema(steamCredentialsTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
.extend({
|
||||
accessToken: z.string(),
|
||||
cookies: z.string().array()
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_credentials.added",
|
||||
z.object({
|
||||
steamID: Info.shape.steamID,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({ accessToken: true, cookies: true, expiry: true })
|
||||
.partial({ id: true }),
|
||||
(input) => {
|
||||
const part = input.refreshToken.split('.')[1] as string
|
||||
|
||||
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
|
||||
|
||||
return createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("credentials")
|
||||
await tx
|
||||
.insert(steamCredentialsTable)
|
||||
.values({
|
||||
id,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
expiry: new Date(payload.exp * 1000),
|
||||
})
|
||||
await afterTx(async () =>
|
||||
await bus.publish(Resource.Bus, Events.New, { steamID: input.steamID })
|
||||
);
|
||||
return id
|
||||
})
|
||||
});
|
||||
|
||||
export const fromSteamID = fn(
|
||||
Info.shape.steamID,
|
||||
(steamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const now = new Date()
|
||||
|
||||
const credential = await tx
|
||||
.select()
|
||||
.from(steamCredentialsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamCredentialsTable.steamID, steamID),
|
||||
isNull(steamCredentialsTable.timeDeleted),
|
||||
gt(steamCredentialsTable.expiry, now)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.at(0));
|
||||
|
||||
if (!credential) return null;
|
||||
|
||||
return serialize(credential);
|
||||
})
|
||||
);
|
||||
|
||||
export function serialize(
|
||||
input: typeof steamCredentialsTable.$inferSelect,
|
||||
) {
|
||||
return {
|
||||
id: input.id,
|
||||
expiry: input.expiry,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Token } from "../utils";
|
||||
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
@@ -33,19 +32,6 @@ export const utc = (name: string) =>
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const encryptedText =
|
||||
customType<{ data: string; driverData: string; }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
},
|
||||
fromDriver(val) {
|
||||
return Token.decrypt(val);
|
||||
},
|
||||
toDriver(val) {
|
||||
return Token.encrypt(val);
|
||||
},
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
|
||||
@@ -34,7 +34,7 @@ export namespace Examples {
|
||||
|
||||
export const SteamAccount = {
|
||||
status: "online" as const, //offline,dnd(do not disturb) or playing
|
||||
id: "74839300282033",// Primary key
|
||||
id: "74839300282033",// Steam ID
|
||||
userID: User.id,// | null FK to User (null if not linked)
|
||||
name: "JD The 65th",
|
||||
username: "jdoe",
|
||||
@@ -55,11 +55,10 @@ export namespace Examples {
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),// Primary key
|
||||
name: "John's Console", // Team name (not null, unique)
|
||||
ownerID: User.id, // FK to User who owns/created the team
|
||||
slug: SteamAccount.profileUrl.toLowerCase(),
|
||||
name: "John", // Team name (not null, unique)
|
||||
maxMembers: 3,
|
||||
inviteCode: "xwydjf",
|
||||
ownerSteamID: SteamAccount.id, // FK to User who owns/created the team
|
||||
members: [SteamAccount]
|
||||
};
|
||||
|
||||
@@ -152,6 +151,9 @@ export namespace Examples {
|
||||
id: "1809540",
|
||||
slug: "nine-sols",
|
||||
name: "Nine Sols",
|
||||
links:[
|
||||
"https://example.com"
|
||||
],
|
||||
controllerSupport: "full" as const,
|
||||
releaseDate: new Date("2024-05-29T06:53:24.000Z"),
|
||||
compatibility: "high" as const,
|
||||
@@ -205,6 +207,13 @@ export namespace Examples {
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
franchises: [],
|
||||
categories: [
|
||||
{
|
||||
name: "Partial Controller",
|
||||
slug: "partial-controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const CommonImg = [
|
||||
|
||||
@@ -8,9 +8,9 @@ import { friendTable } from "./friend.sql";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Friend {
|
||||
|
||||
@@ -23,20 +23,17 @@ export namespace Library {
|
||||
"library.queue",
|
||||
z.object({
|
||||
appID: z.number(),
|
||||
lastPlayed: z.date(),
|
||||
timeAcquired: z.date(),
|
||||
lastPlayed: z.date().nullable(),
|
||||
totalPlaytime: z.number(),
|
||||
isFamilyShared: z.boolean(),
|
||||
isFamilyShareable: z.boolean(),
|
||||
}).array(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const add = fn(
|
||||
Info.partial({ ownerID: true }),
|
||||
Info.partial({ ownerSteamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const ownerSteamID = input.ownerID ?? Actor.steamID()
|
||||
const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
@@ -44,7 +41,7 @@ export namespace Library {
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
eq(steamLibraryTable.ownerID, ownerSteamID),
|
||||
eq(steamLibraryTable.ownerSteamID, ownerSteamID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
@@ -57,21 +54,17 @@ export namespace Library {
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values({
|
||||
ownerID: ownerSteamID,
|
||||
ownerSteamID: ownerSteamID,
|
||||
baseGameID: input.baseGameID,
|
||||
lastPlayed: input.lastPlayed,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
timeAcquired: input.timeAcquired,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID],
|
||||
target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID],
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
lastPlayed: input.lastPlayed,
|
||||
timeAcquired: input.timeAcquired,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,7 +80,7 @@ export namespace Library {
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
)
|
||||
)
|
||||
@@ -105,7 +98,7 @@ export namespace Library {
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, Actor.steamID()),
|
||||
eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { boolean, index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
import { index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
@@ -12,20 +12,18 @@ export const steamLibraryTable = pgTable(
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
ownerID: varchar("owner_id", { length: 255 })
|
||||
ownerSteamID: varchar("owner_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
timeAcquired: utc("time_acquired").notNull(),
|
||||
lastPlayed: utc("last_played").notNull(),
|
||||
lastPlayed: utc("last_played"),
|
||||
totalPlaytime: integer("total_playtime").notNull(),
|
||||
isFamilyShared: boolean("is_family_shared").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.ownerID]
|
||||
columns: [table.baseGameID, table.ownerSteamID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerID),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerSteamID),
|
||||
],
|
||||
);
|
||||
@@ -1,122 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Actor } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { memberTable, RoleEnum } from "./member.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "Associated team identifier for this membership",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
role: z.enum(RoleEnum.enumValues).openapi({
|
||||
description: "Assigned permission role within the team",
|
||||
example: Examples.Member.role
|
||||
}),
|
||||
steamID: z.string().openapi({
|
||||
description: "Steam platform identifier for Steam account integration",
|
||||
example: Examples.Member.steamID
|
||||
}),
|
||||
userID: z.string().nullable().openapi({
|
||||
description: "Optional associated user account identifier",
|
||||
example: Examples.Member.userID
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Member",
|
||||
description: "Team membership entity defining user roles and platform connections",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.partial({
|
||||
id: true,
|
||||
userID: true,
|
||||
teamID: true
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("member");
|
||||
await tx.insert(memberTable).values({
|
||||
id,
|
||||
role: input.role,
|
||||
userID: input.userID,
|
||||
steamID: input.steamID,
|
||||
teamID: input.teamID ?? Actor.teamID(),
|
||||
})
|
||||
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromTeamID = fn(
|
||||
Info.shape.teamID,
|
||||
(teamID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, Actor.userID()),
|
||||
eq(memberTable.teamID, teamID),
|
||||
isNull(memberTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string(),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, userID),
|
||||
eq(memberTable.teamID, Actor.teamID()),
|
||||
isNull(memberTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw member database row into a standardized {@link Member.Info} object.
|
||||
*
|
||||
* @param input - The database row representing a member.
|
||||
* @returns The member information formatted as a {@link Member.Info} object.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
userID: input.userID,
|
||||
teamID: input.teamID,
|
||||
steamID: input.steamID
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { isNotNull } from "drizzle-orm";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { timestamps, teamID, ulid } from "../drizzle/types";
|
||||
import { bigint, pgEnum, pgTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const RoleEnum = pgEnum("member_role", ["child", "adult"])
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"members",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
userID: ulid("user_id")
|
||||
.references(() => userTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "restrict"
|
||||
}),
|
||||
role: RoleEnum("role").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.id, table.teamID] }),
|
||||
uniqueIndex("idx_member_steam_id").on(table.teamID, table.steamID),
|
||||
uniqueIndex("idx_member_user_id")
|
||||
.on(table.teamID, table.userID)
|
||||
.where(isNotNull(table.userID))
|
||||
],
|
||||
);
|
||||
@@ -1,15 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Actor } from "../actor";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Steam {
|
||||
export const Info = z
|
||||
@@ -34,14 +31,6 @@ export namespace Steam {
|
||||
description: "The steam community url of this account",
|
||||
example: Examples.SteamAccount.profileUrl
|
||||
}),
|
||||
username: z.string()
|
||||
.regex(/^[a-z0-9]{1,32}$/, "The Steam username is not slug friendly")
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: "The unique username of this account",
|
||||
example: Examples.SteamAccount.username
|
||||
})
|
||||
.default("unknown"),
|
||||
realName: z.string().nullable().openapi({
|
||||
description: "The real name behind of this Steam account",
|
||||
example: Examples.SteamAccount.realName
|
||||
@@ -76,7 +65,7 @@ export namespace Steam {
|
||||
"steam_account.created",
|
||||
z.object({
|
||||
steamID: Info.shape.id,
|
||||
userID: Info.shape.userID
|
||||
userID: Info.shape.userID,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
@@ -94,9 +83,9 @@ export namespace Steam {
|
||||
useUser: z.boolean(),
|
||||
})
|
||||
.partial({
|
||||
useUser: true,
|
||||
userID: true,
|
||||
status: true,
|
||||
useUser: true,
|
||||
lastSyncedAt: true
|
||||
}),
|
||||
(input) =>
|
||||
@@ -107,8 +96,8 @@ export namespace Steam {
|
||||
.from(steamTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamTable.id, input.id),
|
||||
isNull(steamTable.timeDeleted)
|
||||
isNull(steamTable.timeDeleted),
|
||||
eq(steamTable.id, input.id)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
@@ -129,7 +118,6 @@ export namespace Steam {
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
status: input.status ?? "offline",
|
||||
username: input.username ?? "unknown",
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
})
|
||||
@@ -151,8 +139,8 @@ export namespace Steam {
|
||||
.partial({
|
||||
userID: true
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) => {
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const userID = input.userID ?? Actor.userID()
|
||||
await tx
|
||||
.update(steamTable)
|
||||
@@ -160,6 +148,12 @@ export namespace Steam {
|
||||
userID
|
||||
})
|
||||
.where(eq(steamTable.id, input.steamID));
|
||||
|
||||
// await afterTx(async () =>
|
||||
// bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
|
||||
// );
|
||||
|
||||
return input.steamID
|
||||
})
|
||||
)
|
||||
|
||||
@@ -177,6 +171,26 @@ export namespace Steam {
|
||||
)
|
||||
)
|
||||
|
||||
export const confirmOwnerShip = fn(
|
||||
z.string().min(1),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamTable.userID, userID),
|
||||
eq(steamTable.id, Actor.steamID()),
|
||||
isNull(steamTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromSteamID = fn(
|
||||
z.string(),
|
||||
(steamID) =>
|
||||
@@ -208,15 +222,14 @@ export namespace Steam {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
userID: input.userID,
|
||||
status: input.status,
|
||||
username: input.username,
|
||||
userID: input.userID,
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
lastSyncedAt: input.lastSyncedAt,
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
profileUrl: input.profileUrl ? `https://steamcommunity.com/id/${input.profileUrl}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { id, timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
|
||||
@@ -32,11 +32,7 @@ export const steamTable = pgTable(
|
||||
steamMemberSince: utc("member_since").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
profileUrl: varchar("profile_url", { length: 255 }),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
|
||||
limitations: json("limitations").$type<Limitations>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique("idx_steam_username").on(table.username)
|
||||
]
|
||||
}
|
||||
);
|
||||
@@ -1,188 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createID, fn, Invite } from "../utils";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { groupBy, pipe, values, map } from "remeda";
|
||||
import { createTransaction, useTransaction, type Transaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
slug: z.string().regex(/^[a-z0-9-]{1,32}$/, "Use a URL friendly name.").openapi({
|
||||
description: "URL-friendly unique username (lowercase alphanumeric with hyphens)",
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "Display name of the team",
|
||||
example: Examples.Team.name
|
||||
}),
|
||||
ownerID: z.string().openapi({
|
||||
description: "Unique identifier of the team owner",
|
||||
example: Examples.Team.ownerID
|
||||
}),
|
||||
maxMembers: z.number().openapi({
|
||||
description: "Maximum allowed team members based on subscription tier",
|
||||
example: Examples.Team.maxMembers
|
||||
}),
|
||||
inviteCode: z.string().openapi({
|
||||
description: "Unique invitation code used for adding new team members",
|
||||
example: Examples.Team.inviteCode
|
||||
}),
|
||||
members: Steam.Info.array().openapi({
|
||||
description: "All the team members in this team",
|
||||
example: Examples.Team.members
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "Team entity containing core team information and settings",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
/**
|
||||
* Generates a unique team invite code
|
||||
* @param length The length of the invite code
|
||||
* @param maxAttempts Maximum number of attempts to generate a unique code
|
||||
* @returns A promise resolving to a unique invite code
|
||||
*/
|
||||
async function createUniqueTeamInviteCode(
|
||||
tx: Transaction,
|
||||
length: number = 8,
|
||||
maxAttempts: number = 5
|
||||
): Promise<string> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const code = Invite.generateCode(length);
|
||||
|
||||
const teams =
|
||||
await tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(eq(teamTable.inviteCode, code))
|
||||
.execute()
|
||||
|
||||
if (teams.length === 0) {
|
||||
return code;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// If we've exceeded max attempts, add timestamp to ensure uniqueness
|
||||
const timestampSuffix = Date.now().toString(36).slice(-4);
|
||||
const baseCode = Invite.generateCode(length - 4);
|
||||
return baseCode + timestampSuffix;
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({ members: true })
|
||||
.partial({
|
||||
id: true,
|
||||
inviteCode: true,
|
||||
maxMembers: true,
|
||||
ownerID: true
|
||||
}),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const inviteCode = await createUniqueTeamInviteCode(tx)
|
||||
const id = input.id ?? createID("team");
|
||||
await tx
|
||||
.insert(teamTable)
|
||||
.values({
|
||||
id,
|
||||
inviteCode,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
ownerID: input.ownerID ?? Actor.userID(),
|
||||
maxMembers: input.maxMembers ?? 1,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [teamTable.slug],
|
||||
set: {
|
||||
timeDeleted: null
|
||||
}
|
||||
})
|
||||
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam_accounts: steamTable,
|
||||
teams: teamTable
|
||||
})
|
||||
.from(teamTable)
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.innerJoin(steamTable, eq(memberTable.steamID, steamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, Actor.userID()),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(steamTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows))
|
||||
)
|
||||
|
||||
export const fromSlug = fn(
|
||||
Info.shape.slug,
|
||||
(slug) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.innerJoin(steamTable, eq(memberTable.steamID, steamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, Actor.userID()),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(steamTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
eq(teamTable.slug, slug),
|
||||
)
|
||||
)
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { teams: typeof teamTable.$inferSelect; steam_accounts: typeof steamTable.$inferSelect | null }[]
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.teams.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
id: group[0].teams.id,
|
||||
slug: group[0].teams.slug,
|
||||
name: group[0].teams.name,
|
||||
ownerID: group[0].teams.ownerID,
|
||||
maxMembers: group[0].teams.maxMembers,
|
||||
inviteCode: group[0].teams.inviteCode,
|
||||
members: group.map(i => i.steam_accounts)
|
||||
.filter((c): c is typeof steamTable.$inferSelect => Boolean(c))
|
||||
.map((item) => Steam.serialize(item))
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { timestamps, id, ulid } from "../drizzle/types";
|
||||
import {
|
||||
varchar,
|
||||
pgTable,
|
||||
bigint,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"teams",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
ownerID: ulid("owner_id")
|
||||
.notNull()
|
||||
.references(() => userTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
inviteCode: varchar("invite_code", { length: 10 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.username, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
maxMembers: bigint("max_members", { mode: "number" }).notNull(),
|
||||
},
|
||||
(team) => [
|
||||
uniqueIndex("idx_team_slug").on(team.slug),
|
||||
unique("idx_team_invite_code").on(team.inviteCode)
|
||||
]
|
||||
);
|
||||
@@ -1,15 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Polar } from "../polar/index";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { and, eq, isNull, asc} from "drizzle-orm";
|
||||
import { and, eq, isNull, asc } from "drizzle-orm";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace User {
|
||||
export const Info = z
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from "./id"
|
||||
export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./id"
|
||||
export * from "./invite"
|
||||
export * from "./token"
|
||||
export * from "./helper"
|
||||
@@ -1,58 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { fn } from './fn';
|
||||
import crypto from 'crypto';
|
||||
import { Resource } from 'sst';
|
||||
|
||||
// This is a 32-character random ASCII string
|
||||
const rawKey = Resource.SteamEncryptionKey.value;
|
||||
|
||||
// Turn it into exactly 32 bytes via UTF-8
|
||||
const key = Buffer.from(rawKey, 'utf8');
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
`SteamEncryptionKey must be exactly 32 bytes; got ${key.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const ENCRYPTION_IV_LENGTH = 12; // 96 bits for GCM
|
||||
|
||||
export namespace Token {
|
||||
export const encrypt = fn(
|
||||
z.string().min(4),
|
||||
(token) => {
|
||||
const iv = crypto.randomBytes(ENCRYPTION_IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(token, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return ['v1', iv.toString('hex'), tag.toString('hex'), ciphertext.toString('hex')].join(':');
|
||||
});
|
||||
|
||||
export const decrypt = fn(
|
||||
z.string().min(4),
|
||||
(data) => {
|
||||
const [version, ivHex, tagHex, ciphertextHex] = data.split(':');
|
||||
if (version !== 'v1' || !ivHex || !tagHex || !ciphertextHex) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return plaintext.toString('utf8');
|
||||
});
|
||||
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.8.0",
|
||||
"@actor-core/file-system": "^0.8.0",
|
||||
@@ -27,4 +30,4 @@
|
||||
"steamcommunity": "^3.48.6",
|
||||
"steamid": "^2.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export namespace AccountApi {
|
||||
schema: Result(
|
||||
Account.Info.openapi({
|
||||
description: "User account information",
|
||||
example: { ...Examples.User, teams: [Examples.Team] }
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
|
||||
})
|
||||
),
|
||||
},
|
||||
|
||||
@@ -79,9 +79,9 @@ app.get(
|
||||
},
|
||||
TeamID: {
|
||||
type: "apiKey",
|
||||
description: "The team ID to use for this query",
|
||||
description: "The steam ID to use for this query",
|
||||
in: "header",
|
||||
name: "x-nestri-team"
|
||||
name: "x-nestri-steam"
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import crypto from 'crypto';
|
||||
import { Resource } from "sst";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { chunkArray } from "@nestri/core/utils/helper";
|
||||
import { ErrorResponses, validator, Result } from "./utils";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
||||
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
|
||||
import { ErrorResponses, validator, Result, notPublic } from "./utils";
|
||||
|
||||
const sqs = new SQSClient({});
|
||||
|
||||
@@ -45,273 +42,217 @@ export namespace SteamApi {
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
notPublic,
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Steam.list()
|
||||
})
|
||||
)
|
||||
.get("/login",
|
||||
.get("/callback/:id",
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const cookieID = getCookie(c, "user_id");
|
||||
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
if (!cookieID || cookieID !== userID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"You should not be here"
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = await User.fromID(userID);
|
||||
if (!currentUser) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
|
||||
// Verify OpenID response and get steamID
|
||||
const steamID = await Client.verifyOpenIDResponse(params);
|
||||
|
||||
// If verification failed, return error
|
||||
if (!steamID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Invalid OpenID authentication response"
|
||||
);
|
||||
}
|
||||
|
||||
const user = (await Client.getUserInfo([steamID]))[0];
|
||||
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"internal",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"Steam user data is missing"
|
||||
);
|
||||
}
|
||||
|
||||
const wasAdded = await Steam.create({ ...user, userID });
|
||||
|
||||
if (!wasAdded) {
|
||||
// Update the owner of the Steam account
|
||||
await Steam.updateOwner({ userID, steamID })
|
||||
}
|
||||
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
try {
|
||||
// Get friends info
|
||||
const friends = await Client.getFriendsList(steamID);
|
||||
|
||||
const friendSteamIDs = friends.friendslist.friends.map(f => f.steamid);
|
||||
|
||||
// Steam API has a limit of requesting 100 friends at a go
|
||||
const friendChunks = chunkArray(friendSteamIDs, 100);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
friendChunks.map(async (friendIDs) => {
|
||||
const friendsInfo = await Client.getUserInfo(friendIDs)
|
||||
|
||||
return await Promise.all(
|
||||
friendsInfo.map(async (friend) => {
|
||||
const wasAdded = await Steam.create(friend);
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`Friend ${friend.id} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: friend.id, steamID })
|
||||
|
||||
return friend.id
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
settled
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
const prod = (Resource.App.stage === "production" || Resource.App.stage === "dev")
|
||||
|
||||
const friendIDs = [
|
||||
steamID,
|
||||
...(prod ? settled
|
||||
.filter(result => result.status === "fulfilled")
|
||||
.map(f => f.value)
|
||||
.flat() : [])
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
friendIDs.map(async (currentSteamID) => {
|
||||
// Get user library
|
||||
const gameLibrary = await Client.getUserLibrary(currentSteamID);
|
||||
|
||||
const queryLib = await Promise.allSettled(
|
||||
gameLibrary.response.games.map(async (game) => {
|
||||
await Actor.provide(
|
||||
"steam",
|
||||
{
|
||||
steamID: currentSteamID,
|
||||
},
|
||||
async () => {
|
||||
const payload = await Library.Events.Queue.create({
|
||||
appID: game.appid,
|
||||
lastPlayed: game.rtime_last_played ? new Date(game.rtime_last_played * 1000) : null,
|
||||
totalPlaytime: game.playtime_forever
|
||||
});
|
||||
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: Resource.LibraryQueue.url,
|
||||
// Prevent bombarding Steam with requests at the same time
|
||||
DelaySeconds: 10,
|
||||
MessageBody: JSON.stringify(payload),
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
queryLib
|
||||
.filter(i => i.status === "rejected")
|
||||
.forEach(e => console.warn(`[pushUserLib]: Failed to push user library to queue: ${e.reason}`))
|
||||
})
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to process Steam data for user ${userID}:`, error);
|
||||
}
|
||||
})())
|
||||
|
||||
return c.html(
|
||||
`
|
||||
<script>
|
||||
window.location.href = "about:blank";
|
||||
window.close()
|
||||
</script>
|
||||
`
|
||||
)
|
||||
}
|
||||
)
|
||||
.get("/popup/:id",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "Login to Steam using QR code",
|
||||
description: "Login to Steam using a QR code sent using Server Sent Events",
|
||||
summary: "Login to Steam",
|
||||
description: "Login to Steam in a popup",
|
||||
responses: {
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"header",
|
||||
"param",
|
||||
z.object({
|
||||
"accept": z.string()
|
||||
.refine((v) =>
|
||||
v.toLowerCase()
|
||||
.includes("text/event-stream")
|
||||
)
|
||||
.openapi({
|
||||
description: "Client must accept Server Sent Events",
|
||||
example: "text/event-stream"
|
||||
})
|
||||
})
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(c) => {
|
||||
const currentUser = Actor.user()
|
||||
async (c) => {
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const user = await User.fromID(userID);
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
setCookie(c, "user_id", user.id);
|
||||
|
||||
session.loginTimeout = 30000; //30 seconds is typically when the url expires
|
||||
const returnUrl = `${new URL(c.req.url).origin}/steam/callback/${userID}`
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "connected to steam" })
|
||||
})
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnUrl,
|
||||
'openid.realm': new URL(returnUrl).origin,
|
||||
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'user_id': user.id
|
||||
});
|
||||
|
||||
const challenge = await session.startWithQR();
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'challenge_url',
|
||||
data: JSON.stringify({ url: challenge.qrChallengeUrl })
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
session.on('remoteInteraction', async () => {
|
||||
await stream.writeSSE({
|
||||
event: 'remote_interaction',
|
||||
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
|
||||
})
|
||||
});
|
||||
|
||||
session.on('timeout', async () => {
|
||||
console.log('This login attempt has timed out.');
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Your session timed out" }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'timed_out',
|
||||
data: JSON.stringify({ success: false }),
|
||||
})
|
||||
|
||||
await stream.close()
|
||||
reject("Authentication timed out")
|
||||
});
|
||||
|
||||
session.on('error', async (err) => {
|
||||
// This should ordinarily not happen. This only happens in case there's some kind of unexpected error while
|
||||
// polling, e.g. the network connection goes down or Steam chokes on something.
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Recieved an error while authenticating" }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'error',
|
||||
data: JSON.stringify({ message: err.message }),
|
||||
})
|
||||
|
||||
await stream.close()
|
||||
reject(err.message)
|
||||
});
|
||||
|
||||
|
||||
session.on('authenticated', async () => {
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Login successful" })
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'login_success',
|
||||
data: JSON.stringify({ success: true, })
|
||||
})
|
||||
|
||||
const username = session.accountName;
|
||||
const accessToken = session.accessToken;
|
||||
const refreshToken = session.refreshToken;
|
||||
const steamID = session.steamID.toString();
|
||||
const cookies = await session.getWebCookies();
|
||||
|
||||
// Get user information
|
||||
const community = new SteamCommunity();
|
||||
community.setCookies(cookies);
|
||||
|
||||
const user = await Client.getUserInfo({ steamID, cookies })
|
||||
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
username,
|
||||
id: steamID,
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
userID: currentUser.userID,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState),
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
}
|
||||
})
|
||||
|
||||
// Does not matter if the user is already there or has just been created, just store the credentials
|
||||
await Credentials.create({ refreshToken, steamID, username })
|
||||
|
||||
let teamID: string | undefined
|
||||
|
||||
if (wasAdded) {
|
||||
const rawFirst = (user.name ?? username).trim().split(/\s+/)[0] ?? username;
|
||||
|
||||
const firstName = rawFirst
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ rawFirst
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
|
||||
// create a team
|
||||
teamID = await Team.create({
|
||||
slug: username,
|
||||
name: firstName,
|
||||
ownerID: currentUser.userID,
|
||||
})
|
||||
|
||||
// Add us as a member
|
||||
await Actor.provide(
|
||||
"system",
|
||||
{ teamID },
|
||||
async () =>
|
||||
await Member.create({
|
||||
role: "adult",
|
||||
userID: currentUser.userID,
|
||||
steamID
|
||||
})
|
||||
)
|
||||
|
||||
} else {
|
||||
// Update the owner of the Steam account
|
||||
await Steam.updateOwner({ userID: currentUser.userID, steamID })
|
||||
const t = await Actor.provide(
|
||||
"user",
|
||||
currentUser,
|
||||
async () => {
|
||||
// Get the team associated with this username
|
||||
const team = await Team.fromSlug(username);
|
||||
// This should never happen
|
||||
if (!team) throw Error(`Is Nestri okay???, we could not find the team with this slug ${username}`)
|
||||
|
||||
teamID = team.id
|
||||
|
||||
return team.id
|
||||
}
|
||||
)
|
||||
console.log("t",t)
|
||||
console.log("teamID",teamID)
|
||||
}
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'team_slug',
|
||||
data: JSON.stringify({ username })
|
||||
})
|
||||
|
||||
// Get game library in the background
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
const games = await Client.getUserLibrary(accessToken);
|
||||
|
||||
// Get a batch of 5 games each
|
||||
const apps = games?.response?.apps || [];
|
||||
if (apps.length === 0) {
|
||||
console.info("[SteamApi] Is Steam okay? No games returned for user:", { steamID });
|
||||
return
|
||||
}
|
||||
|
||||
const chunkedGames = chunkArray(apps, 5);
|
||||
// Get the batches to the queue
|
||||
const processQueue = chunkedGames.map(async (chunk) => {
|
||||
const myGames = chunk.map(i => {
|
||||
return {
|
||||
appID: i.appid,
|
||||
totalPlaytime: i.rt_playtime,
|
||||
isFamilyShareable: i.exclude_reason === 0,
|
||||
lastPlayed: new Date(i.rt_last_played * 1000),
|
||||
timeAcquired: new Date(i.rt_time_acquired * 1000),
|
||||
isFamilyShared: !i.owner_steamids.includes(steamID) && i.exclude_reason === 0,
|
||||
}
|
||||
})
|
||||
|
||||
if (teamID) {
|
||||
const deduplicationId = crypto
|
||||
.createHash('md5')
|
||||
.update(`${teamID}_${chunk.map(g => g.appid).join(',')}`)
|
||||
.digest('hex');
|
||||
|
||||
await Actor.provide(
|
||||
"member",
|
||||
{
|
||||
teamID,
|
||||
steamID,
|
||||
userID: currentUser.userID
|
||||
},
|
||||
async () => {
|
||||
const payload = await Library.Events.Queue.create(myGames);
|
||||
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
MessageGroupId: teamID,
|
||||
QueueUrl: Resource.LibraryQueue.url,
|
||||
MessageBody: JSON.stringify(payload),
|
||||
MessageDeduplicationId: deduplicationId,
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const settled = await Promise.allSettled(processQueue)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.error("[LibraryQueue] enqueue failed:", (r as PromiseRejectedResult).reason));
|
||||
})())
|
||||
|
||||
await stream.close();
|
||||
|
||||
resolve();
|
||||
})
|
||||
})
|
||||
})
|
||||
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result, validator } from "./utils";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace TeamApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "List user teams",
|
||||
description: "List the current user's team details",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Team.Info.array().openapi({
|
||||
description: "All team information",
|
||||
example: [Examples.Team]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "All team details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Team.list()
|
||||
})
|
||||
)
|
||||
.get("/:slug",
|
||||
describeRoute({
|
||||
tags: ["Team"],
|
||||
summary: "Get team by slug",
|
||||
description: "Get the current user's team details, by its slug",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Team.Info.openapi({
|
||||
description: "Team details",
|
||||
example: Examples.Team
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Team details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
slug: z.string().openapi({
|
||||
description: "SLug of the team to get",
|
||||
example: Examples.Team.slug,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const teamSlug = c.req.valid("param").slug
|
||||
|
||||
const team = await Team.fromSlug(teamSlug)
|
||||
|
||||
if (!team) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Team ${teamSlug} not found`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: team
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { Resource } from "sst";
|
||||
import { subjects } from "../../subjects";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
@@ -44,19 +44,19 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const teamID = c.req.header("x-nestri-team");
|
||||
if (!teamID) {
|
||||
const steamID = c.req.header("x-nestri-steam");
|
||||
if (!steamID) {
|
||||
return Actor.provide(result.subject.type, result.subject.properties, next);
|
||||
}
|
||||
const userID = result.subject.properties.userID
|
||||
return Actor.provide(
|
||||
"system",
|
||||
"steam",
|
||||
{
|
||||
teamID
|
||||
steamID
|
||||
},
|
||||
async () => {
|
||||
const member = await Member.fromUserID(userID)
|
||||
if (!member || !member.userID) {
|
||||
const steamAcc = await Steam.confirmOwnerShip(userID)
|
||||
if (!steamAcc) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
@@ -66,9 +66,8 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
return Actor.provide(
|
||||
"member",
|
||||
{
|
||||
steamID: member.steamID,
|
||||
userID: member.userID,
|
||||
teamID: member.teamID
|
||||
steamID,
|
||||
userID,
|
||||
},
|
||||
next)
|
||||
});
|
||||
|
||||
@@ -1,117 +1,238 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Images } from "@nestri/core/images/index";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { BaseGame } from "@nestri/core/base-game/index";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
|
||||
import { Categories } from "@nestri/core/categories/index";
|
||||
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({});
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[Credentials.Events.New, BaseGame.Events.New],
|
||||
[
|
||||
BaseGame.Events.New,
|
||||
Steam.Events.Updated,
|
||||
Steam.Events.Created,
|
||||
BaseGame.Events.NewBoxArt,
|
||||
BaseGame.Events.NewHeroArt,
|
||||
],
|
||||
async (event) => {
|
||||
console.log(event.type, event.properties, event.metadata);
|
||||
switch (event.type) {
|
||||
case "new_credentials.added": {
|
||||
const input = event.properties
|
||||
const credentials = await Credentials.fromSteamID(input.steamID)
|
||||
if (credentials) {
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
case "new_image.save": {
|
||||
const input = event.properties;
|
||||
const image = await Client.getImageInfo({ url: input.url, type: input.type });
|
||||
|
||||
session.refreshToken = credentials.refreshToken;
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
const cookies = await session.getWebCookies();
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
const friends = await Client.getFriendsList(cookies);
|
||||
|
||||
const putFriends = friends.map(async (user) => {
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
id: user.steamID.toString(),
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState)
|
||||
}
|
||||
})
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`Steam user ${user.steamID.toString()} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: user.steamID.toString(), steamID: input.steamID })
|
||||
})
|
||||
|
||||
const settled = await Promise.allSettled(putFriends);
|
||||
|
||||
settled
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "new_game.added": {
|
||||
const input = event.properties
|
||||
// Get images and save to s3
|
||||
const images = await Client.getImages(input.appID);
|
||||
|
||||
(await Promise.allSettled(
|
||||
images.map(async (image) => {
|
||||
// Put the images into the db
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
})
|
||||
))
|
||||
.filter(i => i.status === "rejected")
|
||||
.forEach(r => console.warn("[createImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
break;
|
||||
}
|
||||
case "new_box_art_image.save": {
|
||||
const input = event.properties;
|
||||
|
||||
const image = await Client.createBoxArt(input);
|
||||
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "new_hero_art_image.save": {
|
||||
const input = event.properties;
|
||||
|
||||
const images = await Client.createHeroArt(input);
|
||||
|
||||
const settled =
|
||||
await Promise.allSettled(
|
||||
images.map(async (image) => {
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[processHeroArt] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
break;
|
||||
}
|
||||
// case "steam_account.updated":
|
||||
// case "steam_account.created": {
|
||||
// //Get user library and commit it to the db
|
||||
// const steamID = event.properties.steamID;
|
||||
|
||||
// await Actor.provide(
|
||||
// event.metadata.actor.type,
|
||||
// event.metadata.actor.properties,
|
||||
// async () => {
|
||||
// //Get user library
|
||||
// const gameLibrary = await Client.getUserLibrary(steamID);
|
||||
|
||||
// const myLibrary = new Map(gameLibrary.response.games.map(g => [g.appid, g]))
|
||||
|
||||
// const queryLib = await Promise.allSettled(
|
||||
// gameLibrary.response.games.map(async (game) => {
|
||||
// return await Client.getAppInfo(game.appid.toString())
|
||||
// })
|
||||
// )
|
||||
|
||||
// queryLib
|
||||
// .filter(i => i.status === "rejected")
|
||||
// .forEach(e => console.warn(`[getAppInfo]: Failed to get game metadata: ${e.reason}`))
|
||||
|
||||
// const gameInfo = queryLib.filter(i => i.status === "fulfilled").map(f => f.value)
|
||||
|
||||
// const queryGames = gameInfo.map(async (game) => {
|
||||
// await BaseGame.create(game);
|
||||
|
||||
// const allCategories = [...game.tags, ...game.genres, ...game.publishers, ...game.developers];
|
||||
|
||||
// const uniqueCategories = Array.from(
|
||||
// new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
|
||||
// );
|
||||
|
||||
// const gameSettled = await Promise.allSettled(
|
||||
// uniqueCategories.map(async (cat) => {
|
||||
// //Use a single db transaction to get or set the category
|
||||
// await Categories.create({
|
||||
// type: cat.type, slug: cat.slug, name: cat.name
|
||||
// })
|
||||
|
||||
// // Use a single db transaction to get or create the game
|
||||
// await Game.create({ baseGameID: game.id, categorySlug: cat.slug, categoryType: cat.type })
|
||||
// })
|
||||
// )
|
||||
|
||||
// gameSettled
|
||||
// .filter(r => r.status === "rejected")
|
||||
// .forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// const currentGameInLibrary = myLibrary.get(parseInt(game.id))
|
||||
// if (currentGameInLibrary) {
|
||||
// await Library.add({
|
||||
// baseGameID: game.id,
|
||||
// lastPlayed: currentGameInLibrary.rtime_last_played ? new Date(currentGameInLibrary.rtime_last_played * 1000) : null,
|
||||
// totalPlaytime: currentGameInLibrary.playtime_forever,
|
||||
// })
|
||||
// } else {
|
||||
// throw new Error(`Game is not in library, but was found in app info:${game.id}`)
|
||||
// }
|
||||
// })
|
||||
|
||||
// const settled = await Promise.allSettled(queryGames);
|
||||
|
||||
// settled
|
||||
// .filter(i => i.status === "rejected")
|
||||
// .forEach(e => console.warn(`[gameCreate]: Failed to create game: ${e.reason}`))
|
||||
// })
|
||||
|
||||
// break;
|
||||
// }
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,11 +1,14 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { SQSHandler } from "aws-lambda";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Utils } from "@nestri/core/client/utils";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { BaseGame } from "@nestri/core/base-game/index";
|
||||
import { Categories } from "@nestri/core/categories/index";
|
||||
import { ImageTypeEnum } from "@nestri/core/images/images.sql";
|
||||
|
||||
export const handler: SQSHandler = async (event) => {
|
||||
for (const record of event.Records) {
|
||||
@@ -17,71 +20,103 @@ export const handler: SQSHandler = async (event) => {
|
||||
parsed.metadata.actor.type,
|
||||
parsed.metadata.actor.properties,
|
||||
async () => {
|
||||
const processGames = parsed.properties.map(async (game) => {
|
||||
// First check whether the base_game exists, if not get it
|
||||
const appID = game.appID.toString()
|
||||
const exists = await BaseGame.fromID(appID)
|
||||
const game = parsed.properties
|
||||
// First check whether the base_game exists, if not get it
|
||||
const appID = game.appID.toString();
|
||||
const exists = await BaseGame.fromID(appID);
|
||||
|
||||
if (!exists) {
|
||||
const appInfo = await Client.getAppInfo(appID);
|
||||
const tags = appInfo.tags;
|
||||
if (!exists) {
|
||||
const appInfo = await Client.getAppInfo(appID);
|
||||
|
||||
await BaseGame.create({
|
||||
id: appID,
|
||||
name: appInfo.name,
|
||||
size: appInfo.size,
|
||||
score: appInfo.score,
|
||||
slug: appInfo.slug,
|
||||
description: appInfo.description,
|
||||
releaseDate: appInfo.releaseDate,
|
||||
primaryGenre: appInfo.primaryGenre,
|
||||
compatibility: appInfo.compatibility,
|
||||
controllerSupport: appInfo.controllerSupport,
|
||||
})
|
||||
|
||||
if (game.isFamilyShareable) {
|
||||
tags.push(Utils.createTag("Family Share"))
|
||||
}
|
||||
|
||||
const allCategories = [...tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers]
|
||||
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
uniqueCategories.map(async (cat) => {
|
||||
//Use a single db transaction to get or set the category
|
||||
await Categories.create({
|
||||
type: cat.type, slug: cat.slug, name: cat.name
|
||||
})
|
||||
|
||||
// Use a single db transaction to get or create the game
|
||||
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
|
||||
})
|
||||
)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason));
|
||||
}
|
||||
|
||||
// Add to user's library
|
||||
await Library.add({
|
||||
baseGameID: appID,
|
||||
lastPlayed: game.lastPlayed,
|
||||
timeAcquired: game.timeAcquired,
|
||||
totalPlaytime: game.totalPlaytime,
|
||||
isFamilyShared: game.isFamilyShared,
|
||||
await BaseGame.create({
|
||||
id: appID,
|
||||
name: appInfo.name,
|
||||
size: appInfo.size,
|
||||
slug: appInfo.slug,
|
||||
links: appInfo.links,
|
||||
score: appInfo.score,
|
||||
description: appInfo.description,
|
||||
releaseDate: appInfo.releaseDate,
|
||||
primaryGenre: appInfo.primaryGenre,
|
||||
compatibility: appInfo.compatibility,
|
||||
controllerSupport: appInfo.controllerSupport,
|
||||
})
|
||||
|
||||
const allCategories = [...appInfo.tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers, ...appInfo.categories, ...appInfo.franchises]
|
||||
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueCategories.map(async (cat) => {
|
||||
//Create category if it doesn't exist
|
||||
await Categories.create({
|
||||
type: cat.type, slug: cat.slug, name: cat.name
|
||||
})
|
||||
|
||||
//Create game if it doesn't exist
|
||||
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
|
||||
})
|
||||
)
|
||||
|
||||
const imageUrls = appInfo.images
|
||||
|
||||
await Promise.all(
|
||||
ImageTypeEnum.enumValues.map(async (type) => {
|
||||
switch (type) {
|
||||
case "backdrop": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "backdrop", url: imageUrls.backdrop })
|
||||
break;
|
||||
}
|
||||
case "banner": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "banner", url: imageUrls.banner })
|
||||
break;
|
||||
}
|
||||
case "icon": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "icon", url: imageUrls.icon })
|
||||
break;
|
||||
}
|
||||
case "logo": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "logo", url: imageUrls.logo })
|
||||
break;
|
||||
}
|
||||
case "poster": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.New,
|
||||
{ appID, type: "poster", url: imageUrls.poster }
|
||||
)
|
||||
break;
|
||||
}
|
||||
case "heroArt": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.NewHeroArt,
|
||||
{ appID, backdropUrl: imageUrls.backdrop, screenshots: imageUrls.screenshots }
|
||||
)
|
||||
break;
|
||||
}
|
||||
case "boxArt": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.NewBoxArt,
|
||||
{ appID, logoUrl: imageUrls.logo, backgroundUrl: imageUrls.backdrop }
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Add to user's library
|
||||
await Library.add({
|
||||
baseGameID: appID,
|
||||
lastPlayed: game.lastPlayed ? new Date(game.lastPlayed) : null,
|
||||
totalPlaytime: game.totalPlaytime,
|
||||
})
|
||||
|
||||
const settled = await Promise.allSettled(processGames)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[processGames] failed:", (r as PromiseRejectedResult).reason));
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"body-scroll-lock-upgrade": "^1.1.0",
|
||||
"eventsource": "^3.0.5",
|
||||
"fast-average-color": "9.5.0",
|
||||
"focus-trap": "^7.6.4",
|
||||
"hono": "^4.7.4",
|
||||
"modern-normalize": "^3.0.1",
|
||||
|
||||
@@ -9,13 +9,14 @@ import '@fontsource/geist-sans/900.css';
|
||||
import { Text } from '@nestri/www/ui/text';
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { ZeroProvider } from './providers/zero';
|
||||
import { TeamRoute } from '@nestri/www/pages/team';
|
||||
import { ProfilesRoute } from './pages/profiles';
|
||||
import { NewProfile } from '@nestri/www/pages/new';
|
||||
import { SteamRoute } from '@nestri/www/pages/steam';
|
||||
import { OpenAuthProvider } from "@openauthjs/solid";
|
||||
import { NotFound } from '@nestri/www/pages/not-found';
|
||||
import { Navigate, Route, Router } from "@solidjs/router";
|
||||
import { globalStyle, macaron$ } from "@macaron-css/core";
|
||||
import { useStorage } from '@nestri/www/providers/account';
|
||||
import { CreateTeamComponent } from '@nestri/www/pages/new';
|
||||
import { Screen as FullScreen } from '@nestri/www/ui/layout';
|
||||
import { darkClass, lightClass, theme } from '@nestri/www/ui/theme';
|
||||
import { AccountProvider, useAccount } from '@nestri/www/providers/account';
|
||||
@@ -97,7 +98,7 @@ export const App: Component = () => {
|
||||
issuer={import.meta.env.VITE_AUTH_URL}
|
||||
clientID="web"
|
||||
>
|
||||
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
||||
<Root class={theme() === "light" ? lightClass : darkClass}>
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
@@ -109,28 +110,28 @@ export const App: Component = () => {
|
||||
</FullScreen>
|
||||
}>
|
||||
<ZeroProvider>
|
||||
{/* props.children */}
|
||||
{props.children}
|
||||
</ZeroProvider>
|
||||
</AccountProvider>
|
||||
)}
|
||||
>
|
||||
<Route path=":teamSlug">{TeamRoute}</Route>
|
||||
<Route path="new" component={CreateTeamComponent} />
|
||||
<Route path=":steamID">{SteamRoute}</Route>
|
||||
<Route path="profiles" component={ProfilesRoute} />
|
||||
<Route path="new" component={NewProfile} />
|
||||
<Route
|
||||
path="/"
|
||||
component={() => {
|
||||
const account = useAccount();
|
||||
return (
|
||||
<Switch>
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */}
|
||||
<Match when={account.current.teams.length > 0}>
|
||||
<Match when={account.current.profiles.length > 0}>
|
||||
<Navigate
|
||||
href={`/${(
|
||||
account.current.teams.find(
|
||||
(w) => w.id === storage.value.team,
|
||||
) || account.current.teams[0]
|
||||
).slug
|
||||
}`}
|
||||
account.current.profiles.find(
|
||||
(w) => w.id === storage.value.steam,
|
||||
) || account.current.profiles[0]
|
||||
).id}`}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -144,6 +145,6 @@ export const App: Component = () => {
|
||||
</Route>
|
||||
</Router>
|
||||
</Root>
|
||||
// </OpenAuthProvider>
|
||||
</OpenAuthProvider>
|
||||
)
|
||||
}
|
||||
35
packages/www/src/components/profile-picture.tsx
Normal file
35
packages/www/src/components/profile-picture.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createSignal, type JSX, onMount } from "solid-js";
|
||||
|
||||
type SteamAvatarProps = {
|
||||
avatarHash: string;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
style?: string | JSX.CSSProperties;
|
||||
};
|
||||
|
||||
export default function SteamAvatar(props: SteamAvatarProps) {
|
||||
const smallUrl = `https://avatars.cloudflare.steamstatic.com/${props.avatarHash}.jpg`;
|
||||
const fullUrl = `https://avatars.cloudflare.steamstatic.com/${props.avatarHash}_full.jpg`;
|
||||
|
||||
const [src, setSrc] = createSignal(smallUrl);
|
||||
|
||||
onMount(() => {
|
||||
const img = new Image();
|
||||
img.src = fullUrl;
|
||||
img.onload = () => setSrc(fullUrl);
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src()}
|
||||
alt={props.alt ?? "Steam Avatar"}
|
||||
class={props.class}
|
||||
style={{
|
||||
"height": "100%",
|
||||
"width": "100%",
|
||||
"object-fit": "cover",
|
||||
...typeof props.style === "string" ? {} : props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
import { EventSource } from 'eventsource'
|
||||
import { QRCode } from "../ui/custom-qr";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { keyframes } from "@macaron-css/core";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { useAccount } from "../providers/account";
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import { Container, Screen as FullScreen } from "@nestri/www/ui/layout";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
|
||||
const Card = styled("div", {
|
||||
base: {
|
||||
padding: `10px 20px`,
|
||||
maxWidth: 360,
|
||||
gap: 40,
|
||||
maxWidth: 400,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
gap: 20,
|
||||
padding: `10px 20px`,
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}
|
||||
@@ -48,33 +43,45 @@ const Logo = styled("svg", {
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h2", {
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
fontSize: theme.font.size["2xl"],
|
||||
lineHeight: "2rem",
|
||||
textWrap: "balance",
|
||||
letterSpacing: "-0.029375rem",
|
||||
fontSize: theme.font.size["4xl"],
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
fontFamily: theme.font.family.heading
|
||||
}
|
||||
})
|
||||
|
||||
const Subtitle = styled("h2", {
|
||||
base: {
|
||||
fontSize: theme.font.size["base"],
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
}
|
||||
})
|
||||
|
||||
const Button = styled("button", {
|
||||
base: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "not-allowed",
|
||||
padding: "10px 20px",
|
||||
gap: theme.space["2"],
|
||||
cursor: "pointer",
|
||||
padding: "0px 14px",
|
||||
gap: 10,
|
||||
height: 48,
|
||||
borderRadius: theme.space["2"],
|
||||
backgroundColor: theme.color.background.d100,
|
||||
border: `1px solid ${theme.color.gray.d400}`
|
||||
},
|
||||
variants: {
|
||||
comingSoon: {
|
||||
true: {
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 20px",
|
||||
gap: theme.space["2"],
|
||||
cursor: "not-allowed",
|
||||
}
|
||||
},
|
||||
steamBtn: {
|
||||
true: {
|
||||
color: "#FFF",
|
||||
backgroundColor: "#2D73FF"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -86,7 +93,7 @@ const ButtonText = styled("span", {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonIcon = styled("svg", {
|
||||
@@ -105,230 +112,6 @@ const ButtonContainer = styled("div", {
|
||||
}
|
||||
})
|
||||
|
||||
const bgRotate = keyframes({
|
||||
'to': { transform: 'rotate(1turn)' },
|
||||
});
|
||||
|
||||
const shake = keyframes({
|
||||
"0%": {
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
"50%": {
|
||||
transform: "translateX(10px)",
|
||||
},
|
||||
"100%": {
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
});
|
||||
|
||||
const opacity = keyframes({
|
||||
"0%": { opacity: 1 },
|
||||
"100%": { opacity: 0 }
|
||||
})
|
||||
|
||||
const QRContainer = styled("div", {
|
||||
base: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: 30,
|
||||
padding: 9,
|
||||
isolation: "isolate",
|
||||
":after": {
|
||||
content: "",
|
||||
zIndex: -1,
|
||||
inset: 10,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
borderRadius: 30,
|
||||
position: "absolute"
|
||||
}
|
||||
},
|
||||
variants: {
|
||||
login: {
|
||||
true: {
|
||||
":before": {
|
||||
content: "",
|
||||
backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.blue.d700} 10%,${theme.color.blue.d700} 25%,transparent 35%)`,
|
||||
animation: `${bgRotate} 2.25s linear infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
zIndex: -2,
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
position: "absolute"
|
||||
},
|
||||
}
|
||||
},
|
||||
error: {
|
||||
true: {
|
||||
animation: `${shake} 100ms ease 3`,
|
||||
":before": {
|
||||
content: "",
|
||||
inset: 1,
|
||||
background: theme.color.red.d700,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
animation: `${opacity} 3s ease`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
}
|
||||
}
|
||||
},
|
||||
success: {
|
||||
true: {
|
||||
animation: `${shake} 100ms ease 3`,
|
||||
// ":before": {
|
||||
// content: "",
|
||||
// backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.green.d700} 10%,${theme.color.green.d700} 25%,transparent 35%)`,
|
||||
// animation: `${bgRotate} 2.25s linear infinite`,
|
||||
// width: "200%",
|
||||
// height: "200%",
|
||||
// zIndex: -2,
|
||||
// top: "-50%",
|
||||
// left: "-50%",
|
||||
// position: "absolute"
|
||||
// },
|
||||
":before": {
|
||||
content: "",
|
||||
inset: 1,
|
||||
background: theme.color.teal.d700,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
animation: `${opacity} 1.1s ease infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const QRBg = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
margin: 5,
|
||||
borderRadius: 27
|
||||
}
|
||||
})
|
||||
|
||||
const QRWrapper = styled("div", {
|
||||
base: {
|
||||
height: "max-content",
|
||||
width: "max-content",
|
||||
backgroundColor: theme.color.d1000.gray,
|
||||
position: "relative",
|
||||
textWrap: "balance",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
},
|
||||
variants: {
|
||||
error: {
|
||||
true: {
|
||||
filter: "blur(3px)",
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadBtn = styled("button", {
|
||||
base: {
|
||||
background: "none",
|
||||
border: "none",
|
||||
width: 50,
|
||||
height: 50,
|
||||
position: "absolute",
|
||||
borderRadius: 25,
|
||||
zIndex: 5,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
cursor: "pointer",
|
||||
color: theme.color.blue.d700,
|
||||
transition: "color 200ms",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
":before": {
|
||||
zIndex: 3,
|
||||
content: "",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
transition: "opacity 200ms",
|
||||
background: "#FFF"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRRealoadContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
isolation: "isolate",
|
||||
":before": {
|
||||
background: `conic-gradient( from 90deg, currentColor 10%, #FFF 80% )`,
|
||||
inset: 3,
|
||||
borderRadius: 16,
|
||||
position: "absolute",
|
||||
content: "",
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadSvg = styled("svg", {
|
||||
base: {
|
||||
zIndex: 2,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
display: "block"
|
||||
}
|
||||
})
|
||||
|
||||
const LogoContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
color: theme.color.gray.d100
|
||||
}
|
||||
})
|
||||
|
||||
const LogoIcon = styled("svg", {
|
||||
base: {
|
||||
zIndex: 6,
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
overflow: "hidden",
|
||||
borderRadius: 17,
|
||||
}
|
||||
})
|
||||
|
||||
const Divider = styled("hr", {
|
||||
base: {
|
||||
height: "100%",
|
||||
backgroundColor: theme.color.gray.d400,
|
||||
width: 2,
|
||||
border: "none",
|
||||
margin: "0 20px",
|
||||
padding: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const CardWrapper = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
@@ -338,7 +121,7 @@ const CardWrapper = styled("div", {
|
||||
display: "flex",
|
||||
alignItems: "start",
|
||||
justifyContent: "start",
|
||||
top: "25vh"
|
||||
top: "16vh"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -374,97 +157,109 @@ const Link = styled("a", {
|
||||
}
|
||||
})
|
||||
|
||||
export function CreateTeamComponent() {
|
||||
const Divider = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "center",
|
||||
":before": {
|
||||
width: "100%",
|
||||
content: "",
|
||||
borderTop: `1px solid ${theme.color.gray.d500}`,
|
||||
alignSelf: "center"
|
||||
},
|
||||
":after": {
|
||||
width: "100%",
|
||||
content: "",
|
||||
borderTop: `1px solid ${theme.color.gray.d500}`,
|
||||
alignSelf: "center"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const DividerText = styled("span", {
|
||||
base: {
|
||||
margin: "0px 10px",
|
||||
fontSize: theme.font.size["xs"],
|
||||
color: theme.color.gray.d900,
|
||||
lineHeight: "20px",
|
||||
textOverflow: "ellipsis"
|
||||
}
|
||||
})
|
||||
|
||||
export function NewProfile() {
|
||||
const nav = useNavigate();
|
||||
const auth = useOpenAuth();
|
||||
const account = useAccount();
|
||||
|
||||
const [challengeUrl, setChallengeUrl] = createSignal<string | null>(null);
|
||||
const [timedOut, setTimedOut] = createSignal(false);
|
||||
const [errorMsg, setErrorMsg] = createSignal<string | null>("");
|
||||
const [loginSuccess, setLoginSuccess] = createSignal(false);
|
||||
const openPopup = () => {
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// bump this to reconnect
|
||||
const [retryCount, setRetryCount] = createSignal(0);
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
let currentStream: EventSource | null = null;
|
||||
const createDesktopWindow = (authUrl: string) => {
|
||||
const config = {
|
||||
width: 700,
|
||||
height: 700,
|
||||
features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no"
|
||||
};
|
||||
|
||||
const connectStream = async () => {
|
||||
// clear previous state
|
||||
setChallengeUrl(null);
|
||||
setTimedOut(false);
|
||||
setErrorMsg(null);
|
||||
const top = window.top!.outerHeight / 2 + window.top!.screenY - (config.height / 2);
|
||||
const left = window.top!.outerWidth / 2 + window.top!.screenX - (config.width / 2);
|
||||
|
||||
if (currentStream) {
|
||||
currentStream.close();
|
||||
return window.open(
|
||||
authUrl,
|
||||
'Steam Popup',
|
||||
`width=${config.width},height=${config.height},left=${left},top=${top},${config.features}`
|
||||
);
|
||||
};
|
||||
|
||||
const monitorAuthWindow = (
|
||||
targetWindow: Window,
|
||||
{ timeoutMs = 3 * 60 * 1000, pollInterval = 250 } = {}
|
||||
) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(async() => {
|
||||
await cleanup();
|
||||
reject(new Error("Authentication timed out"));
|
||||
}, timeoutMs);
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
if (targetWindow.closed) {
|
||||
await cleanup();
|
||||
resolve(); // Auth window closed by user
|
||||
}
|
||||
}, pollInterval);
|
||||
|
||||
async function cleanup() {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(poll);
|
||||
if (!targetWindow.closed) {
|
||||
try {
|
||||
targetWindow.location.href = "about:blank";
|
||||
targetWindow.close();
|
||||
} catch {
|
||||
// Ignore cross-origin issues
|
||||
}
|
||||
}
|
||||
await account.refresh(account.current.id)
|
||||
nav("/profiles")
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const authUrl = `${BASE_URL}/steam/popup/${account.current.id}`;
|
||||
const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl);
|
||||
|
||||
if (!newWindow) {
|
||||
throw new Error('Failed to open authentication window');
|
||||
}
|
||||
|
||||
const token = await auth.access();
|
||||
const stream = new EventSource(
|
||||
`${import.meta.env.VITE_API_URL}/steam/login`,
|
||||
{
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
currentStream = stream;
|
||||
|
||||
// status
|
||||
// stream.addEventListener("status", (e) => {
|
||||
// // setStatus(JSON.parse(e.data).message);
|
||||
// });
|
||||
|
||||
// challenge URL
|
||||
stream.addEventListener("challenge_url", (e) => {
|
||||
setChallengeUrl(JSON.parse(e.data).url);
|
||||
});
|
||||
|
||||
// success
|
||||
stream.addEventListener("login_success", (e) => {
|
||||
setLoginSuccess(true);
|
||||
});
|
||||
|
||||
// timed out
|
||||
stream.addEventListener("timed_out", (e) => {
|
||||
setTimedOut(true);
|
||||
});
|
||||
|
||||
// server-side error
|
||||
stream.addEventListener("error", (e: any) => {
|
||||
// Network‐level errors also fire here
|
||||
try {
|
||||
const err = JSON.parse(e.data).message
|
||||
setErrorMsg(err);
|
||||
} catch {
|
||||
setErrorMsg("Connection error");
|
||||
}
|
||||
//Event source has inbuilt retry method,this is to prevent it from firing
|
||||
stream.close()
|
||||
});
|
||||
|
||||
// team slug
|
||||
stream.addEventListener("team_slug", async (e) => {
|
||||
await account.refresh(account.current.id)
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ }
|
||||
nav(`/${JSON.parse(e.data).username}`)
|
||||
});
|
||||
};
|
||||
|
||||
// kick it off on mount _and_ whenever retryCount changes
|
||||
createEffect(() => {
|
||||
// read retryCount so effect re-runs
|
||||
retryCount();
|
||||
connectStream();
|
||||
// ensure cleanup if component unmounts
|
||||
onCleanup(() => currentStream?.close());
|
||||
});
|
||||
return monitorAuthWindow(newWindow);
|
||||
}
|
||||
|
||||
return (
|
||||
<FullScreen>
|
||||
@@ -474,9 +269,28 @@ export function CreateTeamComponent() {
|
||||
style={{ position: "fixed", height: "100%" }} >
|
||||
<CardWrapper>
|
||||
<Card >
|
||||
<Title>Connect your game library to get started.</Title>
|
||||
<Title>Connect your game library to get started</Title>
|
||||
<ButtonContainer>
|
||||
<Button>
|
||||
<Button onClick={openPopup} steamBtn>
|
||||
<ButtonIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
|
||||
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</ButtonIcon>
|
||||
<ButtonText>
|
||||
Continue with Steam
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Divider>
|
||||
<DividerText>Or link</DividerText>
|
||||
</Divider>
|
||||
<Button comingSoon>
|
||||
<ButtonText>
|
||||
GOG.com
|
||||
<Soon>Soon</Soon>
|
||||
@@ -485,7 +299,7 @@ export function CreateTeamComponent() {
|
||||
<path fill="currentColor" d="M31,31H3a3,3,0,0,1-3-3V3A3,3,0,0,1,3,0H31a3,3,0,0,1,3,3V28A3,3,0,0,1,31,31ZM4,24.5A1.5,1.5,0,0,0,5.5,26H11V24H6.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5H11V18H5.5A1.5,1.5,0,0,0,4,19.5Zm8-18A1.5,1.5,0,0,0,10.5,5h-5A1.5,1.5,0,0,0,4,6.5v5A1.5,1.5,0,0,0,5.5,13H9V11H6.5a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,6.5,7h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H4v2h6.5A1.5,1.5,0,0,0,12,14.5Zm0,13v5A1.5,1.5,0,0,0,13.5,26h5A1.5,1.5,0,0,0,20,24.5v-5A1.5,1.5,0,0,0,18.5,18h-5A1.5,1.5,0,0,0,12,19.5Zm9-13A1.5,1.5,0,0,0,19.5,5h-5A1.5,1.5,0,0,0,13,6.5v5A1.5,1.5,0,0,0,14.5,13h5A1.5,1.5,0,0,0,21,11.5Zm9,0A1.5,1.5,0,0,0,28.5,5h-5A1.5,1.5,0,0,0,22,6.5v5A1.5,1.5,0,0,0,23.5,13H27V11H24.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H22v2h6.5A1.5,1.5,0,0,0,30,14.5ZM30,18H22.5A1.5,1.5,0,0,0,21,19.5V26h2V20.5a.5.5,0,0,1,.5-.5h1v6h2V20H28v6h2ZM18.5,11h-3a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v3A.5.5,0,0,1,18.5,11Zm-4,9h3a.5.5,0,0,1,.5.5v3a.5.5,0,0,1-.5.5h-3a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,14.5,20Z" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
<Button>
|
||||
<Button comingSoon>
|
||||
<ButtonText>
|
||||
Epic Games
|
||||
<Soon>Soon</Soon>
|
||||
@@ -494,7 +308,7 @@ export function CreateTeamComponent() {
|
||||
<path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4 4 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4 4 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.7 1.7 0 0 1 .591.108a1.8 1.8 0 0 1 .49.299l-.452.546a1.3 1.3 0 0 0-.308-.195a.9.9 0 0 0-.363-.068a.7.7 0 0 0-.28.06a.7.7 0 0 0-.224.163a.8.8 0 0 0-.151.243a.8.8 0 0 0-.056.299v.008a.9.9 0 0 0 .056.31a.7.7 0 0 0 .157.245a.7.7 0 0 0 .238.16a.8.8 0 0 0 .303.058a.8.8 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2 2 0 0 1-.524.307a1.8 1.8 0 0 1-.683.123a1.6 1.6 0 0 1-.602-.107a1.5 1.5 0 0 1-.478-.3a1.4 1.4 0 0 1-.318-.455a1.4 1.4 0 0 1-.115-.58v-.008a1.4 1.4 0 0 1 .113-.57a1.5 1.5 0 0 1 .312-.46a1.4 1.4 0 0 1 .474-.309a1.6 1.6 0 0 1 .598-.111h.045zm11.963.008a2 2 0 0 1 .612.094a1.6 1.6 0 0 1 .507.277l-.386.546a1.6 1.6 0 0 0-.39-.205a1.2 1.2 0 0 0-.388-.07a.35.35 0 0 0-.208.052a.15.15 0 0 0-.07.127v.008a.16.16 0 0 0 .022.084a.2.2 0 0 0 .076.066a1 1 0 0 0 .147.06q.093.03.236.061a3 3 0 0 1 .43.122a1.3 1.3 0 0 1 .328.17a.7.7 0 0 1 .207.24a.74.74 0 0 1 .071.337v.008a.9.9 0 0 1-.081.382a.8.8 0 0 1-.229.285a1 1 0 0 1-.353.18a1.6 1.6 0 0 1-.46.061a2.2 2.2 0 0 1-.71-.116a1.7 1.7 0 0 1-.593-.346l.43-.514q.416.335.9.335a.46.46 0 0 0 .236-.05a.16.16 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.2.2 0 0 0-.073-.066a1 1 0 0 0-.143-.062a3 3 0 0 0-.233-.062a5 5 0 0 1-.413-.113a1.3 1.3 0 0 1-.331-.16a.7.7 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.9.9 0 0 1 .074-.359a.8.8 0 0 1 .214-.283a1 1 0 0 1 .34-.185a1.4 1.4 0 0 1 .448-.066zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
<Button>
|
||||
<Button comingSoon>
|
||||
<ButtonText>
|
||||
Amazon Games
|
||||
<Soon>Soon</Soon>
|
||||
@@ -508,77 +322,6 @@ export function CreateTeamComponent() {
|
||||
<Link target="_blank" href="https://discord.gg/6um5K6jrYj" >Help I can't connect my account</Link>
|
||||
</Footer>
|
||||
</Card>
|
||||
<Divider />
|
||||
<Card
|
||||
style={{
|
||||
"--nestri-qr-dot-color": theme.color.gray.d100,
|
||||
"--nestri-body-background": theme.color.d1000.gray,
|
||||
"align-items": "center",
|
||||
}}>
|
||||
<QRContainer success={loginSuccess()} login={!loginSuccess() && !!challengeUrl() && !timedOut() && !errorMsg()} error={!loginSuccess() && (timedOut() || !!errorMsg())}>
|
||||
<QRBg />
|
||||
<QRWrapper error={loginSuccess() || timedOut() || !!errorMsg()}>
|
||||
<LogoContainer>
|
||||
<LogoIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
|
||||
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</LogoIcon>
|
||||
</LogoContainer>
|
||||
{(challengeUrl()
|
||||
&& !timedOut()
|
||||
&& !loginSuccess()
|
||||
&& !errorMsg()) ? (<QRCode
|
||||
uri={challengeUrl() as string}
|
||||
size={180}
|
||||
ecl="H"
|
||||
clearArea={true}
|
||||
/>) : (<QRCode
|
||||
uri={"https://nestri.io"}
|
||||
size={180}
|
||||
ecl="H"
|
||||
clearArea={true}
|
||||
/>)}
|
||||
|
||||
</QRWrapper>
|
||||
{(!loginSuccess() && timedOut() || errorMsg()) && (
|
||||
<QRReloadBtn onClick={() => setRetryCount((c) => c + 1)}>
|
||||
<QRRealoadContainer>
|
||||
<QRReloadSvg
|
||||
aria-hidden="true"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM24.5001 8.74263C25.0834 8.74263 25.5563 9.21551 25.5563 9.79883V14.5997C25.5563 15.183 25.0834 15.6559 24.5001 15.6559H19.6992C19.1159 15.6559 18.643 15.183 18.643 14.5997C18.643 14.0164 19.1159 13.5435 19.6992 13.5435H21.8378L20.071 11.8798C20.0632 11.8724 20.0555 11.865 20.048 11.8574C19.1061 10.915 17.8835 10.3042 16.5643 10.1171C15.2452 9.92999 13.9009 10.1767 12.7341 10.82C11.5674 11.4634 10.6413 12.4685 10.0955 13.684C9.54968 14.8994 9.41368 16.2593 9.70801 17.5588C10.0023 18.8583 10.711 20.0269 11.7273 20.8885C12.7436 21.7502 14.0124 22.2582 15.3425 22.336C16.6726 22.4138 17.9919 22.0572 19.1017 21.3199C19.5088 21.0495 19.8795 20.7333 20.2078 20.3793C20.6043 19.9515 21.2726 19.9262 21.7004 20.3228C22.1282 20.7194 22.1534 21.3876 21.7569 21.8154C21.3158 22.2912 20.8176 22.7161 20.2706 23.0795C18.7793 24.0702 17.0064 24.5493 15.2191 24.4448C13.4318 24.3402 11.7268 23.6576 10.3612 22.4998C8.9956 21.3419 8.0433 19.7716 7.6478 18.0254C7.2523 16.2793 7.43504 14.4519 8.16848 12.8186C8.90192 11.1854 10.1463 9.83471 11.7142 8.97021C13.282 8.10572 15.0884 7.77421 16.861 8.02565C18.6282 8.27631 20.2664 9.09278 21.5304 10.3525L23.4439 12.1544V9.79883C23.4439 9.21551 23.9168 8.74263 24.5001 8.74263Z" fill="currentColor" />
|
||||
</QRReloadSvg>
|
||||
</QRRealoadContainer>
|
||||
</QRReloadBtn>
|
||||
)}
|
||||
</QRContainer>
|
||||
<ButtonContainer>
|
||||
<Title>{loginSuccess() ?
|
||||
"Login successful" :
|
||||
(timedOut() || !!errorMsg()) ?
|
||||
"Login timed out" :
|
||||
"Scan to connect Steam"
|
||||
}</Title>
|
||||
<Subtitle>{
|
||||
loginSuccess() ?
|
||||
"Just a minute while we create your team" :
|
||||
(timedOut() || !!errorMsg()) ?
|
||||
"Failed to connect Steam. Please try again." :
|
||||
"On your mobile phone, open the Steam App to scan this code"}</Subtitle>
|
||||
</ButtonContainer>
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</Container>
|
||||
<LogoFooter >
|
||||
|
||||
@@ -3,7 +3,7 @@ import { A } from "@solidjs/router";
|
||||
import { Text } from "@nestri/www/ui/text";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { Header } from "@nestri/www/pages/steam/header";
|
||||
import { Screen as FullScreen, Container } from "@nestri/www/ui/layout";
|
||||
|
||||
const NotAllowedDesc = styled("div", {
|
||||
|
||||
179
packages/www/src/pages/profiles.tsx
Normal file
179
packages/www/src/pages/profiles.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { keyframes } from "@macaron-css/core";
|
||||
import { useAccount } from "../providers/account";
|
||||
import SteamAvatar from "../components/profile-picture";
|
||||
import { Container, Screen as FullScreen, theme } from "@nestri/www/ui";
|
||||
|
||||
const Background = styled("div", {
|
||||
base: {
|
||||
position: "fixed",
|
||||
zIndex: "-1",
|
||||
inset: 0,
|
||||
":after": {
|
||||
inset: 0,
|
||||
content: "",
|
||||
userSelect: "none",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
background: `linear-gradient(0deg,${theme.color.background.d200} 30%,transparent),linear-gradient(0deg,${theme.color.background.d200} 30%,transparent)`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const gradient = keyframes({
|
||||
"0%": {
|
||||
backgroundPosition: "0% 50%",
|
||||
},
|
||||
"50%": {
|
||||
backgroundPosition: "100% 50%",
|
||||
},
|
||||
"100%": {
|
||||
backgroundPosition: "0% 50%",
|
||||
},
|
||||
})
|
||||
|
||||
const BackgroundImage = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "70%",
|
||||
position: "relative",
|
||||
filter: "saturate(120%)",
|
||||
backgroundSize: "300% 100%",
|
||||
backgroundPosition: "0% 0%",
|
||||
backgroundRepeat: "repeat-x",
|
||||
animation: `${gradient} 35s linear 0s infinite`,
|
||||
backgroundImage: "linear-gradient(120deg, rgb(232,23,98) 1.26%, rgb(30,134,248) 18.6%, rgb(91,108,255) 34.56%, rgb(52,199,89) 49.76%, rgb(245,197,5) 64.87%, rgb(236,62,62) 85.7%)",
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
base: {
|
||||
margin: "100px 0",
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
maxWidth: 700,
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
fontSize: "50px",
|
||||
fontFamily: theme.font.family.heading,
|
||||
letterSpacing: "-0.515px",
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
const Profiles = styled("div", {
|
||||
base: {
|
||||
// width: "100%",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(150px, auto))",
|
||||
display: "grid",
|
||||
columnGap: 12,
|
||||
rowGap: 10,
|
||||
margin: "100px 0",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}
|
||||
})
|
||||
|
||||
const Profile = styled("div", {
|
||||
base: {
|
||||
width: 150,
|
||||
}
|
||||
})
|
||||
|
||||
const ProfilePicture = styled("div", {
|
||||
base: {
|
||||
width: 150,
|
||||
height: 150,
|
||||
cursor: "pointer",
|
||||
borderRadius: 75,
|
||||
overflow: "hidden",
|
||||
border: `6px solid ${theme.color.gray.d700}`,
|
||||
transition: "all 200ms ease",
|
||||
":hover": {
|
||||
transform: "scale(1.07)",
|
||||
borderColor: theme.color.blue.d700
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileName = styled("div", {
|
||||
base: {
|
||||
margin: "20px 0",
|
||||
lineHeight: "1.25em",
|
||||
color: theme.color.gray.d900,
|
||||
transition: "all 300ms ease",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: theme.font.size.lg
|
||||
}
|
||||
})
|
||||
|
||||
const NewButton = styled(A, {
|
||||
base: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
padding: "0px 14px",
|
||||
gap: 10,
|
||||
width: "max-content",
|
||||
alignSelf: "center",
|
||||
height: 48,
|
||||
borderRadius: theme.space["3"],
|
||||
transition: "all .2s ease",
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
":hover": {
|
||||
transform: "scale(1.02)",
|
||||
borderColor: theme.color.blue.d700
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function ProfilesRoute() {
|
||||
const account = useAccount()
|
||||
return (
|
||||
<FullScreen>
|
||||
<Container
|
||||
vertical="center"
|
||||
horizontal="center"
|
||||
style={{ position: "fixed", height: "100%", width: "100%" }} >
|
||||
<Background>
|
||||
<BackgroundImage />
|
||||
</Background>
|
||||
<Wrapper>
|
||||
<Title>
|
||||
Who's playing?
|
||||
</Title>
|
||||
<Profiles>
|
||||
<For each={account.current.profiles}>
|
||||
{(profile) => (
|
||||
<Profile>
|
||||
<ProfilePicture>
|
||||
<SteamAvatar avatarHash={profile.avatarHash} />
|
||||
</ProfilePicture>
|
||||
<ProfileName>{profile.name}</ProfileName>
|
||||
</Profile>
|
||||
)}
|
||||
</For>
|
||||
</Profiles>
|
||||
<NewButton href="/new" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none"><path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" /><path fill="currentColor" d="M6 7a5 5 0 1 1 10 0A5 5 0 0 1 6 7m-1.178 7.672C6.425 13.694 8.605 13 11 13q.671 0 1.316.07a1 1 0 0 1 .72 1.557A5.97 5.97 0 0 0 12 18c0 .92.207 1.79.575 2.567a1 1 0 0 1-.89 1.428L11 22c-2.229 0-4.335-.14-5.913-.558c-.785-.208-1.524-.506-2.084-.956C2.41 20.01 2 19.345 2 18.5c0-.787.358-1.523.844-2.139c.494-.625 1.177-1.2 1.978-1.69ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1" /></g></svg>
|
||||
Add Steam account
|
||||
</NewButton>
|
||||
</Wrapper>
|
||||
</Container>
|
||||
</FullScreen>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FullScreen, theme } from "@nestri/www/ui";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { Header } from "@nestri/www/pages/steam/header";
|
||||
import { Modal } from "@nestri/www/ui/modal";
|
||||
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
|
||||
import { Text } from "@nestri/www/ui/text"
|
||||
61
packages/www/src/pages/steam/index.tsx
Normal file
61
packages/www/src/pages/steam/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { LibraryRoute } from "./library";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { Route, useParams } from "@solidjs/router";
|
||||
import { ApiProvider } from "@nestri/www/providers/api";
|
||||
import { SteamContext } from "@nestri/www/providers/context";
|
||||
import { createEffect, createMemo, Match, Switch } from "solid-js";
|
||||
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
|
||||
import { useAccount, useStorage } from "@nestri/www/providers/account";
|
||||
|
||||
export const SteamRoute = (
|
||||
<Route
|
||||
// component={(props) => {
|
||||
// const params = useParams();
|
||||
// const account = useAccount();
|
||||
// const storage = useStorage();
|
||||
// const openauth = useOpenAuth();
|
||||
|
||||
// const team = createMemo(() =>
|
||||
// account.current.teams.find(
|
||||
// (item) => item.id === params.steamID,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// createEffect(() => {
|
||||
// const t = team();
|
||||
// if (!t) return;
|
||||
// storage.set("steam", t.id);
|
||||
// });
|
||||
|
||||
// createEffect(() => {
|
||||
// const steamID = params.steamID;
|
||||
// for (const item of Object.values(account.all)) {
|
||||
// for (const profile of item.profiles) {
|
||||
// if (profile.id === steamID && item.id !== openauth.subject!.id) {
|
||||
// openauth.switch(item.id);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// return (
|
||||
// <Switch>
|
||||
// <Match when={!team()}>
|
||||
// {/* TODO: Add a public page for (other) teams */}
|
||||
// <NotAllowed header />
|
||||
// </Match>
|
||||
// <Match when={team()}>
|
||||
// <TeamContext.Provider value={() => team()!}>
|
||||
// <ApiProvider>
|
||||
// {props.children}
|
||||
// </ApiProvider>
|
||||
// </TeamContext.Provider>
|
||||
// </Match>
|
||||
// </Switch>
|
||||
// )
|
||||
// }}
|
||||
>
|
||||
<Route path="library" component={LibraryRoute} />
|
||||
<Route path="*" component={() => <NotFound header />} />
|
||||
</Route>
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For } from "solid-js";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { FullScreen, theme } from "@nestri/www/ui";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { Header } from "@nestri/www/pages/steam/header";
|
||||
|
||||
const Container = styled("div", {
|
||||
base: {
|
||||
@@ -1,63 +0,0 @@
|
||||
import { HomeRoute } from "./home";
|
||||
import { LibraryRoute } from "./library";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { Route, useParams } from "@solidjs/router";
|
||||
import { ApiProvider } from "@nestri/www/providers/api";
|
||||
import { TeamContext } from "@nestri/www/providers/context";
|
||||
import { createEffect, createMemo, Match, Switch } from "solid-js";
|
||||
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
|
||||
import { useAccount, useStorage } from "@nestri/www/providers/account";
|
||||
|
||||
export const TeamRoute = (
|
||||
<Route
|
||||
component={(props) => {
|
||||
const params = useParams();
|
||||
const account = useAccount();
|
||||
const storage = useStorage();
|
||||
const openauth = useOpenAuth();
|
||||
|
||||
const team = createMemo(() =>
|
||||
account.current.teams.find(
|
||||
(item) => item.slug === params.teamSlug,
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const t = team();
|
||||
if (!t) return;
|
||||
storage.set("team", t.id);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const teamSlug = params.teamSlug;
|
||||
for (const item of Object.values(account.all)) {
|
||||
for (const team of item.teams) {
|
||||
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
|
||||
openauth.switch(item.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!team()}>
|
||||
{/* TODO: Add a public page for (other) teams */}
|
||||
<NotAllowed header />
|
||||
</Match>
|
||||
<Match when={team()}>
|
||||
<TeamContext.Provider value={() => team()!}>
|
||||
<ApiProvider>
|
||||
{props.children}
|
||||
</ApiProvider>
|
||||
</TeamContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Route path="" component={HomeRoute} />
|
||||
<Route path="library" component={LibraryRoute} />
|
||||
<Route path="*" component={() => <NotFound header />} />
|
||||
</Route>
|
||||
)
|
||||
@@ -9,7 +9,7 @@ function init() {
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore({
|
||||
account: "",
|
||||
team: "",
|
||||
steam: "",
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hc } from "hono/client";
|
||||
import { useTeam } from "./context";
|
||||
import { useSteam } from "./context";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { type app } from "@nestri/functions/api/index";
|
||||
import { createInitializedContext } from "@nestri/www/common/context";
|
||||
@@ -8,7 +8,7 @@ import { createInitializedContext } from "@nestri/www/common/context";
|
||||
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
"ApiContext",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const steam = useSteam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
|
||||
@@ -18,7 +18,7 @@ export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
input instanceof Request ? input : new Request(input, init);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("authorization", `Bearer ${await auth.access()}`);
|
||||
headers.set("x-nestri-team", team().id);
|
||||
headers.set("x-nestri-steam", steam().id);
|
||||
|
||||
return fetch(
|
||||
new Request(request, {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Accessor, createContext, useContext } from "solid-js";
|
||||
|
||||
export const TeamContext = createContext<Accessor<Team.Info>>();
|
||||
export const SteamContext = createContext<Accessor<Steam.Info>>();
|
||||
|
||||
export function useTeam() {
|
||||
const context = useContext(TeamContext);
|
||||
if (!context) throw new Error("No team context");
|
||||
export function useSteam() {
|
||||
const context = useContext(SteamContext);
|
||||
if (!context) throw new Error("No steam context");
|
||||
return context;
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import QRCodeUtil from 'qrcode';
|
||||
import { createMemo, type JSXElement } from "solid-js"
|
||||
|
||||
const generateMatrix = (
|
||||
value: string,
|
||||
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
|
||||
) => {
|
||||
const arr = Array.prototype.slice.call(
|
||||
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
|
||||
0
|
||||
);
|
||||
const sqrt = Math.sqrt(arr.length);
|
||||
return arr.reduce(
|
||||
(rows, key, index) =>
|
||||
(index % sqrt === 0
|
||||
? rows.push([key])
|
||||
: rows[rows.length - 1].push(key)) && rows,
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
|
||||
size?: number;
|
||||
uri: string;
|
||||
clearArea?: boolean;
|
||||
image?: HTMLImageElement;
|
||||
imageBackground?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an SVG element displaying a QR code generated from a URI.
|
||||
*
|
||||
* This component creates a QR code matrix based on the provided URI and error correction level, then renders
|
||||
* the QR code using SVG elements. It highlights finder patterns and conditionally renders QR code dots,
|
||||
* while optionally embedding a logo in the center with a specified background and an adjustable clear area.
|
||||
*
|
||||
* @param ecl - The error correction level for the QR code (defaults to 'M').
|
||||
* @param size - The overall size (in pixels) of the QR code, including margins (defaults to 200).
|
||||
* @param uri - The URI to encode into the QR code.
|
||||
* @param clearArea - When true, reserves extra space in the QR code for an embedded logo.
|
||||
* @param image - An optional JSX element to render as a central logo within the QR code.
|
||||
* @param imageBackground - The background color for the logo area (defaults to 'transparent').
|
||||
*
|
||||
* @returns An SVG element representing the generated QR code.
|
||||
*/
|
||||
export function QRCode({
|
||||
ecl = 'M',
|
||||
size: sizeProp = 200,
|
||||
uri,
|
||||
clearArea = false,
|
||||
image,
|
||||
imageBackground = 'transparent',
|
||||
}: Props) {
|
||||
const logoSize = clearArea ? 38 : 0;
|
||||
const size = sizeProp - 10 * 2;
|
||||
|
||||
const dots = createMemo(() => {
|
||||
const dots: JSXElement[] = [];
|
||||
const matrix = generateMatrix(uri, ecl);
|
||||
const cellSize = size / matrix.length;
|
||||
let qrList = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
];
|
||||
|
||||
qrList.forEach(({ x, y }) => {
|
||||
const x1 = (matrix.length - 7) * cellSize * x;
|
||||
const y1 = (matrix.length - 7) * cellSize * y;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
dots.push(
|
||||
<rect
|
||||
id={`${i}-${x}-${y}`}
|
||||
fill={
|
||||
i % 2 !== 0
|
||||
? 'var(--nestri-qr-background, var(--nestri-body-background))'
|
||||
: 'var(--nestri-qr-dot-color)'
|
||||
}
|
||||
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
width={cellSize * (7 - i * 2)}
|
||||
height={cellSize * (7 - i * 2)}
|
||||
x={x1 + cellSize * i}
|
||||
y={y1 + cellSize * i}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (image) {
|
||||
const x1 = (matrix.length - 7) * cellSize * 1;
|
||||
const y1 = (matrix.length - 7) * cellSize * 1;
|
||||
dots.push(
|
||||
<>
|
||||
<rect
|
||||
fill={imageBackground}
|
||||
rx={(0 - 2) * -5 + 2}
|
||||
ry={(0 - 2) * -5 + 2}
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
/>
|
||||
<foreignObject
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
>
|
||||
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
|
||||
{image}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
|
||||
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
|
||||
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
|
||||
|
||||
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
|
||||
row.forEach((_: any, j: number) => {
|
||||
if (matrix[i][j]) {
|
||||
// Do not render dots under position squares
|
||||
if (
|
||||
!(
|
||||
(i < 7 && j < 7) ||
|
||||
(i > matrix.length - 8 && j < 7) ||
|
||||
(i < 7 && j > matrix.length - 8)
|
||||
)
|
||||
) {
|
||||
if (
|
||||
image ||
|
||||
!(
|
||||
i > matrixMiddleStart &&
|
||||
i < matrixMiddleEnd &&
|
||||
j > matrixMiddleStart &&
|
||||
j < matrixMiddleEnd
|
||||
)
|
||||
) {
|
||||
dots.push(
|
||||
<circle
|
||||
id={`circle-${i}-${j}`}
|
||||
cx={i * cellSize + cellSize / 2}
|
||||
cy={j * cellSize + cellSize / 2}
|
||||
fill="var(--nestri-qr-dot-color)"
|
||||
r={cellSize / 3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return dots;
|
||||
}, [ecl, size, uri]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<rect fill="transparent" height={size} width={size} />
|
||||
{dots()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Size } from "@nestri/core/src/base-game/base-game.sql";
|
||||
import type { Size, Links} from "@nestri/core/src/base-game/base-game.sql";
|
||||
import type { Limitations } from "@nestri/core/src/steam/steam.sql";
|
||||
import type { ImageColor, ImageDimensions } from "@nestri/core/src/images/images.sql";
|
||||
import {
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
table,
|
||||
number,
|
||||
string,
|
||||
boolean,
|
||||
enumeration,
|
||||
createSchema,
|
||||
relationships,
|
||||
@@ -38,7 +37,6 @@ const steam_accounts = table("steam_accounts")
|
||||
name: string(),
|
||||
status: string(),
|
||||
user_id: string(),
|
||||
username: string(),
|
||||
avatar_hash: string(),
|
||||
member_since: number(),
|
||||
last_synced_at: number(),
|
||||
@@ -53,10 +51,9 @@ const teams = table("teams")
|
||||
.columns({
|
||||
id: string(),
|
||||
name: string(),
|
||||
slug: string(),
|
||||
owner_id: string(),
|
||||
invite_code: string(),
|
||||
max_members: number(),
|
||||
owner_steam_id: string(),
|
||||
...timestamps,
|
||||
})
|
||||
.primaryKey("id");
|
||||
@@ -83,7 +80,7 @@ const games = table("games")
|
||||
.columns({
|
||||
base_game_id: string(),
|
||||
category_slug: string(),
|
||||
type: enumeration<"tag" | "genre" | "publisher" | "developer">(),
|
||||
type: enumeration<"tag" | "genre" | "publisher" | "developer" | "categorie" | "franchise">(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("category_slug", "base_game_id", "type")
|
||||
@@ -93,9 +90,11 @@ const base_games = table("base_games")
|
||||
id: string(),
|
||||
slug: string(),
|
||||
name: string(),
|
||||
release_date: number(),
|
||||
// This should be an array, and i dunno how to include it here
|
||||
size: json<Size>(),
|
||||
description: string(),
|
||||
release_date: number(),
|
||||
links: json<Links>().optional(),
|
||||
description: string().optional(),
|
||||
primary_genre: string().optional(),
|
||||
controller_support: enumeration<"full" | "partial" | "unknown">(),
|
||||
compatibility: enumeration<"high" | "mid" | "low" | "unknown">(),
|
||||
@@ -116,13 +115,11 @@ const categories = table("categories")
|
||||
const game_libraries = table("game_libraries")
|
||||
.columns({
|
||||
base_game_id: string(),
|
||||
owner_id: string(),
|
||||
time_acquired: number(),
|
||||
last_played: number(),
|
||||
total_playtime: number(),
|
||||
is_family_shared: boolean(),
|
||||
owner_steam_id: string(),
|
||||
last_played: number().optional(),
|
||||
...timestamps
|
||||
}).primaryKey("base_game_id", "owner_id")
|
||||
}).primaryKey("base_game_id", "owner_steam_id")
|
||||
|
||||
const images = table("images")
|
||||
.columns({
|
||||
@@ -150,6 +147,11 @@ export const schema = createSchema({
|
||||
destSchema: members,
|
||||
destField: ["steam_id"],
|
||||
}),
|
||||
teams: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: teams,
|
||||
destField: ["owner_steam_id"],
|
||||
}),
|
||||
friends: r.many(
|
||||
{
|
||||
sourceField: ["id"],
|
||||
@@ -166,7 +168,7 @@ export const schema = createSchema({
|
||||
{
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["owner_id"],
|
||||
destField: ["owner_steam_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["base_game_id"],
|
||||
@@ -176,11 +178,6 @@ export const schema = createSchema({
|
||||
),
|
||||
})),
|
||||
relationships(users, (r) => ({
|
||||
teams: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: teams,
|
||||
destField: ["owner_id"],
|
||||
}),
|
||||
members: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: members,
|
||||
@@ -194,8 +191,8 @@ export const schema = createSchema({
|
||||
})),
|
||||
relationships(teams, (r) => ({
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
destSchema: users,
|
||||
sourceField: ["owner_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}),
|
||||
members: r.many({
|
||||
@@ -229,7 +226,7 @@ export const schema = createSchema({
|
||||
destField: ["base_game_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["owner_id"],
|
||||
sourceField: ["owner_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}
|
||||
@@ -289,7 +286,7 @@ export const schema = createSchema({
|
||||
|
||||
relationships(game_libraries, (r) => ({
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
sourceField: ["owner_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"]
|
||||
}),
|
||||
|
||||
8
sst-env.d.ts
vendored
8
sst-env.d.ts
vendored
@@ -53,6 +53,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LibraryDLQ": {
|
||||
"type": "sst.aws.Queue"
|
||||
"url": string
|
||||
}
|
||||
"LibraryQueue": {
|
||||
"type": "sst.aws.Queue"
|
||||
"url": string
|
||||
@@ -90,8 +94,8 @@ declare module "sst" {
|
||||
"endpoint": string
|
||||
"type": "sst.aws.Realtime"
|
||||
}
|
||||
"SteamEncryptionKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"SteamApiKey": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Storage": {
|
||||
|
||||
Reference in New Issue
Block a user