🔄 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:
Wanjohi
2025-06-02 09:22:18 +03:00
committed by GitHub
parent ae364f69bd
commit c0194ecef4
71 changed files with 8268 additions and 2134 deletions

View File

@@ -98,11 +98,13 @@
"sanitize-html": "^2.16.0", "sanitize-html": "^2.16.0",
"sharp": "^0.34.1", "sharp": "^0.34.1",
"steam-session": "*", "steam-session": "*",
"xml2js": "^0.6.2",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@types/xml2js": "^0.4.14",
"aws-iot-device-sdk-v2": "^1.21.1", "aws-iot-device-sdk-v2": "^1.21.1",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"mqtt": "^5.10.3", "mqtt": "^5.10.3",
@@ -224,6 +226,7 @@
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"body-scroll-lock-upgrade": "^1.1.0", "body-scroll-lock-upgrade": "^1.1.0",
"eventsource": "^3.0.5", "eventsource": "^3.0.5",
"fast-average-color": "9.5.0",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
"hono": "^4.7.4", "hono": "^4.7.4",
"modern-normalize": "^3.0.1", "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/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=="], "@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=="], "@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=="], "@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-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=="], "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/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=="], "@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/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/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=="], "@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-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=="], "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=="], "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/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=="], "@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=="], "@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-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/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=="], "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-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=="], "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=="], "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-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=="], "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=="], "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=="],

View File

@@ -1,10 +1,10 @@
import { bus } from "./bus"; import { bus } from "./bus";
import { auth } from "./auth"; import { auth } from "./auth";
import { domain } from "./dns"; import { domain } from "./dns";
import { secret } from "./secret";
import { cluster } from "./cluster"; import { cluster } from "./cluster";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
import { LibraryQueue } from "./steam"; import { libraryQueue } from "./steam";
import { secret, steamEncryptionKey } from "./secret";
export const apiService = new sst.aws.Service("Api", { export const apiService = new sst.aws.Service("Api", {
cluster, cluster,
@@ -14,8 +14,8 @@ export const apiService = new sst.aws.Service("Api", {
bus, bus,
auth, auth,
postgres, postgres,
LibraryQueue, libraryQueue,
steamEncryptionKey, secret.SteamApiKey,
secret.PolarSecret, secret.PolarSecret,
secret.PolarWebhookSecret, secret.PolarWebhookSecret,
secret.NestriFamilyMonthly, secret.NestriFamilyMonthly,

View File

@@ -1,8 +1,8 @@
import { bus } from "./bus"; import { bus } from "./bus";
import { domain } from "./dns"; import { domain } from "./dns";
import { secret } from "./secret";
import { cluster } from "./cluster"; import { cluster } from "./cluster";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
import { secret, steamEncryptionKey } from "./secret";
export const authService = new sst.aws.Service("Auth", { export const authService = new sst.aws.Service("Auth", {
cluster, cluster,
@@ -13,7 +13,6 @@ export const authService = new sst.aws.Service("Auth", {
bus, bus,
postgres, postgres,
secret.PolarSecret, secret.PolarSecret,
steamEncryptionKey,
secret.GithubClientID, secret.GithubClientID,
secret.DiscordClientID, secret.DiscordClientID,
secret.GithubClientSecret, secret.GithubClientSecret,

View File

@@ -1,8 +1,8 @@
import { vpc } from "./vpc"; import { vpc } from "./vpc";
import { storage } from "./storage"; import { secret } from "./secret";
// import { email } from "./email"; // import { email } from "./email";
import { storage } from "./storage";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
import { steamEncryptionKey } from "./secret";
export const bus = new sst.aws.Bus("Bus"); export const bus = new sst.aws.Bus("Bus");
@@ -11,16 +11,25 @@ bus.subscribe("Event", {
handler: "packages/functions/src/events/index.handler", handler: "packages/functions/src/events/index.handler",
link: [ link: [
// email, // email,
postgres, bus,
storage, storage,
steamEncryptionKey postgres,
secret.PolarSecret,
secret.SteamApiKey
], ],
timeout: "10 minutes", timeout: "10 minutes",
memory: "3002 MB",// For faster processing of large(r) images memory: "3002 MB",// For faster processing of large(r) images
permissions: [ permissions: [
{ {
actions: ["ses:SendEmail"], actions: ["ses:SendEmail","sqs:SendMessage"],
resources: ["*"], resources: ["*"],
}, },
], ],
// transform: {
// function: {
// deadLetterConfig: {
// targetArn: EventDlq.arn,
// },
// },
// },
}); });

View File

@@ -1,6 +1,5 @@
import { vpc } from "./vpc"; import { vpc } from "./vpc";
import { isPermanentStage } from "./stage"; 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 // 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", { 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", { new sst.x.DevCommand("Studio", {
link: [postgres, steamEncryptionKey], link: [postgres],
dev: { dev: {
command: "bun db:dev studio", command: "bun db:dev studio",
directory: "packages/core", directory: "packages/core",

View File

@@ -1,5 +1,6 @@
export const secret = { export const secret = {
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY), PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
SteamApiKey: new sst.Secret("SteamApiKey"),
GithubClientID: new sst.Secret("GithubClientID"), GithubClientID: new sst.Secret("GithubClientID"),
DiscordClientID: new sst.Secret("DiscordClientID"), DiscordClientID: new sst.Secret("DiscordClientID"),
PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"), PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"),
@@ -15,16 +16,3 @@ export const secret = {
}; };
export const allSecrets = Object.values(secret); 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,
},
);

View File

@@ -1,19 +1,29 @@
import { bus } from "./bus";
import { vpc } from "./vpc"; import { vpc } from "./vpc";
import { secret } from "./secret";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
import { steamEncryptionKey } from "./secret";
export const LibraryQueue = new sst.aws.Queue("LibraryQueue", { export const libraryDlq = new sst.aws.Queue("LibraryDLQ");
fifo: true,
visibilityTimeout: "10 minutes", export const libraryQueue = new sst.aws.Queue("LibraryQueue", {
dlq: libraryDlq.arn,
visibilityTimeout: "5 minutes",
}); });
LibraryQueue.subscribe({ libraryQueue.subscribe({
vpc, vpc,
timeout: "10 minutes",
memory: "3002 MB", memory: "3002 MB",
timeout: "5 minutes",
handler: "packages/functions/src/queues/library.handler", handler: "packages/functions/src/queues/library.handler",
link: [ link: [
bus,
postgres, postgres,
steamEncryptionKey secret.SteamApiKey
],
permissions: [
{
actions: ["sqs:SendMessage"],
resources: ["*"],
},
], ],
}); });

View 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";

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."category_type" ADD VALUE 'category';--> statement-breakpoint
ALTER TYPE "public"."category_type" ADD VALUE 'franchise';

View 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";

View 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[];

View File

@@ -0,0 +1 @@
ALTER TABLE "base_games" ALTER COLUMN "links" SET DATA TYPE json;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,41 @@
"when": 1747202158003, "when": 1747202158003,
"tag": "0019_charming_namorita", "tag": "0019_charming_namorita",
"breakpoints": true "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
} }
] ]
} }

View File

@@ -17,6 +17,7 @@
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"@types/xml2js": "^0.4.14",
"aws-iot-device-sdk-v2": "^1.21.1", "aws-iot-device-sdk-v2": "^1.21.1",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"mqtt": "^5.10.3", "mqtt": "^5.10.3",
@@ -42,6 +43,7 @@
"postgres": "^3.4.5", "postgres": "^3.4.5",
"sanitize-html": "^2.16.0", "sanitize-html": "^2.16.0",
"sharp": "^0.34.1", "sharp": "^0.34.1",
"steam-session": "*" "steam-session": "*",
"xml2js": "^0.6.2"
} }
} }

View File

@@ -1,6 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { User } from "../user"; import { User } from "../user";
import { Team } from "../team"; import { Steam } from "../steam";
import { Actor } from "../actor"; import { Actor } from "../actor";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { ErrorCodes, VisibleError } from "../error"; import { ErrorCodes, VisibleError } from "../error";
@@ -9,26 +9,26 @@ export namespace Account {
export const Info = export const Info =
User.Info User.Info
.extend({ .extend({
teams: Team.Info profiles: Steam.Info
.array() .array()
.openapi({ .openapi({
description: "The teams that this user is part of", description: "The Steam accounts this user owns",
example: [Examples.Team] example: [Examples.SteamAccount]
}) })
}) })
.openapi({ .openapi({
ref: "Account", ref: "Account",
description: "Represents an account's information stored on Nestri", 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 type Info = z.infer<typeof Info>;
export const list = async (): Promise<Info> => { export const list = async (): Promise<Info> => {
const [userResult, teamsResult] = const [userResult, steamResult] =
await Promise.allSettled([ await Promise.allSettled([
User.fromID(Actor.userID()), User.fromID(Actor.userID()),
Team.list() Steam.list()
]) ])
if (userResult.status === "rejected" || !userResult.value) if (userResult.status === "rejected" || !userResult.value)
@@ -40,7 +40,7 @@ export namespace Account {
return { return {
...userResult.value, ...userResult.value,
teams: teamsResult.status === "rejected" ? [] : teamsResult.value profiles: steamResult.status === "rejected" ? [] : steamResult.value
} }
} }

View File

@@ -12,10 +12,10 @@ export namespace Actor {
}; };
} }
export interface System { export interface Steam {
type: "system"; type: "steam";
properties: { properties: {
teamID: string; steamID: string;
}; };
} }
@@ -32,7 +32,6 @@ export namespace Actor {
properties: { properties: {
userID: string; userID: string;
steamID: string; steamID: string;
teamID: string;
}; };
} }
@@ -41,7 +40,7 @@ export namespace Actor {
properties: {}; properties: {};
} }
export type Info = User | Public | Token | System | Machine; export type Info = User | Public | Token | Machine | Steam;
export const Context = createContext<Info>(); export const Context = createContext<Info>();

View File

@@ -3,15 +3,18 @@ import { timestamps, utc } from "../drizzle/types";
import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core"; import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
export const CompatibilityEnum = pgEnum("compatibility", ["high", "mid", "low", "unknown"]) 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 = export const Size =
z.object({ z.object({
downloadSize: z.number().positive().int(), downloadSize: z.number().positive().int(),
sizeOnDisk: 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( export const baseGamesTable = pgTable(
"base_games", "base_games",
@@ -20,12 +23,13 @@ export const baseGamesTable = pgTable(
id: varchar("id", { length: 255 }) id: varchar("id", { length: 255 })
.primaryKey() .primaryKey()
.notNull(), .notNull(),
links: json("links").$type<Links>(),
slug: varchar("slug", { length: 255 }) slug: varchar("slug", { length: 255 })
.notNull(), .notNull(),
name: text("name").notNull(), name: text("name").notNull(),
description: text("description"),
releaseDate: utc("release_date").notNull(), releaseDate: utc("release_date").notNull(),
size: json("size").$type<Size>().notNull(), size: json("size").$type<Size>().notNull(),
description: text("description").notNull(),
primaryGenre: text("primary_genre"), primaryGenre: text("primary_genre"),
controllerSupport: ControllerEnum("controller_support").notNull(), controllerSupport: ControllerEnum("controller_support").notNull(),
compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"), compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"),

View File

@@ -1,13 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { eq, isNull, and } from "drizzle-orm"; import { eq, isNull, and } from "drizzle-orm";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; import { ImageTypeEnum } from "../images/images.sql";
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum } from "./base-game.sql"; import { createTransaction, useTransaction } from "../drizzle/transaction";
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
export namespace BaseGame { export namespace BaseGame {
export const Info = z.object({ export const Info = z.object({
@@ -31,7 +30,7 @@ export namespace BaseGame {
description: "The initial public release date of the game on Steam", description: "The initial public release date of the game on Steam",
example: Examples.BaseGame.releaseDate 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", description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
example: Examples.BaseGame.description 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", description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
example: Examples.BaseGame.score example: Examples.BaseGame.score
}), }),
links: Links
.nullable()
.openapi({
description: "The social links of this game",
example: Examples.BaseGame.links
}),
primaryGenre: z.string().nullable().openapi({ primaryGenre: z.string().nullable().openapi({
description: "The main category or genre that best represents the game's content and gameplay style", description: "The main category or genre that best represents the game's content and gameplay style",
example: Examples.BaseGame.primaryGenre example: Examples.BaseGame.primaryGenre
@@ -50,7 +55,7 @@ export namespace BaseGame {
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({ compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems", description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
example: Examples.BaseGame.compatibility example: Examples.BaseGame.compatibility
}) }),
}).openapi({ }).openapi({
ref: "BaseGame", ref: "BaseGame",
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata", 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 = { export const Events = {
New: createEvent( New: createEvent(
"new_game.added", "new_image.save",
z.object({ z.object({
appID: Info.shape.id, 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, Info,
(input) => (input) =>
createTransaction(async (tx) => { 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 await tx
.insert(baseGamesTable) .insert(baseGamesTable)
.values(input) .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 return input.id
}) })
) )
@@ -116,6 +150,7 @@ export namespace BaseGame {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
size: input.size, size: input.size,
links: input.links,
score: input.score, score: input.score,
description: input.description, description: input.description,
releaseDate: input.releaseDate, releaseDate: input.releaseDate,

View File

@@ -1,7 +1,8 @@
import { timestamps } from "../drizzle/types"; import { timestamps } from "../drizzle/types";
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core"; 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( export const categoriesTable = pgTable(
"categories", "categories",

View File

@@ -1,10 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { eq, isNull, and } from "drizzle-orm";
import { createSelectSchema } from "drizzle-zod"; import { createSelectSchema } from "drizzle-zod";
import { categoriesTable } from "./categories.sql"; import { categoriesTable } from "./categories.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction"; import { createTransaction, useTransaction } from "../drizzle/transaction";
import { eq, isNull, and } from "drizzle-orm";
export namespace Categories { export namespace Categories {
@@ -36,7 +36,16 @@ export namespace Categories {
genres: Category.array().openapi({ genres: Category.array().openapi({
description: "Primary classification categories that define the game's style and type of gameplay", description: "Primary classification categories that define the game's style and type of gameplay",
example: Examples.Categories.genres 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({ }).openapi({
ref: "Categories", ref: "Categories",
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification", description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
@@ -111,7 +120,9 @@ export namespace Categories {
tags: [], tags: [],
genres: [], genres: [],
publishers: [], publishers: [],
developers: [] developers: [],
categories: [],
franchises: []
}) })
} }
} }

View File

@@ -1,159 +1,172 @@
import type { import type {
Shot,
AppInfo, AppInfo,
GameTagsResponse,
SteamApiResponse,
GameDetailsResponse,
SteamAppDataResponse,
ImageInfo, ImageInfo,
ImageType, ImageType,
Shot SteamAccount,
GameTagsResponse,
GameDetailsResponse,
SteamAppDataResponse,
SteamOwnedGamesResponse,
SteamPlayerBansResponse,
SteamFriendsListResponse,
SteamPlayerSummaryResponse,
SteamStoreResponse,
} from "./types"; } from "./types";
import { z } from "zod"; import { z } from "zod";
import pLimit from 'p-limit';
import SteamID from "steamid";
import { fn } from "../utils"; import { fn } from "../utils";
import { Resource } from "sst";
import { Steam } from "./steam";
import { Utils } from "./utils"; import { Utils } from "./utils";
import SteamCommunity from "steamcommunity"; import { ImageTypeEnum } from "../images/images.sql";
import { Credentials } from "../credentials";
import type CSteamUser from "steamcommunity/classes/CSteamUser";
const requestLimit = pLimit(10); // max concurrent requests
export namespace Client { export namespace Client {
export const getUserLibrary = fn( export const getUserLibrary = fn(
Credentials.Info.shape.accessToken, z.string(),
async (accessToken) => async (steamID) =>
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`) 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( export const getFriendsList = fn(
Credentials.Info.shape.cookies, z.string(),
async (cookies): Promise<CSteamUser[]> => { async (steamID) =>
const community = new SteamCommunity(); await Utils.fetchApi<SteamFriendsListResponse>(`https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&relationship=friend`)
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);
}
); );
export const getUserInfo = fn( export const getUserInfo = fn(
Credentials.Info.pick({ cookies: true, steamID: true }), z.string().array(),
async (input) => async (steamIDs) => {
new Promise((resolve, reject) => { const [userInfo, banInfo, profileInfo] = await Promise.all([
const community = new SteamCommunity() Utils.fetchApi<SteamPlayerSummaryResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
community.setCookies(input.cookies); Utils.fetchApi<SteamPlayerBansResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
const steamID = new SteamID(input.steamID); Utils.fetchProfilesInfo(steamIDs)
community.getSteamUser(steamID, async (err, user) => { ])
if (err) {
reject(`Could not get steam user info: ${err.message}`) // 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 { } 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( export const getAppInfo = fn(
z.string(), z.string(),
async (appid) => { async (appid) => {
const [infoData, tagsData, details] = await Promise.all([ try {
const info = await Promise.all([
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`), Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
Utils.fetchApi<GameTagsResponse>("https://store.steampowered.com/actions/ajaxgetstoretags"), 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}}`),
Utils.fetchApi<GameDetailsResponse>(
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
),
]); ]);
const tags = tagsData.tags; const cmd = info[0].data[appid]
const game = infoData.data[appid]; const store = info[1].response.store_items[0]
// Guard against an empty string - When there are no genres, Steam returns an empty string
const genres = details.strGenres ? Utils.parseGenres(details.strGenres) : [];
const controllerTag = game.common.controller_support ? if (!cmd) {
Utils.createTag(`${Utils.capitalise(game.common.controller_support)} Controller Support`) : throw new Error(`App data not found for appid: ${appid}`)
Utils.createTag(`Unknown Controller Support`) }
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 tags = store.tagids
const appInfo: AppInfo = { .map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
genres, .filter((name): name is string => typeof name === 'string')
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
),
};
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(), z.string(),
async (appid) => { async (appid) => {
const [appData, details] = await Promise.all([ const [appData, details] = await Promise.all([
@@ -167,18 +180,49 @@ export namespace Client {
if (!game) throw new Error('Game info missing'); if (!game) throw new Error('Game info missing');
// 2. Prepare URLs // 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 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 return { screenshots, icon, ...assetUrls }
const baselineBuffer = await Utils.fetchBuffer(assetUrls.backdrop); }
)
// 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( 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) // 4. Score screenshots (or pick single)
const scores = const scores =
shots.length === 1 shots.length === 1
@@ -204,37 +248,69 @@ export namespace Client {
); );
} }
// 5b. Asset images const settled = await Promise.allSettled(tasks);
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)
settled settled
.filter(r => r.status === "rejected") .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) 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;
}
}
} }

View 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",
}
}

View File

@@ -284,16 +284,27 @@ export type GenreType = {
export interface AppInfo { export interface AppInfo {
name: string; name: string;
slug: string; slug: string;
images: {
logo: string;
backdrop: string;
poster: string;
banner: string;
screenshots: string[];
icon: string;
}
links: string[] | null;
score: number; score: number;
gameid: string; id: string;
releaseDate: Date; releaseDate: Date;
description: string; description: string | null;
compatibility: "low" | "mid" | "high" | "unknown"; compatibility: "low" | "mid" | "high" | "unknown";
controllerSupport: "partial" | "full" | "unknown"; controllerSupport: "partial" | "full" | "unknown";
primaryGenre: string | null; primaryGenre: string | null;
size: { downloadSize: number; sizeOnDisk: number }; size: { downloadSize: number; sizeOnDisk: number };
tags: Array<{ name: string; slug: string; type: "tag" }>; tags: Array<{ name: string; slug: string; type: "tag" }>;
genres: Array<{ type: "genre"; name: string; slug: string }>; 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" }>; developers: Array<{ name: string; slug: string; type: "developer" }>;
publishers: Array<{ name: string; slug: string; type: "publisher" }>; publishers: Array<{ name: string; slug: string; type: "publisher" }>;
} }
@@ -342,3 +353,248 @@ export interface RankedShot {
url: string; url: string;
score: number; 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;
}

View File

@@ -9,6 +9,7 @@ import type {
CompareResult, CompareResult,
RankedShot, RankedShot,
Shot, Shot,
ProfileInfo,
} from "./types"; } from "./types";
import crypto from 'crypto'; import crypto from 'crypto';
import pLimit from 'p-limit'; import pLimit from 'p-limit';
@@ -18,6 +19,7 @@ import { LRUCache } from 'lru-cache';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import { Agent as HttpAgent } from 'http'; import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https'; import { Agent as HttpsAgent } from 'https';
import { parseStringPromise } from "xml2js";
import sharp, { type Metadata } from 'sharp'; import sharp, { type Metadata } from 'sharp';
import AbortController from 'abort-controller'; import AbortController from 'abort-controller';
import fetch, { RequestInit } from 'node-fetch'; import fetch, { RequestInit } from 'node-fetch';
@@ -90,29 +92,21 @@ export namespace Utils {
// --- Optimized Box Art creation --- // --- Optimized Box Art creation ---
export async function createBoxArtBuffer( export async function createBoxArtBuffer(
assets: LibraryAssetsFull, logoUrl: string,
appid: number | string, backgroundUrl: string,
logoPercent = 0.9 logoPercent = 0.9
): Promise<Buffer> { ): 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([ const [bgBuf, logoBuf] = await Promise.all([
downloadLimit(() => downloadLimit(() =>
fetchBuffer(pick('library_hero')) fetchBuffer(backgroundUrl)
.catch(error => { .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`); throw new Error(`Failed to create box art: hero image unavailable`);
}), }),
), ),
downloadLimit(() => fetchBuffer(pick('library_logo')) downloadLimit(() => fetchBuffer(logoUrl)
.catch(error => { .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`); throw new Error(`Failed to create box art: logo image unavailable`);
}), }),
), ),
@@ -182,9 +176,11 @@ export namespace Utils {
export function createSlug(name: string): string { export function createSlug(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
.replace(/[^\w\s -]/g, "") .normalize("NFKD") // Normalize to decompose accented characters
.replace(/\s+/g, "-") .replace(/[^\p{L}\p{N}\s-]/gu, '') // Keep Unicode letters, numbers, spaces, and hyphens
.replace(/-+/g, "-") .replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
.trim(); .trim();
} }
@@ -328,16 +324,26 @@ export namespace Utils {
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" { export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
switch (type) { switch (type) {
case "1": case "1":
return "low"; return "high";
case "2": case "2":
return "mid"; return "mid";
case "3": case "3":
return "high"; return "low";
default: default:
return "unknown"; 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< export function mapGameTags<
T extends string = "tag" T extends string = "tag"
>( >(
@@ -353,6 +359,20 @@ export namespace Utils {
return result; 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 * Create a tag object with name, slug, and type
* @typeparam T Literal type of the `type` field (defaults to 'tag') * @typeparam T Literal type of the `type` field (defaults to 'tag')
@@ -380,17 +400,39 @@ export namespace Utils {
.toLowerCase(); .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) { export function getPublicDepotSizes(depots: AppDepots) {
const sum = { download: 0, size: 0 }; let download = 0;
for (const key in depots) { let size = 0;
for (const key of Object.keys(depots)) {
if (key === 'branches' || key === 'privatebranches') continue; if (key === 'branches' || key === 'privatebranches') continue;
const entry = depots[key] as DepotEntry; const entry = depots[key] as DepotEntry;
if ('manifests' in entry && entry.manifests.public) { if (!isDepotEntry(entry)) {
sum.download += Number(entry.manifests.public.download); continue;
sum.size += Number(entry.manifests.public.size);
} }
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;
} }
return { downloadSize: sum.download, sizeOnDisk: sum.size };
download += dl;
size += sz;
}
return { downloadSize: download, sizeOnDisk: size };
} }
export function parseGenres(str: string): GenreType[] { export function parseGenres(str: string): GenreType[] {
@@ -419,4 +461,64 @@ export namespace Utils {
return cleaned.trim() 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 }])
);
}
} }

View File

@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import "zod-openapi/extend"; import "zod-openapi/extend";
import { sql } from "drizzle-orm";
export namespace Common { export namespace Common {
export const IdDescription = `Unique object identifier. export const IdDescription = `Unique object identifier.

View File

@@ -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]
})
]
)

View File

@@ -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,
};
}
}

View File

@@ -1,5 +1,4 @@
import { Token } from "../utils"; import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
export const ulid = (name: string) => char(name, { length: 26 + 4 }); export const ulid = (name: string) => char(name, { length: 26 + 4 });
@@ -33,19 +32,6 @@ export const utc = (name: string) =>
// mode: "date" // 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 = { export const timestamps = {
timeCreated: utc("time_created").notNull().defaultNow(), timeCreated: utc("time_created").notNull().defaultNow(),
timeUpdated: utc("time_updated").notNull().defaultNow(), timeUpdated: utc("time_updated").notNull().defaultNow(),

View File

@@ -34,7 +34,7 @@ export namespace Examples {
export const SteamAccount = { export const SteamAccount = {
status: "online" as const, //offline,dnd(do not disturb) or playing 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) userID: User.id,// | null FK to User (null if not linked)
name: "JD The 65th", name: "JD The 65th",
username: "jdoe", username: "jdoe",
@@ -55,11 +55,10 @@ export namespace Examples {
export const Team = { export const Team = {
id: Id("team"),// Primary key id: Id("team"),// Primary key
name: "John's Console", // Team name (not null, unique) name: "John", // Team name (not null, unique)
ownerID: User.id, // FK to User who owns/created the team
slug: SteamAccount.profileUrl.toLowerCase(),
maxMembers: 3, maxMembers: 3,
inviteCode: "xwydjf", inviteCode: "xwydjf",
ownerSteamID: SteamAccount.id, // FK to User who owns/created the team
members: [SteamAccount] members: [SteamAccount]
}; };
@@ -152,6 +151,9 @@ export namespace Examples {
id: "1809540", id: "1809540",
slug: "nine-sols", slug: "nine-sols",
name: "Nine Sols", name: "Nine Sols",
links:[
"https://example.com"
],
controllerSupport: "full" as const, controllerSupport: "full" as const,
releaseDate: new Date("2024-05-29T06:53:24.000Z"), releaseDate: new Date("2024-05-29T06:53:24.000Z"),
compatibility: "high" as const, compatibility: "high" as const,
@@ -205,6 +207,13 @@ export namespace Examples {
slug: "redcandlegames" slug: "redcandlegames"
} }
], ],
franchises: [],
categories: [
{
name: "Partial Controller",
slug: "partial-controller"
}
]
} }
export const CommonImg = [ export const CommonImg = [

View File

@@ -8,9 +8,9 @@ import { friendTable } from "./friend.sql";
import { userTable } from "../user/user.sql"; import { userTable } from "../user/user.sql";
import { steamTable } from "../steam/steam.sql"; import { steamTable } from "../steam/steam.sql";
import { createSelectSchema } from "drizzle-zod"; import { createSelectSchema } from "drizzle-zod";
import { and, eq, isNull, sql } from "drizzle-orm";
import { groupBy, map, pipe, values } from "remeda"; import { groupBy, map, pipe, values } from "remeda";
import { ErrorCodes, VisibleError } from "../error"; import { ErrorCodes, VisibleError } from "../error";
import { and, eq, isNull, sql } from "drizzle-orm";
import { createTransaction, useTransaction } from "../drizzle/transaction"; import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Friend { export namespace Friend {

View File

@@ -23,20 +23,17 @@ export namespace Library {
"library.queue", "library.queue",
z.object({ z.object({
appID: z.number(), appID: z.number(),
lastPlayed: z.date(), lastPlayed: z.date().nullable(),
timeAcquired: z.date(),
totalPlaytime: z.number(), totalPlaytime: z.number(),
isFamilyShared: z.boolean(), }),
isFamilyShareable: z.boolean(),
}).array(),
), ),
}; };
export const add = fn( export const add = fn(
Info.partial({ ownerID: true }), Info.partial({ ownerSteamID: true }),
async (input) => async (input) =>
createTransaction(async (tx) => { createTransaction(async (tx) => {
const ownerSteamID = input.ownerID ?? Actor.steamID() const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
const result = const result =
await tx await tx
.select() .select()
@@ -44,7 +41,7 @@ export namespace Library {
.where( .where(
and( and(
eq(steamLibraryTable.baseGameID, input.baseGameID), eq(steamLibraryTable.baseGameID, input.baseGameID),
eq(steamLibraryTable.ownerID, ownerSteamID), eq(steamLibraryTable.ownerSteamID, ownerSteamID),
isNull(steamLibraryTable.timeDeleted) isNull(steamLibraryTable.timeDeleted)
) )
) )
@@ -57,21 +54,17 @@ export namespace Library {
await tx await tx
.insert(steamLibraryTable) .insert(steamLibraryTable)
.values({ .values({
ownerID: ownerSteamID, ownerSteamID: ownerSteamID,
baseGameID: input.baseGameID, baseGameID: input.baseGameID,
lastPlayed: input.lastPlayed, lastPlayed: input.lastPlayed,
totalPlaytime: input.totalPlaytime, totalPlaytime: input.totalPlaytime,
timeAcquired: input.timeAcquired,
isFamilyShared: input.isFamilyShared
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID], target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID],
set: { set: {
timeDeleted: null, timeDeleted: null,
lastPlayed: input.lastPlayed, lastPlayed: input.lastPlayed,
timeAcquired: input.timeAcquired,
totalPlaytime: input.totalPlaytime, totalPlaytime: input.totalPlaytime,
isFamilyShared: input.isFamilyShared
} }
}) })
@@ -87,7 +80,7 @@ export namespace Library {
.set({ timeDeleted: sql`now()` }) .set({ timeDeleted: sql`now()` })
.where( .where(
and( and(
eq(steamLibraryTable.ownerID, input.ownerID), eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
eq(steamLibraryTable.baseGameID, input.baseGameID), eq(steamLibraryTable.baseGameID, input.baseGameID),
) )
) )
@@ -105,7 +98,7 @@ export namespace Library {
.from(steamLibraryTable) .from(steamLibraryTable)
.where( .where(
and( and(
eq(steamLibraryTable.ownerID, Actor.steamID()), eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
isNull(steamLibraryTable.timeDeleted) isNull(steamLibraryTable.timeDeleted)
) )
) )

View File

@@ -1,7 +1,7 @@
import { timestamps, utc, } from "../drizzle/types";
import { steamTable } from "../steam/steam.sql"; import { steamTable } from "../steam/steam.sql";
import { timestamps, utc, } from "../drizzle/types";
import { baseGamesTable } from "../base-game/base-game.sql"; 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( export const steamLibraryTable = pgTable(
"game_libraries", "game_libraries",
@@ -12,20 +12,18 @@ export const steamLibraryTable = pgTable(
.references(() => baseGamesTable.id, { .references(() => baseGamesTable.id, {
onDelete: "cascade" onDelete: "cascade"
}), }),
ownerID: varchar("owner_id", { length: 255 }) ownerSteamID: varchar("owner_steam_id", { length: 255 })
.notNull() .notNull()
.references(() => steamTable.id, { .references(() => steamTable.id, {
onDelete: "cascade" onDelete: "cascade"
}), }),
timeAcquired: utc("time_acquired").notNull(), lastPlayed: utc("last_played"),
lastPlayed: utc("last_played").notNull(),
totalPlaytime: integer("total_playtime").notNull(), totalPlaytime: integer("total_playtime").notNull(),
isFamilyShared: boolean("is_family_shared").notNull()
}, },
(table) => [ (table) => [
primaryKey({ 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),
], ],
); );

View File

@@ -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
};
}
}

View File

@@ -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))
],
);

View File

@@ -1,15 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { fn } from "../utils"; import { fn } from "../utils";
import { Resource } from "sst";
import { Actor } from "../actor"; import { Actor } from "../actor";
import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { createEvent } from "../event";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { createEvent } from "../event";
import { eq, and, isNull, desc } from "drizzle-orm"; import { eq, and, isNull, desc } from "drizzle-orm";
import { steamTable, StatusEnum, Limitations } from "./steam.sql"; import { steamTable, StatusEnum, Limitations } from "./steam.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; import { createTransaction, useTransaction } from "../drizzle/transaction";
import { teamTable } from "../team/team.sql";
export namespace Steam { export namespace Steam {
export const Info = z export const Info = z
@@ -34,14 +31,6 @@ export namespace Steam {
description: "The steam community url of this account", description: "The steam community url of this account",
example: Examples.SteamAccount.profileUrl 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({ realName: z.string().nullable().openapi({
description: "The real name behind of this Steam account", description: "The real name behind of this Steam account",
example: Examples.SteamAccount.realName example: Examples.SteamAccount.realName
@@ -76,7 +65,7 @@ export namespace Steam {
"steam_account.created", "steam_account.created",
z.object({ z.object({
steamID: Info.shape.id, steamID: Info.shape.id,
userID: Info.shape.userID userID: Info.shape.userID,
}), }),
), ),
Updated: createEvent( Updated: createEvent(
@@ -94,9 +83,9 @@ export namespace Steam {
useUser: z.boolean(), useUser: z.boolean(),
}) })
.partial({ .partial({
useUser: true,
userID: true, userID: true,
status: true, status: true,
useUser: true,
lastSyncedAt: true lastSyncedAt: true
}), }),
(input) => (input) =>
@@ -107,8 +96,8 @@ export namespace Steam {
.from(steamTable) .from(steamTable)
.where( .where(
and( and(
eq(steamTable.id, input.id), isNull(steamTable.timeDeleted),
isNull(steamTable.timeDeleted) eq(steamTable.id, input.id)
) )
) )
.execute() .execute()
@@ -129,7 +118,6 @@ export namespace Steam {
avatarHash: input.avatarHash, avatarHash: input.avatarHash,
limitations: input.limitations, limitations: input.limitations,
status: input.status ?? "offline", status: input.status ?? "offline",
username: input.username ?? "unknown",
steamMemberSince: input.steamMemberSince, steamMemberSince: input.steamMemberSince,
lastSyncedAt: input.lastSyncedAt ?? Common.utc(), lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
}) })
@@ -151,8 +139,8 @@ export namespace Steam {
.partial({ .partial({
userID: true userID: true
}), }),
(input) => async (input) =>
useTransaction(async (tx) => { createTransaction(async (tx) => {
const userID = input.userID ?? Actor.userID() const userID = input.userID ?? Actor.userID()
await tx await tx
.update(steamTable) .update(steamTable)
@@ -160,6 +148,12 @@ export namespace Steam {
userID userID
}) })
.where(eq(steamTable.id, input.steamID)); .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( export const fromSteamID = fn(
z.string(), z.string(),
(steamID) => (steamID) =>
@@ -208,15 +222,14 @@ export namespace Steam {
return { return {
id: input.id, id: input.id,
name: input.name, name: input.name,
userID: input.userID,
status: input.status, status: input.status,
username: input.username, userID: input.userID,
realName: input.realName, realName: input.realName,
profileUrl: input.profileUrl,
avatarHash: input.avatarHash, avatarHash: input.avatarHash,
limitations: input.limitations, limitations: input.limitations,
lastSyncedAt: input.lastSyncedAt, lastSyncedAt: input.lastSyncedAt,
steamMemberSince: input.steamMemberSince, steamMemberSince: input.steamMemberSince,
profileUrl: input.profileUrl ? `https://steamcommunity.com/id/${input.profileUrl}` : null,
}; };
} }

View File

@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { userTable } from "../user/user.sql"; 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"; import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"]) export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
@@ -32,11 +32,7 @@ export const steamTable = pgTable(
steamMemberSince: utc("member_since").notNull(), steamMemberSince: utc("member_since").notNull(),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
profileUrl: varchar("profile_url", { length: 255 }), profileUrl: varchar("profile_url", { length: 255 }),
username: varchar("username", { length: 255 }).notNull(),
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(), avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
limitations: json("limitations").$type<Limitations>().notNull(), limitations: json("limitations").$type<Limitations>().notNull(),
}, }
(table) => [
unique("idx_steam_username").on(table.username)
]
); );

View File

@@ -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))
})),
)
}
}

View File

@@ -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)
]
);

View File

@@ -1,15 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { Polar } from "../polar/index"; import { Polar } from "../polar/index";
import { createID, fn } from "../utils"; import { createID, fn } from "../utils";
import { userTable } from "./user.sql"; import { userTable } from "./user.sql";
import { Examples } from "../examples"; 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 { ErrorCodes, VisibleError } from "../error";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace User { export namespace User {
export const Info = z export const Info = z

View File

@@ -1,6 +1,5 @@
export * from "./id"
export * from "./fn" export * from "./fn"
export * from "./log" export * from "./log"
export * from "./id"
export * from "./invite" export * from "./invite"
export * from "./token"
export * from "./helper" export * from "./helper"

View File

@@ -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');
});
}

View File

@@ -14,6 +14,9 @@
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
}, },
"exports": {
"./*": "./src/*.ts"
},
"dependencies": { "dependencies": {
"@actor-core/bun": "^0.8.0", "@actor-core/bun": "^0.8.0",
"@actor-core/file-system": "^0.8.0", "@actor-core/file-system": "^0.8.0",

View File

@@ -20,7 +20,7 @@ export namespace AccountApi {
schema: Result( schema: Result(
Account.Info.openapi({ Account.Info.openapi({
description: "User account information", description: "User account information",
example: { ...Examples.User, teams: [Examples.Team] } example: { ...Examples.User, profiles: [Examples.SteamAccount] }
}) })
), ),
}, },

View File

@@ -79,9 +79,9 @@ app.get(
}, },
TeamID: { TeamID: {
type: "apiKey", type: "apiKey",
description: "The team ID to use for this query", description: "The steam ID to use for this query",
in: "header", in: "header",
name: "x-nestri-team" name: "x-nestri-steam"
}, },
}, },
}, },

View File

@@ -1,22 +1,19 @@
import { z } from "zod"; import { z } from "zod";
import { Hono } from "hono"; import { Hono } from "hono";
import crypto from 'crypto';
import { Resource } from "sst"; import { Resource } from "sst";
import { streamSSE } from "hono/streaming";
import { Actor } from "@nestri/core/actor"; import { Actor } from "@nestri/core/actor";
import SteamCommunity from "steamcommunity";
import { describeRoute } from "hono-openapi"; 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 { Examples } from "@nestri/core/examples";
import { Steam } from "@nestri/core/steam/index"; 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 { Client } from "@nestri/core/client/index";
import { Friend } from "@nestri/core/friend/index";
import { Library } from "@nestri/core/library/index"; import { Library } from "@nestri/core/library/index";
import { chunkArray } from "@nestri/core/utils/helper"; import { chunkArray } from "@nestri/core/utils/helper";
import { ErrorResponses, validator, Result } from "./utils"; import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { Credentials } from "@nestri/core/credentials/index";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; 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({}); const sqs = new SQSClient({});
@@ -45,273 +42,217 @@ export namespace SteamApi {
429: ErrorResponses[429], 429: ErrorResponses[429],
} }
}), }),
notPublic,
async (c) => async (c) =>
c.json({ c.json({
data: await Steam.list() 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({ describeRoute({
tags: ["Steam"], tags: ["Steam"],
summary: "Login to Steam using QR code", summary: "Login to Steam",
description: "Login to Steam using a QR code sent using Server Sent Events", description: "Login to Steam in a popup",
responses: { responses: {
400: ErrorResponses[400], 400: ErrorResponses[400],
429: ErrorResponses[429], 429: ErrorResponses[429],
} }
}), }),
validator( validator(
"header", "param",
z.object({ z.object({
"accept": z.string() id: z.string().openapi({
.refine((v) => description: "ID of the user to login",
v.toLowerCase() example: Examples.User.id,
.includes("text/event-stream") }),
) }),
.openapi({
description: "Client must accept Server Sent Events",
example: "text/event-stream"
})
})
), ),
(c) => { async (c) => {
const currentUser = Actor.user() 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({ const params = new URLSearchParams({
event: 'status', 'openid.ns': 'http://specs.openid.net/auth/2.0',
data: JSON.stringify({ message: "connected to steam" }) 'openid.mode': 'checkid_setup',
}) 'openid.return_to': returnUrl,
'openid.realm': new URL(returnUrl).origin,
const challenge = await session.startWithQR(); 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
await stream.writeSSE({ 'user_id': user.id
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 () => { return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
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();
})
})
})
} }
) )
} }

View File

@@ -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
})
}
)
}

View File

@@ -2,9 +2,9 @@ import { Resource } from "sst";
import { subjects } from "../../subjects"; import { subjects } from "../../subjects";
import { Actor } from "@nestri/core/actor"; import { Actor } from "@nestri/core/actor";
import { type MiddlewareHandler } from "hono"; import { type MiddlewareHandler } from "hono";
import { Steam } from "@nestri/core/steam/index";
import { createClient } from "@openauthjs/openauth/client"; import { createClient } from "@openauthjs/openauth/client";
import { ErrorCodes, VisibleError } from "@nestri/core/error"; import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { Member } from "@nestri/core/member/index";
const client = createClient({ const client = createClient({
clientID: "api", clientID: "api",
@@ -44,19 +44,19 @@ export const auth: MiddlewareHandler = async (c, next) => {
} }
if (result.subject.type === "user") { if (result.subject.type === "user") {
const teamID = c.req.header("x-nestri-team"); const steamID = c.req.header("x-nestri-steam");
if (!teamID) { if (!steamID) {
return Actor.provide(result.subject.type, result.subject.properties, next); return Actor.provide(result.subject.type, result.subject.properties, next);
} }
const userID = result.subject.properties.userID const userID = result.subject.properties.userID
return Actor.provide( return Actor.provide(
"system", "steam",
{ {
teamID steamID
}, },
async () => { async () => {
const member = await Member.fromUserID(userID) const steamAcc = await Steam.confirmOwnerShip(userID)
if (!member || !member.userID) { if (!steamAcc) {
throw new VisibleError( throw new VisibleError(
"authentication", "authentication",
ErrorCodes.Authentication.UNAUTHORIZED, ErrorCodes.Authentication.UNAUTHORIZED,
@@ -66,9 +66,8 @@ export const auth: MiddlewareHandler = async (c, next) => {
return Actor.provide( return Actor.provide(
"member", "member",
{ {
steamID: member.steamID, steamID,
userID: member.userID, userID,
teamID: member.teamID
}, },
next) next)
}); });

View File

@@ -1,74 +1,33 @@
import "zod-openapi/extend";
import { Resource } from "sst"; import { Resource } from "sst";
import { bus } from "sst/aws/bus"; 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 { Steam } from "@nestri/core/steam/index";
import { Client } from "@nestri/core/client/index"; import { Client } from "@nestri/core/client/index";
import { Images } from "@nestri/core/images/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 { BaseGame } from "@nestri/core/base-game/index";
import { Credentials } from "@nestri/core/credentials/index"; import { Categories } from "@nestri/core/categories/index";
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({}); const s3 = new S3Client({});
export const handler = bus.subscriber( 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) => { async (event) => {
console.log(event.type, event.properties, event.metadata); console.log(event.type, event.properties, event.metadata);
switch (event.type) { switch (event.type) {
case "new_credentials.added": { case "new_image.save": {
const input = event.properties const input = event.properties;
const credentials = await Credentials.fromSteamID(input.steamID) const image = await Client.getImageInfo({ url: input.url, type: input.type });
if (credentials) {
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
session.refreshToken = credentials.refreshToken;
const cookies = await session.getWebCookies();
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))
}
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({ await Images.create({
type: image.type, type: image.type,
imageHash: image.hash, imageHash: image.hash,
@@ -105,13 +64,175 @@ export const handler = bus.subscriber(
) )
} }
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}`,
}) })
)) );
.filter(i => i.status === "rejected")
.forEach(r => console.warn("[createImages] failed:", (r 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; 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;
// }
} }
}, },
); );

View File

@@ -1,11 +1,14 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { SQSHandler } from "aws-lambda"; import { SQSHandler } from "aws-lambda";
import { Actor } from "@nestri/core/actor"; import { Actor } from "@nestri/core/actor";
import { Game } from "@nestri/core/game/index"; import { Game } from "@nestri/core/game/index";
import { Utils } from "@nestri/core/client/utils";
import { Client } from "@nestri/core/client/index"; import { Client } from "@nestri/core/client/index";
import { Library } from "@nestri/core/library/index"; import { Library } from "@nestri/core/library/index";
import { BaseGame } from "@nestri/core/base-game/index"; import { BaseGame } from "@nestri/core/base-game/index";
import { Categories } from "@nestri/core/categories/index"; import { Categories } from "@nestri/core/categories/index";
import { ImageTypeEnum } from "@nestri/core/images/images.sql";
export const handler: SQSHandler = async (event) => { export const handler: SQSHandler = async (event) => {
for (const record of event.Records) { for (const record of event.Records) {
@@ -17,21 +20,21 @@ export const handler: SQSHandler = async (event) => {
parsed.metadata.actor.type, parsed.metadata.actor.type,
parsed.metadata.actor.properties, parsed.metadata.actor.properties,
async () => { async () => {
const processGames = parsed.properties.map(async (game) => { const game = parsed.properties
// First check whether the base_game exists, if not get it // First check whether the base_game exists, if not get it
const appID = game.appID.toString() const appID = game.appID.toString();
const exists = await BaseGame.fromID(appID) const exists = await BaseGame.fromID(appID);
if (!exists) { if (!exists) {
const appInfo = await Client.getAppInfo(appID); const appInfo = await Client.getAppInfo(appID);
const tags = appInfo.tags;
await BaseGame.create({ await BaseGame.create({
id: appID, id: appID,
name: appInfo.name, name: appInfo.name,
size: appInfo.size, size: appInfo.size,
score: appInfo.score,
slug: appInfo.slug, slug: appInfo.slug,
links: appInfo.links,
score: appInfo.score,
description: appInfo.description, description: appInfo.description,
releaseDate: appInfo.releaseDate, releaseDate: appInfo.releaseDate,
primaryGenre: appInfo.primaryGenre, primaryGenre: appInfo.primaryGenre,
@@ -39,49 +42,81 @@ export const handler: SQSHandler = async (event) => {
controllerSupport: appInfo.controllerSupport, controllerSupport: appInfo.controllerSupport,
}) })
if (game.isFamilyShareable) { const allCategories = [...appInfo.tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers, ...appInfo.categories, ...appInfo.franchises]
tags.push(Utils.createTag("Family Share"))
}
const allCategories = [...tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers]
const uniqueCategories = Array.from( const uniqueCategories = Array.from(
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values() new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
); );
const settled = await Promise.allSettled( await Promise.all(
uniqueCategories.map(async (cat) => { uniqueCategories.map(async (cat) => {
//Use a single db transaction to get or set the category //Create category if it doesn't exist
await Categories.create({ await Categories.create({
type: cat.type, slug: cat.slug, name: cat.name type: cat.type, slug: cat.slug, name: cat.name
}) })
// Use a single db transaction to get or create the game //Create game if it doesn't exist
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type }) await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
}) })
) )
settled const imageUrls = appInfo.images
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason)); 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 // Add to user's library
await Library.add({ await Library.add({
baseGameID: appID, baseGameID: appID,
lastPlayed: game.lastPlayed, lastPlayed: game.lastPlayed ? new Date(game.lastPlayed) : null,
timeAcquired: game.timeAcquired,
totalPlaytime: game.totalPlaytime, totalPlaytime: game.totalPlaytime,
isFamilyShared: game.isFamilyShared,
}) })
}) })
const settled = await Promise.allSettled(processGames)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[processGames] failed:", (r as PromiseRejectedResult).reason));
}
)
} }
} }

View File

@@ -37,6 +37,7 @@
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"body-scroll-lock-upgrade": "^1.1.0", "body-scroll-lock-upgrade": "^1.1.0",
"eventsource": "^3.0.5", "eventsource": "^3.0.5",
"fast-average-color": "9.5.0",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
"hono": "^4.7.4", "hono": "^4.7.4",
"modern-normalize": "^3.0.1", "modern-normalize": "^3.0.1",

View File

@@ -9,13 +9,14 @@ import '@fontsource/geist-sans/900.css';
import { Text } from '@nestri/www/ui/text'; import { Text } from '@nestri/www/ui/text';
import { styled } from "@macaron-css/solid"; import { styled } from "@macaron-css/solid";
import { ZeroProvider } from './providers/zero'; 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 { OpenAuthProvider } from "@openauthjs/solid";
import { NotFound } from '@nestri/www/pages/not-found'; import { NotFound } from '@nestri/www/pages/not-found';
import { Navigate, Route, Router } from "@solidjs/router"; import { Navigate, Route, Router } from "@solidjs/router";
import { globalStyle, macaron$ } from "@macaron-css/core"; import { globalStyle, macaron$ } from "@macaron-css/core";
import { useStorage } from '@nestri/www/providers/account'; import { useStorage } from '@nestri/www/providers/account';
import { CreateTeamComponent } from '@nestri/www/pages/new';
import { Screen as FullScreen } from '@nestri/www/ui/layout'; import { Screen as FullScreen } from '@nestri/www/ui/layout';
import { darkClass, lightClass, theme } from '@nestri/www/ui/theme'; import { darkClass, lightClass, theme } from '@nestri/www/ui/theme';
import { AccountProvider, useAccount } from '@nestri/www/providers/account'; import { AccountProvider, useAccount } from '@nestri/www/providers/account';
@@ -97,7 +98,7 @@ export const App: Component = () => {
issuer={import.meta.env.VITE_AUTH_URL} issuer={import.meta.env.VITE_AUTH_URL}
clientID="web" clientID="web"
> >
<Root class={theme() === "light" ? lightClass : darkClass} id="styled"> <Root class={theme() === "light" ? lightClass : darkClass}>
<Router> <Router>
<Route <Route
path="*" path="*"
@@ -109,28 +110,28 @@ export const App: Component = () => {
</FullScreen> </FullScreen>
}> }>
<ZeroProvider> <ZeroProvider>
{/* props.children */}
{props.children} {props.children}
</ZeroProvider> </ZeroProvider>
</AccountProvider> </AccountProvider>
)} )}
> >
<Route path=":teamSlug">{TeamRoute}</Route> <Route path=":steamID">{SteamRoute}</Route>
<Route path="new" component={CreateTeamComponent} /> <Route path="profiles" component={ProfilesRoute} />
<Route path="new" component={NewProfile} />
<Route <Route
path="/" path="/"
component={() => { component={() => {
const account = useAccount(); const account = useAccount();
return ( return (
<Switch> <Switch>
{/**FIXME: Somehow this does not work when the user is in the "/new" page */} <Match when={account.current.profiles.length > 0}>
<Match when={account.current.teams.length > 0}>
<Navigate <Navigate
href={`/${( href={`/${(
account.current.teams.find( account.current.profiles.find(
(w) => w.id === storage.value.team, (w) => w.id === storage.value.steam,
) || account.current.teams[0] ) || account.current.profiles[0]
).slug ).id}`}
}`}
/> />
</Match> </Match>
<Match when={true}> <Match when={true}>
@@ -144,6 +145,6 @@ export const App: Component = () => {
</Route> </Route>
</Router> </Router>
</Root> </Root>
// </OpenAuthProvider> </OpenAuthProvider>
) )
} }

View 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,
}}
/>
);
}

View File

@@ -1,22 +1,17 @@
import { EventSource } from 'eventsource'
import { QRCode } from "../ui/custom-qr";
import { styled } from "@macaron-css/solid"; import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme"; 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 { useAccount } from "../providers/account";
import { createEffect, createSignal, onCleanup } from "solid-js";
import { Container, Screen as FullScreen } from "@nestri/www/ui/layout"; import { Container, Screen as FullScreen } from "@nestri/www/ui/layout";
import { useNavigate } from "@solidjs/router";
const Card = styled("div", { const Card = styled("div", {
base: { base: {
padding: `10px 20px`, gap: 40,
maxWidth: 360, maxWidth: 400,
width: "100%", width: "100%",
position: "relative",
display: "flex", display: "flex",
gap: 20, padding: `10px 20px`,
position: "relative",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
} }
@@ -48,33 +43,45 @@ const Logo = styled("svg", {
} }
}) })
const Title = styled("h2", { const Title = styled("h1", {
base: { 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, 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", { const Button = styled("button", {
base: { base: {
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "center",
alignItems: "center", alignItems: "center",
cursor: "not-allowed", cursor: "pointer",
padding: "10px 20px", padding: "0px 14px",
gap: theme.space["2"], gap: 10,
height: 48,
borderRadius: theme.space["2"], borderRadius: theme.space["2"],
backgroundColor: theme.color.background.d100, backgroundColor: theme.color.background.d100,
border: `1px solid ${theme.color.gray.d400}` 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", position: "relative",
display: "flex", display: "flex",
alignItems: "center" alignItems: "center"
} },
}) })
const ButtonIcon = styled("svg", { 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", { const CardWrapper = styled("div", {
base: { base: {
width: "100%", width: "100%",
@@ -338,7 +121,7 @@ const CardWrapper = styled("div", {
display: "flex", display: "flex",
alignItems: "start", alignItems: "start",
justifyContent: "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 nav = useNavigate();
const auth = useOpenAuth();
const account = useAccount(); const account = useAccount();
const [challengeUrl, setChallengeUrl] = createSignal<string | null>(null); const openPopup = () => {
const [timedOut, setTimedOut] = createSignal(false); const BASE_URL = import.meta.env.VITE_API_URL;
const [errorMsg, setErrorMsg] = createSignal<string | null>("");
const [loginSuccess, setLoginSuccess] = createSignal(false);
// bump this to reconnect const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
const [retryCount, setRetryCount] = createSignal(0); navigator.userAgent
let currentStream: EventSource | null = null;
const connectStream = async () => {
// clear previous state
setChallengeUrl(null);
setTimedOut(false);
setErrorMsg(null);
if (currentStream) {
currentStream.close();
}
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 const createDesktopWindow = (authUrl: string) => {
// stream.addEventListener("status", (e) => { const config = {
// // setStatus(JSON.parse(e.data).message); width: 700,
// }); height: 700,
features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no"
};
// challenge URL const top = window.top!.outerHeight / 2 + window.top!.screenY - (config.height / 2);
stream.addEventListener("challenge_url", (e) => { const left = window.top!.outerWidth / 2 + window.top!.screenX - (config.width / 2);
setChallengeUrl(JSON.parse(e.data).url);
});
// success return window.open(
stream.addEventListener("login_success", (e) => { authUrl,
setLoginSuccess(true); 'Steam Popup',
}); `width=${config.width},height=${config.height},left=${left},top=${top},${config.features}`
);
};
// timed out const monitorAuthWindow = (
stream.addEventListener("timed_out", (e) => { targetWindow: Window,
setTimedOut(true); { 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);
// server-side error const poll = setInterval(async () => {
stream.addEventListener("error", (e: any) => { if (targetWindow.closed) {
// Networklevel errors also fire here await cleanup();
try { resolve(); // Auth window closed by user
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 }, pollInterval);
stream.close()
});
// team slug async function cleanup() {
stream.addEventListener("team_slug", async (e) => { 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) await account.refresh(account.current.id)
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ } nav("/profiles")
nav(`/${JSON.parse(e.data).username}`) window.focus();
}
}); });
}; };
// kick it off on mount _and_ whenever retryCount changes
createEffect(() => { const authUrl = `${BASE_URL}/steam/popup/${account.current.id}`;
// read retryCount so effect re-runs const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl);
retryCount();
connectStream(); if (!newWindow) {
// ensure cleanup if component unmounts throw new Error('Failed to open authentication window');
onCleanup(() => currentStream?.close()); }
});
return monitorAuthWindow(newWindow);
}
return ( return (
<FullScreen> <FullScreen>
@@ -474,9 +269,28 @@ export function CreateTeamComponent() {
style={{ position: "fixed", height: "100%" }} > style={{ position: "fixed", height: "100%" }} >
<CardWrapper> <CardWrapper>
<Card > <Card >
<Title>Connect your game library to get started.</Title> <Title>Connect your game library to get started</Title>
<ButtonContainer> <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> <ButtonText>
GOG.com GOG.com
<Soon>Soon</Soon> <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" /> <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> </ButtonIcon>
</Button> </Button>
<Button> <Button comingSoon>
<ButtonText> <ButtonText>
Epic Games Epic Games
<Soon>Soon</Soon> <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" /> <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> </ButtonIcon>
</Button> </Button>
<Button> <Button comingSoon>
<ButtonText> <ButtonText>
Amazon Games Amazon Games
<Soon>Soon</Soon> <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> <Link target="_blank" href="https://discord.gg/6um5K6jrYj" >Help I can't connect my account</Link>
</Footer> </Footer>
</Card> </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> </CardWrapper>
</Container> </Container>
<LogoFooter > <LogoFooter >

View File

@@ -3,7 +3,7 @@ import { A } from "@solidjs/router";
import { Text } from "@nestri/www/ui/text"; import { Text } from "@nestri/www/ui/text";
import { styled } from "@macaron-css/solid"; import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme"; 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"; import { Screen as FullScreen, Container } from "@nestri/www/ui/layout";
const NotAllowedDesc = styled("div", { const NotAllowedDesc = styled("div", {

View 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>
)
}

View File

@@ -1,6 +1,6 @@
import { FullScreen, theme } from "@nestri/www/ui"; import { FullScreen, theme } from "@nestri/www/ui";
import { styled } from "@macaron-css/solid"; 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 { Modal } from "@nestri/www/ui/modal";
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js"; import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
import { Text } from "@nestri/www/ui/text" import { Text } from "@nestri/www/ui/text"

View 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>
)

View File

@@ -1,7 +1,7 @@
import { For } from "solid-js"; import { For } from "solid-js";
import { styled } from "@macaron-css/solid"; import { styled } from "@macaron-css/solid";
import { FullScreen, theme } from "@nestri/www/ui"; 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", { const Container = styled("div", {
base: { base: {

View File

@@ -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>
)

View File

@@ -9,7 +9,7 @@ function init() {
const [store, setStore] = makePersisted( const [store, setStore] = makePersisted(
createStore({ createStore({
account: "", account: "",
team: "", steam: "",
}) })
); );

View File

@@ -1,5 +1,5 @@
import { hc } from "hono/client"; import { hc } from "hono/client";
import { useTeam } from "./context"; import { useSteam } from "./context";
import { useOpenAuth } from "@openauthjs/solid"; import { useOpenAuth } from "@openauthjs/solid";
import { type app } from "@nestri/functions/api/index"; import { type app } from "@nestri/functions/api/index";
import { createInitializedContext } from "@nestri/www/common/context"; 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( export const { use: useApi, provider: ApiProvider } = createInitializedContext(
"ApiContext", "ApiContext",
() => { () => {
const team = useTeam(); const steam = useSteam();
const auth = useOpenAuth(); const auth = useOpenAuth();
const client = hc<typeof app>(import.meta.env.VITE_API_URL, { 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); input instanceof Request ? input : new Request(input, init);
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set("authorization", `Bearer ${await auth.access()}`); headers.set("authorization", `Bearer ${await auth.access()}`);
headers.set("x-nestri-team", team().id); headers.set("x-nestri-steam", steam().id);
return fetch( return fetch(
new Request(request, { new Request(request, {

View File

@@ -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"; import { Accessor, createContext, useContext } from "solid-js";
export const TeamContext = createContext<Accessor<Team.Info>>(); export const SteamContext = createContext<Accessor<Steam.Info>>();
export function useTeam() { export function useSteam() {
const context = useContext(TeamContext); const context = useContext(SteamContext);
if (!context) throw new Error("No team context"); if (!context) throw new Error("No steam context");
return context; return context;
} }

View File

@@ -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>
);
}

View File

@@ -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 { Limitations } from "@nestri/core/src/steam/steam.sql";
import type { ImageColor, ImageDimensions } from "@nestri/core/src/images/images.sql"; import type { ImageColor, ImageDimensions } from "@nestri/core/src/images/images.sql";
import { import {
@@ -6,7 +6,6 @@ import {
table, table,
number, number,
string, string,
boolean,
enumeration, enumeration,
createSchema, createSchema,
relationships, relationships,
@@ -38,7 +37,6 @@ const steam_accounts = table("steam_accounts")
name: string(), name: string(),
status: string(), status: string(),
user_id: string(), user_id: string(),
username: string(),
avatar_hash: string(), avatar_hash: string(),
member_since: number(), member_since: number(),
last_synced_at: number(), last_synced_at: number(),
@@ -53,10 +51,9 @@ const teams = table("teams")
.columns({ .columns({
id: string(), id: string(),
name: string(), name: string(),
slug: string(),
owner_id: string(),
invite_code: string(), invite_code: string(),
max_members: number(), max_members: number(),
owner_steam_id: string(),
...timestamps, ...timestamps,
}) })
.primaryKey("id"); .primaryKey("id");
@@ -83,7 +80,7 @@ const games = table("games")
.columns({ .columns({
base_game_id: string(), base_game_id: string(),
category_slug: string(), category_slug: string(),
type: enumeration<"tag" | "genre" | "publisher" | "developer">(), type: enumeration<"tag" | "genre" | "publisher" | "developer" | "categorie" | "franchise">(),
...timestamps ...timestamps
}) })
.primaryKey("category_slug", "base_game_id", "type") .primaryKey("category_slug", "base_game_id", "type")
@@ -93,9 +90,11 @@ const base_games = table("base_games")
id: string(), id: string(),
slug: string(), slug: string(),
name: string(), name: string(),
release_date: number(), // This should be an array, and i dunno how to include it here
size: json<Size>(), size: json<Size>(),
description: string(), release_date: number(),
links: json<Links>().optional(),
description: string().optional(),
primary_genre: string().optional(), primary_genre: string().optional(),
controller_support: enumeration<"full" | "partial" | "unknown">(), controller_support: enumeration<"full" | "partial" | "unknown">(),
compatibility: enumeration<"high" | "mid" | "low" | "unknown">(), compatibility: enumeration<"high" | "mid" | "low" | "unknown">(),
@@ -116,13 +115,11 @@ const categories = table("categories")
const game_libraries = table("game_libraries") const game_libraries = table("game_libraries")
.columns({ .columns({
base_game_id: string(), base_game_id: string(),
owner_id: string(),
time_acquired: number(),
last_played: number(),
total_playtime: number(), total_playtime: number(),
is_family_shared: boolean(), owner_steam_id: string(),
last_played: number().optional(),
...timestamps ...timestamps
}).primaryKey("base_game_id", "owner_id") }).primaryKey("base_game_id", "owner_steam_id")
const images = table("images") const images = table("images")
.columns({ .columns({
@@ -150,6 +147,11 @@ export const schema = createSchema({
destSchema: members, destSchema: members,
destField: ["steam_id"], destField: ["steam_id"],
}), }),
teams: r.many({
sourceField: ["id"],
destSchema: teams,
destField: ["owner_steam_id"],
}),
friends: r.many( friends: r.many(
{ {
sourceField: ["id"], sourceField: ["id"],
@@ -166,7 +168,7 @@ export const schema = createSchema({
{ {
sourceField: ["id"], sourceField: ["id"],
destSchema: game_libraries, destSchema: game_libraries,
destField: ["owner_id"], destField: ["owner_steam_id"],
}, },
{ {
sourceField: ["base_game_id"], sourceField: ["base_game_id"],
@@ -176,11 +178,6 @@ export const schema = createSchema({
), ),
})), })),
relationships(users, (r) => ({ relationships(users, (r) => ({
teams: r.many({
sourceField: ["id"],
destSchema: teams,
destField: ["owner_id"],
}),
members: r.many({ members: r.many({
sourceField: ["id"], sourceField: ["id"],
destSchema: members, destSchema: members,
@@ -194,8 +191,8 @@ export const schema = createSchema({
})), })),
relationships(teams, (r) => ({ relationships(teams, (r) => ({
owner: r.one({ owner: r.one({
sourceField: ["owner_id"], sourceField: ["owner_steam_id"],
destSchema: users, destSchema: steam_accounts,
destField: ["id"], destField: ["id"],
}), }),
members: r.many({ members: r.many({
@@ -229,7 +226,7 @@ export const schema = createSchema({
destField: ["base_game_id"], destField: ["base_game_id"],
}, },
{ {
sourceField: ["owner_id"], sourceField: ["owner_steam_id"],
destSchema: steam_accounts, destSchema: steam_accounts,
destField: ["id"], destField: ["id"],
} }
@@ -289,7 +286,7 @@ export const schema = createSchema({
relationships(game_libraries, (r) => ({ relationships(game_libraries, (r) => ({
owner: r.one({ owner: r.one({
sourceField: ["owner_id"], sourceField: ["owner_steam_id"],
destSchema: steam_accounts, destSchema: steam_accounts,
destField: ["id"] destField: ["id"]
}), }),

8
sst-env.d.ts vendored
View File

@@ -53,6 +53,10 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"LibraryDLQ": {
"type": "sst.aws.Queue"
"url": string
}
"LibraryQueue": { "LibraryQueue": {
"type": "sst.aws.Queue" "type": "sst.aws.Queue"
"url": string "url": string
@@ -90,8 +94,8 @@ declare module "sst" {
"endpoint": string "endpoint": string
"type": "sst.aws.Realtime" "type": "sst.aws.Realtime"
} }
"SteamEncryptionKey": { "SteamApiKey": {
"type": "random.index/randomString.RandomString" "type": "sst.sst.Secret"
"value": string "value": string
} }
"Storage": { "Storage": {