├── .github └── workflows │ └── images.yml ├── .gitignore ├── .prettierrc ├── LICENSE.txt ├── common ├── package.json └── types.ts ├── config.json ├── docker-compose.yml ├── ebs ├── Dockerfile ├── package.json ├── src │ ├── index.ts │ ├── modules │ │ ├── config │ │ │ ├── endpoints.ts │ │ │ └── index.ts │ │ ├── game │ │ │ ├── connection.ts │ │ │ ├── endpoints.ts │ │ │ ├── index.ts │ │ │ ├── messages.game.ts │ │ │ ├── messages.server.ts │ │ │ ├── messages.ts │ │ │ └── stresstest.ts │ │ ├── orders │ │ │ ├── endpoints │ │ │ │ ├── index.ts │ │ │ │ ├── private.ts │ │ │ │ └── public.ts │ │ │ ├── index.ts │ │ │ ├── prepurchase.ts │ │ │ └── transaction.ts │ │ ├── pishock.ts │ │ ├── twitch │ │ │ └── index.ts │ │ └── user │ │ │ ├── endpoints.ts │ │ │ └── index.ts │ ├── types.ts │ └── util │ │ ├── db.ts │ │ ├── jwt.ts │ │ ├── logger.ts │ │ ├── middleware.ts │ │ ├── pishock.ts │ │ ├── pubsub.ts │ │ └── twitch.ts └── tsconfig.json ├── frontend ├── package.json ├── tsconfig.json ├── webpack.common.ts ├── webpack.dev.ts ├── webpack.prod.ts └── www │ ├── css │ ├── alert.css │ ├── base.css │ ├── buttons.css │ ├── config.css │ ├── modals.css │ ├── onboarding.css │ ├── redeems.css │ └── spinner.css │ ├── html │ └── index.html │ ├── img │ └── bits.png │ └── src │ ├── index.ts │ ├── modules │ ├── auth.ts │ ├── modal │ │ ├── form.ts │ │ └── index.ts │ ├── pubsub.ts │ ├── redeems.ts │ └── transaction.ts │ └── util │ ├── config.ts │ ├── ebs.ts │ ├── jwt.ts │ ├── logger.ts │ └── twitch.ts ├── images ├── MODS.gif ├── airbladder.png ├── bigerm.png ├── chat.svg ├── ermfish_nametag.png ├── ermshark.png ├── flooding.png ├── gold.png ├── loading.png ├── oxygen_plant.png ├── pda.webp ├── pepegaphone.webp ├── pishock.webp ├── recaptcha.png ├── seamonkey.webp ├── seatruck_docking_module.png ├── seatruck_signal.png ├── signal.webp ├── thermal_plant_fragments.gif ├── tomfooleryphone.png ├── trashcan.png ├── tutel.png └── wysi.webp ├── logger ├── Dockerfile ├── package.json ├── src │ ├── index.ts │ ├── modules │ │ └── endpoints.ts │ └── util │ │ ├── db.ts │ │ ├── discord.ts │ │ └── stringify.ts └── tsconfig.json ├── package.json └── scripts ├── access_db.sh ├── attach_ebs.sh ├── run_ebs.sh └── sql └── init_db.sql /.github/workflows/images.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload images folder only 40 | path: 'images' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node template 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variable files 77 | .env 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | .env.local 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | ### WebStorm template 133 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 134 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 135 | 136 | # User-specific stuff 137 | .idea/**/workspace.xml 138 | .idea/**/tasks.xml 139 | .idea/**/usage.statistics.xml 140 | .idea/**/dictionaries 141 | .idea/**/shelf 142 | 143 | # AWS User-specific 144 | .idea/**/aws.xml 145 | 146 | # Generated files 147 | .idea/**/contentModel.xml 148 | 149 | # Sensitive or high-churn files 150 | .idea/**/dataSources/ 151 | .idea/**/dataSources.ids 152 | .idea/**/dataSources.local.xml 153 | .idea/**/sqlDataSources.xml 154 | .idea/**/dynamic.xml 155 | .idea/**/uiDesigner.xml 156 | .idea/**/dbnavigator.xml 157 | 158 | # Gradle 159 | .idea/**/gradle.xml 160 | .idea/**/libraries 161 | 162 | # Gradle and Maven with auto-import 163 | # When using Gradle or Maven with auto-import, you should exclude module files, 164 | # since they will be recreated, and may cause churn. Uncomment if using 165 | # auto-import. 166 | # .idea/artifacts 167 | # .idea/compiler.xml 168 | # .idea/jarRepositories.xml 169 | # .idea/modules.xml 170 | # .idea/*.iml 171 | # .idea/modules 172 | # *.iml 173 | # *.ipr 174 | 175 | # CMake 176 | cmake-build-*/ 177 | 178 | # Mongo Explorer plugin 179 | .idea/**/mongoSettings.xml 180 | 181 | # File-based project format 182 | *.iws 183 | 184 | # IntelliJ 185 | out/ 186 | 187 | # mpeltonen/sbt-idea plugin 188 | .idea_modules/ 189 | 190 | # JIRA plugin 191 | atlassian-ide-plugin.xml 192 | 193 | # Cursive Clojure plugin 194 | .idea/replstate.xml 195 | 196 | # SonarLint plugin 197 | .idea/sonarlint/ 198 | 199 | # Crashlytics plugin (for Android Studio and IntelliJ) 200 | com_crashlytics_export_strings.xml 201 | crashlytics.properties 202 | crashlytics-build.properties 203 | fabric.properties 204 | 205 | # Editor-based Rest Client 206 | .idea/httpRequests 207 | 208 | # Android studio 3.1+ serialized cache file 209 | .idea/caches/build_file_checksums.ser 210 | 211 | ### User-ignored files 212 | .idea 213 | package-lock.json 214 | _volumes 215 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "endOfLine": "lf", 7 | "htmlWhitespaceSensitivity": "css", 8 | "insertPragma": false, 9 | "jsxBracketSameLine": false, 10 | "jsxSingleQuote": false, 11 | "printWidth": 160, 12 | "proseWrap": "preserve", 13 | "quoteProps": "as-needed", 14 | "requirePragma": false, 15 | "semi": true, 16 | "singleQuote": false, 17 | "tabWidth": 4, 18 | "trailingComma": "es5", 19 | "useTabs": false 20 | } 21 | -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "typescript": "^5.4.5" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /common/types.ts: -------------------------------------------------------------------------------- 1 | export const enum LiteralTypes { 2 | String, 3 | Integer, 4 | Float, 5 | Boolean, 6 | Vector, 7 | } 8 | 9 | type EnumTypeName = string; 10 | type ParamType = LiteralTypes | EnumTypeName; 11 | 12 | export type Parameter = TextParam | NumericParam | BooleanParam | EnumParam | VectorParam; 13 | type ParameterBase = { 14 | name: string; 15 | title?: string; 16 | description?: string; 17 | type: ParamType; 18 | required?: boolean; 19 | }; 20 | 21 | export type TextParam = ParameterBase & { 22 | type: LiteralTypes.String; 23 | defaultValue?: string; 24 | minLength?: number; 25 | maxLength?: number; 26 | }; 27 | 28 | export type NumericParam = ParameterBase & { 29 | type: LiteralTypes.Integer | LiteralTypes.Float; 30 | defaultValue?: number; 31 | min?: number; 32 | max?: number; 33 | }; 34 | 35 | export type BooleanParam = ParameterBase & { 36 | type: LiteralTypes.Boolean; 37 | defaultValue?: boolean; 38 | }; 39 | 40 | export type EnumParam = ParameterBase & { 41 | type: EnumTypeName; 42 | defaultValue?: string; 43 | }; 44 | 45 | export type VectorParam = ParameterBase & { 46 | type: LiteralTypes.Vector; 47 | defaultValue?: [number, number, number]; 48 | min?: number[]; 49 | max?: number[]; 50 | }; 51 | 52 | export type Redeem = { 53 | id: string; 54 | title: string; 55 | description: string; 56 | args: Parameter[]; 57 | announce?: boolean; 58 | moderated?: boolean; 59 | 60 | image: string; 61 | price: number; 62 | sku: string; 63 | disabled?: boolean; 64 | hidden?: boolean; 65 | }; 66 | 67 | export type Config = { 68 | version: number; 69 | enums?: { [name: string]: string[] }; 70 | redeems?: { [id: string]: Redeem }; 71 | message?: string; 72 | }; 73 | 74 | export type Cart = { 75 | version: number; 76 | clientSession: string; // any string to disambiguate between multiple tabs 77 | id: string; 78 | sku: string; 79 | args: { [name: string]: any }; 80 | }; 81 | 82 | export type IdentifiableCart = Cart & { userId: string }; 83 | 84 | export type Transaction = { 85 | token: string; // JWT with TransactionToken (given by EBS on prepurchase) 86 | clientSession: string; // same session as in Cart 87 | receipt: string; // JWT with BitsTransactionPayload (coming from Twitch) 88 | }; 89 | export type DecodedTransaction = { 90 | token: TransactionTokenPayload; 91 | receipt: BitsTransactionPayload; 92 | }; 93 | 94 | export type TransactionToken = { 95 | id: string; 96 | time: number; // Unix millis 97 | user: { 98 | id: string; // user channel id 99 | }; 100 | product: { 101 | sku: string; 102 | cost: number; 103 | }; 104 | }; 105 | export type TransactionTokenPayload = { 106 | exp: number; 107 | data: TransactionToken; 108 | }; 109 | 110 | export type BitsTransactionPayload = { 111 | topic: string; 112 | exp: number; 113 | data: { 114 | transactionId: string; 115 | time: string; 116 | userId: string; 117 | product: { 118 | domainId: string; 119 | sku: string; 120 | displayName: string; 121 | cost: { 122 | amount: number; 123 | type: "bits"; 124 | }; 125 | }; 126 | }; 127 | }; 128 | 129 | export type PubSubMessage = { 130 | type: "config_refreshed" | "banned"; 131 | data: string; 132 | }; 133 | 134 | export type BannedData = { 135 | id: string; 136 | banned: boolean; 137 | }; 138 | 139 | export type LogMessage = { 140 | transactionToken: string | null; 141 | userIdInsecure: string | null; 142 | important: boolean; 143 | fields: { header: string; content: any }[]; 144 | }; 145 | 146 | export type User = { 147 | id: string; 148 | login?: string; 149 | displayName?: string; 150 | banned: boolean; 151 | }; 152 | 153 | export type OrderState = 154 | | "rejected" 155 | | "prepurchase" 156 | | "cancelled" 157 | | "paid" // waiting for game 158 | | "failed" // game failed/timed out 159 | | "succeeded"; 160 | 161 | export type Order = { 162 | id: string; 163 | userId: string; 164 | state: OrderState; 165 | cart: Cart; 166 | receipt?: string; 167 | result?: string; 168 | createdAt: number; 169 | updatedAt: number; 170 | }; 171 | 172 | export type Callback = (data: T) => void; 173 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 14, 3 | "enums": { 4 | "isekaiTechType": [ 5 | "Ermfish", 6 | "Anneel", 7 | "Tutel", 8 | "Ermshark", 9 | "Bladderfish", 10 | "Titanium", 11 | "Copper", 12 | "Silver", 13 | "Lead", 14 | "Gold", 15 | "Diamond", 16 | "Ruby" 17 | ], 18 | "aggressiveCreature": [ 19 | "Ermshark", 20 | "Brinewing", 21 | "Cryptosuchus", 22 | "[DISABLED] Squid Shark" 23 | ], 24 | "passiveCreature": [ 25 | "Ermfish", 26 | "Anneel", 27 | "Tutel", 28 | "Arctic Peeper", 29 | "Bladderfish", 30 | "Boomerang", 31 | "Spinner Fish", 32 | "Hoopfish", 33 | "Spinefish", 34 | "Pengling", 35 | "Pengwing", 36 | "Rockgrub", 37 | "Noot Fish", 38 | "Discus Fish", 39 | "Feather Fish" 40 | ], 41 | "commonItems": [ 42 | "Salt", 43 | "Quartz", 44 | "Titanium", 45 | "Copper", 46 | "Silver", 47 | "Lead", 48 | "Neuro fumo", 49 | "Evil fumo", 50 | "Ermfish", 51 | "Tutel", 52 | "Gold", 53 | "Table Coral", 54 | "Ribbon Plant", 55 | "Filtered Water", 56 | "Nutrient Block" 57 | ], 58 | "normalNoise": [ 59 | "Ermfish Ambient", 60 | "Ermfish Cook", 61 | "Ermfish Eat", 62 | "Anneel Ambient", 63 | "Anneel Hurt", 64 | "[DISABLED] Tutel Ambient", 65 | "Tutel Cook", 66 | "Tutel Eat", 67 | "Ermshark Ambient", 68 | "Ermshark Attack", 69 | "Ermshark Hurt" 70 | ], 71 | "trollNoise": [ 72 | "Someone tell Vedal", 73 | "Leviathan Roar", 74 | "Discord Disconnect", 75 | "Discord Join", 76 | "USB Disconnect", 77 | "USB Connect", 78 | "Grindr", 79 | "Filian Scream", 80 | "Program in C", 81 | "Stereo Knock" 82 | ] 83 | }, 84 | 85 | "redeems": { 86 | "redeem_hint": { 87 | "id": "redeem_hint", 88 | "title": "Show Message", 89 | "description": "Your next Twitch chat message will be displayed in-game.", 90 | "args": [], 91 | "announce": false, 92 | "image": "https://vedalai.github.io/swarm-control/chat.svg", 93 | "price": 69, 94 | "sku": "bits69" 95 | }, 96 | "spawn_passive": { 97 | "id": "spawn_passive", 98 | "title": "Spawn Passive Creature", 99 | "description": "Spawn a passive creature near the player.", 100 | "args": [ 101 | { 102 | "type": "passiveCreature", 103 | "required": true, 104 | "name": "creature", 105 | "title": "Creature", 106 | "description": "The type of creature to spawn." 107 | }, 108 | { 109 | "type": 3, 110 | "required": true, 111 | "defaultValue": false, 112 | "name": "behind", 113 | "title": "Spawn Behind", 114 | "description": "Spawn the creature behind the player." 115 | } 116 | ], 117 | "image": "https://vedalai.github.io/swarm-control/tutel.png", 118 | "price": 69, 119 | "sku": "bits69" 120 | }, 121 | "spawn_aggressive": { 122 | "id": "spawn_aggressive", 123 | "title": "Spawn Aggressive Creature", 124 | "description": "Spawn an aggressive creature near the player.", 125 | "args": [ 126 | { 127 | "type": "aggressiveCreature", 128 | "required": true, 129 | "name": "creature", 130 | "title": "Creature", 131 | "description": "The type of creature to spawn." 132 | }, 133 | { 134 | "type": 3, 135 | "required": true, 136 | "defaultValue": false, 137 | "name": "behind", 138 | "title": "Spawn Behind", 139 | "description": "Spawn the creature behind the player." 140 | } 141 | ], 142 | "image": "https://vedalai.github.io/swarm-control/ermshark.png", 143 | "price": 69, 144 | "sku": "bits69" 145 | }, 146 | "redeem_isekai": { 147 | "id": "redeem_isekai", 148 | "title": "Remove Entities", 149 | "description": "Remove a specific type of entity around the player. Does not include inventory/containers.", 150 | "args": [ 151 | { 152 | "type": "isekaiTechType", 153 | "required": true, 154 | "name": "creature", 155 | "title": "Type", 156 | "description": "Which type of entity to remove." 157 | } 158 | ], 159 | "image": "https://vedalai.github.io/swarm-control/trashcan.png", 160 | "price": 69, 161 | "sku": "bits69" 162 | }, 163 | "redeem_addsignal": { 164 | "id": "redeem_addsignal", 165 | "title": "Add Signal", 166 | "description": "Add a signal marker to the world. After redeeming, your next Twitch chat message will name the beacon.", 167 | "args": [ 168 | { 169 | "type": 4, 170 | "required": true, 171 | "name": "coords", 172 | "title": "Coordinates", 173 | "description": "Beacon coordinates" 174 | } 175 | ], 176 | "image": "https://vedalai.github.io/swarm-control/signal.webp", 177 | "price": 500, 178 | "sku": "bits500", 179 | "hidden": true 180 | }, 181 | "redeem_nametag": { 182 | "id": "redeem_nametag", 183 | "title": "Your Ermfish", 184 | "description": "Put your name on an ermfish!", 185 | "args": [], 186 | "image": "https://vedalai.github.io/swarm-control/ermfish_nametag.png", 187 | "price": 69, 188 | "sku": "bits69" 189 | }, 190 | "redeem_seamonkey_common": { 191 | "id": "redeem_seamonkey_common", 192 | "title": "Sea Monkey Delivery System", 193 | "description": "DISCLAIMER: Delivery not guaranteed. Streamer might ignore the sea monkey.", 194 | "args": [ 195 | { 196 | "type": "commonItems", 197 | "required": true, 198 | "name": "item", 199 | "title": "Item", 200 | "description": "Item you wish delivered." 201 | } 202 | ], 203 | "image": "https://vedalai.github.io/swarm-control/seamonkey.webp", 204 | "price": 750, 205 | "sku": "bits750", 206 | "hidden": true 207 | }, 208 | "redeem_save": { 209 | "id": "redeem_save", 210 | "title": "Save Game", 211 | "description": "Please don't spam this one or we will disable it. Has no effect if game was saved during the last minute.", 212 | "args": [], 213 | "image": "https://vedalai.github.io/swarm-control/loading.png", 214 | "price": 1000, 215 | "sku": "bits1000", 216 | "hidden": true 217 | }, 218 | "redeem_noise": { 219 | "id": "redeem_noise", 220 | "title": "Play Sound", 221 | "args": [ 222 | { 223 | "type": "normalNoise", 224 | "required": true, 225 | "name": "noise", 226 | "title": "Sound" 227 | } 228 | ], 229 | "announce": false, 230 | "image": "https://vedalai.github.io/swarm-control/pepegaphone.webp", 231 | "price": 1, 232 | "sku": "bits1" 233 | }, 234 | "redeem_trollnoise": { 235 | "id": "redeem_trollnoise", 236 | "title": "Play Troll Sound", 237 | "description": "IMPORTANT: Leviathan sound will not play if not underwater!", 238 | "args": [ 239 | { 240 | "type": "trollNoise", 241 | "required": true, 242 | "name": "noise", 243 | "title": "Sound" 244 | } 245 | ], 246 | "announce": false, 247 | "image": "https://vedalai.github.io/swarm-control/tomfooleryphone.png", 248 | "price": 69, 249 | "sku": "bits69" 250 | }, 251 | "redeem_airbladder": { 252 | "id": "redeem_airbladder", 253 | "title": "Floaties", 254 | "description": "Push player up towards the surface for 10 seconds. Does nothing if above water.", 255 | "args": [], 256 | "image": "https://vedalai.github.io/swarm-control/airbladder.png", 257 | "price": 2000, 258 | "sku": "bits2000", 259 | "hidden": true 260 | }, 261 | "redeem_o2plants": { 262 | "id": "redeem_o2plants", 263 | "title": "Deplete O2 Plants", 264 | "description": "Deplete all Oxygen Plants around the player.", 265 | "args": [], 266 | "image": "https://vedalai.github.io/swarm-control/oxygen_plant.png", 267 | "price": 500, 268 | "sku": "bits500", 269 | "hidden": true 270 | }, 271 | "redeem_detach": { 272 | "id": "redeem_detach", 273 | "title": "Bad Glue", 274 | "description": "Whoever designed the Sea Truck module connector should be fired...", 275 | "args": [], 276 | "image": "https://vedalai.github.io/swarm-control/seatruck_docking_module.png", 277 | "announce": false, 278 | "price": 2000, 279 | "sku": "bits2000", 280 | "hidden": true 281 | }, 282 | "redeem_midas": { 283 | "id": "redeem_midas", 284 | "title": "Midas' Touch", 285 | "description": "And all that glitters is gold...", 286 | "args": [], 287 | "image": "https://vedalai.github.io/swarm-control/gold.png", 288 | "price": 2500, 289 | "sku": "bits2500", 290 | "hidden": true 291 | }, 292 | "redeem_bigerm": { 293 | "id": "redeem_bigerm", 294 | "title": "Big Erm", 295 | "description": "The Erm Moon sends its regards.", 296 | "args": [], 297 | "image": "https://vedalai.github.io/swarm-control/bigerm.png", 298 | "price": 69, 299 | "sku": "bits69" 300 | }, 301 | "redeem_deadpixel": { 302 | "id": "redeem_deadpixel", 303 | "title": "Dead Pixel", 304 | "description": "The whole screen is your canvas... Lasts 5 minutes.", 305 | "args": [], 306 | "announce": false, 307 | "image": "https://vedalai.github.io/swarm-control/wysi.webp", 308 | "price": 1, 309 | "sku": "bits1" 310 | }, 311 | "redeem_flippda": { 312 | "id": "redeem_flippda", 313 | "title": "Flip PDA", 314 | "description": "Oops I forgot how to hold things...", 315 | "args": [], 316 | "image": "https://vedalai.github.io/swarm-control/pda.webp", 317 | "price": 1500, 318 | "sku": "bits1500", 319 | "hidden": true 320 | }, 321 | "redeem_floodbase": { 322 | "id": "redeem_floodbase", 323 | "title": "Flood Base", 324 | "description": "Flood all underwater bases.", 325 | "args": [], 326 | "image": "https://vedalai.github.io/swarm-control/flooding.png", 327 | "price": 2500, 328 | "sku": "bits2500", 329 | "hidden": true 330 | }, 331 | "redeem_hidecarpings": { 332 | "id": "redeem_hidecarpings", 333 | "title": "Hide Vehicle Signals", 334 | "description": "Hides vehicle and base signals for 1 minute.", 335 | "args": [], 336 | "image": "https://vedalai.github.io/swarm-control/seatruck_signal.png", 337 | "price": 1000, 338 | "sku": "bits1000", 339 | "hidden": true 340 | }, 341 | "redeem_scanrandomfragment": { 342 | "id": "redeem_scanrandomfragment", 343 | "title": "Scan Random Fragment", 344 | "description": "Auto-scans a random fragment, chosen by fair dice roll.", 345 | "args": [], 346 | "image": "https://vedalai.github.io/swarm-control/thermal_plant_fragments.gif", 347 | "price": 69, 348 | "sku": "bits69" 349 | }, 350 | "redeem_captcha": { 351 | "id": "redeem_captcha", 352 | "title": "Show Captcha", 353 | "description": "Force Vedal to prove he's human (or he dies).", 354 | "disabled": true, 355 | "args": [], 356 | "image": "https://vedalai.github.io/swarm-control/recaptcha.png", 357 | "price": 1500, 358 | "sku": "bits1500", 359 | "hidden": true 360 | }, 361 | "redeem_spin": { 362 | "id": "redeem_spin", 363 | "title": "MODS", 364 | "description": "Spin him around and make him dizzy.", 365 | "args": [], 366 | "image": "https://vedalai.github.io/swarm-control/MODS.gif", 367 | "price": 2000, 368 | "sku": "bits2000", 369 | "hidden": true 370 | }, 371 | "redeem_pishock": { 372 | "id": "redeem_pishock", 373 | "title": "Controlled Shock", 374 | "description": "The animatronic characters here do get a bit quirky at night", 375 | "args": [], 376 | "image": "https://vedalai.github.io/swarm-control/Erm.png", 377 | "price": 10000, 378 | "sku": "bits10000", 379 | "hidden": true 380 | } 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ebs: 3 | build: ebs 4 | restart: unless-stopped 5 | ports: 6 | - 8081:3000 7 | depends_on: 8 | - db 9 | - logger 10 | environment: 11 | HTTPS_METHOD: nohttps 12 | VIRTUAL_HOST: subnautica.vedal.ai 13 | MYSQL_HOST: db 14 | MYSQL_USER: ebs 15 | MYSQL_PASSWORD: ebs 16 | MYSQL_DATABASE: ebs 17 | LOGGER_HOST: logger 18 | 19 | logger: 20 | build: logger 21 | restart: unless-stopped 22 | ports: 23 | - 8082:3000 24 | depends_on: 25 | - db 26 | environment: 27 | HTTPS_METHOD: nohttps 28 | VIRTUAL_HOST: logger-subnautica.vedal.ai 29 | MYSQL_HOST: db 30 | MYSQL_USER: ebs 31 | MYSQL_PASSWORD: ebs 32 | MYSQL_DATABASE: ebs 33 | 34 | db: 35 | image: mysql:5.7 36 | restart: unless-stopped 37 | environment: 38 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 39 | MYSQL_DATABASE: ebs 40 | MYSQL_USER: ebs 41 | MYSQL_PASSWORD: ebs 42 | volumes: 43 | - ./_volumes/db:/var/lib/mysql 44 | - ./scripts/sql/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql 45 | 46 | nginx-proxy: 47 | image: nginxproxy/nginx-proxy:latest 48 | restart: unless-stopped 49 | ports: 50 | - 80:80 51 | volumes: 52 | - /var/run/docker.sock:/tmp/docker.sock:ro 53 | labels: 54 | - com.github.nginx-proxy.nginx 55 | environment: 56 | TRUST_DOWNSTREAM_PROXY: false 57 | -------------------------------------------------------------------------------- /ebs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | RUN npm run build 12 | 13 | EXPOSE 3000 14 | 15 | CMD ["node", "--enable-source-maps", "dist/index.js"] 16 | -------------------------------------------------------------------------------- /ebs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ebs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "npx tsx watch src/index.ts", 7 | "build": "esbuild --bundle --minify --platform=node --sourcemap=inline --outfile=dist/index.js src/index.ts" 8 | }, 9 | "dependencies": { 10 | "@octokit/webhooks": "^13.2.7", 11 | "@twurple/api": "^7.1.0", 12 | "@twurple/ebs-helper": "^7.1.0", 13 | "body-parser": "^1.20.2", 14 | "cors": "^2.8.5", 15 | "dotenv": "^16.4.5", 16 | "express": "^4.19.2", 17 | "express-ws": "^5.0.2", 18 | "fflate": "^0.8.2", 19 | "jsonwebtoken": "^9.0.2", 20 | "mysql2": "^3.10.0", 21 | "uuid": "^9.0.1" 22 | }, 23 | "devDependencies": { 24 | "@types/body-parser": "^1.19.5", 25 | "@types/cors": "^2.8.17", 26 | "@types/express": "^4.17.21", 27 | "@types/express-ws": "^3.0.4", 28 | "@types/jsonwebtoken": "^9.0.6", 29 | "@types/uuid": "^9.0.8", 30 | "esbuild": "^0.21.4", 31 | "tsx": "^4.11.2", 32 | "typescript": "^5.4.5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ebs/src/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import cors from "cors"; 3 | import express from "express"; 4 | import expressWs from "express-ws"; 5 | import bodyParser from "body-parser"; 6 | import { asyncCatch, privateApiAuth, publicApiAuth } from "./util/middleware"; 7 | import { initDb } from "./util/db"; 8 | import { sendToLogger } from "./util/logger"; 9 | 10 | const port = 3000; 11 | 12 | export const { app } = expressWs(express()); 13 | app.use(cors({ origin: "*" })); 14 | app.use(bodyParser.json()); 15 | app.use("/public/*", asyncCatch(publicApiAuth)); 16 | app.use("/private/*", privateApiAuth); 17 | 18 | app.get("/", (_, res) => { 19 | res.send("YOU ARE TRESPASSING ON PRIVATE PROPERTY YOU HAVE 5 SECONDS TO GET OUT OR I WILL CALL THE POLICE"); 20 | }); 21 | 22 | async function main() { 23 | await initDb(); 24 | 25 | app.listen(port, () => { 26 | console.log("Listening on port " + port); 27 | 28 | // add endpoints 29 | require("./modules/config"); 30 | require("./modules/game"); 31 | require("./modules/orders"); 32 | require("./modules/twitch"); 33 | require("./modules/user"); 34 | 35 | const { setIngame } = require("./modules/config"); 36 | 37 | process.stdin.resume(); 38 | 39 | ["exit", "SIGINT", "SIGTERM"].forEach((signal) => 40 | process.on(signal, () => { 41 | try { 42 | console.log("Exiting..."); 43 | 44 | setIngame(false); 45 | 46 | // Give the pubsub some time to broadcast the new config. 47 | setTimeout(() => { 48 | process.exit(0); 49 | }, 5_000); 50 | } catch (err) { 51 | console.error("Error while exiting:", err); 52 | process.exit(1); 53 | } 54 | }) 55 | ); 56 | 57 | ["unhandledRejection", "uncaughtException"].forEach((event) => 58 | process.on(event, (err) => { 59 | try { 60 | console.error("Unhandled error:", err); 61 | 62 | sendToLogger({ 63 | transactionToken: null, 64 | userIdInsecure: null, 65 | important: true, 66 | fields: [ 67 | { 68 | header: "Unhandled error/exception:", 69 | content: err?.stack ?? err, 70 | }, 71 | ], 72 | }).then(); 73 | 74 | // Exit and hope that Docker will auto-restart the container. 75 | process.kill(process.pid, "SIGTERM"); 76 | } catch (err) { 77 | console.error("Error while error handling, mhm:", err); 78 | process.exit(1); 79 | } 80 | }) 81 | ); 82 | }); 83 | } 84 | 85 | main().catch(console.error); 86 | -------------------------------------------------------------------------------- /ebs/src/modules/config/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { Webhooks } from "@octokit/webhooks"; 2 | import { getConfig, getRawConfigData, sendRefresh } from "."; 3 | import { app } from "../.."; 4 | import { asyncCatch } from "../../util/middleware"; 5 | 6 | const webhooks = new Webhooks({ 7 | secret: process.env.PRIVATE_API_KEY!, 8 | }); 9 | 10 | app.get( 11 | "/public/config", 12 | asyncCatch(async (req, res) => { 13 | const config = await getConfig(); 14 | res.send(JSON.stringify(config)); 15 | }) 16 | ); 17 | 18 | app.post( 19 | "/webhook/refresh", 20 | asyncCatch(async (req, res) => { 21 | // github webhook 22 | const signature = req.headers["x-hub-signature-256"] as string; 23 | const body = JSON.stringify(req.body); 24 | 25 | if (!(await webhooks.verify(body, signature))) { 26 | res.sendStatus(403); 27 | return; 28 | } 29 | 30 | // only refresh if the config.json file was changed 31 | if (req.body.commits.some((commit: any) => commit.modified.includes("config.json"))) { 32 | sendRefresh(); 33 | 34 | res.status(200).send("Config refreshed."); 35 | } else { 36 | res.status(200).send("Config not refreshed."); 37 | } 38 | }) 39 | ); 40 | 41 | app.get( 42 | "/private/refresh", 43 | asyncCatch(async (_, res) => { 44 | sendRefresh(); 45 | 46 | res.send(await getRawConfigData()); 47 | }) 48 | ); 49 | -------------------------------------------------------------------------------- /ebs/src/modules/config/index.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "common/types"; 2 | import { sendPubSubMessage } from "../../util/pubsub"; 3 | import { compressSync, strFromU8, strToU8 } from "fflate"; 4 | import { sendToLogger } from "../../util/logger"; 5 | 6 | let configData: Config | undefined; 7 | let activeConfig: Config | undefined; 8 | let ingameState = false; 9 | 10 | const apiURL = "https://api.github.com/repos/vedalai/swarm-control/contents/config.json"; 11 | const rawURL = "https://raw.githubusercontent.com/VedalAI/swarm-control/main/config.json"; 12 | 13 | require("./endpoints"); 14 | 15 | (async () => { 16 | const config = await getConfig(); 17 | await broadcastConfigRefresh(config); 18 | })().then(); 19 | 20 | async function fetchConfig(): Promise { 21 | let url = `${apiURL}?${Date.now()}`; 22 | 23 | try { 24 | const response = await fetch(url); 25 | const responseData = await response.json(); 26 | 27 | const data: Config = JSON.parse(atob(responseData.content)); 28 | 29 | return data; 30 | } catch (e: any) { 31 | console.error("Error when fetching config from api URL, falling back to raw URL"); 32 | console.error(e); 33 | 34 | sendToLogger({ 35 | transactionToken: null, 36 | userIdInsecure: null, 37 | important: true, 38 | fields: [ 39 | { 40 | header: "Error when fetching config from api URL, falling back to raw URL", 41 | content: e.toString(), 42 | }, 43 | ], 44 | }).then(); 45 | 46 | try { 47 | url = `${rawURL}?${Date.now()}`; 48 | const response = await fetch(url); 49 | const data: Config = await response.json(); 50 | 51 | return data; 52 | } catch (e: any) { 53 | console.error("Error when fetching config from raw URL, panic"); 54 | console.error(e); 55 | 56 | sendToLogger({ 57 | transactionToken: null, 58 | userIdInsecure: null, 59 | important: true, 60 | fields: [ 61 | { 62 | header: "Error when fetching config from raw URL, panic", 63 | content: e.toString(), 64 | }, 65 | ], 66 | }).then(); 67 | 68 | return { 69 | version: -1, 70 | message: "Error when fetching config from raw URL, panic", 71 | }; 72 | } 73 | } 74 | } 75 | 76 | export function isIngame() { 77 | return ingameState; 78 | } 79 | 80 | export async function setIngame(newIngame: boolean) { 81 | if (ingameState == newIngame) return; 82 | ingameState = newIngame; 83 | await setActiveConfig(await getRawConfigData()); 84 | } 85 | 86 | function processConfig(data: Config) { 87 | const config: Config = JSON.parse(JSON.stringify(data)); 88 | if (!ingameState) { 89 | Object.values(config.redeems!).forEach((redeem) => (redeem.disabled = true)); 90 | } 91 | return config; 92 | } 93 | 94 | export async function getConfig(): Promise { 95 | if (!configData) { 96 | await refreshConfig(); 97 | } 98 | 99 | return activeConfig!; 100 | } 101 | 102 | export async function getRawConfigData(): Promise { 103 | if (!configData) { 104 | await refreshConfig(); 105 | } 106 | 107 | return configData!; 108 | } 109 | 110 | export async function setActiveConfig(data: Config) { 111 | activeConfig = processConfig(data); 112 | await broadcastConfigRefresh(activeConfig); 113 | } 114 | 115 | export async function broadcastConfigRefresh(config: Config) { 116 | return sendPubSubMessage({ 117 | type: "config_refreshed", 118 | data: strFromU8(compressSync(strToU8(JSON.stringify(config))), true), 119 | }); 120 | } 121 | 122 | async function refreshConfig() { 123 | configData = await fetchConfig(); 124 | activeConfig = processConfig(configData); 125 | } 126 | 127 | export async function sendRefresh() { 128 | await refreshConfig(); 129 | console.log("Refreshed config, new config version is ", activeConfig!.version); 130 | await broadcastConfigRefresh(activeConfig!); 131 | } 132 | -------------------------------------------------------------------------------- /ebs/src/modules/game/connection.ts: -------------------------------------------------------------------------------- 1 | import { Message, MessageType, TwitchUser } from "./messages"; 2 | import { GameMessage, ResultMessage } from "./messages.game"; 3 | import * as ServerWS from "ws"; 4 | import { v4 as uuid } from "uuid"; 5 | import { CommandInvocationSource, RedeemMessage, ServerMessage } from "./messages.server"; 6 | import { Redeem, Order } from "common/types"; 7 | import { setIngame } from "../config"; 8 | 9 | const VERSION = "0.1.0"; 10 | 11 | type RedeemHandler = (redeem: Redeem, order: Order, user: TwitchUser) => Promise; 12 | type ResultHandler = (result: ResultMessage) => any; 13 | 14 | export class GameConnection { 15 | private handshake: boolean = false; 16 | private socket: ServerWS | null = null; 17 | private unsentQueue: ServerMessage[] = []; 18 | private outstandingRedeems: Map = new Map(); 19 | private resultHandlers: Map = new Map(); 20 | static resultWaitTimeout: number = 10000; 21 | private resendIntervalHandle?: number; 22 | private resendInterval = 500; 23 | private redeemHandlers: RedeemHandler[] = [GameConnection.prototype.sendRedeemToGame.bind(this)]; 24 | 25 | public isConnected() { 26 | return this.socket?.readyState == ServerWS.OPEN; 27 | } 28 | public setSocket(ws: ServerWS | null) { 29 | if (this.isConnected()) { 30 | this.socket!.close(); 31 | } 32 | this.socket = ws; 33 | if (!ws) { 34 | return; 35 | } 36 | console.log("Connected to game"); 37 | this.handshake = false; 38 | this.resendIntervalHandle = +setInterval(() => this.tryResendFromQueue(), this.resendInterval); 39 | ws.on("message", async (message) => { 40 | const msgText = message.toString(); 41 | let msg: GameMessage; 42 | try { 43 | msg = JSON.parse(msgText); 44 | } catch { 45 | console.error("Could not parse message" + msgText); 46 | return; 47 | } 48 | if (msg.messageType !== MessageType.Ping) console.log(`Got message ${JSON.stringify(msg)}`); 49 | this.processMessage(msg); 50 | }); 51 | ws.on("close", (code, reason) => { 52 | const reasonStr = reason ? `reason '${reason}'` : "no reason"; 53 | console.log(`Game socket closed with code ${code} and ${reasonStr}`); 54 | setIngame(false).then(); 55 | if (this.resendIntervalHandle) { 56 | clearInterval(this.resendIntervalHandle); 57 | } 58 | }); 59 | ws.on("error", (error) => { 60 | console.log(`Game socket error\n${error}`); 61 | }); 62 | } 63 | public processMessage(msg: GameMessage) { 64 | switch (msg.messageType) { 65 | case MessageType.Hello: 66 | this.handshake = true; 67 | const reply = { 68 | ...this.makeMessage(MessageType.HelloBack), 69 | allowed: msg.version == VERSION, 70 | }; 71 | this.sendMessage(reply) 72 | .then() 73 | .catch((e) => e); 74 | break; 75 | case MessageType.Ping: 76 | this.sendMessage(this.makeMessage(MessageType.Pong)) 77 | .then() 78 | .catch((e) => e); 79 | break; 80 | case MessageType.Result: 81 | if (!this.outstandingRedeems.has(msg.guid)) { 82 | console.error(`[${msg.guid}] Redeeming untracked ${msg.guid} (either unpaid or more than once)`); 83 | } 84 | const resolve = this.resultHandlers.get(msg.guid); 85 | if (!resolve) { 86 | // nobody cares about this redeem :( 87 | console.warn(`[${msg.guid}] No result handler for ${msg.guid}`); 88 | } else { 89 | resolve(msg); 90 | } 91 | this.outstandingRedeems.delete(msg.guid); 92 | this.resultHandlers.delete(msg.guid); 93 | break; 94 | case MessageType.IngameStateChanged: 95 | setIngame(msg.ingame).then(); 96 | break; 97 | default: 98 | this.logMessage(msg, `Unknown message type ${msg.messageType}`); 99 | break; 100 | } 101 | } 102 | 103 | public sendMessage(msg: ServerMessage): Promise { 104 | return new Promise((resolve, reject) => { 105 | if (!this.isConnected()) { 106 | const error = `Tried to send message without a connected socket`; 107 | this.msgSendError(msg, error); 108 | reject(error); 109 | return; 110 | } 111 | // allow pong for stress test 112 | if (!this.handshake && msg.messageType !== MessageType.Pong) { 113 | const error = `Tried to send message before handshake was complete`; 114 | this.msgSendError(msg, error); 115 | reject(error); 116 | return; 117 | } 118 | this.socket!.send(JSON.stringify(msg), { binary: false, fin: true }, (err) => { 119 | if (err) { 120 | this.msgSendError(msg, `${err.name}: ${err.message}`); 121 | reject(err); 122 | return; 123 | } 124 | if (msg.messageType !== MessageType.Pong) console.debug(`Sent message ${JSON.stringify(msg)}`); 125 | resolve(); 126 | }); 127 | }); 128 | } 129 | public makeMessage(type: MessageType, guid?: string): Message { 130 | return { 131 | messageType: type, 132 | guid: guid ?? uuid(), 133 | timestamp: Date.now(), 134 | }; 135 | } 136 | public redeem(redeem: Redeem, order: Order, user: TwitchUser): Promise { 137 | return Promise.race([ 138 | new Promise((_, reject) => 139 | setTimeout( 140 | () => reject(`Timed out waiting for result. The redeem may still go through later, contact AlexejheroDev if it doesn't.`), 141 | GameConnection.resultWaitTimeout 142 | ) 143 | ), 144 | new Promise((resolve, reject) => { 145 | this.runRedeemHandlers(redeem, order, user) 146 | .then(handlersResult => { 147 | if (handlersResult) { 148 | resolve(handlersResult); 149 | } else { 150 | reject("Unhandled redeem"); 151 | } 152 | }) 153 | .catch(e => reject(e)); 154 | }), 155 | ]); 156 | } 157 | 158 | private sendRedeemToGame(redeem: Redeem, order: Order, user: TwitchUser): Promise { 159 | return new Promise((resolve, reject) => { 160 | const msg: RedeemMessage = { 161 | ...this.makeMessage(MessageType.Redeem), 162 | guid: order.id, 163 | source: CommandInvocationSource.Swarm, 164 | command: redeem.id, 165 | title: redeem.title, 166 | announce: redeem.announce ?? true, 167 | args: order.cart.args, 168 | user, 169 | } as RedeemMessage; 170 | if (this.outstandingRedeems.has(msg.guid)) { 171 | reject(`Redeeming ${msg.guid} more than once`); 172 | return; 173 | } 174 | this.outstandingRedeems.set(msg.guid, msg); 175 | this.resultHandlers.set(msg.guid, resolve); 176 | 177 | this.sendMessage(msg) 178 | .then() 179 | .catch((e) => e); // will get queued to re-send later 180 | }); 181 | } 182 | 183 | private logMessage(msg: Message, message: string) { 184 | console.log(`[${msg.guid}] ${message}`); 185 | } 186 | 187 | private msgSendError(msg: ServerMessage, error: any) { 188 | this.unsentQueue.push(msg); 189 | console.error(`Error sending message\n\tMessage: ${JSON.stringify(msg)}\n\tError: ${error}`); 190 | console.log(`Position ${this.unsentQueue.length} in queue`); 191 | } 192 | 193 | private tryResendFromQueue() { 194 | const msg = this.unsentQueue.shift(); 195 | if (!msg) { 196 | //console.log("Nothing to re-send"); 197 | return; 198 | } 199 | 200 | console.log(`Re-sending message ${JSON.stringify(msg)}`); 201 | this.sendMessage(msg) 202 | .then() 203 | .catch((e) => e); 204 | } 205 | public stressTestSetHandshake(handshake: boolean) { 206 | this.handshake = handshake; 207 | } 208 | 209 | public getUnsent() { 210 | return Array.from(this.unsentQueue); 211 | } 212 | public getOutstanding() { 213 | return Array.from(this.outstandingRedeems.values()); 214 | } 215 | 216 | public onResult(guid: string, callback: ResultHandler) { 217 | const existing = this.resultHandlers.get(guid); 218 | if (existing) { 219 | this.resultHandlers.set(guid, (result: ResultMessage) => { 220 | existing(result); 221 | callback(result); 222 | }); 223 | } else { 224 | this.resultHandlers.set(guid, callback); 225 | } 226 | } 227 | 228 | public addRedeemHandler(handler: RedeemHandler) { 229 | this.redeemHandlers.push(handler); 230 | } 231 | 232 | private async runRedeemHandlers(redeem: Redeem, order: Order, user: TwitchUser) { 233 | for (let i = this.redeemHandlers.length - 1; i >= 0; i--) { 234 | const handler = this.redeemHandlers[i]; 235 | const res = await handler(redeem, order, user); 236 | if (!res) continue; 237 | 238 | return res; 239 | } 240 | return null; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /ebs/src/modules/game/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { app } from "../.."; 2 | import { connection } from "."; 3 | import { asyncCatch } from "../../util/middleware"; 4 | import { MessageType } from "./messages"; 5 | import { ResultMessage } from "./messages.game"; 6 | import { CommandInvocationSource, RedeemMessage } from "./messages.server"; 7 | import { isStressTesting, startStressTest, StressTestRequest } from "./stresstest"; 8 | 9 | app.post( 10 | "/private/redeem", 11 | asyncCatch(async (req, res) => { 12 | //console.log(req.body); 13 | const msg = { 14 | ...connection.makeMessage(MessageType.Redeem), 15 | source: CommandInvocationSource.Dev, 16 | ...req.body, 17 | } as RedeemMessage; 18 | if (!connection.isConnected()) { 19 | res.status(500).send("Not connected"); 20 | return; 21 | } 22 | 23 | try { 24 | await connection.sendMessage(msg); 25 | res.status(201).send(JSON.stringify(msg)); 26 | } catch (e) { 27 | res.status(500).send(e); 28 | } 29 | }) 30 | ); 31 | 32 | app.post("/private/setresult", (req, res) => { 33 | const msg = { 34 | ...connection.makeMessage(MessageType.Result), 35 | ...req.body, 36 | } as ResultMessage; 37 | if (!connection.isConnected()) { 38 | res.status(500).send("Not connected"); 39 | return; 40 | } 41 | 42 | connection.processMessage(msg); 43 | res.sendStatus(200); 44 | }); 45 | 46 | app.post("/private/stress", (req, res) => { 47 | if (!process.env.ENABLE_STRESS_TEST) { 48 | res.status(501).send("Disabled unless you set the ENABLE_STRESS_TEST env var\nREMEMBER TO REMOVE IT FROM PROD"); 49 | return; 50 | } 51 | 52 | if (isStressTesting()) { 53 | res.status(400).send("Already stress testing"); 54 | return; 55 | } 56 | 57 | if (!connection.isConnected()) { 58 | res.status(500).send("Not connected"); 59 | return; 60 | } 61 | 62 | const reqObj = req.body as StressTestRequest; 63 | if (reqObj.type === undefined || reqObj.duration === undefined || reqObj.interval === undefined) { 64 | res.status(400).send("Must have type, duration, and interval"); 65 | return; 66 | } 67 | console.log(reqObj); 68 | startStressTest(reqObj.type, reqObj.duration, reqObj.interval); 69 | res.sendStatus(200); 70 | }); 71 | 72 | app.get("/private/unsent", (req, res) => { 73 | const unsent = connection.getUnsent(); 74 | res.send(JSON.stringify(unsent)); 75 | }); 76 | 77 | app.get("/private/outstanding", (req, res) => { 78 | const outstanding = connection.getOutstanding(); 79 | res.send(JSON.stringify(outstanding)); 80 | }); 81 | -------------------------------------------------------------------------------- /ebs/src/modules/game/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from "../.."; 2 | import { GameConnection } from "./connection"; 3 | 4 | export let connection: GameConnection = new GameConnection(); 5 | 6 | app.ws("/private/socket", (ws) => { 7 | connection.setSocket(ws); 8 | }); 9 | 10 | require("./endpoints"); 11 | -------------------------------------------------------------------------------- /ebs/src/modules/game/messages.game.ts: -------------------------------------------------------------------------------- 1 | import { Message as MessageBase, MessageType } from "./messages"; 2 | 3 | export type GameMessage = 4 | | HelloMessage 5 | | PingMessage 6 | | LogMessage 7 | | ResultMessage 8 | | IngameStateChangedMessage; 9 | 10 | type GameMessageBase = MessageBase; // no extra properties 11 | export type HelloMessage = GameMessageBase & { 12 | messageType: MessageType.Hello; 13 | version: string; 14 | }; 15 | 16 | export type PingMessage = GameMessageBase & { 17 | messageType: MessageType.Ping; 18 | }; 19 | export type LogMessage = GameMessageBase & { 20 | messageType: MessageType.Log; 21 | important: boolean; 22 | message: string; 23 | }; 24 | 25 | export type ResultMessage = GameMessageBase & { 26 | messageType: MessageType.Result; 27 | success: boolean; 28 | message?: string; 29 | }; 30 | 31 | export type IngameStateChangedMessage = GameMessageBase & { 32 | messageType: MessageType.IngameStateChanged; 33 | // disable all redeems if false 34 | ingame: boolean; 35 | }; 36 | -------------------------------------------------------------------------------- /ebs/src/modules/game/messages.server.ts: -------------------------------------------------------------------------------- 1 | import { MessageType, Message, TwitchUser } from "./messages"; 2 | 3 | export type ServerMessage = Message & { 4 | /** User that triggered the message. e.g. for redeems, the user who bought the redeem. */ 5 | user?: TwitchUser; 6 | }; 7 | export type HelloBackMessage = ServerMessage & { 8 | messageType: MessageType.HelloBack; 9 | allowed: boolean; 10 | }; 11 | 12 | export type ConsoleInputMessage = ServerMessage & { 13 | messageType: MessageType.ConsoleInput; 14 | input: string; 15 | }; 16 | 17 | export enum CommandInvocationSource { 18 | Swarm, 19 | Dev, 20 | } 21 | export type RedeemMessage = ServerMessage & { 22 | messageType: MessageType.Redeem; 23 | source: CommandInvocationSource; 24 | command: string; 25 | title?: string; 26 | announce: boolean; 27 | args: any; 28 | }; 29 | -------------------------------------------------------------------------------- /ebs/src/modules/game/messages.ts: -------------------------------------------------------------------------------- 1 | export enum MessageType { 2 | // game to server 3 | Hello, 4 | Ping, 5 | Log, 6 | Result, 7 | IngameStateChanged, 8 | //CommandAvailabilityChanged, 9 | 10 | // server to game 11 | HelloBack, 12 | Pong, 13 | ConsoleInput, 14 | Redeem, 15 | } 16 | 17 | export type Guid = string; 18 | export type UnixTimestampUtc = number; 19 | 20 | export type Message = { 21 | messageType: MessageType; 22 | guid: Guid; 23 | timestamp: UnixTimestampUtc; 24 | }; 25 | 26 | export type TwitchUser = { 27 | /** Numeric user id */ 28 | id: string; 29 | /** Twitch username (login name) */ 30 | login: string; 31 | /** User's chosen display name. */ 32 | displayName: string; 33 | }; 34 | -------------------------------------------------------------------------------- /ebs/src/modules/game/stresstest.ts: -------------------------------------------------------------------------------- 1 | import { BitsTransactionPayload, Order } from "common/types"; 2 | import { connection } from "."; 3 | import { getConfig } from "../config"; 4 | import { signJWT } from "../../util/jwt"; 5 | import { AuthorizationPayload } from "../../types"; 6 | 7 | export enum StressTestType { 8 | GameSpawnQueue, 9 | GameUnsentQueue, 10 | TransactionSpam, 11 | } 12 | 13 | export type StressTestRequest = { 14 | type: StressTestType; 15 | duration: number; 16 | interval: number; 17 | } 18 | 19 | let inStressTest: boolean = false; 20 | 21 | export function isStressTesting(): boolean { 22 | return inStressTest; 23 | } 24 | 25 | let activeInterval: number; 26 | 27 | export async function startStressTest(type: StressTestType, duration: number, interval: number) { 28 | console.log(`Starting stress test ${StressTestType[type]} for ${duration}ms`); 29 | switch (type) { 30 | case StressTestType.GameSpawnQueue: 31 | activeInterval = +setInterval(() => sendSpawnRedeem().then(), interval); 32 | break; 33 | case StressTestType.GameUnsentQueue: 34 | connection.stressTestSetHandshake(false); 35 | const count = Math.floor(duration / interval); 36 | console.log(`Sending ${count} spawns...`); 37 | for (let i = 0; i < count; i++) { 38 | sendSpawnRedeem().then().catch(e => e); 39 | } 40 | break; 41 | case StressTestType.TransactionSpam: 42 | activeInterval = +setInterval(() => sendTransaction().then(), interval); 43 | break; 44 | } 45 | inStressTest = true; 46 | setTimeout(() => { 47 | inStressTest = false; 48 | if (type === StressTestType.GameUnsentQueue) 49 | connection.stressTestSetHandshake(true); 50 | return clearInterval(activeInterval); 51 | }, duration); 52 | } 53 | 54 | const redeemId: string = "spawn_passive"; 55 | const user = { 56 | id: "stress", 57 | login: "stresstest", 58 | displayName: "Stress Test", 59 | }; 60 | const order: Order = { 61 | id: "stress", 62 | state: "paid", 63 | userId: "stress", 64 | cart: { 65 | version: 1, 66 | clientSession: "stress", 67 | id: redeemId, 68 | sku: "bits1", 69 | args: { 70 | "creature": "0", 71 | "behind": false, 72 | } 73 | }, 74 | createdAt: Date.now(), 75 | updatedAt: Date.now(), 76 | }; 77 | async function sendSpawnRedeem() { 78 | const config = await getConfig(); 79 | const redeem = config.redeems![redeemId]; 80 | 81 | connection.redeem(redeem, order, user).then().catch(err => { 82 | console.log(err); 83 | }); 84 | } 85 | 86 | const invalidAuth: AuthorizationPayload = { 87 | channel_id: "stress", 88 | exp: Date.now() + 1000, 89 | is_unlinked: false, 90 | opaque_user_id: "Ustress", 91 | pubsub_perms: { 92 | listen: [], 93 | send: [], 94 | }, 95 | role: "viewer", 96 | }; 97 | const validAuth: AuthorizationPayload = { 98 | ...invalidAuth, 99 | user_id: "stress", 100 | } 101 | const signedValidJWT = signJWT(validAuth); 102 | const signedInvalidJWT = signJWT(invalidAuth); 103 | const invalidJWT = "trust me bro"; 104 | const variants = [ 105 | { 106 | name: "signed valid", 107 | token: signedValidJWT, 108 | shouldSucceed: true, 109 | error: "Valid JWT should have succeeded" 110 | }, 111 | { 112 | name: "signed invalid", 113 | token: signedInvalidJWT, 114 | shouldSucceed: false, 115 | error: "JWT without user ID should have failed" 116 | }, 117 | { 118 | name: "unsigned", 119 | token: invalidJWT, 120 | shouldSucceed: false, 121 | error: "Invalid bearer token should have failed" 122 | }, 123 | ]; 124 | 125 | async function sendTransaction() { 126 | // we have to go through the http flow because the handler is scuffed 127 | // and we need to stress the logging webhook as well 128 | const urlPrepurchase = "http://localhost:3000/public/prepurchase"; 129 | const urlTransaction = "http://localhost:3000/public/transaction"; 130 | 131 | const jwtChoice = Math.floor(3*Math.random()); 132 | const variant = variants[jwtChoice]; 133 | const token = variant.token; 134 | const auth = `Bearer ${token}`; 135 | console.log(`Prepurchasing with ${variant.name}`); 136 | 137 | const prepurchase = await fetch(urlPrepurchase, { 138 | method: "POST", 139 | headers: { 140 | "Authorization": auth, 141 | "Content-Type": "application/json", 142 | }, 143 | body: JSON.stringify(order.cart), 144 | }); 145 | let succeeded = prepurchase.ok; 146 | if (succeeded != variant.shouldSucceed) { 147 | console.error(`${variant.error} (prepurchase)`); 148 | } 149 | const transactionId = await prepurchase.text(); 150 | 151 | const receipt: BitsTransactionPayload = { 152 | exp: Date.now() + 1000, 153 | topic: "topic", 154 | data: { 155 | transactionId, 156 | product: { 157 | sku: "bits1", 158 | cost: { 159 | amount: 1, 160 | type: "bits" 161 | }, 162 | displayName: "", 163 | domainId: "" 164 | }, 165 | userId: "stress", 166 | time: "time" 167 | } 168 | }; 169 | 170 | console.log(`Sending transaction (${variant.name})`); 171 | const transaction = await fetch(urlTransaction, { 172 | method: "POST", 173 | headers: { 174 | "Authorization": auth, 175 | "Content-Type": "application/json", 176 | }, 177 | body: JSON.stringify({ 178 | token: transactionId, 179 | receipt: signJWT(receipt), 180 | }), 181 | }); 182 | succeeded = transaction.ok; 183 | if (succeeded != variant.shouldSucceed) { 184 | console.error(`${variant.error} (transaction)`); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /ebs/src/modules/orders/endpoints/index.ts: -------------------------------------------------------------------------------- 1 | require("./public"); 2 | require("./private"); 3 | -------------------------------------------------------------------------------- /ebs/src/modules/orders/endpoints/private.ts: -------------------------------------------------------------------------------- 1 | import { app } from "../../.."; 2 | import { getOrder } from "../../../util/db"; 3 | import { asyncCatch } from "../../../util/middleware"; 4 | 5 | app.get( 6 | "/private/order/:guid", 7 | asyncCatch(async (req, res) => { 8 | res.json(await getOrder(req.params["guid"])); 9 | }) 10 | ); 11 | -------------------------------------------------------------------------------- /ebs/src/modules/orders/endpoints/public.ts: -------------------------------------------------------------------------------- 1 | import { Cart, LogMessage, Transaction, Order, TransactionTokenPayload, TransactionToken } from "common/types"; 2 | import { app } from "../../.."; 3 | import { getConfig } from "../../config"; 4 | import { createOrder, getOrder, saveOrder, updateUserTwitchInfo } from "../../../util/db"; 5 | import { sendToLogger } from "../../../util/logger"; 6 | import { connection } from "../../game"; 7 | import { TwitchUser } from "../../game/messages"; 8 | import { asyncCatch } from "../../../util/middleware"; 9 | import { validatePrepurchase } from "../prepurchase"; 10 | import { setUserBanned } from "../../user"; 11 | import { decodeJWTPayloads, getAndCheckOrder, jwtExpirySeconds, makeTransactionToken, processRedeemResult } from "../transaction"; 12 | import { parseJWT, signJWT, verifyJWT } from "../../../util/jwt"; 13 | import { HttpResult } from "../../../types"; 14 | 15 | const usedBitsTransactionIds: Set = new Set(); 16 | 17 | app.post( 18 | "/public/prepurchase", 19 | asyncCatch(async (req, res) => { 20 | const cart = req.body as Cart; 21 | const userId = req.user.id; 22 | 23 | const logContext: LogMessage = { 24 | transactionToken: null, 25 | userIdInsecure: userId, 26 | important: false, 27 | fields: [{ header: "", content: "" }], 28 | }; 29 | const logMessage = logContext.fields[0]; 30 | 31 | if (!connection.isConnected()) { 32 | res.status(502).send("Game connection is not available"); 33 | return; 34 | } 35 | 36 | let order: Order; 37 | let validationError: HttpResult | null; 38 | let fail = "register"; 39 | try { 40 | order = await createOrder(userId, cart); 41 | fail = "validate"; 42 | validationError = await validatePrepurchase(order, req.user); 43 | } catch (e: any) { 44 | logContext.important = true; 45 | logMessage.header = `Failed to ${fail} prepurchase`; 46 | logMessage.content = { cart, userId, error: e }; 47 | sendToLogger(logContext).then(); 48 | 49 | res.status(500).send("Failed to register prepurchase"); 50 | return; 51 | } 52 | 53 | if (validationError) { 54 | logMessage.header = validationError.logHeaderOverride ?? validationError.message; 55 | logMessage.content = validationError.logContents ?? { order: order.id }; 56 | sendToLogger(logContext).then(); 57 | res.status(validationError.status).send(validationError.message); 58 | order.result = validationError.message; 59 | await saveOrder(order); 60 | return; 61 | } 62 | 63 | order.state = "prepurchase"; 64 | await saveOrder(order); 65 | 66 | let transactionToken: TransactionToken; 67 | try { 68 | transactionToken = makeTransactionToken(order, req.user); 69 | } catch (e: any) { 70 | logContext.important = true; 71 | logMessage.header = `Failed to create transaction token`; 72 | logMessage.content = { cart, userId, error: e }; 73 | sendToLogger(logContext).then(); 74 | res.status(500).send("Internal configuration error"); 75 | return; 76 | } 77 | const transactionTokenJWT = signJWT({ data: transactionToken }, { expiresIn: jwtExpirySeconds }); 78 | 79 | logMessage.header = "Created prepurchase"; 80 | logMessage.content = { orderId: order.id, token: transactionTokenJWT }; 81 | sendToLogger(logContext).then(); 82 | res.status(200).send(transactionTokenJWT); 83 | return; 84 | }) 85 | ); 86 | 87 | app.post( 88 | "/public/transaction", 89 | asyncCatch(async (req, res) => { 90 | const transaction = req.body as Transaction; 91 | 92 | const logContext: LogMessage = { 93 | transactionToken: null, 94 | userIdInsecure: req.user.id, 95 | important: true, 96 | fields: [{ header: "", content: transaction }], 97 | }; 98 | const logMessage = logContext.fields[0]; 99 | 100 | const decoded = decodeJWTPayloads(transaction); 101 | if ("status" in decoded) { 102 | logMessage.header = decoded.logHeaderOverride ?? decoded.message; 103 | logMessage.content = decoded.logContents ?? { transaction }; 104 | if (decoded.status === 403) { 105 | setUserBanned(req.user, true); 106 | } 107 | sendToLogger(logContext).then(); 108 | res.status(decoded.status).send(decoded.message); 109 | return; 110 | } 111 | logContext.transactionToken = decoded.token.data.id; 112 | 113 | let order: Order; 114 | try { 115 | const orderMaybe = await getAndCheckOrder(transaction, decoded, req.user); 116 | if ("status" in orderMaybe) { 117 | const checkRes = orderMaybe; 118 | logMessage.header = checkRes.logHeaderOverride ?? checkRes.message; 119 | logMessage.content = checkRes.logContents ?? { transaction }; 120 | if (checkRes.status === 403) { 121 | setUserBanned(req.user, true); 122 | } 123 | sendToLogger(logContext).then(); 124 | res.status(orderMaybe.status).send(orderMaybe.message); 125 | return; 126 | } else { 127 | order = orderMaybe; 128 | } 129 | } catch (e: any) { 130 | logContext.important = true; 131 | logMessage.header = "Failed to get order"; 132 | logMessage.content = { 133 | transaction: transaction, 134 | error: e, 135 | }; 136 | sendToLogger(logContext).then(); 137 | res.status(500).send("Failed to get transaction"); 138 | return; 139 | } 140 | 141 | const bitsTransaction = decoded.receipt.data.transactionId; 142 | if (usedBitsTransactionIds.has(bitsTransaction)) { 143 | // happens if there are X extension tabs that are all open on the twitch bits modal 144 | // twitch broadcasts onTransactionComplete to all of them and the client ends up 145 | // sending X requests for each completed transaction (where all but 1 will obviously be duplicates) 146 | // we don't want to auto-ban people just for having multiple tabs open 147 | // but it's still obviously not ideal behaviour 148 | if (order.cart.clientSession === transaction.clientSession) { 149 | // if it's not coming from a different tab, you're obviously trying to replay 150 | logMessage.content = { 151 | order: order.id, 152 | bitsTransaction: decoded.receipt.data, 153 | }; 154 | logMessage.header = "Transaction replay"; 155 | sendToLogger(logContext).then(); 156 | } 157 | // unfortunately, in this case any other tab(s) awaiting twitchUseBits will still lose their purchase 158 | // so we do our best to not allow multiple active prepurchases in the first place 159 | res.status(401).send("Invalid transaction"); 160 | return; 161 | } 162 | usedBitsTransactionIds.add(bitsTransaction); 163 | 164 | if (order.userId != req.user.id) { 165 | // paying for somebody else, how generous 166 | logContext.important = true; 167 | logMessage.header = "Mismatched user ID"; 168 | logMessage.content = { 169 | user: req.user, 170 | order: order.id, 171 | transaction, 172 | }; 173 | sendToLogger(logContext).then(); 174 | } 175 | 176 | const currentConfig = await getConfig(); 177 | if (order.cart.version != currentConfig.version) { 178 | logContext.important = true; 179 | logMessage.header = "Mismatched config version"; 180 | logMessage.content = { 181 | config: currentConfig.version, 182 | order, 183 | transaction, 184 | }; 185 | sendToLogger(logContext).then(); 186 | } 187 | 188 | console.log(transaction); 189 | console.log(decoded); 190 | console.log(order.cart); 191 | 192 | const redeem = currentConfig.redeems?.[order.cart.id]; 193 | if (!redeem) { 194 | logContext.important = true; 195 | logMessage.header = "Redeem not found"; 196 | logMessage.content = { 197 | configVersion: currentConfig.version, 198 | order, 199 | }; 200 | sendToLogger(logContext).then(); 201 | res.status(500).send("Redeem could not be found"); 202 | return; 203 | } 204 | 205 | let userInfo: TwitchUser = { 206 | id: req.user.id, 207 | login: req.user.login ?? req.user.id, 208 | displayName: req.user.displayName ?? req.user.id, 209 | }; 210 | if (!req.user.login || !req.user.displayName) { 211 | try { 212 | await updateUserTwitchInfo(req.user); 213 | userInfo.login = req.user.login!; 214 | userInfo.displayName = req.user.displayName!; 215 | } catch (error) { 216 | logContext.important = true; 217 | logMessage.header = "Could not get Twitch user info"; 218 | logMessage.content = { 219 | configVersion: currentConfig.version, 220 | order, 221 | }; 222 | sendToLogger(logContext).then(); 223 | // very much not ideal but they've already paid... so... 224 | console.log(`Error while trying to get Twitch user info: ${error}`); 225 | } 226 | } 227 | 228 | try { 229 | const result = await connection.redeem(redeem, order, userInfo); 230 | const processedResult = await processRedeemResult(order, result); 231 | logContext.important = processedResult.status === 500; 232 | logMessage.header = processedResult.logHeaderOverride ?? processedResult.message; 233 | logMessage.content = processedResult.logContents ?? { transaction }; 234 | sendToLogger(logContext).then(); 235 | res.status(processedResult.status).send(processedResult.message); 236 | return; 237 | } catch (error) { 238 | logContext.important = true; 239 | logMessage.header = "Failed to send redeem"; 240 | logMessage.content = { config: currentConfig.version, order, error }; 241 | sendToLogger(logContext).then(); 242 | connection.onResult(order.id, (res) => { 243 | console.log(`Got late result (from re-send?) for ${order.id}`); 244 | processRedeemResult(order, res).then(); 245 | }); 246 | res.status(500).send(`Failed to process redeem - ${error}`); 247 | return; 248 | } 249 | }) 250 | ); 251 | 252 | app.post( 253 | "/public/transaction/cancel", 254 | asyncCatch(async (req, res) => { 255 | const jwt = req.body.jwt as string; 256 | if (!verifyJWT(jwt)) { 257 | res.sendStatus(403); 258 | return; 259 | } 260 | const token = parseJWT(jwt) as TransactionTokenPayload; 261 | const logContext: LogMessage = { 262 | transactionToken: token.data.id, 263 | userIdInsecure: req.user.id, 264 | important: true, 265 | fields: [{ header: "", content: "" }], 266 | }; 267 | const logMessage = logContext.fields[0]; 268 | 269 | try { 270 | const order = await getOrder(token.data.id); 271 | 272 | if (!order) { 273 | res.status(404).send("Transaction not found"); 274 | return; 275 | } 276 | 277 | if (order.userId != req.user.id) { 278 | logMessage.header = "Unauthorized transaction cancel"; 279 | logMessage.content = { 280 | order, 281 | user: req.user, 282 | }; 283 | sendToLogger(logContext).then(); 284 | res.status(403).send("This transaction doesn't belong to you"); 285 | return; 286 | } 287 | 288 | if (order.state !== "prepurchase") { 289 | res.status(409).send("Cannot cancel this transaction"); 290 | return; 291 | } 292 | 293 | order.state = "cancelled"; 294 | await saveOrder(order); 295 | res.sendStatus(200); 296 | } catch (error) { 297 | logMessage.header = "Failed to cancel order"; 298 | logMessage.content = error; 299 | sendToLogger(logContext).then(); 300 | 301 | res.sendStatus(500); 302 | } 303 | }) 304 | ); 305 | -------------------------------------------------------------------------------- /ebs/src/modules/orders/index.ts: -------------------------------------------------------------------------------- 1 | require("./endpoints"); 2 | -------------------------------------------------------------------------------- /ebs/src/modules/orders/prepurchase.ts: -------------------------------------------------------------------------------- 1 | import { Cart, Config, Order, User } from "common/types"; 2 | import { getConfig } from "../config"; 3 | import { HttpResult } from "../../types"; 4 | import { getUserSession } from "../user"; 5 | 6 | const defaultResult: HttpResult = { status: 409, message: "Validation failed" }; 7 | 8 | export async function validatePrepurchase(order: Order, user: User): Promise { 9 | const cart = order.cart; 10 | if (!cart?.clientSession) { 11 | return { ...defaultResult, logHeaderOverride: "Missing client session", logContents: { cart } }; 12 | } 13 | 14 | const existingSession = await getUserSession(user); 15 | if (existingSession && order.cart.clientSession != existingSession) { 16 | return { 17 | ...defaultResult, 18 | message: "Extension already open in another tab, please try again there or reload this page to make this the main session", 19 | logHeaderOverride: "Non-main session", 20 | logContents: { existingSession: existingSession, order: order.id }, 21 | }; 22 | } 23 | 24 | const config = await getConfig(); 25 | if (cart.version != config.version) { 26 | return { ...defaultResult, message: "Invalid config version", logContents: { received: cart.version, expected: config.version } }; 27 | } 28 | 29 | const redeem = config.redeems?.[cart.id]; 30 | if (!redeem || redeem.sku != cart.sku || redeem.disabled || redeem.hidden) { 31 | return { ...defaultResult, message: "Invalid redeem", logContents: { received: cart, inConfig: redeem } }; 32 | } 33 | 34 | const valError = validateArgs(config, cart); 35 | if (valError) { 36 | return { 37 | ...defaultResult, 38 | message: "Invalid arguments", 39 | logHeaderOverride: "Arg validation failed", 40 | logContents: { 41 | error: valError, 42 | redeem: cart.id, 43 | expected: redeem.args, 44 | provided: cart.args, 45 | }, 46 | }; 47 | } 48 | 49 | return null; 50 | } 51 | 52 | function validateArgs(config: Config, cart: Cart): string | undefined { 53 | const redeem = config.redeems![cart.id]; 54 | 55 | for (const arg of redeem.args) { 56 | const value = cart.args[arg.name]; 57 | if (!value) { 58 | if (!arg.required) continue; 59 | 60 | // LiteralTypes.Boolean 61 | if (arg.type === 3) { 62 | // HTML form conventions - false is not transmitted, true is "on" (to save 2 bytes i'm guessing) 63 | continue; 64 | } 65 | 66 | return `Missing required argument ${arg.name}`; 67 | } 68 | let parsed: number; 69 | switch (arg.type) { 70 | // esbuild dies if you use enums 71 | // so we have to use their pure values instead 72 | case 0: // LiteralTypes.String 73 | if (typeof value !== "string") { 74 | return `Argument ${arg.name} not a string`; 75 | } 76 | const minLength = arg.minLength ?? 0; 77 | const maxLength = arg.maxLength ?? 255; 78 | if (value.length < minLength || value.length > maxLength) { 79 | return `Text length out of range for ${arg.name}`; 80 | } 81 | break; 82 | case 1: // LiteralTypes.Integer 83 | case 2: // LiteralTypes.Float 84 | parsed = parseInt(value); 85 | if (Number.isNaN(parsed)) { 86 | return `Argument ${arg.name} is not a number`; 87 | } 88 | // LiteralTypes.Integer 89 | if (arg.type === 1 && parseFloat(value) != parsed) { 90 | return `Argument ${arg.name} is not an integer`; 91 | } 92 | if ((arg.min !== undefined && parsed < arg.min) || (arg.max !== undefined && parsed > arg.max)) { 93 | return `Number ${arg.name} out of range`; 94 | } 95 | break; 96 | case 3: // LiteralTypes.Boolean 97 | if (typeof value !== "boolean" && value !== "true" && value !== "false" && value !== "on") { 98 | return `Argument ${arg.name} not a boolean`; 99 | } 100 | if (value === "on") { 101 | cart.args[arg.name] = true; 102 | } 103 | break; 104 | case 4: // LiteralTypes.Vector 105 | if (!Array.isArray(value) || value.length < 3) { 106 | return `Vector3 ${arg.name} not a 3-elem array`; 107 | } 108 | // workaround for #49 109 | const lastThree = value.slice(value.length - 3); 110 | for (const v of lastThree) { 111 | parsed = parseFloat(v); 112 | if (Number.isNaN(parsed)) { 113 | return `Vector3 ${arg.name} components not all floats`; 114 | } 115 | } 116 | cart.args[arg.name] = lastThree; 117 | break; 118 | default: 119 | const argEnum = config.enums?.[arg.type]; 120 | if (!argEnum) { 121 | return `No such enum ${arg.type}`; 122 | } 123 | parsed = parseInt(value); 124 | if (Number.isNaN(parsed) || parsed != parseFloat(value)) { 125 | return `Enum value ${value} (for enum ${arg.type}) not an integer`; 126 | } 127 | if (parsed < 0 || parsed >= argEnum.length) { 128 | return `Enum value ${value} (for enum ${arg.type}) out of range`; 129 | } 130 | if (argEnum[parsed].startsWith("[DISABLED]")) { 131 | return `Enum value ${value} (for enum ${arg.type}) is disabled`; 132 | } 133 | break; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /ebs/src/modules/orders/transaction.ts: -------------------------------------------------------------------------------- 1 | import { Order, Transaction, User, OrderState, TransactionToken, TransactionTokenPayload, DecodedTransaction, BitsTransactionPayload } from "common/types"; 2 | import { verifyJWT, parseJWT } from "../../util/jwt"; 3 | import { getOrder, saveOrder } from "../../util/db"; 4 | import { ResultMessage } from "../game/messages.game"; 5 | import { HttpResult } from "../../types"; 6 | 7 | export const jwtExpirySeconds = 60; 8 | const jwtExpiryToleranceSeconds = 15; 9 | const defaultResult: HttpResult = { status: 403, message: "Invalid transaction" }; 10 | 11 | export function decodeJWTPayloads(transaction: Transaction): HttpResult | DecodedTransaction { 12 | if (!transaction.token || !verifyJWT(transaction.token)) { 13 | return { ...defaultResult, logHeaderOverride: "Invalid token" }; 14 | } 15 | const token = parseJWT(transaction.token) as TransactionTokenPayload; 16 | if (!transaction.receipt || !verifyJWT(transaction.receipt)) { 17 | return { ...defaultResult, logHeaderOverride: "Invalid receipt" }; 18 | } 19 | return { 20 | token, 21 | receipt: parseJWT(transaction.receipt) as BitsTransactionPayload, 22 | }; 23 | } 24 | 25 | export function verifyTransaction(decoded: DecodedTransaction): HttpResult | TransactionToken { 26 | const token = decoded.token; 27 | 28 | // we don't care if our token JWT expired 29 | // because if the bits t/a is valid, the person paid and we have to honour it 30 | const receipt = decoded.receipt; 31 | if (receipt.topic != "bits_transaction_receipt") { 32 | // e.g. someone trying to put a token JWT in the receipt field 33 | return { ...defaultResult, logHeaderOverride: "Invalid receipt topic" }; 34 | } 35 | if (receipt.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { 36 | // status 403 and not 400 because bits JWTs have an expiry of 1 hour 37 | // if you're sending a transaction 1 hour after it happened... you're sus 38 | return { ...defaultResult, logHeaderOverride: "Bits receipt expired" }; 39 | } 40 | 41 | return token.data; 42 | } 43 | 44 | export async function getAndCheckOrder(transaction: Transaction, decoded: DecodedTransaction, user: User): Promise { 45 | const token = verifyTransaction(decoded); 46 | if ("status" in token) { 47 | return token; 48 | } 49 | 50 | const orderMaybe = await getOrder(token.id); 51 | if (!orderMaybe) { 52 | return { status: 404, message: "Transaction not found" }; 53 | } 54 | const order = orderMaybe; 55 | if (order.state != "prepurchase") { 56 | return { status: 409, message: "Transaction already processed" }; 57 | } 58 | 59 | if (!order.cart) { 60 | return { status: 500, message: "Internal error", logHeaderOverride: "Missing cart", logContents: { order: order.id } }; 61 | } 62 | if (order.cart.sku != token.product.sku) { 63 | return { 64 | status: 400, 65 | message: "Invalid transaction", 66 | logHeaderOverride: "SKU mismatch", 67 | logContents: { cartSku: order.cart.sku, tokenSku: token.product.sku }, 68 | }; 69 | } 70 | 71 | // we verified the receipt JWT earlier (in verifyTransaction) 72 | order.receipt = transaction.receipt; 73 | 74 | order.state = "paid"; 75 | await saveOrder(order); 76 | 77 | return order; 78 | } 79 | 80 | export async function processRedeemResult(order: Order, result: ResultMessage): Promise { 81 | order.state = result.success ? "succeeded" : "failed"; 82 | order.result = result.message; 83 | await saveOrder(order); 84 | let msg = result.message; 85 | const res = { logContents: { order: order.id, cart: order.cart } }; 86 | if (result.success) { 87 | console.log(`[${result.guid}] Redeem succeeded: ${JSON.stringify(result)}`); 88 | msg = "Your transaction was successful! Your redeem will appear on stream soon."; 89 | if (result.message) { 90 | msg += "\n\n" + result.message; 91 | } 92 | return { status: 200, message: msg, logHeaderOverride: "Redeem succeeded", ...res }; 93 | } else { 94 | console.error(`[${result.guid}] Redeem failed: ${JSON.stringify(result)}`); 95 | msg ??= "Redeem failed."; 96 | return { status: 500, message: msg, logHeaderOverride: "Redeem failed", ...res }; 97 | } 98 | } 99 | 100 | export function makeTransactionToken(order: Order, user: User): TransactionToken { 101 | const sku = order.cart.sku; 102 | const cost = parseInt(sku.substring(4)); 103 | if (!isFinite(cost) || cost <= 0) { 104 | throw new Error(`Bad SKU ${sku}`); 105 | } 106 | 107 | return { 108 | id: order.id, 109 | time: Date.now(), 110 | user: { 111 | id: user.id, 112 | }, 113 | product: { sku, cost }, 114 | }; 115 | } 116 | 117 | function getBitsPrice(sku: string) { 118 | // highly advanced pricing technology (all SKUs are in the form bitsXXX where XXX is the price) 119 | return parseInt(sku.substring(4)); 120 | } 121 | -------------------------------------------------------------------------------- /ebs/src/modules/pishock.ts: -------------------------------------------------------------------------------- 1 | import { Order, Redeem } from "common/types"; 2 | import { ResultMessage } from "./game/messages.game"; 3 | import { MessageType, TwitchUser } from "./game/messages"; 4 | import { connection } from "./game"; 5 | import { sendToLogger } from "../util/logger"; 6 | import { sendShock } from "../util/pishock"; 7 | 8 | const pishockRedeemId = "redeem_pishock"; 9 | 10 | require("./game"); // init connection just in case import order screwed us over 11 | 12 | connection.addRedeemHandler(pishockRedeem); 13 | 14 | export async function pishockRedeem(redeem: Redeem, order: Order, user: TwitchUser): Promise { 15 | if (redeem.id != pishockRedeemId) { 16 | return null; 17 | } 18 | 19 | sendToLogger({ 20 | transactionToken: order.id, 21 | userIdInsecure: order.userId, 22 | important: false, 23 | fields: [{ header: "PiShock Redeem", content: `${user.displayName} redeemed PiShock` }], 24 | }); 25 | 26 | const success = await sendShock(50, 100); 27 | const result: ResultMessage = { 28 | messageType: MessageType.Result, 29 | guid: order.id, 30 | timestamp: Date.now(), 31 | success, 32 | }; 33 | if (!success) { 34 | result.message = "Failed to send PiShock operation"; 35 | } 36 | return result; 37 | } 38 | 39 | -------------------------------------------------------------------------------- /ebs/src/modules/twitch/index.ts: -------------------------------------------------------------------------------- 1 | import { getHelixUser } from "../../util/twitch"; 2 | import { TwitchUser } from "../game/messages"; 3 | 4 | export async function getTwitchUser(id: string): Promise { 5 | const user = await getHelixUser(id); 6 | if (!user) { 7 | console.warn(`Twitch user ${id} was not found`); 8 | return null; 9 | } 10 | return { 11 | id: user.id, 12 | login: user.name, 13 | displayName: user.displayName, 14 | }; 15 | } -------------------------------------------------------------------------------- /ebs/src/modules/user/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { app } from "../.."; 2 | import { updateUserTwitchInfo, lookupUser } from "../../util/db"; 3 | import { asyncCatch } from "../../util/middleware"; 4 | import { setUserBanned, setUserSession } from "."; 5 | 6 | app.post( 7 | "/public/authorized", 8 | asyncCatch(async (req, res) => { 9 | const {session} = req.body as {session: string}; 10 | // console.log(`${req.auth.opaque_user_id} opened extension (session ${session})`); 11 | setUserSession(req.user, session); 12 | 13 | updateUserTwitchInfo(req.user).then(); 14 | 15 | res.sendStatus(200); 16 | }) 17 | ); 18 | 19 | app.get( 20 | "/private/user/:idOrName", 21 | asyncCatch(async (req, res) => { 22 | res.json(await lookupUser(req.params["idOrName"])); 23 | }) 24 | ); 25 | 26 | app.post( 27 | "/private/user/:idOrName/ban", 28 | asyncCatch(async (req, res) => { 29 | const user = await lookupUser(req.params["idOrName"]); 30 | if (!user) { 31 | res.sendStatus(404); 32 | return; 33 | } 34 | 35 | await setUserBanned(user, true); 36 | console.log(`[Private API] Banned ${user.login ?? user.id}`); 37 | res.status(200).json(user); 38 | }) 39 | ); 40 | 41 | app.delete( 42 | "/private/user/:idOrName/ban", 43 | asyncCatch(async (req, res) => { 44 | const user = await lookupUser(req.params["idOrName"]); 45 | if (!user) { 46 | res.sendStatus(404); 47 | return; 48 | } 49 | 50 | await setUserBanned(user, false); 51 | console.log(`[Private API] Unbanned ${user.login ?? user.id}`); 52 | res.status(200).json(user); 53 | }) 54 | ); 55 | -------------------------------------------------------------------------------- /ebs/src/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | import { User } from "common/types"; 2 | import { saveUser } from "../../util/db"; 3 | import { sendPubSubMessage } from "../../util/pubsub"; 4 | 5 | require("./endpoints"); 6 | 7 | const sessions: Map = new Map(); 8 | 9 | export async function setUserBanned(user: User, banned: boolean) { 10 | user.banned = banned; 11 | await saveUser(user); 12 | sendPubSubMessage({ 13 | type: "banned", 14 | data: JSON.stringify({ id: user.id, banned }), 15 | }).then(); 16 | } 17 | 18 | export async function getUserSession(user: User): Promise { 19 | return sessions.get(user.id) || null; 20 | } 21 | 22 | export async function setUserSession(user: User, session: string) { 23 | const existing = sessions.get(user.id); 24 | if (existing) { 25 | console.log(`Closing existing session ${existing} in favor of ${session}`); 26 | } 27 | sessions.set(user.id, session); 28 | } 29 | -------------------------------------------------------------------------------- /ebs/src/types.ts: -------------------------------------------------------------------------------- 1 | export type AuthorizationPayload = { 2 | exp: number; 3 | opaque_user_id: string; 4 | user_id?: string; 5 | channel_id: string; 6 | role: "broadcaster" | "moderator" | "viewer" | "external"; 7 | is_unlinked: boolean; 8 | pubsub_perms: { 9 | listen: string[]; 10 | send: string[]; 11 | }; 12 | }; 13 | 14 | export type HttpResult = { 15 | status: number; 16 | message: string; 17 | logHeaderOverride?: string; 18 | logContents?: any; 19 | }; -------------------------------------------------------------------------------- /ebs/src/util/db.ts: -------------------------------------------------------------------------------- 1 | import { RowDataPacket } from "mysql2"; 2 | import mysql from "mysql2/promise"; 3 | import { v4 as uuid } from "uuid"; 4 | import { User, Order, Cart } from "common/types"; 5 | import { getTwitchUser } from "../modules/twitch"; 6 | 7 | export let db: mysql.Connection; 8 | 9 | export async function initDb() { 10 | while (!db) { 11 | try { 12 | db = await mysql.createConnection({ 13 | host: process.env.MYSQL_HOST, 14 | user: process.env.MYSQL_USER, 15 | password: process.env.MYSQL_PASSWORD, 16 | database: process.env.MYSQL_DATABASE, 17 | namedPlaceholders: true, 18 | }); 19 | } catch { 20 | console.log("Failed to connect to database. Retrying in 1 second..."); 21 | await new Promise((resolve) => setTimeout(resolve, 1000)); 22 | } 23 | } 24 | } 25 | 26 | export async function getOrder(guid: string) { 27 | try { 28 | const [rows] = (await db.query("SELECT * FROM orders WHERE id = ?", [guid])) as [RowDataPacket[], any]; 29 | if (!rows.length) { 30 | return null; 31 | } 32 | return rows[0] as Order; 33 | } catch (e: any) { 34 | console.error("Database query failed (getOrder)"); 35 | console.error(e); 36 | throw e; 37 | } 38 | } 39 | 40 | export async function createOrder(userId: string, cart: Cart) { 41 | const order: Order = { 42 | state: "rejected", 43 | cart, 44 | id: uuid(), 45 | userId, 46 | createdAt: Date.now(), 47 | updatedAt: Date.now(), 48 | }; 49 | try { 50 | await db.query( 51 | `INSERT INTO orders (id, userId, state, cart, createdAt, updatedAt) 52 | VALUES (?, ?, ?, ?, ?, ?)`, 53 | [order.id, order.userId, order.state, JSON.stringify(order.cart), order.createdAt, order.updatedAt] 54 | ); 55 | return order; 56 | } catch (e: any) { 57 | console.error("Database query failed (createOrder)"); 58 | console.error(e); 59 | throw e; 60 | } 61 | } 62 | 63 | export async function saveOrder(order: Order) { 64 | order.updatedAt = Date.now(); 65 | await db.query( 66 | `UPDATE orders 67 | SET state = ?, cart = ?, receipt = ?, result = ?, updatedAt = ? 68 | WHERE id = ?`, 69 | [order.state, JSON.stringify(order.cart), order.receipt, order.result, order.updatedAt, order.id] 70 | ); 71 | } 72 | 73 | export async function getOrAddUser(id: string): Promise { 74 | try { 75 | const [rows] = (await db.query("SELECT * FROM users WHERE id = ?", [id])) as [RowDataPacket[], any]; 76 | if (!rows.length) { 77 | return await createUser(id); 78 | } 79 | return rows[0] as User; 80 | } catch (e: any) { 81 | console.error("Database query failed (getOrAddUser)"); 82 | console.error(e); 83 | throw e; 84 | } 85 | } 86 | 87 | export async function lookupUser(idOrName: string): Promise { 88 | try { 89 | const lookupStr = `%${idOrName}%`; 90 | const [rows] = (await db.query( 91 | `SELECT * FROM users 92 | WHERE id = :idOrName 93 | OR login LIKE :lookupStr 94 | OR displayName LIKE :lookupStr`, 95 | { idOrName, lookupStr } 96 | )) as [RowDataPacket[], any]; 97 | if (!rows.length) { 98 | return null; 99 | } 100 | return rows[0] as User; 101 | } catch (e: any) { 102 | console.error("Database query failed (getUser)"); 103 | console.error(e); 104 | throw e; 105 | } 106 | } 107 | 108 | async function createUser(id: string): Promise { 109 | const user: User = { 110 | id, 111 | banned: false, 112 | }; 113 | try { 114 | await db.query( 115 | ` 116 | INSERT INTO users (id, login, displayName, banned) 117 | VALUES (:id, :login, :displayName, :banned)`, 118 | user 119 | ); 120 | } catch (e: any) { 121 | console.error("Database query failed (createUser)"); 122 | console.error(e); 123 | throw e; 124 | } 125 | return user; 126 | } 127 | 128 | export async function saveUser(user: User) { 129 | try { 130 | await db.query( 131 | ` 132 | UPDATE users 133 | SET login = :login, displayName = :displayName, banned = :banned 134 | WHERE id = :id`, 135 | { ...user } 136 | ); 137 | } catch (e: any) { 138 | console.error("Database query failed (saveUser)"); 139 | console.error(e); 140 | throw e; 141 | } 142 | } 143 | 144 | export async function updateUserTwitchInfo(user: User): Promise { 145 | try { 146 | user = { 147 | ...user, 148 | ...(await getTwitchUser(user.id)), 149 | }; 150 | } catch (e: any) { 151 | console.error("Twitch API GetUsers call failed (updateUserTwitchInfo)"); 152 | console.error(e); 153 | throw e; 154 | } 155 | try { 156 | await db.query( 157 | ` 158 | UPDATE users 159 | SET login = :login, displayName = :displayName 160 | WHERE id = :id`, 161 | { ...user } 162 | ); 163 | } catch (e: any) { 164 | console.error("Database query failed (updateUserTwitchInfo)"); 165 | console.error(e); 166 | throw e; 167 | } 168 | return user; 169 | } 170 | -------------------------------------------------------------------------------- /ebs/src/util/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | 3 | const memo: { [key: string]: string | jwt.JwtPayload } = {}; 4 | let cachedBuffer: Buffer; 5 | 6 | export function verifyJWT(token: string): boolean { 7 | try { 8 | parseJWT(token); 9 | return true; 10 | } catch (e) { 11 | console.error(e); 12 | return false; 13 | } 14 | } 15 | 16 | export function parseJWT(token: string) { 17 | if (memo[token]) return memo[token]; 18 | 19 | const result = jwt.verify(token, getJwtSecretBuffer(), { ignoreExpiration: true }); 20 | memo[token] = result; 21 | return result; 22 | } 23 | 24 | function getJwtSecretBuffer() { 25 | return cachedBuffer ??= Buffer.from(process.env.JWT_SECRET!, "base64"); 26 | } 27 | 28 | export function signJWT(payload: object, options?: jwt.SignOptions) { 29 | return jwt.sign(payload, getJwtSecretBuffer(), options); 30 | } 31 | -------------------------------------------------------------------------------- /ebs/src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import { LogMessage } from "common/types"; 2 | 3 | const logEndpoint = `http://${process.env.LOGGER_HOST!}:3000/log`; 4 | 5 | export async function sendToLogger(data: LogMessage) { 6 | try { 7 | const result = await fetch(logEndpoint, { 8 | method: "POST", 9 | headers: { "Content-Type": "application/json" }, 10 | body: JSON.stringify({ 11 | ...data, 12 | backendToken: process.env.PRIVATE_LOGGER_TOKEN!, 13 | } satisfies LogMessage & { backendToken?: string }), 14 | }); 15 | 16 | if (!result.ok) { 17 | console.error("Failed to log to Discord"); 18 | console.error(await result.text()); 19 | console.log(data); 20 | } 21 | } catch (e: any) { 22 | console.error("Error when logging to Discord"); 23 | console.error(e); 24 | console.log(data); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ebs/src/util/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { parseJWT, verifyJWT } from "./jwt"; 3 | import { AuthorizationPayload } from "../types"; 4 | import { sendToLogger } from "./logger"; 5 | import { User } from "common/types"; 6 | import { getOrAddUser } from "./db"; 7 | 8 | export async function publicApiAuth(req: Request, res: Response, next: NextFunction) { 9 | const auth = req.header("Authorization"); 10 | 11 | if (!auth || !auth.startsWith("Bearer ")) { 12 | res.status(401).send("Missing or malformed session token"); 13 | return; 14 | } 15 | 16 | const token = auth.substring(7); 17 | if (!verifyJWT(token)) { 18 | res.status(401).send("Invalid session token"); 19 | return; 20 | } 21 | 22 | const twitchAuthorization = parseJWT(token) as AuthorizationPayload; 23 | 24 | if (!twitchAuthorization.user_id) { 25 | sendToLogger({ 26 | transactionToken: null, 27 | userIdInsecure: null, 28 | important: false, 29 | fields: [ 30 | { 31 | header: "Missing user ID in JWT", 32 | content: twitchAuthorization, 33 | }, 34 | ], 35 | }).then(); 36 | res.status(500).send("Missing required data in JWT"); 37 | return; 38 | } 39 | 40 | req.user = await getOrAddUser(twitchAuthorization.user_id); 41 | req.auth = twitchAuthorization; 42 | 43 | if (req.user.banned) { 44 | res.status(403).send("You are banned from using this extension"); 45 | return; 46 | } 47 | 48 | next(); 49 | } 50 | 51 | export function privateApiAuth(req: Request, res: Response, next: NextFunction) { 52 | const auth = req.header("Authorization"); 53 | if (auth != "Bearer " + process.env.PRIVATE_API_KEY) { 54 | res.status(401).send("Invalid private API key... Why are you here? Please leave."); 55 | return; 56 | } 57 | 58 | next(); 59 | } 60 | 61 | declare global { 62 | namespace Express { 63 | export interface Request { 64 | user: User; 65 | auth: AuthorizationPayload; 66 | } 67 | } 68 | } 69 | 70 | export function asyncCatch(fn: (req: Request, res: Response, next: NextFunction) => Promise) { 71 | return async (req: Request, res: Response, next: NextFunction) => { 72 | try { 73 | await fn(req, res, next); 74 | } catch (err: any) { 75 | console.log(err); 76 | 77 | sendToLogger({ 78 | transactionToken: null, 79 | userIdInsecure: null, 80 | important: true, 81 | fields: [ 82 | { 83 | header: "Error in asyncCatch", 84 | content: err?.stack ?? err, 85 | }, 86 | ], 87 | }).then(); 88 | 89 | next(err); 90 | } 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /ebs/src/util/pishock.ts: -------------------------------------------------------------------------------- 1 | const apiUrl: string = "https://do.pishock.com/api/apioperate/"; 2 | 3 | async function sendOperation(op: number, intensity: number, duration: number) { 4 | try { 5 | const data = { 6 | Username: process.env.PISHOCK_USERNAME, 7 | Apikey: process.env.PISHOCK_APIKEY, 8 | Code: process.env.PISHOCK_CODE, 9 | Name: "Swarm Control", 10 | 11 | Op: op, 12 | Intensity: intensity, 13 | Duration: duration, 14 | }; 15 | 16 | console.log(`Sending PiShock operation: ${op} ${intensity} ${duration}`); 17 | 18 | const response = await fetch(apiUrl, { 19 | method: "POST", 20 | headers: { "Content-Type": "application/json" }, 21 | body: JSON.stringify(data), 22 | }); 23 | 24 | if (!response.ok) { 25 | console.error("Failed to send PiShock operation"); 26 | console.error(response.status, await response.text()); 27 | return false; 28 | } 29 | 30 | return true; 31 | } catch (e: any) { 32 | console.error("Failed to send PiShock operation"); 33 | console.error(e); 34 | return false; 35 | } 36 | } 37 | 38 | export function sendShock(intensity: number, duration: number) { 39 | return sendOperation(0, intensity, duration); 40 | } 41 | -------------------------------------------------------------------------------- /ebs/src/util/pubsub.ts: -------------------------------------------------------------------------------- 1 | import { EbsCallConfig, sendExtensionPubSubGlobalMessage } from "@twurple/ebs-helper"; 2 | import { PubSubMessage } from "common/types"; 3 | 4 | const config: EbsCallConfig = { 5 | clientId: process.env.CLIENT_ID!, 6 | ownerId: process.env.OWNER_ID!, 7 | secret: process.env.JWT_SECRET!, 8 | }; 9 | 10 | export async function sendPubSubMessage(message: PubSubMessage) { 11 | return sendExtensionPubSubGlobalMessage(config, JSON.stringify(message)); 12 | } 13 | -------------------------------------------------------------------------------- /ebs/src/util/twitch.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient, HelixUser } from "@twurple/api"; 2 | import { RefreshingAuthProvider } from "@twurple/auth"; 3 | 4 | const authProvider = new RefreshingAuthProvider({ 5 | clientId: process.env.TWITCH_API_CLIENT_ID!, 6 | clientSecret: process.env.TWITCH_API_CLIENT_SECRET!, 7 | }); 8 | const api = new ApiClient({ authProvider, batchDelay: 100 }); 9 | 10 | export async function getHelixUser(userId: string): Promise { 11 | return api.users.getUserByIdBatched(userId); 12 | } 13 | -------------------------------------------------------------------------------- /ebs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es6", 5 | "outDir": "./dist/", 6 | "noImplicitAny": true, 7 | "allowJs": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "moduleResolution": "node" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "webpack --config webpack.prod.ts", 7 | "start": "webpack-dev-server --config webpack.dev.ts" 8 | }, 9 | "devDependencies": { 10 | "@types/twitch-ext": "^1.24.9", 11 | "@types/zip-webpack-plugin": "^3.0.6", 12 | "clean-webpack-plugin": "^4.0.0", 13 | "copy-webpack-plugin": "^12.0.2", 14 | "css-loader": "^7.1.2", 15 | "fflate": "^0.8.2", 16 | "file-loader": "^6.2.0", 17 | "html-webpack-plugin": "^5.6.0", 18 | "mini-css-extract-plugin": "^2.9.0", 19 | "ts-loader": "^9.5.1", 20 | "ts-node": "^10.9.2", 21 | "typescript": "^5.4.5", 22 | "webpack": "^5.91.0", 23 | "webpack-cli": "^5.1.4", 24 | "webpack-dev-server": "^5.0.4", 25 | "webpack-merge": "^5.10.0", 26 | "zip-webpack-plugin": "^4.0.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "es6", 5 | "outDir": "./dist/", 6 | "noImplicitAny": true, 7 | "allowJs": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "moduleResolution": "node" 13 | }, 14 | "ts-node": { 15 | "compilerOptions": { 16 | "module": "commonjs" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/webpack.common.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { CleanWebpackPlugin } from "clean-webpack-plugin"; 3 | import ZipWebpackPlugin from "zip-webpack-plugin"; 4 | import CopyWebpackPlugin from "copy-webpack-plugin"; 5 | import MiniCssExtractPlugin from "mini-css-extract-plugin"; 6 | import HtmlWebpackPlugin from "html-webpack-plugin"; 7 | import { Configuration } from "webpack"; 8 | 9 | const config: Configuration = { 10 | entry: "./www/src/index.ts", 11 | plugins: [ 12 | new CleanWebpackPlugin(), 13 | new MiniCssExtractPlugin({ 14 | filename: "[name].css", 15 | }), 16 | new HtmlWebpackPlugin({ 17 | title: "Video Component View", 18 | template: "./www/html/index.html", 19 | filename: "video_component.html", 20 | }), 21 | new HtmlWebpackPlugin({ 22 | title: "Mobile View", 23 | template: "./www/html/index.html", 24 | filename: "mobile.html", 25 | }), 26 | new HtmlWebpackPlugin({ 27 | title: "Config", 28 | template: "./www/html/index.html", 29 | filename: "config.html", 30 | }), 31 | new CopyWebpackPlugin({ 32 | patterns: [ 33 | { 34 | from: "./www/img", 35 | to: "img", 36 | }, 37 | ], 38 | }), 39 | new ZipWebpackPlugin({ 40 | filename: "frontend.zip", 41 | }), 42 | ], 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.css$/, 47 | use: [MiniCssExtractPlugin.loader, "css-loader"], 48 | }, 49 | { 50 | test: /\.(png|svg|jpg|gif)$/, 51 | type: "asset/resource", 52 | }, 53 | { 54 | test: /\.tsx?$/, 55 | use: "ts-loader", 56 | exclude: /node_modules/, 57 | }, 58 | ], 59 | }, 60 | resolve: { 61 | extensions: [".tsx", ".ts", ".js"], 62 | }, 63 | optimization: { 64 | splitChunks: { 65 | cacheGroups: { 66 | commons: { 67 | test: /[\\/]node_modules[\\/]/, 68 | name: "vendors", 69 | chunks: "all", 70 | }, 71 | styles: { 72 | name: "styles", 73 | test: /\.css$/, 74 | chunks: "all", 75 | enforce: true, 76 | }, 77 | }, 78 | }, 79 | }, 80 | output: { 81 | filename: "[name].bundle.js", 82 | path: path.resolve(__dirname, "dist"), 83 | }, 84 | }; 85 | 86 | export default config; 87 | -------------------------------------------------------------------------------- /frontend/webpack.dev.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "webpack-merge"; 2 | import common from "./webpack.common"; 3 | import { Configuration } from "webpack"; 4 | import "webpack-dev-server"; 5 | 6 | const config = merge(common, { 7 | mode: "development", 8 | devtool: "inline-source-map", 9 | devServer: { 10 | static: "./dist", 11 | port: 8080, 12 | }, 13 | }); 14 | 15 | // noinspection JSUnusedGlobalSymbols 16 | export default config; 17 | -------------------------------------------------------------------------------- /frontend/webpack.prod.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "webpack-merge"; 2 | import common from "./webpack.common"; 3 | 4 | const config = merge(common, { 5 | mode: "production", 6 | }); 7 | 8 | // noinspection JSUnusedGlobalSymbols 9 | export default config; 10 | -------------------------------------------------------------------------------- /frontend/www/css/alert.css: -------------------------------------------------------------------------------- 1 | .alert { 2 | padding: 20px; 3 | 4 | width: calc(100% - 20px); 5 | 6 | background-color: rgb(var(--danger-color)); 7 | color: white; 8 | 9 | margin: 10px; 10 | 11 | border-radius: 10px; 12 | } -------------------------------------------------------------------------------- /frontend/www/css/base.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", "Lucida Sans", Arial, sans-serif; 6 | } 7 | 8 | body { 9 | background-color: #111; 10 | color: #fff; 11 | overscroll-behavior: contain; 12 | } 13 | 14 | html { 15 | overscroll-behavior: contain; 16 | } 17 | 18 | main { 19 | height: 100dvh; 20 | /* Some older browsers don't support the 21 | dvh property and we need a fallback value. */ 22 | height: 100vh; 23 | overscroll-behavior: contain; 24 | overflow-y: auto; 25 | } 26 | 27 | h1 { 28 | color: white; 29 | text-align: center; 30 | } 31 | 32 | input { 33 | padding: 5px; 34 | border-radius: 7.5px; 35 | border: 1px solid #ffffff1A; 36 | background-color: #333; 37 | color: #eee; 38 | } 39 | 40 | input[type="checkbox"] { 41 | position: relative; 42 | 43 | width: 20px; 44 | height: 20px; 45 | 46 | cursor: pointer; 47 | 48 | -webkit-appearance: none; 49 | -moz-appearance: none; 50 | appearance: none; 51 | 52 | background-color: #333; 53 | border: 1px solid #ffffff1A; 54 | border-radius: 5px; 55 | 56 | transition: all; 57 | transition-duration: 0.25s; 58 | } 59 | 60 | input[type="checkbox"]:checked { 61 | background-color: rgba(var(--primary-color), 0.5); 62 | } 63 | 64 | input[type="checkbox"]:hover { 65 | background-color: rgba(var(--primary-color), 0.25); 66 | } 67 | 68 | input[type="checkbox"]:checked::before { 69 | position: absolute; 70 | 71 | top: 50%; 72 | left: 50%; 73 | 74 | transform: translate(-50%, -50%); 75 | 76 | content: "✔"; 77 | font-size: 1rem; 78 | color: white; 79 | } 80 | 81 | select { 82 | padding: 5px; 83 | border-radius: 7.5px; 84 | border: 1px solid #ffffff1A; 85 | background-color: #333; 86 | color: #eee; 87 | } -------------------------------------------------------------------------------- /frontend/www/css/buttons.css: -------------------------------------------------------------------------------- 1 | .button { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | 7 | white-space: nowrap; 8 | text-align: center; 9 | text-wrap: none; 10 | overflow: hidden; 11 | text-overflow: ellipsis; 12 | 13 | background-color: transparent; 14 | color: #fff; 15 | 16 | border: none; 17 | border-radius: 10px; 18 | 19 | padding: 14px 24px; 20 | 21 | cursor: pointer; 22 | font-size: 1rem; 23 | 24 | transition: all; 25 | transition-duration: 0.25s; 26 | } 27 | 28 | .button:hover { 29 | opacity: 0.8; 30 | } 31 | 32 | .button:active { 33 | opacity: 1; 34 | } 35 | 36 | .button:focus { 37 | outline: none; 38 | } 39 | 40 | .btn-primary { 41 | background-color: rgba(var(--primary-color), 0.75); 42 | border: 1px solid #ffffff26; 43 | color: #fff; 44 | } 45 | 46 | .btn-primary:focus { 47 | box-shadow: 0 0 0 4px rgba(var(--primary-color), 0.5); 48 | } 49 | 50 | .btn-success { 51 | background-color: rgba(var(--success-color), 0.75); 52 | border: 1px solid #ffffff26; 53 | color: #fff; 54 | } 55 | 56 | .btn-success:focus { 57 | box-shadow: 0 0 0 4px rgba(var(--success-color), 0.5); 58 | } 59 | 60 | .btn-danger { 61 | background-color: rgba(var(--danger-color), 0.75); 62 | border: 1px solid #ffffff26; 63 | color: #fff; 64 | } 65 | 66 | .btn-danger:focus { 67 | box-shadow: 0 0 0 4px rgba(var(--danger-color), 0.5); 68 | } 69 | 70 | .button[disabled], 71 | .button[aria-disabled] { 72 | cursor: not-allowed; 73 | opacity: 0.5; 74 | } -------------------------------------------------------------------------------- /frontend/www/css/config.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: 203, 4, 165; 3 | --success-color: 2, 109, 58; 4 | --danger-color: 153, 14, 6; 5 | --redeem-card-border-radius: 10px; 6 | } -------------------------------------------------------------------------------- /frontend/www/css/modals.css: -------------------------------------------------------------------------------- 1 | .modal-wrapper { 2 | z-index: 9999; 3 | 4 | position: absolute; 5 | 6 | top: 0; 7 | left: 0; 8 | 9 | width: 100%; 10 | height: 100%; 11 | background-color: rgba(0, 0, 0, 0.8); 12 | 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | 17 | transition: all; 18 | transition-duration: 0.25s; 19 | 20 | overscroll-behavior: contain; 21 | } 22 | 23 | .modal { 24 | position: relative; 25 | 26 | background-color: #111; 27 | border: 1px solid #ffffff1A; 28 | 29 | border-radius: 20px; 30 | font-size: 1rem; 31 | 32 | transition: all; 33 | transition-duration: 0.25s; 34 | 35 | color: #eee; 36 | 37 | overflow: hidden; 38 | 39 | -webkit-user-select: none; 40 | user-select: none; 41 | 42 | overscroll-behavior: contain; 43 | } 44 | 45 | .modal-inside-wrapper { 46 | width: 100%; 47 | height: auto; 48 | 49 | padding: 20px; 50 | 51 | display: flex; 52 | flex-direction: row; 53 | /* justify-content: center; */ 54 | gap: 20px; 55 | 56 | max-height: 80vh; 57 | 58 | overflow-y: auto; 59 | 60 | overscroll-behavior: contain; 61 | } 62 | 63 | .modal-image { 64 | width: 128px; 65 | height: 128px; 66 | 67 | border-radius: 10px; 68 | 69 | transition: all; 70 | transition-duration: 0.25s; 71 | 72 | background-color: #111; 73 | 74 | object-fit: contain; 75 | } 76 | 77 | .modal-descriptors { 78 | display: flex; 79 | flex-direction: column; 80 | justify-content: center; 81 | gap: 10px; 82 | 83 | width: 100%; 84 | } 85 | 86 | #modal-options { 87 | width: 100%; 88 | 89 | display: flex; 90 | flex-direction: column; 91 | gap: 10px; 92 | } 93 | 94 | .modal-section { 95 | font-size: 1rem; 96 | font-weight: bold; 97 | color: #ffffff80; 98 | 99 | width: 100%; 100 | 101 | padding-bottom: 4px; 102 | 103 | border-bottom: 1px solid #ffffff1A; 104 | } 105 | 106 | #modal-options-form { 107 | display: flex; 108 | flex-direction: column; 109 | justify-content: center; 110 | align-items: center; 111 | 112 | width: 100%; 113 | } 114 | 115 | #modal-options-form>* { 116 | width: 100%; 117 | 118 | padding: 7.5px; 119 | 120 | height: 44px; 121 | 122 | display: flex; 123 | flex-direction: row; 124 | justify-content: space-between; 125 | align-items: center; 126 | 127 | border-bottom: 1px solid #ffffff1A; 128 | } 129 | 130 | #modal-options-form>*:nth-child(even) { 131 | background: #222; 132 | } 133 | 134 | #modal-options-form>*:last-child { 135 | border-bottom: none; 136 | } 137 | 138 | form *:invalid { 139 | border: 1px solid red !important; 140 | } 141 | 142 | form label[aria-required]::before { 143 | content: "* "; 144 | color: red; 145 | } 146 | 147 | form label:not([aria-required]) { 148 | padding-left: 0.66em; 149 | } 150 | 151 | .modal-buttons { 152 | display: flex; 153 | flex-direction: row; 154 | gap: 10px; 155 | 156 | width: 100%; 157 | } 158 | 159 | .modal-buttons>* { 160 | width: 100%; 161 | } 162 | 163 | .bits-image { 164 | width: 20px; 165 | height: 20px; 166 | 167 | object-fit: contain; 168 | margin-left: 6px; 169 | margin-right: 6px; 170 | } 171 | 172 | .modal-overlay { 173 | z-index: 10000; 174 | 175 | position: absolute; 176 | 177 | top: 0; 178 | left: 0; 179 | 180 | width: 100%; 181 | height: 100%; 182 | 183 | display: flex; 184 | flex-direction: row; 185 | justify-content: center; 186 | align-items: center; 187 | gap: 20px; 188 | 189 | transition: all; 190 | transition-duration: 0.25s; 191 | 192 | padding: 20px; 193 | 194 | background: #111; 195 | } 196 | 197 | .modal-error-x { 198 | font-size: 5rem; 199 | color: rgb(var(--danger-color)); 200 | } 201 | 202 | .modal-success-x { 203 | font-size: 5rem; 204 | color: rgb(var(--success-color)); 205 | } 206 | 207 | .modal-processing { 208 | display: flex; 209 | flex-direction: column; 210 | justify-content: center; 211 | align-items: center; 212 | gap: 20px; 213 | } 214 | 215 | .modal-spinner-container { 216 | display: flex; 217 | flex-direction: row; 218 | justify-content: center; 219 | align-items: center; 220 | gap: 20px; 221 | } 222 | 223 | #modal-processing-description, 224 | #modal-error-description, 225 | #modal-success-description { 226 | text-align: center; 227 | } 228 | 229 | .modal-vector-input { 230 | width: 64px !important; 231 | } 232 | 233 | @media screen and (min-width: 600px) { 234 | .modal { 235 | transform: scale(0.9); 236 | opacity: 0; 237 | } 238 | 239 | .modal.active-modal { 240 | transform: scale(1); 241 | opacity: 1; 242 | } 243 | } 244 | 245 | @media screen and (max-width: 600px) { 246 | .modal-wrapper { 247 | position: fixed; 248 | 249 | align-items: flex-end; 250 | } 251 | 252 | .modal { 253 | transform: translateY(100%); 254 | } 255 | 256 | .modal.active-modal { 257 | transform: translateY(0); 258 | } 259 | 260 | .modal, 261 | .modal-overlay, 262 | .modal-inside-wrapper { 263 | flex-direction: column; 264 | align-items: center; 265 | 266 | width: 100%; 267 | 268 | border-bottom-left-radius: 0; 269 | border-bottom-right-radius: 0; 270 | } 271 | 272 | .modal-descriptors { 273 | align-items: center; 274 | } 275 | } -------------------------------------------------------------------------------- /frontend/www/css/onboarding.css: -------------------------------------------------------------------------------- 1 | .onboarding { 2 | z-index: 1000; 3 | 4 | position: absolute; 5 | 6 | top: 0; 7 | left: 0; 8 | 9 | width: 100%; 10 | height: 100%; 11 | 12 | padding: 20px; 13 | 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | gap: 20px; 19 | 20 | transition: all; 21 | transition-duration: 0.2s; 22 | 23 | background-color: #111; 24 | color: #fff; 25 | } 26 | 27 | .onboarding>* { 28 | font-size: 1.5rem; 29 | text-align: center; 30 | } -------------------------------------------------------------------------------- /frontend/www/css/redeems.css: -------------------------------------------------------------------------------- 1 | #items { 2 | /* 20px is the margin left + right */ 3 | width: calc(100% - 20px); 4 | margin: 10px; 5 | display: grid; 6 | grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); 7 | grid-gap: 10px; 8 | overflow-x: hidden; 9 | } 10 | 11 | .redeems-content-spinner { 12 | /* 20px is the margin left+right */ 13 | width: calc(100vw - 20px); 14 | height: 100dvh; 15 | /* Some older browsers don't support the 16 | dvh property and we need a fallback value. */ 17 | height: 100vh; 18 | 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: center; 22 | align-items: center; 23 | gap: 20px; 24 | } 25 | 26 | .redeemable-item { 27 | position: relative; 28 | 29 | padding: 10px; 30 | 31 | width: 100%; 32 | aspect-ratio: 1 / 1; 33 | 34 | background: #333; 35 | color: #eee; 36 | 37 | border-radius: var(--redeem-card-border-radius); 38 | border: 1px solid #ffffff1A; 39 | 40 | text-align: center; 41 | 42 | display: flex; 43 | align-items: center; 44 | flex-direction: column; 45 | 46 | font-size: 1rem; 47 | 48 | cursor: pointer; 49 | 50 | transition: all; 51 | transition-duration: 0.2s; 52 | 53 | -webkit-user-select: none; 54 | user-select: none; 55 | 56 | overflow: hidden; 57 | } 58 | 59 | .redeemable-item:hover { 60 | background: rgba(var(--primary-color), 0.5); 61 | } 62 | 63 | .redeemable-item:active { 64 | background-color: rgba(var(--primary-color), 0.75); 65 | } 66 | 67 | .redeemable-item:focus { 68 | outline: none; 69 | } 70 | 71 | .redeemable-item img { 72 | width: 75%; 73 | height: 75%; 74 | 75 | object-fit: contain; 76 | 77 | border-radius: 10px; 78 | } 79 | 80 | .redeemable-item-disabled { 81 | filter: grayscale(100%); 82 | 83 | opacity: 0.5; 84 | 85 | pointer-events: none; 86 | } 87 | 88 | .redeemable-item-descriptor { 89 | position: absolute; 90 | 91 | padding: 10px; 92 | 93 | top: 0; 94 | bottom: 0; 95 | left: 0; 96 | right: 0; 97 | 98 | display: flex; 99 | flex-direction: column; 100 | justify-content: flex-end; 101 | gap: 10px; 102 | 103 | border-radius: var(--redeem-card-border-radius); 104 | background: linear-gradient(0deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); 105 | } 106 | 107 | .redeemable-item-title { 108 | font-size: 1.15rem; 109 | font-weight: bold; 110 | text-align: left; 111 | text-shadow: 0 0 5px #000; 112 | 113 | display: -webkit-box; 114 | line-clamp: 2; 115 | -webkit-line-clamp: 2; 116 | -webkit-box-orient: vertical; 117 | 118 | overflow: hidden; 119 | } 120 | 121 | .redeemable-item-price-wrapper { 122 | position: absolute; 123 | top: 10px; 124 | background-color: #fff; 125 | border-radius: 10px; 126 | padding: 2px 6px; 127 | border: 1px solid hsl(300, 45%, 45%); 128 | display: flex; 129 | flex-direction: row; 130 | align-items: center; 131 | justify-content: left; 132 | gap: 3px; 133 | 134 | transition: all; 135 | transition-duration: 0.15s; 136 | 137 | transform: translateY(-150%); 138 | } 139 | 140 | .redeemable-item:hover .redeemable-item-price-wrapper, 141 | .redeemable-item:focus .redeemable-item-price-wrapper { 142 | transform: translateY(0%); 143 | } 144 | .mobile .redeemable-item .redeemable-item-price-wrapper { 145 | transform: translateY(0%) !important; 146 | } 147 | 148 | .redeemable-item-price-wrapper>img { 149 | width: 20px; 150 | height: 20px; 151 | 152 | object-fit: contain; 153 | } 154 | 155 | .redeemable-item-price { 156 | color: #333; 157 | text-shadow: 0 0 3px #333; 158 | } -------------------------------------------------------------------------------- /frontend/www/css/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | width: 36px; 3 | height: 36px; 4 | 5 | border-radius: 100%; 6 | border: 4px solid #ffffff1A; 7 | border-top: 4px solid #fff; 8 | 9 | animation: spin 1s linear infinite; 10 | } 11 | 12 | @keyframes spin { 13 | 0% { 14 | transform: rotate(0deg); 15 | } 16 | 17 | 100% { 18 | transform: rotate(360deg); 19 | } 20 | } -------------------------------------------------------------------------------- /frontend/www/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% if (htmlWebpackPlugin.options.title=="Config" ) { %> 13 |

This extension is not configurable.

14 | <% } else { %> 15 |
16 |
17 |

Welcome to Swarm Control™

18 | 19 |

Please authenticate with Twitch in order to continue.

20 | 21 |
22 | 23 | 24 | 95 | 96 |
97 |
98 |
99 |

Loading content...

100 |
101 |
102 |
103 | 104 | 132 | <% } %> 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /frontend/www/img/bits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/frontend/www/img/bits.png -------------------------------------------------------------------------------- /frontend/www/src/index.ts: -------------------------------------------------------------------------------- 1 | import "../css/base.css"; 2 | import "../css/config.css"; 3 | import "../css/onboarding.css"; 4 | import "../css/alert.css"; 5 | import "../css/buttons.css"; 6 | import "../css/spinner.css"; 7 | import "../css/redeems.css"; 8 | import "../css/modals.css"; 9 | 10 | import "./modules/auth"; 11 | import "./modules/modal"; 12 | import "./modules/pubsub"; 13 | import "./modules/redeems"; 14 | import "./modules/transaction"; 15 | import "./util/twitch"; 16 | -------------------------------------------------------------------------------- /frontend/www/src/modules/auth.ts: -------------------------------------------------------------------------------- 1 | import { ebsFetch } from "../util/ebs"; 2 | import { renderRedeemButtons } from "./redeems"; 3 | import { refreshConfig, setConfig } from "../util/config"; 4 | import { onTwitchAuth, twitchAuth } from "../util/twitch"; 5 | import { clientSession } from "./transaction"; 6 | 7 | const $loginPopup = document.getElementById("onboarding")!; 8 | const $loginButton = document.getElementById("twitch-login")!; 9 | 10 | onTwitchAuth(onAuth); 11 | 12 | document.addEventListener("DOMContentLoaded", () => { 13 | $loginButton.onclick = async () => { 14 | await twitchAuth(); 15 | }; 16 | }); 17 | 18 | function onAuth(auth: Twitch.ext.Authorized) { 19 | if (!Twitch.ext.viewer.id) { 20 | $loginPopup.style.display = ""; 21 | return; 22 | } 23 | $loginPopup.style.display = "none"; 24 | ebsFetch("public/authorized", { 25 | method: "POST", 26 | headers: { "Content-Type": "application/json" }, 27 | body: JSON.stringify({ session: clientSession }), 28 | }).then((res) => { 29 | if (res.status === 403) { 30 | setBanned(true); 31 | } 32 | renderRedeemButtons().then(); 33 | }); 34 | } 35 | 36 | let _banned = false; 37 | const callbacks: (() => void)[] = []; 38 | 39 | export function getBanned() { 40 | return _banned; 41 | } 42 | 43 | export async function setBanned(banned: boolean) { 44 | if (_banned === banned) return; 45 | 46 | _banned = banned; 47 | if (banned) { 48 | callbacks.forEach((c) => c()); 49 | setConfig({ version: -1, message: "You have been banned from using this extension." }); 50 | renderRedeemButtons().then(); 51 | } else { 52 | await refreshConfig(); 53 | renderRedeemButtons().then(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/www/src/modules/modal/form.ts: -------------------------------------------------------------------------------- 1 | import { BooleanParam, EnumParam, LiteralTypes, NumericParam, Parameter, Redeem, TextParam, VectorParam } from "common/types"; 2 | import { $modalConfirm, cart, showErrorModal } from "."; 3 | import { getConfig } from "../../util/config"; 4 | 5 | /* Options */ 6 | export const $modalOptionsForm = document.getElementById("modal-options-form")! as HTMLFormElement; 7 | const $modalOptions = document.getElementById("modal-options")!; 8 | const $paramToggle = document.getElementById("modal-toggle")!; 9 | const $paramText = document.getElementById("modal-text")!; 10 | const $paramNumber = document.getElementById("modal-number")!; 11 | const $paramDropdown = document.getElementById("modal-dropdown")!; 12 | const $paramVector = document.getElementById("modal-vector")!; 13 | 14 | const $paramTemplates = { 15 | text: $paramText, 16 | number: $paramNumber, 17 | dropdown: $paramDropdown, 18 | toggle: $paramToggle, 19 | vector: $paramVector, 20 | }; 21 | 22 | document.addEventListener("DOMContentLoaded", () => { 23 | $modalOptionsForm.oninput = checkForm; 24 | $modalOptionsForm.onsubmit = (e) => { 25 | e.preventDefault(); 26 | setCartArgsFromForm(e.target as HTMLFormElement); 27 | }; 28 | }); 29 | 30 | export function setupForm(redeem: Redeem) { 31 | for (let node of Array.from($modalOptionsForm.childNodes)) $modalOptionsForm.removeChild(node); 32 | 33 | $modalOptions.style.display = (redeem.args || []).length === 0 ? "none" : "flex"; 34 | 35 | addOptionsFields($modalOptionsForm, redeem); 36 | } 37 | 38 | export function checkForm() { 39 | $modalConfirm.ariaDisabled = $modalOptionsForm.checkValidity() ? null : ""; 40 | } 41 | 42 | export function setCartArgsFromForm(form: HTMLFormElement) { 43 | const formData = new FormData(form); 44 | formData.forEach((val, name) => { 45 | const match = /(?\w+)\[(?\d{1,2})\]$/.exec(name); 46 | if (!match?.length) { 47 | cart!.args[name] = val; 48 | } else { 49 | const paramName = match.groups!["paramName"]; 50 | cart!.args[paramName] ??= []; 51 | const index = parseInt(match.groups!["index"]); 52 | cart!.args[paramName][index] = val; 53 | } 54 | }); 55 | } 56 | 57 | export function addOptionsFields(modal: HTMLFormElement, redeem: Redeem) { 58 | for (const param of redeem.args || []) { 59 | switch (param.type) { 60 | case LiteralTypes.String: 61 | addText(modal, param); 62 | break; 63 | case LiteralTypes.Integer: 64 | case LiteralTypes.Float: 65 | addNumeric(modal, param); 66 | break; 67 | case LiteralTypes.Boolean: 68 | addCheckbox(modal, param); 69 | break; 70 | case LiteralTypes.Vector: 71 | addVector(modal, param); 72 | break; 73 | default: 74 | addDropdown(modal, param).then(); 75 | break; 76 | } 77 | } 78 | } 79 | 80 | function addText(modal: HTMLElement, param: TextParam) { 81 | const field = $paramTemplates.text.cloneNode(true) as HTMLSelectElement; 82 | const input = field.querySelector("input")!; 83 | setupField(field, input, param); 84 | input.minLength = param.minLength ?? param.required ? 1 : 0; 85 | input.maxLength = param.maxLength ?? 255; 86 | if (param.defaultValue !== undefined) { 87 | input.value = param.defaultValue; 88 | } 89 | modal.appendChild(field); 90 | } 91 | 92 | function addNumeric(modal: HTMLElement, param: NumericParam) { 93 | const field = $paramTemplates.number.cloneNode(true) as HTMLSelectElement; 94 | const input = field.querySelector("input")!; 95 | input.type = "number"; 96 | if (param.type == LiteralTypes.Integer) { 97 | input.step = "1"; 98 | } else if (param.type == LiteralTypes.Float) { 99 | input.step = "0.01"; 100 | } 101 | input.min = param.min?.toString() ?? ""; 102 | input.max = param.max?.toString() ?? ""; 103 | setupField(field, input, param); 104 | 105 | if (Number.isFinite(param.defaultValue)) input.value = param.defaultValue!.toString(); 106 | 107 | modal.appendChild(field); 108 | } 109 | 110 | function addCheckbox(modal: HTMLElement, param: BooleanParam) { 111 | const field = $paramTemplates.toggle.cloneNode(true) as HTMLSelectElement; 112 | const input = field.querySelector("input")!; 113 | setupField(field, input, param); 114 | if (param.defaultValue !== undefined) { 115 | input.checked = param.defaultValue; 116 | } 117 | // browser says "required" means "must be checked" 118 | input.required = false; 119 | modal.appendChild(field); 120 | } 121 | 122 | async function addDropdown(modal: HTMLElement, param: EnumParam) { 123 | let options: string[] | undefined = []; 124 | 125 | options = (await getConfig()).enums?.[param.type]; 126 | if (!options) return; // someone's messing with the config, screw em 127 | 128 | const field = $paramTemplates.dropdown.cloneNode(true) as HTMLSelectElement; 129 | const select = field.querySelector("select")!; 130 | 131 | setupField(field, select, param); 132 | for (let i = 0; i < options.length; i++) { 133 | const option = document.createElement("option"); 134 | const name = options[i]; 135 | option.value = i.toString(); 136 | option.disabled = name.startsWith("[DISABLED] "); 137 | option.textContent = name.substring(option.disabled ? 11 : 0); 138 | select.appendChild(option); 139 | } 140 | const firstEnabled = Array.from(select.options).findIndex((op) => !op.disabled); 141 | if (firstEnabled < 0 || firstEnabled >= select.options.length) { 142 | console.error(`No enabled options in enum ${param.type}`); 143 | showErrorModal("Config error", `This redeem is misconfigured, please message AlexejheroDev\nError: ${param.type} has no enabled options`); 144 | return; 145 | } 146 | 147 | if (param.defaultValue !== undefined) { 148 | select.value = param.defaultValue; 149 | } else { 150 | select.value = select.options[firstEnabled].value; 151 | } 152 | modal.appendChild(field); 153 | } 154 | 155 | function addVector(modal: HTMLElement, param: VectorParam) { 156 | const field = $paramTemplates.vector.cloneNode(true) as HTMLSelectElement; 157 | const inputs = Array.from(field.querySelectorAll("input")!) as HTMLInputElement[]; 158 | 159 | for (let i = 0; i < 3; i++) { 160 | const input = inputs[i]; 161 | 162 | input.step = "1"; 163 | 164 | input.min = param.min?.toString() ?? ""; 165 | input.max = param.max?.toString() ?? ""; 166 | 167 | setupField(field, input, param, i); 168 | 169 | const defVal = param.defaultValue?.[i]; 170 | input.value = Number.isFinite(defVal) ? defVal!.toString() : "0"; 171 | } 172 | 173 | modal.appendChild(field); 174 | } 175 | 176 | function setupField(field: HTMLElement, inputElem: HTMLSelectElement | HTMLInputElement, param: Parameter, arrayIndex?: number) { 177 | const label = field.querySelector("label")!; 178 | 179 | field.id += "-" + param.name; 180 | 181 | if (param.description) { 182 | field.title = param.description; 183 | } 184 | 185 | inputElem.id += "-" + param.name; 186 | inputElem.name = param.name.concat(arrayIndex !== undefined ? `[${arrayIndex}]` : ""); 187 | 188 | label.id += "-" + param.name; 189 | label.htmlFor = inputElem.id; 190 | label.textContent = param.title ?? param.name; 191 | 192 | if (param.required) { 193 | inputElem.required = true; 194 | label.ariaRequired = ""; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /frontend/www/src/modules/modal/index.ts: -------------------------------------------------------------------------------- 1 | import { Cart, Redeem, TransactionToken, TransactionTokenPayload } from "common/types"; 2 | import { ebsFetch } from "../../util/ebs"; 3 | import { getConfig } from "../../util/config"; 4 | import { logToDiscord } from "../../util/logger"; 5 | import { setBanned } from "../auth"; 6 | import { clientSession, promptTransaction, transactionCancelled, transactionComplete, } from "../transaction"; 7 | import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; 8 | import { getJWTPayload as decodeJWT } from "../../util/jwt"; 9 | 10 | document.body.addEventListener("dblclick", (e) => { 11 | e.stopPropagation(); 12 | e.preventDefault(); 13 | }); 14 | 15 | /* Containers */ 16 | const $modalWrapper = document.getElementById("modal-wrapper")!; 17 | const $modal = document.getElementById("modal-wrapper")!.getElementsByClassName("modal")[0]!; 18 | const $modalInsideWrapper = $modal.getElementsByClassName("modal-inside-wrapper")[0]!; 19 | 20 | /* Descriptors */ 21 | const $modalTitle = document.getElementById("modal-title")!; 22 | const $modalDescription = document.getElementById("modal-description")!; 23 | const $modalImage = document.getElementById("modal-image")! as HTMLImageElement; 24 | 25 | /* Price */ 26 | const $modalPrice = document.getElementById("modal-bits")!; 27 | 28 | /* Buttons */ 29 | export const $modalConfirm = document.getElementById("modal-confirm")! as HTMLButtonElement; 30 | export const $modalCancel = document.getElementById("modal-cancel")! as HTMLButtonElement; 31 | 32 | /* Modal overlays */ 33 | const $modalProcessing = document.getElementById("modal-processing")!; 34 | const $modalProcessingDescription = document.getElementById("modal-processing-description")!; 35 | const $modalProcessingClose = document.getElementById("modal-processing-close")!; 36 | 37 | const $modalError = document.getElementById("modal-error")!; 38 | const $modalErrorTitle = document.getElementById("modal-error-title")!; 39 | const $modalErrorDescription = document.getElementById("modal-error-description")!; 40 | const $modalErrorClose = document.getElementById("modal-error-close")!; 41 | 42 | const $modalSuccess = document.getElementById("modal-success")!; 43 | const $modalSuccessTitle = document.getElementById("modal-success-title")!; 44 | const $modalSuccessDescription = document.getElementById("modal-success-description")!; 45 | const $modalSuccessClose = document.getElementById("modal-success-close")!; 46 | 47 | export let cart: Cart | undefined; 48 | export let transactionToken: TransactionToken | undefined; 49 | export let transactionTokenJwt: string | undefined; 50 | 51 | let processingTimeout: number | undefined; 52 | 53 | document.addEventListener("DOMContentLoaded", () => { 54 | $modalConfirm.onclick = confirmPurchase; 55 | $modalCancel.onclick = closeModal; 56 | 57 | // Twitch sets some parameters in the query string (https://dev.twitch.tv/docs/extensions/reference/#client-query-parameters) 58 | const queryParams = new URLSearchParams(window.location.search); 59 | if (queryParams.get("platform") === "mobile") { 60 | document.body.classList.add("mobile"); 61 | } 62 | 63 | $modalWrapper.onclick = (e) => { 64 | if (e.target !== $modalWrapper) return; 65 | if ($modalProcessing.style.opacity == "1") return; 66 | 67 | closeModal(); 68 | }; 69 | }); 70 | 71 | export async function openModal(redeem: Redeem | null) { 72 | if (redeem == null) { 73 | $modalWrapper.style.opacity = "1"; 74 | $modalWrapper.style.pointerEvents = "unset"; 75 | setTimeout(() => $modal.classList.add("active-modal"), 10); 76 | return; 77 | } 78 | if (redeem.disabled) return; 79 | 80 | const config = await getConfig(); 81 | 82 | cart = { version: config.version, clientSession, sku: redeem.sku, id: redeem.id, args: {} }; 83 | 84 | $modalWrapper.style.opacity = "1"; 85 | $modalWrapper.style.pointerEvents = "unset"; 86 | 87 | $modalTitle.textContent = redeem.title; 88 | $modalDescription.textContent = redeem.description; 89 | $modalPrice.textContent = redeem.price.toString(); 90 | $modalImage.src = redeem.image; 91 | 92 | // scroll to top of modal 93 | $modalInsideWrapper.scrollTop = 0; 94 | 95 | setTimeout(() => $modal.classList.add("active-modal"), 10); 96 | 97 | hideProcessingModal(); 98 | hideSuccessModal(); 99 | hideErrorModal(); 100 | 101 | setupForm(redeem); 102 | checkForm(); 103 | } 104 | 105 | export function showProcessingModal() { 106 | $modalProcessing.style.opacity = "1"; 107 | $modalProcessing.style.pointerEvents = "unset"; 108 | 109 | $modalProcessingDescription.style.display = "none"; 110 | $modalProcessingClose.style.display = "none"; 111 | 112 | if (processingTimeout) clearTimeout(processingTimeout); 113 | 114 | processingTimeout = +setTimeout(() => { 115 | $modalProcessingDescription.style.display = "unset"; 116 | $modalProcessingDescription.textContent = "This is taking longer than expected."; 117 | 118 | $modalProcessingClose.style.display = "unset"; 119 | $modalProcessingClose.onclick = () => { 120 | hideProcessingModal(); 121 | closeModal(); 122 | }; 123 | }, 30 * 1000); 124 | } 125 | 126 | export function showErrorModal(title: string, description: string) { 127 | $modalError.style.opacity = "1"; 128 | $modalError.style.pointerEvents = "unset"; 129 | $modalErrorTitle.textContent = title; 130 | $modalErrorDescription.innerText = description; 131 | $modalErrorClose.onclick = () => hideErrorModal(true); 132 | } 133 | 134 | export function showSuccessModal(title: string, description: string, onClose?: () => void) { 135 | $modalSuccess.style.opacity = "1"; 136 | $modalSuccess.style.pointerEvents = "unset"; 137 | $modalSuccessTitle.textContent = title; 138 | $modalSuccessDescription.innerText = description; 139 | $modalSuccessClose.onclick = () => { 140 | hideSuccessModal(true); 141 | onClose?.(); 142 | }; 143 | } 144 | 145 | function closeModal() { 146 | cart = undefined; 147 | transactionToken = undefined; 148 | 149 | $modal.classList.remove("active-modal"); 150 | 151 | setTimeout(() => { 152 | $modalWrapper.style.opacity = "0"; 153 | $modalWrapper.style.pointerEvents = "none"; 154 | }, 250); 155 | } 156 | 157 | export function hideProcessingModal() { 158 | $modalProcessing.style.opacity = "0"; 159 | $modalProcessing.style.pointerEvents = "none"; 160 | 161 | if (processingTimeout) clearTimeout(processingTimeout); 162 | } 163 | 164 | function hideErrorModal(closeMainModal = false) { 165 | $modalError.style.opacity = "0"; 166 | $modalError.style.pointerEvents = "none"; 167 | 168 | if (closeMainModal) closeModal(); 169 | } 170 | 171 | function hideSuccessModal(closeMainModal = false) { 172 | $modalSuccess.style.opacity = "0"; 173 | $modalSuccess.style.pointerEvents = "none"; 174 | 175 | if (closeMainModal) closeModal(); 176 | } 177 | 178 | async function confirmPurchase() { 179 | setCartArgsFromForm($modalOptionsForm); 180 | if (!$modalOptionsForm.reportValidity()) { 181 | return; 182 | } 183 | showProcessingModal(); 184 | 185 | if (!(await prePurchase()) || !transactionToken) { 186 | return; 187 | } 188 | 189 | logToDiscord({ 190 | transactionToken: transactionToken.id, 191 | userIdInsecure: Twitch.ext.viewer.id!, 192 | important: false, 193 | fields: [{ header: "Transaction started", content: cart }], 194 | }).then(); 195 | 196 | const product = transactionToken.product; 197 | const res = await promptTransaction(product.sku, product.cost); 198 | if (res === "cancelled") { 199 | await transactionCancelled(); 200 | } else { 201 | await transactionComplete(res); 202 | } 203 | } 204 | 205 | async function prePurchase(): Promise { 206 | if (!cart) { 207 | console.error("Can't send prepurchase without cart"); 208 | return false; 209 | } 210 | 211 | const response = await ebsFetch("/public/prepurchase", { 212 | method: "POST", 213 | headers: { "Content-Type": "application/json" }, 214 | body: JSON.stringify(cart), 215 | }); 216 | 217 | if (!response.ok) { 218 | hideProcessingModal(); 219 | if (response.status == 403) { 220 | setBanned(true); 221 | showErrorModal("You are banned from using this extension.", `${response.status} ${response.statusText} - ${await response.text()}\n`); 222 | } else { 223 | showErrorModal( 224 | "Invalid transaction, please try again.", 225 | `${response.status} ${response.statusText} - ${await response.text()}\nIf this problem persists, please refresh the page or contact a moderator (preferably AlexejheroDev).` 226 | ); 227 | } 228 | return false; 229 | } 230 | 231 | transactionTokenJwt = await response.text(); 232 | const decodedJWT = decodeJWT(transactionTokenJwt) as TransactionTokenPayload; 233 | console.log(decodedJWT); 234 | transactionToken = decodedJWT.data; 235 | if (transactionToken.user.id !== Twitch.ext.viewer.id) { 236 | logToDiscord({ 237 | transactionToken: transactionToken.id, 238 | userIdInsecure: Twitch.ext.viewer.id!, 239 | important: true, 240 | fields: [{ header: "Transaction token was not for me", content: { transactionTokenJwt } }], 241 | }).then(); 242 | showErrorModal("Server Error", "Server returned invalid transaction token. The developers have been notified, please try again later."); 243 | return false; 244 | } 245 | 246 | return true; 247 | } 248 | -------------------------------------------------------------------------------- /frontend/www/src/modules/pubsub.ts: -------------------------------------------------------------------------------- 1 | import { BannedData, Config, PubSubMessage } from "common/types"; 2 | import { setConfig } from "../util/config"; 3 | import { renderRedeemButtons } from "./redeems"; 4 | import { strToU8, decompressSync, strFromU8 } from "fflate"; 5 | import { getBanned, setBanned } from "./auth"; 6 | 7 | Twitch.ext.listen("global", onPubsubMessage); 8 | 9 | async function onPubsubMessage(target: string, contentType: string, message: string) { 10 | const fullMessage = JSON.parse(message) as PubSubMessage; 11 | 12 | console.log(fullMessage); 13 | 14 | switch (fullMessage.type) { 15 | case "config_refreshed": 16 | const config = JSON.parse(strFromU8(decompressSync(strToU8(fullMessage.data, true)))) as Config; 17 | setConfig(config); 18 | if (!getBanned()) { 19 | await renderRedeemButtons(); 20 | } 21 | break; 22 | case "banned": 23 | const data = JSON.parse(fullMessage.data) as BannedData; 24 | const bannedId = data.id; 25 | if (bannedId === Twitch.ext.viewer.id || bannedId === Twitch.ext.viewer.opaqueId) { 26 | setBanned(data.banned); 27 | } 28 | break; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/www/src/modules/redeems.ts: -------------------------------------------------------------------------------- 1 | import { hideProcessingModal, openModal, showErrorModal } from "./modal"; 2 | import { getConfig } from "../util/config"; 3 | 4 | const $mainContainer = document.getElementsByTagName("main")!; 5 | const $redeemContainer = document.getElementById("items")!; 6 | const $modalProcessing = document.getElementById("modal-processing")!; 7 | 8 | export async function renderRedeemButtons() { 9 | $redeemContainer.innerHTML = `

Loading content...

`; 10 | 11 | const config = await getConfig(); 12 | const redeems = Object.entries(config.redeems || {}); 13 | 14 | $redeemContainer.innerHTML = ""; 15 | 16 | const alerts = document.getElementsByClassName("alert"); 17 | while (alerts.length > 0) alerts[0].remove(); 18 | 19 | if (config.message) $mainContainer[0].insertAdjacentHTML("afterbegin", `
${config.message}
`); 20 | 21 | if (redeems.length === 0) $redeemContainer.innerHTML = `

No content is available.

`; 22 | 23 | for (const [id, redeem] of redeems) { 24 | if (redeem.hidden) continue; 25 | 26 | const item = document.createElement("button"); 27 | item.classList.add("redeemable-item"); 28 | if (redeem.disabled) { 29 | item.classList.add("redeemable-item-disabled"); 30 | } 31 | item.onclick = () => !redeem.disabled && openModal(redeem); 32 | 33 | const img = document.createElement("img"); 34 | img.src = redeem.image; 35 | item.appendChild(img); 36 | 37 | const redeemableDescriptor = document.createElement("div"); 38 | redeemableDescriptor.className = "redeemable-item-descriptor"; 39 | item.appendChild(redeemableDescriptor); 40 | 41 | const priceWrapper = document.createElement("div"); 42 | priceWrapper.className = "redeemable-item-price-wrapper"; 43 | item.appendChild(priceWrapper); 44 | 45 | const bitsImage = document.createElement("img"); 46 | bitsImage.src = "img/bits.png"; 47 | priceWrapper.appendChild(bitsImage); 48 | 49 | const price = document.createElement("p"); 50 | price.className = "redeemable-item-price"; 51 | price.textContent = redeem.price.toString(); 52 | priceWrapper.appendChild(price); 53 | 54 | const name = document.createElement("p"); 55 | name.className = "redeemable-item-title"; 56 | name.textContent = redeem.title; 57 | redeemableDescriptor.appendChild(name); 58 | 59 | $redeemContainer.appendChild(item); 60 | } 61 | 62 | if ($modalProcessing.style.opacity !== "1") { 63 | hideProcessingModal(); 64 | showErrorModal("New update!", "The items have been updated, because of this you need to reopen this modal."); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/www/src/modules/transaction.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from "common/types"; 2 | import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken, transactionTokenJwt } from "./modal"; 3 | import { logToDiscord } from "../util/logger"; 4 | import { ebsFetch } from "../util/ebs"; 5 | import { twitchUseBits } from "../util/twitch"; 6 | 7 | type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; 8 | 9 | export const clientSession = Math.random().toString(36).substring(2); 10 | 11 | export async function promptTransaction(sku: string, cost: number): Promise { 12 | console.log(`Purchasing ${sku} for ${cost} bits`); 13 | return await twitchUseBits(sku); 14 | } 15 | 16 | export async function transactionComplete(transaction: Twitch.ext.BitsTransaction) { 17 | if (!transactionToken) { 18 | logToDiscord({ 19 | transactionToken: null, 20 | userIdInsecure: Twitch.ext.viewer.id!, 21 | important: true, 22 | fields: [{ header: "Missing transaction token", content: transaction }], 23 | }).then(); 24 | await openModal(null); 25 | hideProcessingModal(); 26 | showErrorModal( 27 | "An error occurred.", 28 | "If you made a purchase from another tab/browser/mobile, you can safely ignore this message. Otherwise, please contant a moderator (preferably AlexejheroDev) about this!" 29 | ); 30 | return; 31 | } 32 | 33 | logToDiscord({ 34 | transactionToken: transactionToken.id, 35 | userIdInsecure: Twitch.ext.viewer.id!, 36 | important: false, 37 | fields: [{ header: "Transaction complete", content: transaction }], 38 | }).then(); 39 | 40 | const result = await ebsFetch("/public/transaction", { 41 | method: "POST", 42 | headers: { "Content-Type": "application/json" }, 43 | body: JSON.stringify({ 44 | token: transactionTokenJwt!, 45 | clientSession, 46 | ...{ receipt: transaction.transactionReceipt }, 47 | } satisfies Transaction), 48 | }); 49 | 50 | setTimeout(() => hideProcessingModal(), 250); 51 | 52 | const text = await result.text(); 53 | const cost = transactionToken.product.cost; 54 | if (result.ok) { 55 | showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken.id}`); 56 | } else { 57 | const errorText = `${result.status} ${result.statusText} - ${text}`; 58 | logToDiscord({ 59 | transactionToken: transactionToken.id, 60 | userIdInsecure: Twitch.ext.viewer.id!, 61 | important: true, 62 | fields: [{ header: "Redeem failed", content: errorText }], 63 | }).then(); 64 | showErrorModal( 65 | "An error occurred.", 66 | `${errorText} 67 | Please contact a moderator (preferably AlexejheroDev) about the error! 68 | Transaction ID: ${transactionToken.id}` 69 | ); 70 | } 71 | } 72 | 73 | export async function transactionCancelled() { 74 | if (transactionToken) { 75 | logToDiscord({ 76 | transactionToken: transactionToken.id, 77 | userIdInsecure: Twitch.ext.viewer.id!, 78 | important: false, 79 | fields: [{ header: "Transaction cancelled", content: "User cancelled the transaction." }], 80 | }).then(); 81 | 82 | await ebsFetch("/public/transaction/cancel", { 83 | method: "POST", 84 | headers: { "Content-Type": "application/json" }, 85 | body: JSON.stringify({ jwt: transactionTokenJwt }), 86 | }); 87 | } 88 | 89 | hideProcessingModal(); 90 | showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken?.id ?? "none"}`); 91 | } 92 | -------------------------------------------------------------------------------- /frontend/www/src/util/config.ts: -------------------------------------------------------------------------------- 1 | import { ebsFetch } from "./ebs"; 2 | import { Config } from "common/types"; 3 | 4 | let config: Config; 5 | 6 | const emptyConfig: Config = { 7 | version: -1, 8 | redeems: {}, 9 | enums: {}, 10 | }; 11 | 12 | async function fetchConfig() { 13 | const response = await ebsFetch("/public/config"); 14 | 15 | if (!response.ok) { 16 | return { 17 | ...emptyConfig, 18 | message: `An error occurred while fetching the config\n${response.status} ${response.statusText} - ${await response.text()}`, 19 | } satisfies Config; 20 | } 21 | 22 | const config: Config = await response.json(); 23 | 24 | return config; 25 | } 26 | 27 | export async function refreshConfig() { 28 | config = await fetchConfig(); 29 | } 30 | 31 | export async function getConfig(): Promise { 32 | if (!config) { 33 | config = await fetchConfig(); 34 | } 35 | 36 | return config; 37 | } 38 | 39 | export function setConfig(newConfig: Config) { 40 | config = newConfig; 41 | } 42 | -------------------------------------------------------------------------------- /frontend/www/src/util/ebs.ts: -------------------------------------------------------------------------------- 1 | const backendUrl = "https://subnautica.vedal.ai"; 2 | 3 | export async function ebsFetch(url: string, options: RequestInit = {}): Promise { 4 | while (!Twitch.ext.viewer.sessionToken) { 5 | await new Promise(resolve => setTimeout(resolve, 100)); 6 | } 7 | 8 | const headers = new Headers(options.headers); 9 | headers.set("Authorization", `Bearer ${Twitch.ext.viewer.sessionToken}`); 10 | 11 | try { 12 | return await fetch(new URL(url, backendUrl), { 13 | ...options, 14 | headers, 15 | }); 16 | } catch (e: any) { 17 | console.error(e); 18 | return new Response(null, { status: 500, statusText: "Internal Server Error" }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/www/src/util/jwt.ts: -------------------------------------------------------------------------------- 1 | // jsonwebtoken is node-only so we'll do this one manually 2 | export function getJWTPayload(token: string) { 3 | const firstDot = token.indexOf('.'); 4 | if (firstDot < 0) return null; 5 | 6 | const secondDot = token.indexOf('.', firstDot + 1); 7 | if (secondDot < 0) return null; 8 | 9 | const payload = token.substring(firstDot + 1, secondDot); 10 | try { 11 | return JSON.parse(atob(payload)); 12 | } catch (e) { 13 | console.error("failed to parse JWT", e); 14 | return null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/www/src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import { LogMessage } from "common/types"; 2 | 3 | const logEndpoint = `https://logger-subnautica.vedal.ai/log`; 4 | 5 | export async function logToDiscord(data: LogMessage) { 6 | try { 7 | const result = await fetch(logEndpoint, { 8 | method: "POST", 9 | headers: { "Content-Type": "application/json" }, 10 | body: JSON.stringify({ 11 | ...data, 12 | } satisfies LogMessage), 13 | }); 14 | 15 | if (!result.ok) { 16 | console.error("Failed to log to backend"); 17 | console.error(await result.text()); 18 | console.log(data); 19 | } 20 | } catch (e: any) { 21 | console.error("Error when logging to backend"); 22 | console.error(e); 23 | console.log(data); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/www/src/util/twitch.ts: -------------------------------------------------------------------------------- 1 | import { Callback } from "common/types"; 2 | 3 | type AuthResponse = Twitch.ext.Authorized; 4 | type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; 5 | 6 | class Callbacks { 7 | private persistent: Callback[] = []; 8 | private transient: Callback[] = []; 9 | 10 | public addPersistent(callback: Callback) { 11 | this.persistent.push(callback); 12 | } 13 | 14 | public addTransient(callback: Callback) { 15 | this.transient.push(callback); 16 | } 17 | 18 | public call(data: T) { 19 | this.persistent.forEach((cb) => cb(data)); 20 | this.transient.forEach((cb) => cb(data)); 21 | this.transient.splice(0, this.transient.length); 22 | } 23 | } 24 | 25 | const authCallbacks: Callbacks = new Callbacks(); 26 | const transactionCallbacks: Callbacks = new Callbacks(); 27 | 28 | Twitch.ext.onAuthorized((auth) => { 29 | authCallbacks.call(auth); 30 | }); 31 | 32 | Twitch.ext.bits.onTransactionComplete((transaction) => { 33 | transactionCallbacks.call(transaction); 34 | }); 35 | 36 | Twitch.ext.bits.onTransactionCancelled(() => { 37 | transactionCallbacks.call("cancelled"); 38 | }); 39 | 40 | export async function twitchAuth(requestIdShare = true): Promise { 41 | // if id is set, we're authorized 42 | if (!Twitch.ext.viewer.id && requestIdShare) { 43 | Twitch.ext.actions.requestIdShare(); 44 | } 45 | return new Promise(Callbacks.prototype.addTransient.bind(authCallbacks)); 46 | } 47 | 48 | export async function twitchUseBits(sku: string): Promise { 49 | Twitch.ext.bits.useBits(sku); 50 | return new Promise(Callbacks.prototype.addTransient.bind(transactionCallbacks)); 51 | } 52 | 53 | export function onTwitchAuth(callback: Callback) { 54 | authCallbacks.addPersistent(callback); 55 | } 56 | 57 | export function onTwitchBits(callback: Callback) { 58 | transactionCallbacks.addPersistent(callback); 59 | } 60 | -------------------------------------------------------------------------------- /images/MODS.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/MODS.gif -------------------------------------------------------------------------------- /images/airbladder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/airbladder.png -------------------------------------------------------------------------------- /images/bigerm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/bigerm.png -------------------------------------------------------------------------------- /images/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /images/ermfish_nametag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/ermfish_nametag.png -------------------------------------------------------------------------------- /images/ermshark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/ermshark.png -------------------------------------------------------------------------------- /images/flooding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/flooding.png -------------------------------------------------------------------------------- /images/gold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/gold.png -------------------------------------------------------------------------------- /images/loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/loading.png -------------------------------------------------------------------------------- /images/oxygen_plant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/oxygen_plant.png -------------------------------------------------------------------------------- /images/pda.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/pda.webp -------------------------------------------------------------------------------- /images/pepegaphone.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/pepegaphone.webp -------------------------------------------------------------------------------- /images/pishock.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/pishock.webp -------------------------------------------------------------------------------- /images/recaptcha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/recaptcha.png -------------------------------------------------------------------------------- /images/seamonkey.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/seamonkey.webp -------------------------------------------------------------------------------- /images/seatruck_docking_module.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/seatruck_docking_module.png -------------------------------------------------------------------------------- /images/seatruck_signal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/seatruck_signal.png -------------------------------------------------------------------------------- /images/signal.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/signal.webp -------------------------------------------------------------------------------- /images/thermal_plant_fragments.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/thermal_plant_fragments.gif -------------------------------------------------------------------------------- /images/tomfooleryphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/tomfooleryphone.png -------------------------------------------------------------------------------- /images/trashcan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/trashcan.png -------------------------------------------------------------------------------- /images/tutel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/tutel.png -------------------------------------------------------------------------------- /images/wysi.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VedalAI/swarm-control/7899c128f56b4344a37366e8966cf1d3d06578d3/images/wysi.webp -------------------------------------------------------------------------------- /logger/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | RUN npm run build 12 | 13 | EXPOSE 3000 14 | 15 | CMD ["node", "--enable-source-maps", "dist/index.js"] 16 | -------------------------------------------------------------------------------- /logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logger", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "npx tsx watch src/index.ts", 7 | "build": "esbuild --bundle --minify --platform=node --sourcemap=inline --outfile=dist/index.js src/index.ts" 8 | }, 9 | "dependencies": { 10 | "@vermaysha/discord-webhook": "^1.4.0", 11 | "cors": "^2.8.5", 12 | "dotenv": "^16.4.5", 13 | "express": "^4.19.2", 14 | "mysql2": "^3.10.0", 15 | "tslib": "^2.6.3" 16 | }, 17 | "devDependencies": { 18 | "@types/cors": "^2.8.17", 19 | "@types/express": "^4.17.21", 20 | "tsx": "^4.13.2", 21 | "typescript": "^5.4.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /logger/src/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import cors from "cors"; 3 | import express from "express"; 4 | import bodyParser from "body-parser"; 5 | import { initDb } from "./util/db"; 6 | 7 | const port = 3000; 8 | 9 | export const app = express(); 10 | app.use(cors({ origin: "*" })); 11 | app.use(bodyParser.json()); 12 | 13 | app.get("/", (_, res) => { 14 | res.send("YOU ARE TRESPASSING ON PRIVATE PROPERTY YOU HAVE 5 SECONDS TO GET OUT OR I WILL CALL THE POLICE"); 15 | }); 16 | 17 | async function main() { 18 | await initDb(); 19 | 20 | app.listen(port, () => { 21 | console.log("Listening on port " + port); 22 | 23 | require("./modules/endpoints"); 24 | }); 25 | } 26 | 27 | main().catch(console.error); 28 | -------------------------------------------------------------------------------- /logger/src/modules/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { app } from ".."; 2 | import { logToDiscord } from "../util/discord"; 3 | import { LogMessage, OrderState } from "common/types"; 4 | import { getOrderById, getUserById, logToDatabase } from "../util/db"; 5 | 6 | // prevent replaying completed transactions 7 | const orderStatesCanLog: { [key in OrderState]: boolean } = { 8 | rejected: false, // completed 9 | prepurchase: true, // idk some js errors or something 10 | cancelled: false, // completed 11 | paid: true, // log timeout response 12 | failed: true, // log error 13 | succeeded: false, // completed 14 | }; 15 | // allow frontend to send logs for orders that were just completed 16 | // since frontend always finds out about errors after the ebs 17 | const completedOrderLogGracePeriod = 5 * 1000; 18 | const rejectLogsWithNoToken = true; 19 | 20 | app.post("/log", async (req, res) => { 21 | try { 22 | const logMessage = req.body as LogMessage & { backendToken?: string }; 23 | const isBackendRequest = process.env.PRIVATE_LOGGER_TOKEN == logMessage.backendToken; 24 | 25 | const logDenied = await canLog(logMessage, isBackendRequest); 26 | if (logDenied) { 27 | res.status(logDenied.status).send(logDenied.reason); 28 | return; 29 | } 30 | 31 | await logToDatabase(logMessage, isBackendRequest); 32 | 33 | if (logMessage.important) { 34 | logToDiscord(logMessage, isBackendRequest); 35 | } 36 | 37 | res.sendStatus(200); 38 | } catch (e: any) { 39 | console.error("Failed to log"); 40 | console.error(e); 41 | res.status(500).send("Failed to log"); 42 | } 43 | }); 44 | 45 | type LogDenied = { 46 | status: number; 47 | reason: string; 48 | }; 49 | async function canLog(logMessage: LogMessage, isBackendRequest: boolean): Promise { 50 | if (isBackendRequest) return null; 51 | 52 | if (!logMessage.transactionToken && rejectLogsWithNoToken) return { status: 400, reason: "Invalid transaction token." }; 53 | 54 | const claimedUser = await getUserById(logMessage.userIdInsecure); 55 | if (!claimedUser) { 56 | return { status: 403, reason: "Invalid user id." }; 57 | } 58 | if (claimedUser.banned) { 59 | return { status: 403, reason: "User is banned." }; 60 | } 61 | 62 | const order = await getOrderById(logMessage.transactionToken); 63 | if (!order || (!orderStatesCanLog[order.state] && Date.now() - order.updatedAt > completedOrderLogGracePeriod)) { 64 | return { status: 400, reason: "Invalid transaction token." }; 65 | } 66 | 67 | const errorContext: LogMessage = { 68 | transactionToken: logMessage.transactionToken, 69 | userIdInsecure: logMessage.userIdInsecure, 70 | important: true, 71 | fields: [{ header: "", content: {} }], 72 | }; 73 | const errorMessage = errorContext.fields[0]; 74 | 75 | const user = await getUserById(order.userId); 76 | if (!user) { 77 | errorMessage.header = "Tried to log for order whose userId is not in users table"; 78 | errorMessage.content = { 79 | orderUser: order.userId, 80 | order: order.id, 81 | logMessage, 82 | }; 83 | logToDiscord(errorContext, false); 84 | logToDatabase(errorContext, false).then(); 85 | return { status: 500, reason: "Invalid user id in transaction." }; 86 | } 87 | if (user.id != logMessage.userIdInsecure) { 88 | errorMessage.header = "Someone tried to bamboozle the logger user id check"; 89 | errorMessage.content = { 90 | claimedUser: logMessage.userIdInsecure, 91 | orderUser: user.id, 92 | order: order.id, 93 | logMessage, 94 | }; 95 | logToDiscord(errorContext, false); 96 | logToDatabase(errorContext, false).then(); 97 | return { status: 403, reason: "Invalid user id." }; 98 | } 99 | 100 | if (user.banned) { 101 | return { status: 403, reason: "User is banned." }; 102 | } 103 | 104 | return null; 105 | } 106 | -------------------------------------------------------------------------------- /logger/src/util/db.ts: -------------------------------------------------------------------------------- 1 | import { RowDataPacket } from "mysql2"; 2 | import { LogMessage, Order, User } from "common/types"; 3 | import { stringify } from "./stringify"; 4 | import { logToDiscord } from "./discord"; 5 | import mysql from "mysql2/promise"; 6 | 7 | export let db: mysql.Connection; 8 | 9 | export async function initDb() { 10 | while (!db) { 11 | try { 12 | db = await mysql.createConnection({ 13 | host: process.env.MYSQL_HOST, 14 | user: process.env.MYSQL_USER, 15 | password: process.env.MYSQL_PASSWORD, 16 | database: process.env.MYSQL_DATABASE, 17 | namedPlaceholders: true, 18 | }); 19 | } catch { 20 | console.log("Failed to connect to database. Retrying in 1 second..."); 21 | await new Promise((resolve) => setTimeout(resolve, 1000)); 22 | } 23 | } 24 | } 25 | 26 | async function getById(table: string, id: string | null): Promise { 27 | try { 28 | if (!id) return null; 29 | const [rows] = (await db.query(`SELECT * FROM ${table} WHERE id = ?`, [id])) as [RowDataPacket[], any]; 30 | return (rows[0] as T) || null; 31 | } catch (e: any) { 32 | console.error(`Database query failed (getById from ${table})`); 33 | console.error(e); 34 | return null; 35 | } 36 | } 37 | 38 | export async function getOrderById(orderId: string | null): Promise { 39 | return getById("orders", orderId); 40 | } 41 | 42 | export async function getUserById(userId: string | null): Promise { 43 | return getById("users", userId); 44 | } 45 | 46 | export async function logToDatabase(logMessage: LogMessage, isFromBackend: boolean) { 47 | try { 48 | await db.query("INSERT INTO logs (userId, transactionToken, data, fromBackend) VALUES (?, ?, ?, ?)", [ 49 | logMessage.userIdInsecure, 50 | logMessage.transactionToken, 51 | stringify(logMessage, isFromBackend), 52 | isFromBackend, 53 | ]); 54 | } catch (e: any) { 55 | console.error("Database query failed (logToDatabase)"); 56 | console.error(e); 57 | 58 | if (!logMessage.important) logToDiscord(logMessage, isFromBackend); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /logger/src/util/discord.ts: -------------------------------------------------------------------------------- 1 | import { Webhook } from "@vermaysha/discord-webhook"; 2 | import { LogMessage } from "common/types"; 3 | import { stringify } from "./stringify"; 4 | 5 | const hook = new Webhook(process.env.DISCORD_WEBHOOK!); 6 | hook.setUsername("Swarm Control"); 7 | 8 | function log(message: string) { 9 | console.error(message); 10 | hook.setContent(message.substring(0, 1950)); 11 | hook.send().then(); 12 | } 13 | 14 | export function logToDiscord(logMessage: LogMessage, isFromBackend: boolean) { 15 | log(stringify(logMessage, isFromBackend)); 16 | } 17 | -------------------------------------------------------------------------------- /logger/src/util/stringify.ts: -------------------------------------------------------------------------------- 1 | import { LogMessage } from "common/types"; 2 | 3 | export function stringify(logMessage: LogMessage, isFromBackend: boolean) { 4 | let data = ""; 5 | 6 | if (logMessage.important) data += "<@183249892712513536>\n"; 7 | 8 | data += `${logMessage.userIdInsecure} | ${logMessage.transactionToken} | ${isFromBackend ? "Backend" : "Extension"}\n`; 9 | 10 | for (const field of logMessage.fields) { 11 | if (field.content) { 12 | let contentStr = field.content.toString(); 13 | if (contentStr == "[object Object]") contentStr = JSON.stringify(field.content, null, 4); 14 | data += `### ${field.header}\n\`\`\`${contentStr}\`\`\`\n`; 15 | } else { 16 | data += `### ${field.header}\n`; 17 | } 18 | } 19 | 20 | return data; 21 | } 22 | -------------------------------------------------------------------------------- /logger/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es6", 5 | "outDir": "./dist/", 6 | "noImplicitAny": true, 7 | "allowJs": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "moduleResolution": "node" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swarm-control", 3 | "version": "1.0.0", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/VedalAI/swarm-control.git" 8 | }, 9 | "workspaces": [ 10 | "common", 11 | "ebs", 12 | "frontend", 13 | "logger" 14 | ], 15 | "devDependencies": { 16 | "prettier": "^3.3.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/access_db.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | sudo docker exec -it swarm-control-db-1 /bin/mysql 3 | -------------------------------------------------------------------------------- /scripts/attach_ebs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | git pull 3 | sudo docker compose up --build 4 | -------------------------------------------------------------------------------- /scripts/run_ebs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | git pull 3 | sudo docker compose up -d --build 4 | sudo docker compose logs -f -t --since 1m 5 | -------------------------------------------------------------------------------- /scripts/sql/init_db.sql: -------------------------------------------------------------------------------- 1 | USE ebs; 2 | 3 | CREATE TABLE IF NOT EXISTS users ( 4 | id VARCHAR(255) PRIMARY KEY, 5 | login VARCHAR(255), 6 | displayName VARCHAR(255), 7 | banned BOOLEAN 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS orders ( 11 | id VARCHAR(36) PRIMARY KEY, 12 | userId VARCHAR(255) NOT NULL, 13 | state ENUM('rejected', 'prepurchase', 'cancelled', 'paid', 'failed', 'succeeded'), 14 | cart JSON, 15 | receipt VARCHAR(1024), 16 | result TEXT, 17 | createdAt BIGINT, 18 | updatedAt BIGINT 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS logs ( 22 | id INT PRIMARY KEY AUTO_INCREMENT, 23 | userId VARCHAR(255), 24 | transactionToken VARCHAR(255), 25 | data TEXT NOT NULL, 26 | fromBackend BOOLEAN NOT NULL, 27 | timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP 28 | ); 29 | 30 | DELIMITER $$ 31 | DROP PROCEDURE IF EXISTS debug 32 | $$ 33 | CREATE PROCEDURE debug() 34 | BEGIN 35 | SET GLOBAL general_log = 'ON'; 36 | SET GLOBAL log_output = 'TABLE'; 37 | -- Then use: 38 | -- SELECT * FROM mysql.general_log; 39 | END 40 | $$ 41 | DELIMITER ; 42 | --------------------------------------------------------------------------------