mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
⭐feat: Add Steam account linking with team creation (#274)
## Description <!-- Briefly describe the purpose and scope of your changes --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a real-time Steam login flow using QR codes and server-sent events (SSE) for team creation and authentication. - Added Steam account and friend management, including secure credential storage and friend list synchronization. - Integrated Steam login endpoints into the API, enabling QR code-based login and automated team setup. - **Improvements** - Enhanced data security by implementing encrypted storage for sensitive tokens. - Updated database schema to support Steam accounts, teams, memberships, and social connections. - Refined type definitions and consolidated account-related information for improved consistency. - **Bug Fixes** - Fixed trade ban status representation for Steam accounts. - **Chores** - Removed legacy C# Steam authentication service and related configuration files. - Updated and cleaned up package dependencies and development tooling. - Streamlined type declaration files and resource definitions. - **Style** - Redesigned the team creation page UI with a modern, animated QR code login interface. - **Documentation** - Updated OpenAPI documentation for new Steam login endpoints. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1 +0,0 @@
|
||||
#FIXME: A simple docker-compose file for running the MoQ relay and the cachyos server
|
||||
@@ -1,5 +1,6 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { isPermanentStage } from "./stage";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||
export const postgres = new sst.aws.Aurora("Database", {
|
||||
@@ -41,7 +42,7 @@ export const postgres = new sst.aws.Aurora("Database", {
|
||||
|
||||
|
||||
new sst.x.DevCommand("Studio", {
|
||||
link: [postgres],
|
||||
link: [postgres, steamEncryptionKey],
|
||||
dev: {
|
||||
command: "bun db:dev studio",
|
||||
directory: "packages/core",
|
||||
|
||||
29
nestri.sln
29
nestri.sln
@@ -1,29 +0,0 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "steam", "packages\steam\steam.csproj", "{96118F95-BF02-0ED3-9042-36FA1B740D67}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -18,11 +18,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@rocicorp/zero": "0.16.2025022000"
|
||||
"@rocicorp/zero": "0.16.2025022000",
|
||||
"steam-session": "1.9.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@macaron-css/solid@1.5.3": "patches/@macaron-css%2Fsolid@1.5.3.patch",
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch"
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch",
|
||||
"steam-session@1.9.3": "patches/steam-session@1.9.3.patch"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"core-js-pure",
|
||||
|
||||
94
packages/core/migrations/0010_certain_dust.sql
Normal file
94
packages/core/migrations/0010_certain_dust.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
CREATE TYPE "public"."member_role" AS ENUM('child', 'adult');--> statement-breakpoint
|
||||
CREATE TYPE "public"."steam_status" AS ENUM('online', 'offline', 'dnd', 'playing');--> statement-breakpoint
|
||||
CREATE TABLE "steam_account_credentials" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"expiry" timestamp with time zone NOT NULL,
|
||||
"username" varchar(255) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "friends_list" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"friend_steam_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "friends_list_steam_id_friend_steam_id_pk" PRIMARY KEY("steam_id","friend_steam_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "members" (
|
||||
"id" char(30) NOT NULL,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"user_id" char(30),
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"role" "member_role" NOT NULL,
|
||||
CONSTRAINT "members_id_team_id_pk" PRIMARY KEY("id","team_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "steam_accounts" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" char(30),
|
||||
"status" "steam_status" NOT NULL,
|
||||
"last_synced_at" timestamp with time zone NOT NULL,
|
||||
"real_name" varchar(255),
|
||||
"member_since" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"profile_url" varchar(255),
|
||||
"username" varchar(255) NOT NULL,
|
||||
"avatar_hash" varchar(255) NOT NULL,
|
||||
"limitations" json NOT NULL,
|
||||
CONSTRAINT "idx_steam_username" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "teams" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"owner_id" char(30) NOT NULL,
|
||||
"invite_code" varchar(10) NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"max_members" bigint NOT NULL,
|
||||
CONSTRAINT "idx_team_invite_code" UNIQUE("invite_code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"avatar_url" text,
|
||||
"last_login" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"polar_customer_id" varchar(255),
|
||||
CONSTRAINT "idx_user_email" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "machine" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "member" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "steam" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "subscription" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "team" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "user" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" ADD CONSTRAINT "steam_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_slug_steam_accounts_username_fk" FOREIGN KEY ("slug") REFERENCES "public"."steam_accounts"("username") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_steam_id" ON "members" USING btree ("team_id","steam_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_user_id" ON "members" USING btree ("team_id","user_id") WHERE "members"."user_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_team_slug" ON "teams" USING btree ("slug");
|
||||
651
packages/core/migrations/meta/0010_snapshot.json
Normal file
651
packages/core/migrations/meta/0010_snapshot.json
Normal file
@@ -0,0 +1,651 @@
|
||||
{
|
||||
"id": "56a4d60a-c062-47e5-a97e-625443411ad8",
|
||||
"prevId": "1717c769-cee0-4242-bcbb-9538c80d985c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.steam_account_credentials": {
|
||||
"name": "steam_account_credentials",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expiry": {
|
||||
"name": "expiry",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_account_credentials_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "steam_account_credentials_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "steam_account_credentials",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.members": {
|
||||
"name": "members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_member_steam_id": {
|
||||
"name": "idx_member_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_member_user_id": {
|
||||
"name": "idx_member_user_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"members\".\"user_id\" is not null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"members_user_id_users_id_fk": {
|
||||
"name": "members_user_id_users_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"members_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "members_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "restrict"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"members_id_team_id_pk": {
|
||||
"name": "members_id_team_id_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"team_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_steam_username": {
|
||||
"name": "idx_steam_username",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"invite_code": {
|
||||
"name": "invite_code",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_members": {
|
||||
"name": "max_members",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_team_slug": {
|
||||
"name": "idx_team_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"teams_owner_id_users_id_fk": {
|
||||
"name": "teams_owner_id_users_id_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"teams_slug_steam_accounts_username_fk": {
|
||||
"name": "teams_slug_steam_accounts_username_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"slug"
|
||||
],
|
||||
"columnsTo": [
|
||||
"username"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_team_invite_code": {
|
||||
"name": "idx_team_invite_code",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"invite_code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.member_role": {
|
||||
"name": "member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"child",
|
||||
"adult"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1744651817581,
|
||||
"tag": "0009_luxuriant_wraith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1746726715456,
|
||||
"tag": "0010_certain_dust",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,24 +17,22 @@
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"loops": "^3.4.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.21.2",
|
||||
"ulid": "^2.3.0",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1",
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot-data-plane": "^3.758.0",
|
||||
"@aws-sdk/client-rds-data": "^3.758.0",
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@instantdb/admin": "^0.17.7",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"postgres": "^3.4.5"
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"postgres": "^3.4.5",
|
||||
"steam-session": "*"
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export namespace Actor {
|
||||
export interface Token {
|
||||
type: "steam";
|
||||
properties: {
|
||||
steamID: bigint;
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
20
packages/core/src/credentials/credentials.sql.ts
Normal file
20
packages/core/src/credentials/credentials.sql.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { pgTable, varchar } from "drizzle-orm/pg-core";
|
||||
import { encryptedText, timestamps, utc } from "../drizzle/types";
|
||||
|
||||
export const steamCredentialsTable = pgTable(
|
||||
"steam_account_credentials",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
refreshToken: encryptedText("refresh_token")
|
||||
.notNull(),
|
||||
expiry: utc("expiry").notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
}
|
||||
)
|
||||
115
packages/core/src/credentials/index.ts
Normal file
115
packages/core/src/credentials/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, and, isNull, gt } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamCredentialsTable } from "./credentials.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Credentials {
|
||||
export const Info = createSelectSchema(steamCredentialsTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
.extend({
|
||||
accessToken: z.string(),
|
||||
cookies: z.string().array()
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_credentials.added",
|
||||
z.object({
|
||||
steamID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({ accessToken: true, cookies: true, expiry: true }),
|
||||
(input) => {
|
||||
const part = input.refreshToken.split('.')[1] as string
|
||||
|
||||
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
|
||||
|
||||
return createTransaction(async (tx) => {
|
||||
const id = input.id
|
||||
await tx
|
||||
.insert(steamCredentialsTable)
|
||||
.values({
|
||||
id,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
expiry: new Date(payload.exp * 1000),
|
||||
})
|
||||
// await afterTx(async () =>
|
||||
// await bus.publish(Resource.Bus, Events.New, { steamID: input.id })
|
||||
// );
|
||||
return id
|
||||
})
|
||||
});
|
||||
|
||||
export const getByID = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) => {
|
||||
const now = new Date()
|
||||
|
||||
const credential = await tx
|
||||
.select()
|
||||
.from(steamCredentialsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamCredentialsTable.id, id),
|
||||
isNull(steamCredentialsTable.timeDeleted),
|
||||
gt(steamCredentialsTable.expiry, now)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.at(0));
|
||||
|
||||
if (!credential) return null;
|
||||
|
||||
return serialize(credential);
|
||||
})
|
||||
);
|
||||
|
||||
// export const getBySteamID = fn(
|
||||
// Info.shape.steamID,
|
||||
// (steamID) =>
|
||||
// useTransaction(async (tx) => {
|
||||
// const now = new Date()
|
||||
|
||||
// const credential = await tx
|
||||
// .select()
|
||||
// .from(steamCredentialsTable)
|
||||
// .where(
|
||||
// and(
|
||||
// eq(steamCredentialsTable.steamID, steamID),
|
||||
// isNull(steamCredentialsTable.timeDeleted),
|
||||
// gt(steamCredentialsTable.expiry, now)
|
||||
// )
|
||||
// )
|
||||
// .execute()
|
||||
// .then(rows => rows.at(0));
|
||||
|
||||
// if (!credential) return null;
|
||||
|
||||
// return serialize(credential);
|
||||
// })
|
||||
// );
|
||||
|
||||
export function serialize(
|
||||
input: typeof steamCredentialsTable.$inferSelect,
|
||||
) {
|
||||
return {
|
||||
id: input.id,
|
||||
expiry: input.expiry,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { Token } from "../utils";
|
||||
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
@@ -33,6 +33,19 @@ export const utc = (name: string) =>
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const encryptedText =
|
||||
customType<{ data: string; driverData: string; }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
},
|
||||
fromDriver(val) {
|
||||
return Token.decrypt(val);
|
||||
},
|
||||
toDriver(val) {
|
||||
return Token.encrypt(val);
|
||||
},
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
|
||||
@@ -44,7 +44,7 @@ export namespace Examples {
|
||||
accountStatus: "new" as const, //active or pending
|
||||
limitations: {
|
||||
isLimited: false,
|
||||
isTradeBanned: false,
|
||||
tradeBanState: "none" as const,
|
||||
isVacBanned: false,
|
||||
visibilityState: 3,
|
||||
privacyState: "public" as const,
|
||||
|
||||
25
packages/core/src/friend/friend.sql.ts
Normal file
25
packages/core/src/friend/friend.sql.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { pgTable,primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const friendTable = pgTable(
|
||||
"friends_list",
|
||||
{
|
||||
...timestamps,
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
friendSteamID: varchar("friend_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.friendSteamID]
|
||||
}),
|
||||
]
|
||||
);
|
||||
166
packages/core/src/friend/index.ts
Normal file
166
packages/core/src/friend/index.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { User } from "../user";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { friendTable } from "./friend.sql";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
|
||||
export namespace Friend {
|
||||
export const Info = Steam.Info
|
||||
.extend({
|
||||
user: User.Info.nullable().openapi({
|
||||
description: "The user account that owns this Steam account",
|
||||
example: Examples.User
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Friend",
|
||||
description: "Represents a friend's information stored on Nestri",
|
||||
example: { ...Examples.SteamAccount, user: Examples.User },
|
||||
});
|
||||
|
||||
export const InputInfo = createSelectSchema(friendTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type InputInfo = z.infer<typeof InputInfo>;
|
||||
|
||||
export const add = fn(
|
||||
InputInfo.partial({ steamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const steamID = input.steamID ?? Actor.steamID()
|
||||
if (steamID === input.friendSteamID) {
|
||||
throw new VisibleError(
|
||||
"forbidden",
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
"Cannot add yourself as a friend"
|
||||
);
|
||||
}
|
||||
|
||||
await tx
|
||||
.insert(friendTable)
|
||||
.values({
|
||||
steamID,
|
||||
friendSteamID: input.friendSteamID
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [friendTable.steamID, friendTable.friendSteamID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return steamID
|
||||
}),
|
||||
)
|
||||
|
||||
export const end = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(friendTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, input.steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const list = async () =>
|
||||
useTransaction(async (tx) => {
|
||||
const userSteamAccounts =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(eq(steamTable.userID, Actor.userID()))
|
||||
.execute();
|
||||
|
||||
if (userSteamAccounts.length === 0) {
|
||||
return []; // User has no steam accounts
|
||||
}
|
||||
|
||||
const friendPromises =
|
||||
userSteamAccounts.map(async (steamAccount) => {
|
||||
return await fromSteamID(steamAccount.id)
|
||||
})
|
||||
|
||||
return (await Promise.all(friendPromises)).flat()
|
||||
})
|
||||
|
||||
export const fromSteamID = fn(
|
||||
InputInfo.shape.steamID,
|
||||
(steamID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam: steamTable,
|
||||
user: userTable
|
||||
})
|
||||
.from(friendTable)
|
||||
.innerJoin(
|
||||
steamTable,
|
||||
eq(friendTable.friendSteamID, steamTable.id)
|
||||
)
|
||||
.leftJoin(
|
||||
userTable,
|
||||
eq(steamTable.userID, userTable.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, steamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(friendTable.timeCreated)
|
||||
.limit(100)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows))
|
||||
)
|
||||
)
|
||||
|
||||
export const areFriends = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, input.steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
|
||||
return result.length > 0
|
||||
})
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.steam.id.toString()),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...Steam.serialize(group[0].steam),
|
||||
user: group[0].user ? User.serialize(group[0].user!) : null
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { steamTable, StatusEnum, AccountStatusEnum, Limitations } from "./steam.sql";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
|
||||
export namespace Steam {
|
||||
export const Info = z
|
||||
@@ -25,10 +26,6 @@ export namespace Steam {
|
||||
description: "The current connection status of this Steam account",
|
||||
example: Examples.SteamAccount.status
|
||||
}),
|
||||
accountStatus: z.enum(AccountStatusEnum.enumValues).openapi({
|
||||
description: "The current status of this Steam account",
|
||||
example: Examples.SteamAccount.accountStatus
|
||||
}),
|
||||
userID: z.string().nullable().openapi({
|
||||
description: "The user id of which account owns this steam account",
|
||||
example: Examples.SteamAccount.userID
|
||||
@@ -45,7 +42,7 @@ export namespace Steam {
|
||||
example: Examples.SteamAccount.username
|
||||
})
|
||||
.default("unknown"),
|
||||
realName: z.string().openapi({
|
||||
realName: z.string().nullable().openapi({
|
||||
description: "The real name behind of this Steam account",
|
||||
example: Examples.SteamAccount.realName
|
||||
}),
|
||||
@@ -100,7 +97,6 @@ export namespace Steam {
|
||||
useUser: true,
|
||||
userID: true,
|
||||
status: true,
|
||||
accountStatus: true,
|
||||
lastSyncedAt: true
|
||||
}),
|
||||
(input) =>
|
||||
@@ -131,67 +127,65 @@ export namespace Steam {
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
limitations: input.limitations,
|
||||
status: input.status ?? "offline",
|
||||
username: input.username ?? "unknown",
|
||||
accountStatus: input.accountStatus ?? "new",
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
})
|
||||
|
||||
await afterTx(async () =>
|
||||
bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
|
||||
);
|
||||
// await afterTx(async () =>
|
||||
// bus.publish(Resource.Bus, Events.Created, { userID, steamID: input.id })
|
||||
// );
|
||||
|
||||
return input.id
|
||||
}),
|
||||
);
|
||||
|
||||
export const update = fn(
|
||||
Info
|
||||
.extend({
|
||||
useUser: z.boolean(),
|
||||
})
|
||||
.partial({
|
||||
useUser: true,
|
||||
userID: true,
|
||||
status: true,
|
||||
lastSyncedAt: true,
|
||||
avatarHash: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
limitations: true,
|
||||
accountStatus: true,
|
||||
name: true,
|
||||
profileUrl: true,
|
||||
steamMemberSince: true,
|
||||
}),
|
||||
async (input) =>
|
||||
useTransaction(async (tx) => {
|
||||
const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : undefined;
|
||||
await tx
|
||||
.update(steamTable)
|
||||
.set({
|
||||
userID,
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
status: input.status ?? "offline",
|
||||
username: input.username ?? "unknown",
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
accountStatus: input.accountStatus ?? "new",
|
||||
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
})
|
||||
.where(eq(steamTable.id, input.id));
|
||||
// TODO: This needs to be handled better, as it has the potential to turn unnecessary fields into `null`
|
||||
// export const update = fn(
|
||||
// Info
|
||||
// .extend({
|
||||
// useUser: z.boolean(),
|
||||
// })
|
||||
// .partial({
|
||||
// useUser: true,
|
||||
// userID: true,
|
||||
// status: true,
|
||||
// name: true,
|
||||
// lastSyncedAt: true,
|
||||
// avatarHash: true,
|
||||
// username: true,
|
||||
// realName: true,
|
||||
// limitations: true,
|
||||
// profileUrl: true,
|
||||
// steamMemberSince: true,
|
||||
// }),
|
||||
// async (input) =>
|
||||
// useTransaction(async (tx) => {
|
||||
// const userID = typeof input.userID === "string" ? input.userID : input.useUser ? Actor.userID() : undefined;
|
||||
// await tx
|
||||
// .update(steamTable)
|
||||
// .set({
|
||||
// userID,
|
||||
// id: input.id,
|
||||
// name: input.name,
|
||||
// realName: input.realName,
|
||||
// profileUrl: input.profileUrl,
|
||||
// avatarHash: input.avatarHash,
|
||||
// limitations: input.limitations,
|
||||
// status: input.status ?? "offline",
|
||||
// username: input.username ?? "unknown",
|
||||
// steamMemberSince: input.steamMemberSince,
|
||||
// lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
// })
|
||||
// .where(eq(steamTable.id, input.id));
|
||||
|
||||
await afterTx(async () =>
|
||||
bus.publish(Resource.Bus, Events.Updated, { userID: userID ?? null, steamID: input.id })
|
||||
);
|
||||
})
|
||||
)
|
||||
// await afterTx(async () =>
|
||||
// bus.publish(Resource.Bus, Events.Updated, { userID: userID ?? null, steamID: input.id })
|
||||
// );
|
||||
// })
|
||||
// )
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string().min(1),
|
||||
@@ -245,7 +239,6 @@ export namespace Steam {
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
lastSyncedAt: input.lastSyncedAt,
|
||||
accountStatus: input.accountStatus,
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
profileUrl: input.profileUrl ? `https://steamcommunity.com/id/${input.profileUrl}` : null,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { pgTable, varchar, text, bigint, pgEnum, json, unique } from "drizzle-orm/pg-core";
|
||||
import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
export const AccountStatusEnum = pgEnum("steam_account_status", ["new", "pending", "active"])
|
||||
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
|
||||
|
||||
export const Limitations = z.object({
|
||||
isLimited: z.boolean(),
|
||||
isTradeBanned: z.boolean(),
|
||||
tradeBanState: z.enum(["none", "probation", "banned"]),
|
||||
isVacBanned: z.boolean(),
|
||||
visibilityState: z.number(),
|
||||
privacyState: z.enum(["public", "private"]),
|
||||
privacyState: z.enum(["public", "private", "friendsfriendsonly", "friendsonly"]),
|
||||
})
|
||||
|
||||
export type Limitations = z.infer<typeof Limitations>;
|
||||
@@ -29,33 +28,15 @@ export const steamTable = pgTable(
|
||||
}),
|
||||
status: StatusEnum("status").notNull(),
|
||||
lastSyncedAt: utc("last_synced_at").notNull(),
|
||||
realName: varchar("real_name", { length: 255 }),
|
||||
steamMemberSince: utc("member_since").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
profileUrl: varchar("profileUrl", { length: 255 }),
|
||||
profileUrl: varchar("profile_url", { length: 255 }),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
realName: varchar("real_name", { length: 255 }).notNull(),
|
||||
accountStatus: AccountStatusEnum("account_status").notNull(),
|
||||
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
|
||||
limitations: json("limitations").$type<Limitations>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique("idx_steam_username").on(table.username)
|
||||
]
|
||||
);
|
||||
|
||||
// export const steamCredentialsTable = pgTable(
|
||||
// "steam_account_credentials",
|
||||
// {
|
||||
// ...timestamps,
|
||||
// refreshToken: text("refresh_token")
|
||||
// .notNull(),
|
||||
// expiry: utc("expiry").notNull(),
|
||||
// id: bigint("steam_id", { mode: "bigint" })
|
||||
// .notNull()
|
||||
// .primaryKey()
|
||||
// .references(() => steamTable.id, {
|
||||
// onDelete: "cascade"
|
||||
// }),
|
||||
// username: varchar("username", { length: 255 }).notNull(),
|
||||
// }
|
||||
// )
|
||||
);
|
||||
@@ -100,10 +100,6 @@ export namespace User {
|
||||
if (result.count === 0) {
|
||||
throw new UserExistsError(input.email)
|
||||
}
|
||||
|
||||
await afterTx(async () =>
|
||||
bus.publish(Resource.Bus, Events.Created, { userID: id })
|
||||
);
|
||||
})
|
||||
|
||||
return id;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ulid } from "ulid";
|
||||
|
||||
export const prefixes = {
|
||||
user: "usr",
|
||||
credentials:"crd",
|
||||
team: "tem",
|
||||
product: "prd",
|
||||
session: "ses",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./id"
|
||||
export * from "./invite"
|
||||
export * from "./invite"
|
||||
export * from "./token"
|
||||
58
packages/core/src/utils/token.ts
Normal file
58
packages/core/src/utils/token.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
import { fn } from './fn';
|
||||
import crypto from 'crypto';
|
||||
import { Resource } from 'sst';
|
||||
|
||||
// This is a 32-character random ASCII string
|
||||
const rawKey = Resource.SteamEncryptionKey.value;
|
||||
|
||||
// Turn it into exactly 32 bytes via UTF-8
|
||||
const key = Buffer.from(rawKey, 'utf8');
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
`SteamEncryptionKey must be exactly 32 bytes; got ${key.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const ENCRYPTION_IV_LENGTH = 12; // 96 bits for GCM
|
||||
|
||||
export namespace Token {
|
||||
export const encrypt = fn(
|
||||
z.string().min(4),
|
||||
(token) => {
|
||||
const iv = crypto.randomBytes(ENCRYPTION_IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(token, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return ['v1', iv.toString('hex'), tag.toString('hex'), ciphertext.toString('hex')].join(':');
|
||||
});
|
||||
|
||||
export const decrypt = fn(
|
||||
z.string().min(4),
|
||||
(data) => {
|
||||
const [version, ivHex, tagHex, ciphertextHex] = data.split(':');
|
||||
if (version !== 'v1' || !ivHex || !tagHex || !ciphertextHex) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return plaintext.toString('utf8');
|
||||
});
|
||||
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"@types/steamcommunity": "^3.43.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:auth": "bun run --watch ./src/auth/index.ts",
|
||||
@@ -15,9 +16,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.8.0",
|
||||
"@nestri/core":"workspace:",
|
||||
"@nestri/core": "workspace:",
|
||||
"actor-core": "^0.8.0",
|
||||
"hono": "^4.7.8",
|
||||
"hono-openapi": "^0.4.8"
|
||||
"hono-openapi": "^0.4.8",
|
||||
"steam-session": "*",
|
||||
"steamcommunity": "^3.48.6",
|
||||
"steamid": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
@@ -8,6 +9,7 @@ import { openAPISpecs } from "hono-openapi";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { SteamApi } from "./steam";
|
||||
|
||||
patchLogger();
|
||||
|
||||
@@ -24,6 +26,7 @@ app
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.onError((error, c) => {
|
||||
if (error instanceof VisibleError) {
|
||||
|
||||
199
packages/functions/src/api/steam.ts
Normal file
199
packages/functions/src/api/steam.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { ErrorResponses, validator } from "./utils";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import type CSteamUser from "steamcommunity/classes/CSteamUser";
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono()
|
||||
.get("/login",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "Login to Steam using QR code",
|
||||
description: "Login to Steam using a QR code sent using Server Sent Events",
|
||||
responses: {
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"header",
|
||||
z.object({
|
||||
"accept": z.string()
|
||||
.refine((v) =>
|
||||
v.toLowerCase()
|
||||
.includes("text/event-stream")
|
||||
)
|
||||
.openapi({
|
||||
description: "Client must accept Server Sent Events",
|
||||
example: "text/event-stream"
|
||||
})
|
||||
})
|
||||
),
|
||||
(c) => {
|
||||
const currentUser = Actor.user()
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
|
||||
session.loginTimeout = 30000; //30 seconds is typically when the url expires
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "connected to steam" })
|
||||
})
|
||||
|
||||
const challenge = await session.startWithQR();
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'challenge_url',
|
||||
data: JSON.stringify({ url: challenge.qrChallengeUrl })
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
session.on('remoteInteraction', async () => {
|
||||
await stream.writeSSE({
|
||||
event: 'remote_interaction',
|
||||
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
|
||||
})
|
||||
});
|
||||
|
||||
session.on('timeout', async () => {
|
||||
console.log('This login attempt has timed out.');
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Your session timed out" }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'timed_out',
|
||||
data: JSON.stringify({ success: false }),
|
||||
})
|
||||
|
||||
await stream.close()
|
||||
reject("Authentication timed out")
|
||||
});
|
||||
|
||||
session.on('error', async (err) => {
|
||||
// This should ordinarily not happen. This only happens in case there's some kind of unexpected error while
|
||||
// polling, e.g. the network connection goes down or Steam chokes on something.
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Recieved an error while authenticating" }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'error',
|
||||
data: JSON.stringify({ message: err.message }),
|
||||
})
|
||||
|
||||
await stream.close()
|
||||
reject(err.message)
|
||||
});
|
||||
|
||||
|
||||
session.on('authenticated', async () => {
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Login successful" })
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'login_success',
|
||||
data: JSON.stringify({ success: true, })
|
||||
})
|
||||
|
||||
const username = session.accountName;
|
||||
const accessToken = session.accessToken;
|
||||
const refreshToken = session.refreshToken;
|
||||
const steamID = session.steamID.toString();
|
||||
const cookies = await session.getWebCookies();
|
||||
|
||||
// Get user information
|
||||
const community = new SteamCommunity();
|
||||
community.setCookies(cookies);
|
||||
|
||||
const user = await new Promise((res, rej) => {
|
||||
community.getSteamUser(session.steamID, async (error, user) => {
|
||||
if (!error) {
|
||||
res(user)
|
||||
} else {
|
||||
rej(error)
|
||||
}
|
||||
})
|
||||
}) as CSteamUser
|
||||
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
username,
|
||||
id: steamID,
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
userID: currentUser.userID,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState),
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
}
|
||||
})
|
||||
|
||||
// Does not matter if the user is already there or has just been created, just store the credentials
|
||||
await Credentials.create({ refreshToken, id: steamID, username })
|
||||
|
||||
if (!!wasAdded) {
|
||||
// create a team
|
||||
const teamID = await Team.create({
|
||||
slug: username,
|
||||
name: `${user.name.split(" ")[0]}'s Team`,
|
||||
ownerID: currentUser.userID,
|
||||
})
|
||||
|
||||
await Actor.provide(
|
||||
"system",
|
||||
{ teamID },
|
||||
async () => {
|
||||
await Member.create({
|
||||
role: "adult",
|
||||
userID: currentUser.userID,
|
||||
steamID
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'team_slug',
|
||||
data: JSON.stringify({ username })
|
||||
})
|
||||
|
||||
//TODO: Get game library
|
||||
|
||||
await stream.close()
|
||||
|
||||
resolve()
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Resource } from "sst"
|
||||
import { type Env } from "hono";
|
||||
import { PasswordUI } from "./ui";
|
||||
import { PasswordUI, Select } from "./ui";
|
||||
import { logger } from "hono/logger";
|
||||
import { subjects } from "../subjects"
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
@@ -15,6 +15,7 @@ patchLogger();
|
||||
|
||||
const app = issuer({
|
||||
//TODO: Create our own Storage (?)
|
||||
select: Select(),
|
||||
storage: MemoryStorage({
|
||||
persist: process.env.STORAGE
|
||||
}),
|
||||
|
||||
@@ -1,35 +1,67 @@
|
||||
import SteamID from "steamid"
|
||||
import { bus } from "sst/aws/bus";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Email } from "@nestri/core/email/index"
|
||||
import { useActor } from "@nestri/core/actor";
|
||||
// import { Stripe } from "@nestri/core/stripe";
|
||||
// import { Template } from "@nestri/core/email/template";
|
||||
// import { EmailOctopus } from "@nestri/core/email-octopus";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[User.Events.Updated, User.Events.Created],
|
||||
[Credentials.Events.New],
|
||||
async (event) => {
|
||||
console.log(event.type, event.properties, event.metadata);
|
||||
switch (event.type) {
|
||||
// case "order.created": {
|
||||
// await Shippo.createShipment(event.properties.orderID);
|
||||
// await Template.sendOrderConfirmation(event.properties.orderID);
|
||||
// await EmailOctopus.addToCustomersList(event.properties.orderID);
|
||||
// break;
|
||||
// }
|
||||
case "user.created": {
|
||||
console.log("Send email here")
|
||||
// const actor = useActor()
|
||||
// if (actor.type !== "user") throw new Error("User actor is needed here")
|
||||
// await Email.send(
|
||||
// "welcome",
|
||||
// actor.properties.email,
|
||||
// `Welcome to Nestri`,
|
||||
// `Welcome to Nestri`,
|
||||
// )
|
||||
// await Stripe.syncUser(event.properties.userID);
|
||||
// // await EmailOctopus.addToMarketingList(event.properties.userID);
|
||||
// break;
|
||||
case "new_credentials.added": {
|
||||
const input = event.properties
|
||||
const credentials = await Credentials.getByID(input.steamID)
|
||||
if (credentials) {
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
|
||||
session.refreshToken = credentials.refreshToken;
|
||||
|
||||
const cookies = await session.getWebCookies()
|
||||
|
||||
const community = new SteamCommunity()
|
||||
community.setCookies(cookies);
|
||||
|
||||
//FIXME: use a promise as promises inside callbacks are not awaited
|
||||
community.getFriendsList((error, allFriends) => {
|
||||
if (!error) {
|
||||
const friends = Object.entries(allFriends);
|
||||
for (const [id, nonce] of friends) {
|
||||
const friendID = new SteamID(id);
|
||||
community.getSteamUser(friendID, async (error, user) => {
|
||||
if (!error) {
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
id: friendID.toString(),
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState)
|
||||
}
|
||||
})
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`steam user ${friendID.toString()} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: friendID.toString(), steamID: input.steamID })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
111
packages/functions/sst-env.d.ts
vendored
111
packages/functions/sst-env.d.ts
vendored
@@ -3,116 +3,7 @@
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": {
|
||||
"service": string
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"Auth": {
|
||||
"service": string
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"Bus": {
|
||||
"arn": string
|
||||
"name": string
|
||||
"type": "sst.aws.Bus"
|
||||
}
|
||||
"Database": {
|
||||
"clusterArn": string
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"reader": string
|
||||
"secretArn": string
|
||||
"type": "sst.aws.Aurora"
|
||||
"username": string
|
||||
}
|
||||
"DatabaseMigrator": {
|
||||
"name": string
|
||||
"type": "sst.aws.Function"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DiscordClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Email": {
|
||||
"configSet": string
|
||||
"sender": string
|
||||
"type": "sst.aws.Email"
|
||||
}
|
||||
"GithubClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GithubClientSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriFamilyMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriFamilyYearly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriFreeMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriProMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"NestriProYearly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"PolarSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"PolarWebhookSecret": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Realtime": {
|
||||
"authorizer": string
|
||||
"endpoint": string
|
||||
"type": "sst.aws.Realtime"
|
||||
}
|
||||
"Storage": {
|
||||
"name": string
|
||||
"type": "sst.aws.Bucket"
|
||||
}
|
||||
"VPC": {
|
||||
"bastion": string
|
||||
"type": "sst.aws.Vpc"
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.aws.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"Zero": {
|
||||
"service": string
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"ZeroPermissions": {
|
||||
"name": string
|
||||
"type": "sst.aws.Function"
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
489
packages/steam/.gitignore
vendored
489
packages/steam/.gitignore
vendored
@@ -1,489 +0,0 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
bin
|
||||
obj
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
bin/
|
||||
obj/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
#Steam credentials file
|
||||
*steam*.json
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# Mac bundle stuff
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
@@ -1,42 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Steam;
|
||||
|
||||
public class HttpClientFactory
|
||||
{
|
||||
public static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = IPv4ConnectAsync
|
||||
});
|
||||
|
||||
var assemblyVersion = typeof(HttpClientFactory).Assembly.GetName().Version?.ToString(fieldCount: 3);
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DepotDownloader", assemblyVersion));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
static async ValueTask<Stream> IPv4ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// By default, we create dual-mode sockets:
|
||||
// Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Steam;
|
||||
|
||||
/// <summary>
|
||||
/// Provides information about the machine that the application is running on.
|
||||
/// For Steam Guard purposes, these values must return consistent results when run
|
||||
/// on the same machine / in the same container / etc., otherwise it will be treated
|
||||
/// as a separate machine and you may need to reauthenticate.
|
||||
/// </summary>
|
||||
public class IMachineInfoProvider : SteamKit2.IMachineInfoProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a unique machine ID as binary data.
|
||||
/// </summary>
|
||||
/// <returns>The unique machine ID, or <c>null</c> if no such value could be found.</returns>
|
||||
public byte[]? GetMachineGuid() => Encoding.UTF8.GetBytes("Nestri-Machine");
|
||||
/// <summary>
|
||||
/// Provides the primary MAC address as binary data.
|
||||
/// </summary>
|
||||
/// <returns>The primary MAC address, or <c>null</c> if no such value could be found.</returns>
|
||||
public byte[]? GetMacAddress() => Encoding.UTF8.GetBytes("Nestri-MacAddress");
|
||||
/// <summary>
|
||||
/// Provides the boot disk's unique ID as binary data.
|
||||
/// </summary>
|
||||
/// <returns>The boot disk's unique ID, or <c>null</c> if no such value could be found.</returns>
|
||||
public byte[]? GetDiskId() => Encoding.UTF8.GetBytes("Nestri-DiskId");
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
namespace Steam
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
const string UnixSocketPath = "/tmp/steam.sock";
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// Delete the socket file if it exists
|
||||
if (File.Exists(UnixSocketPath))
|
||||
{
|
||||
File.Delete(UnixSocketPath);
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Kestrel to listen on Unix socket
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenUnixSocket(UnixSocketPath);
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSingleton<SteamAuthService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Health check endpoint
|
||||
app.MapGet("/", () => "Steam Auth Service is running");
|
||||
|
||||
// QR Code login endpoint with Server-Sent Events
|
||||
app.MapGet("/api/steam/login", async (HttpResponse response, SteamAuthService steamService) =>
|
||||
{
|
||||
// Generate a unique session ID for this login attempt
|
||||
string sessionId = Guid.NewGuid().ToString();
|
||||
|
||||
Console.WriteLine($"Starting new login session: {sessionId}");
|
||||
|
||||
// Set up SSE response
|
||||
response.Headers.Append("Content-Type", "text/event-stream");
|
||||
response.Headers.Append("Cache-Control", "no-cache");
|
||||
response.Headers.Append("Connection", "keep-alive");
|
||||
|
||||
try
|
||||
{
|
||||
// Start QR login session with SSE updates
|
||||
await steamService.StartQrLoginSessionAsync(response, sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error in login session {sessionId}: {ex.Message}");
|
||||
|
||||
// Send error message as SSE
|
||||
await response.WriteAsync($"event: error\n");
|
||||
await response.WriteAsync($"data: {{\"message\":\"{ex.Message}\"}}\n\n");
|
||||
await response.Body.FlushAsync();
|
||||
}
|
||||
});
|
||||
|
||||
// Login with credentials endpoint (returns JSON)
|
||||
app.MapPost("/api/steam/login-with-credentials", async (LoginCredentials credentials, SteamAuthService steamService) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(credentials.Username) || string.IsNullOrEmpty(credentials.RefreshToken))
|
||||
{
|
||||
return Results.BadRequest("Username and refresh token are required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await steamService.LoginWithCredentialsAsync(
|
||||
credentials.Username,
|
||||
credentials.RefreshToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error logging in with credentials: {ex.Message}");
|
||||
return Results.Problem(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
// Get user info endpoint (returns JSON)
|
||||
app.MapGet("/api/steam/user", async (HttpRequest request, SteamAuthService steamService) =>
|
||||
{
|
||||
// Get credentials from headers
|
||||
var username = request.Headers["X-Steam-Username"].ToString();
|
||||
var refreshToken = request.Headers["X-Steam-Token"].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return Results.BadRequest("Username and refresh token headers are required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userInfo = await steamService.GetUserInfoAsync(username, refreshToken);
|
||||
return Results.Ok(userInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error getting user info: {ex.Message}");
|
||||
return Results.Problem(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
|
||||
public class LoginCredentials
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string RefreshToken { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:41347",
|
||||
"sslPort": 44359
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5221",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7060;http://localhost:5221",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
using SteamKit2;
|
||||
using SteamKit2.Authentication;
|
||||
|
||||
namespace Steam
|
||||
{
|
||||
public class SteamAuthService
|
||||
{
|
||||
private readonly SteamClient _steamClient;
|
||||
private readonly SteamUser _steamUser;
|
||||
private readonly SteamFriends _steamFriends;
|
||||
private readonly CallbackManager _manager;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _callbackTask;
|
||||
private readonly Dictionary<string, TaskCompletionSource<bool>> _authCompletionSources = new();
|
||||
|
||||
public SteamAuthService()
|
||||
{
|
||||
var configuration = SteamConfiguration.Create(config =>
|
||||
{
|
||||
config.WithHttpClientFactory(HttpClientFactory.CreateHttpClient);
|
||||
config.WithMachineInfoProvider(new IMachineInfoProvider());
|
||||
config.WithConnectionTimeout(TimeSpan.FromSeconds(10));
|
||||
});
|
||||
|
||||
_steamClient = new SteamClient(configuration);
|
||||
_manager = new CallbackManager(_steamClient);
|
||||
_steamUser = _steamClient.GetHandler<SteamUser>() ?? throw new InvalidOperationException("SteamUser handler not available");
|
||||
_steamFriends = _steamClient.GetHandler<SteamFriends>() ?? throw new InvalidOperationException("SteamFriends handler not available");
|
||||
|
||||
// Register basic callbacks
|
||||
_manager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
|
||||
_manager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
|
||||
_manager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
|
||||
_manager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
|
||||
}
|
||||
|
||||
// Main login method - initiates QR authentication and sends SSE updates
|
||||
public async Task StartQrLoginSessionAsync(HttpResponse response, string sessionId)
|
||||
{
|
||||
response.Headers.Append("Content-Type", "text/event-stream");
|
||||
response.Headers.Append("Cache-Control", "no-cache");
|
||||
response.Headers.Append("Connection", "keep-alive");
|
||||
|
||||
// Create a completion source for this session
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_authCompletionSources[sessionId] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
// Connect to Steam if not already connected
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
// Send initial status
|
||||
await SendSseEvent(response, "status", new { message = "Starting QR authentication..." });
|
||||
|
||||
// Begin auth session
|
||||
var authSession = await _steamClient.Authentication.BeginAuthSessionViaQRAsync(
|
||||
new AuthSessionDetails
|
||||
{
|
||||
PlatformType = SteamKit2.Internal.EAuthTokenPlatformType.k_EAuthTokenPlatformType_SteamClient,
|
||||
DeviceFriendlyName = "Nestri Cloud Gaming",
|
||||
ClientOSType = EOSType.Linux5x
|
||||
}
|
||||
);
|
||||
|
||||
// Handle URL changes
|
||||
authSession.ChallengeURLChanged = async () =>
|
||||
{
|
||||
await SendSseEvent(response, "challenge_url", new { url = authSession.ChallengeURL });
|
||||
};
|
||||
|
||||
// Send initial QR code URL
|
||||
await SendSseEvent(response, "challenge_url", new { url = authSession.ChallengeURL });
|
||||
|
||||
// Poll for authentication result
|
||||
try
|
||||
{
|
||||
var pollResponse = await authSession.PollingWaitForResultAsync();
|
||||
|
||||
// Send credentials to client
|
||||
await SendSseEvent(response, "credentials", new
|
||||
{
|
||||
username = pollResponse.AccountName,
|
||||
refreshToken = pollResponse.RefreshToken
|
||||
});
|
||||
|
||||
// Log in with obtained credentials
|
||||
await SendSseEvent(response, "status", new { message = $"Logging in as '{pollResponse.AccountName}'..." });
|
||||
|
||||
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||
{
|
||||
Username = pollResponse.AccountName,
|
||||
MachineName = "Nestri Cloud Gaming",
|
||||
ClientOSType = EOSType.Linux5x,
|
||||
AccessToken = pollResponse.RefreshToken
|
||||
});
|
||||
|
||||
// Wait for login to complete (handled by OnLoggedOn callback)
|
||||
await tcs.Task;
|
||||
|
||||
// Send final success message
|
||||
await SendSseEvent(response, "login-successful", new
|
||||
{
|
||||
steamId = _steamUser.SteamID?.ConvertToUInt64(),
|
||||
username = pollResponse.AccountName
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await SendSseEvent(response, "login-unsuccessful", new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await SendSseEvent(response, "error", new { message = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up
|
||||
_authCompletionSources.Remove(sessionId);
|
||||
await response.Body.FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Method to login with existing credentials and return result (no SSE)
|
||||
public async Task<LoginResult> LoginWithCredentialsAsync(string username, string refreshToken)
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_authCompletionSources[sessionId] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
// Connect to Steam if not already connected
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
// Log in with provided credentials
|
||||
_steamUser.LogOn(new SteamUser.LogOnDetails
|
||||
{
|
||||
Username = username,
|
||||
MachineName = "Nestri Cloud Gaming",
|
||||
AccessToken = refreshToken,
|
||||
ClientOSType = EOSType.Linux5x,
|
||||
});
|
||||
|
||||
// Wait for login to complete (handled by OnLoggedOn callback)
|
||||
var success = await tcs.Task;
|
||||
|
||||
if (success)
|
||||
{
|
||||
return new LoginResult
|
||||
{
|
||||
Success = true,
|
||||
SteamId = _steamUser.SteamID?.ConvertToUInt64(),
|
||||
Username = username
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LoginResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "Login failed"
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LoginResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_authCompletionSources.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to get user information - waits for all required callbacks to complete
|
||||
public async Task<UserInfo> GetUserInfoAsync(string username, string refreshToken)
|
||||
{
|
||||
// First ensure we're logged in
|
||||
var loginResult = await LoginWithCredentialsAsync(username, refreshToken);
|
||||
if (!loginResult.Success)
|
||||
{
|
||||
throw new Exception($"Failed to log in: {loginResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var userInfo = new UserInfo
|
||||
{
|
||||
SteamId = _steamUser.SteamID?.ConvertToUInt64() ?? 0,
|
||||
Username = username
|
||||
};
|
||||
|
||||
// Set up completion sources for each piece of information
|
||||
var accountInfoTcs = new TaskCompletionSource<bool>();
|
||||
var personaStateTcs = new TaskCompletionSource<bool>();
|
||||
var emailInfoTcs = new TaskCompletionSource<bool>();
|
||||
|
||||
// Subscribe to one-time callbacks
|
||||
var accountSub = _manager.Subscribe<SteamUser.AccountInfoCallback>(callback =>
|
||||
{
|
||||
userInfo.Country = callback.Country;
|
||||
userInfo.PersonaName = callback.PersonaName;
|
||||
accountInfoTcs.TrySetResult(true);
|
||||
});
|
||||
|
||||
var personaSub = _manager.Subscribe<SteamFriends.PersonaStateCallback>(callback =>
|
||||
{
|
||||
if (callback.FriendID == _steamUser.SteamID)
|
||||
{
|
||||
// Convert avatar hash to URL
|
||||
if (callback.AvatarHash != null && callback.AvatarHash.Length > 0)
|
||||
{
|
||||
var avatarStr = BitConverter.ToString(callback.AvatarHash).Replace("-", "").ToLowerInvariant();
|
||||
userInfo.AvatarUrl = $"https://avatars.akamai.steamstatic.com/{avatarStr}_full.jpg";
|
||||
}
|
||||
|
||||
userInfo.PersonaName = callback.Name;
|
||||
userInfo.GameId = callback.GameID?.ToUInt64() ?? 0;
|
||||
userInfo.GamePlayingName = callback.GameName;
|
||||
userInfo.LastLogOn = callback.LastLogOn;
|
||||
userInfo.LastLogOff = callback.LastLogOff;
|
||||
personaStateTcs.TrySetResult(true);
|
||||
}
|
||||
});
|
||||
|
||||
var emailSub = _manager.Subscribe<SteamUser.EmailAddrInfoCallback>(callback =>
|
||||
{
|
||||
userInfo.Email = callback.EmailAddress;
|
||||
emailInfoTcs.TrySetResult(true);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Request all the info
|
||||
if (_steamUser.SteamID != null)
|
||||
{
|
||||
_steamFriends.RequestFriendInfo(_steamUser.SteamID);
|
||||
}
|
||||
|
||||
// Wait for all callbacks with timeout
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
|
||||
var tasks = new[]
|
||||
{
|
||||
accountInfoTcs.Task,
|
||||
personaStateTcs.Task,
|
||||
emailInfoTcs.Task
|
||||
};
|
||||
|
||||
await Task.WhenAny(Task.WhenAll(tasks), timeoutTask);
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Unsubscribe from callbacks
|
||||
// _manager.Unsubscribe(accountSub);
|
||||
// _manager.Unsubscribe(personaSub);
|
||||
// _manager.Unsubscribe(emailSub);
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
|
||||
if (_steamUser.SteamID != null)
|
||||
{
|
||||
_steamUser.LogOff();
|
||||
}
|
||||
|
||||
_steamClient.Disconnect();
|
||||
}
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
private async Task EnsureConnectedAsync()
|
||||
{
|
||||
if (_callbackTask == null)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_steamClient.Connect();
|
||||
|
||||
// Run callback loop in background
|
||||
_callbackTask = Task.Run(() =>
|
||||
{
|
||||
while (!_cts.Token.IsCancellationRequested)
|
||||
{
|
||||
_manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(500));
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}, _cts.Token);
|
||||
var connectionTcs = new TaskCompletionSource<bool>();
|
||||
var connectionSub = _manager.Subscribe<SteamClient.ConnectedCallback>(_ =>
|
||||
{
|
||||
connectionTcs.TrySetResult(true);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Wait up to 10 seconds for connection
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
|
||||
var completedTask = await Task.WhenAny(connectionTcs.Task, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
throw new TimeoutException("Connection to Steam timed out");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// _manager.Unsubscribe(connectionSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendSseEvent(HttpResponse response, string eventType, object data)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(data);
|
||||
await response.WriteAsync($"event: {eventType}\n");
|
||||
await response.WriteAsync($"data: {json}\n\n");
|
||||
await response.Body.FlushAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Callback Handlers
|
||||
|
||||
private void OnConnected(SteamClient.ConnectedCallback callback)
|
||||
{
|
||||
Console.WriteLine("Connected to Steam");
|
||||
}
|
||||
|
||||
private void OnDisconnected(SteamClient.DisconnectedCallback callback)
|
||||
{
|
||||
Console.WriteLine("Disconnected from Steam");
|
||||
|
||||
// Only try to reconnect if not deliberately disconnected
|
||||
if (_callbackTask != null && !_cts!.IsCancellationRequested)
|
||||
{
|
||||
Task.Delay(TimeSpan.FromSeconds(5)).ContinueWith(_ => _steamClient.Connect());
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
|
||||
{
|
||||
var success = callback.Result == EResult.OK;
|
||||
Console.WriteLine($"Logged on: {success}");
|
||||
|
||||
// Complete all pending auth completion sources
|
||||
foreach (var tcs in _authCompletionSources.Values)
|
||||
{
|
||||
tcs.TrySetResult(success);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedOff(SteamUser.LoggedOffCallback callback)
|
||||
{
|
||||
Console.WriteLine($"Logged off: {callback.Result}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class LoginResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public ulong? SteamId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public class UserInfo
|
||||
{
|
||||
public ulong SteamId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? PersonaName { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
public ulong GameId { get; set; }
|
||||
public string? GamePlayingName { get; set; }
|
||||
public DateTime LastLogOn { get; set; }
|
||||
public DateTime LastLogOff { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
// steam-auth-client.ts
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import { Socket } from 'node:net';
|
||||
|
||||
/**
|
||||
* Event types emitted by the SteamAuthClient
|
||||
*/
|
||||
export enum SteamAuthEvent {
|
||||
CHALLENGE_URL = 'challenge_url',
|
||||
STATUS_UPDATE = 'status_update',
|
||||
CREDENTIALS = 'credentials',
|
||||
LOGIN_SUCCESS = 'login_success',
|
||||
LOGIN_ERROR = 'login_error',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for Steam credentials
|
||||
*/
|
||||
export interface SteamCredentials {
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for SteamAuthClient constructor
|
||||
*/
|
||||
export interface SteamAuthClientOptions {
|
||||
socketPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SteamAuthClient provides methods to authenticate with Steam
|
||||
* through a C# service over Unix sockets.
|
||||
*/
|
||||
export class SteamAuthClient {
|
||||
private socketPath: string;
|
||||
private activeSocket: Socket | null = null;
|
||||
private eventListeners: Map<string, Function[]> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new Steam authentication client
|
||||
*
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: SteamAuthClientOptions = {}) {
|
||||
this.socketPath = options.socketPath || '/tmp/steam.sock';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Steam service is healthy
|
||||
*
|
||||
* @returns Promise resolving to true if service is healthy
|
||||
*/
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
await this.makeRequest({ method: 'GET', path: '/' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the QR code login flow
|
||||
*
|
||||
* @returns Promise that resolves when login completes (success or failure)
|
||||
*/
|
||||
startQRLogin(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Create Socket connection for SSE
|
||||
this.activeSocket = netConnect({ path: this.socketPath });
|
||||
|
||||
// Build the HTTP request manually for SSE
|
||||
const request =
|
||||
'GET /api/steam/login HTTP/1.1\r\n' +
|
||||
'Host: localhost\r\n' +
|
||||
'Accept: text/event-stream\r\n' +
|
||||
'Cache-Control: no-cache\r\n' +
|
||||
'Connection: keep-alive\r\n\r\n';
|
||||
|
||||
this.activeSocket.on('connect', () => {
|
||||
this.activeSocket?.write(request);
|
||||
});
|
||||
|
||||
this.activeSocket.on('error', (error) => {
|
||||
this.emit(SteamAuthEvent.ERROR, { error: error.message });
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Simple parser for SSE events over raw socket
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
let eventData = '';
|
||||
|
||||
this.activeSocket.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
buffer += chunk;
|
||||
|
||||
// Skip HTTP headers if present
|
||||
if (buffer.includes('\r\n\r\n')) {
|
||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||
buffer = buffer.substring(headerEnd + 4);
|
||||
}
|
||||
|
||||
// Process each complete event
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep the last incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
eventData = line.substring(6);
|
||||
|
||||
// Complete event received
|
||||
if (eventType && eventData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(eventData);
|
||||
|
||||
// Handle specific events
|
||||
if (eventType === 'challenge_url') {
|
||||
this.emit(SteamAuthEvent.CHALLENGE_URL, parsedData);
|
||||
} else if (eventType === 'credentials') {
|
||||
this.emit(SteamAuthEvent.CREDENTIALS, {
|
||||
username: parsedData.username,
|
||||
refreshToken: parsedData.refreshToken
|
||||
});
|
||||
} else if (eventType === 'login-success') {
|
||||
this.emit(SteamAuthEvent.LOGIN_SUCCESS, { steamId: parsedData.steamId });
|
||||
this.closeSocket();
|
||||
resolve();
|
||||
} else if (eventType === 'status') {
|
||||
this.emit(SteamAuthEvent.STATUS_UPDATE, parsedData);
|
||||
} else if (eventType === 'error' || eventType === 'login-unsuccessful') {
|
||||
this.emit(SteamAuthEvent.LOGIN_ERROR, {
|
||||
message: parsedData.message || parsedData.error
|
||||
});
|
||||
this.closeSocket();
|
||||
resolve();
|
||||
} else {
|
||||
// Emit any other events as is
|
||||
this.emit(eventType, parsedData);
|
||||
}
|
||||
} catch (e) {
|
||||
this.emit(SteamAuthEvent.ERROR, {
|
||||
error: `Error parsing event data: ${e}`
|
||||
});
|
||||
}
|
||||
|
||||
// Reset for next event
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in with existing credentials
|
||||
*
|
||||
* @param credentials Steam credentials
|
||||
* @returns Promise resolving to login result
|
||||
*/
|
||||
async loginWithCredentials(credentials: SteamCredentials): Promise<{
|
||||
success: boolean,
|
||||
steamId?: string,
|
||||
errorMessage?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await this.makeRequest({
|
||||
method: 'POST',
|
||||
path: '/api/steam/login-with-credentials',
|
||||
body: credentials
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
success: true,
|
||||
steamId: response.steamId
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: response.errorMessage || 'Unknown error'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user information using the provided credentials
|
||||
*
|
||||
* @param credentials Steam credentials
|
||||
* @returns Promise resolving to user information
|
||||
*/
|
||||
async getUserInfo(credentials: SteamCredentials): Promise<any> {
|
||||
try {
|
||||
return await this.makeRequest({
|
||||
method: 'GET',
|
||||
path: '/api/steam/user',
|
||||
headers: {
|
||||
'X-Steam-Username': credentials.username,
|
||||
'X-Steam-Token': credentials.refreshToken
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to fetch user info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener
|
||||
*
|
||||
* @param event Event name to listen for
|
||||
* @param callback Function to call when event occurs
|
||||
*/
|
||||
on(event: string, callback: Function): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, []);
|
||||
}
|
||||
this.eventListeners.get(event)?.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an event listener
|
||||
*
|
||||
* @param event Event name
|
||||
* @param callback Function to remove
|
||||
*/
|
||||
off(event: string, callback: Function): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
const index = listeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event listeners
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
this.eventListeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the active socket connection
|
||||
*/
|
||||
closeSocket(): void {
|
||||
if (this.activeSocket) {
|
||||
this.activeSocket.end();
|
||||
this.activeSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.closeSocket();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to emit events to listeners
|
||||
*
|
||||
* @param event Event name
|
||||
* @param data Event data
|
||||
*/
|
||||
private emit(event: string, data: any): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
for (const callback of listeners) {
|
||||
callback(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes HTTP requests over Unix socket
|
||||
*
|
||||
* @param options Request options
|
||||
* @returns Promise resolving to response
|
||||
*/
|
||||
private makeRequest(options: {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
socketPath: this.socketPath,
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
if (data && data.length > 0) {
|
||||
resolve(JSON.parse(data));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
} catch (e) {
|
||||
resolve(data); // Return raw data if not JSON
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Request failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(JSON.stringify(options.body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "steam",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotnet watch run",
|
||||
"client": "bun index",
|
||||
"client:node": "ts-node index",
|
||||
"build": "dotnet build",
|
||||
"db:migrate": "dotnet ef migrations add"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventsource": "^3.0.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
9
packages/steam/sst-env.d.ts
vendored
9
packages/steam/sst-env.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
||||
<PackageReference Include="SteamKit2" Version="3.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
@steam_HostAddress = http://localhost:5221
|
||||
|
||||
GET {{steam_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -1,301 +0,0 @@
|
||||
import { Agent, request as httpRequest } from 'node:http';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
// Socket path matching the one in your C# code
|
||||
const SOCKET_PATH = '/tmp/steam.sock';
|
||||
const CREDENTIALS_PATH = join(process.cwd(), 'steam-credentials.json');
|
||||
|
||||
// Create readline interface for user input
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// Function to prompt user for input
|
||||
const question = (query: string): Promise<string> => {
|
||||
return new Promise(resolve => rl.question(query, resolve));
|
||||
};
|
||||
|
||||
// Function to make HTTP requests over Unix socket
|
||||
function makeRequest(options: {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
socketPath: SOCKET_PATH,
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
if (data && data.length > 0) {
|
||||
resolve(JSON.parse(data));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
} catch (e) {
|
||||
resolve(data); // Return raw data if not JSON
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Request failed with status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(JSON.stringify(options.body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Check if credentials file exists
|
||||
const credentialsExist = (): boolean => {
|
||||
return existsSync(CREDENTIALS_PATH);
|
||||
};
|
||||
|
||||
// Load saved credentials
|
||||
const loadCredentials = (): { username: string, refreshToken: string } => {
|
||||
try {
|
||||
const data = readFileSync(CREDENTIALS_PATH, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading credentials:', error);
|
||||
return { username: '', refreshToken: '' };
|
||||
}
|
||||
};
|
||||
|
||||
// Save credentials to file
|
||||
const saveCredentials = (credentials: { username: string, refreshToken: string }): void => {
|
||||
try {
|
||||
writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2));
|
||||
console.log('💾 Credentials saved to', CREDENTIALS_PATH);
|
||||
} catch (error) {
|
||||
console.error('Error saving credentials:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Test health check endpoint
|
||||
async function testHealthCheck(): Promise<boolean> {
|
||||
console.log('\n🔍 Testing health check endpoint...');
|
||||
try {
|
||||
const response = await makeRequest({ method: 'GET', path: '/' });
|
||||
console.log('✅ Health check successful:', response);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Health check failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test QR code login endpoint (SSE)
|
||||
async function loginWithQrCode(): Promise<{ username: string, refreshToken: string } | null> {
|
||||
console.log('\n🔍 Starting QR code login...');
|
||||
|
||||
return new Promise<{ username: string, refreshToken: string } | null>((resolve) => {
|
||||
// Create Socket connection for SSE
|
||||
const socket = netConnect({ path: SOCKET_PATH });
|
||||
|
||||
// Build the HTTP request manually for SSE
|
||||
const request =
|
||||
'GET /api/steam/login HTTP/1.1\r\n' +
|
||||
'Host: localhost\r\n' +
|
||||
'Accept: text/event-stream\r\n' +
|
||||
'Cache-Control: no-cache\r\n' +
|
||||
'Connection: keep-alive\r\n\r\n';
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('📡 Connected to socket, sending SSE request...');
|
||||
socket.write(request);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('❌ Socket error:', error.message);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
// Simple parser for SSE events over raw socket
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
let eventData = '';
|
||||
let credentials: { username: string, refreshToken: string } | null = null;
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
buffer += chunk;
|
||||
|
||||
// Skip HTTP headers if present
|
||||
if (buffer.includes('\r\n\r\n')) {
|
||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||
buffer = buffer.substring(headerEnd + 4);
|
||||
}
|
||||
|
||||
// Process each complete event
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep the last incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
eventData = line.substring(6);
|
||||
|
||||
// Complete event received
|
||||
if (eventType && eventData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(eventData);
|
||||
console.log(`📬 Received event [${eventType}]`);
|
||||
|
||||
// Handle specific events
|
||||
if (eventType === 'challenge_url') {
|
||||
console.log('⚠️ Please scan this QR code with the Steam mobile app to authenticate:');
|
||||
qrcode.generate(parsedData.url, { small: true });
|
||||
} else if (eventType === 'credentials') {
|
||||
console.log('🔑 Received credentials!');
|
||||
credentials = {
|
||||
username: parsedData.username,
|
||||
refreshToken: parsedData.refreshToken
|
||||
};
|
||||
}else if (eventType === 'status') {
|
||||
console.log(`\n🔄 Status: ${parsedData.message}\n`);
|
||||
} else if (eventType === 'login-success' || eventType === 'login-successful') {
|
||||
console.log(`\n✅ Login successful, Steam ID: ${parsedData.steamId}\n`);
|
||||
socket.end();
|
||||
if (credentials) {
|
||||
saveCredentials(credentials);
|
||||
}
|
||||
resolve(credentials);
|
||||
} else if (eventType === 'error' || eventType === 'login-unsuccessful') {
|
||||
console.error('❌ Login failed:', parsedData.message || parsedData.error);
|
||||
socket.end();
|
||||
resolve(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Error parsing event data:', e);
|
||||
}
|
||||
|
||||
// Reset for next event
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Login with existing credentials
|
||||
async function loginWithCredentials(credentials: { username: string, refreshToken: string }): Promise<boolean> {
|
||||
console.log('\n🔍 Logging in with saved credentials...');
|
||||
try {
|
||||
const response = await makeRequest({
|
||||
method: 'POST',
|
||||
path: '/api/steam/login-with-credentials',
|
||||
body: credentials
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('✅ Login successful, Steam ID:', response.steamId);
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ Login failed:', response.errorMessage);
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ Login request failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user info
|
||||
async function getUserInfo(credentials: { username: string, refreshToken: string }): Promise<any> {
|
||||
console.log('\n🔍 Fetching user info...');
|
||||
try {
|
||||
const response = await makeRequest({
|
||||
method: 'GET',
|
||||
path: '/api/steam/user',
|
||||
headers: {
|
||||
'X-Steam-Username': credentials.username,
|
||||
'X-Steam-Token': credentials.refreshToken
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Failed to fetch user info:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
// Check health first
|
||||
const isHealthy = await testHealthCheck();
|
||||
if (!isHealthy) {
|
||||
console.error('❌ Service appears to be down. Exiting...');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials: { username: string, refreshToken: string } | null = null;
|
||||
|
||||
// Check if we have saved credentials
|
||||
if (credentialsExist()) {
|
||||
const useExisting = await question('🔑 Found saved credentials. Use them? (y/n): ');
|
||||
if (useExisting.toLowerCase() === 'y') {
|
||||
credentials = loadCredentials();
|
||||
const success = await loginWithCredentials(credentials);
|
||||
if (!success) {
|
||||
console.log('⚠️ Saved credentials failed. Let\'s try QR login instead.');
|
||||
credentials = await loginWithQrCode();
|
||||
}
|
||||
} else {
|
||||
credentials = await loginWithQrCode();
|
||||
}
|
||||
} else {
|
||||
console.log('🔑 No saved credentials found. Starting QR login...');
|
||||
credentials = await loginWithQrCode();
|
||||
}
|
||||
|
||||
// If we have valid credentials, offer to fetch user info
|
||||
if (credentials) {
|
||||
const getInfo = await question('📋 Fetch user info? (y/n): ');
|
||||
if (getInfo.toLowerCase() === 'y') {
|
||||
const userInfo = await getUserInfo(credentials);
|
||||
if (userInfo) {
|
||||
console.log('\n👤 User Information:');
|
||||
console.log(JSON.stringify(userInfo, null, 2));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Failed to obtain valid credentials.');
|
||||
}
|
||||
|
||||
rl.close();
|
||||
}
|
||||
|
||||
// Start the program
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
rl.close();
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-variants": "^0.2.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.3.3",
|
||||
"valibot": "^0.42.1"
|
||||
|
||||
@@ -119,6 +119,7 @@ export const App: Component = () => {
|
||||
const account = useAccount();
|
||||
return (
|
||||
<Switch>
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */}
|
||||
<Match when={account.current.teams.length > 0}>
|
||||
<Navigate
|
||||
href={`/${(
|
||||
|
||||
@@ -1,260 +1,603 @@
|
||||
import * as v from "valibot"
|
||||
import { Show } from "solid-js";
|
||||
import { Button } from "@nestri/www/ui";
|
||||
import { Text } from "@nestri/www/ui/text";
|
||||
import { EventSource } from 'eventsource'
|
||||
import { QRCode } from "../ui/custom-qr";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { keyframes } from "@macaron-css/core";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { utility } from "@nestri/www/ui/utility";
|
||||
import { useAccount } from "../providers/account";
|
||||
import { FormField, Input, Select } from "@nestri/www/ui/form";
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import { Container, Screen as FullScreen } from "@nestri/www/ui/layout";
|
||||
import { createForm, getValue, setError, valiForm } from "@modular-forms/solid";
|
||||
|
||||
const nameRegex = /^[a-z0-9\-]+$/
|
||||
const Card = styled("div", {
|
||||
base: {
|
||||
padding: `10px 20px`,
|
||||
maxWidth: 360,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
gap: 20,
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}
|
||||
})
|
||||
|
||||
const FieldList = styled("div", {
|
||||
const LogoFooter = styled("section", {
|
||||
base: {
|
||||
position: "fixed",
|
||||
bottom: -1,
|
||||
fontSize: "100%",
|
||||
maxWidth: 1440,
|
||||
width: "100%",
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 8px",
|
||||
zIndex: 10,
|
||||
overflow: "hidden",
|
||||
},
|
||||
})
|
||||
|
||||
const Logo = styled("svg", {
|
||||
base: {
|
||||
width: "100%",
|
||||
maxWidth: 380,
|
||||
...utility.stack(5),
|
||||
height: "100%",
|
||||
transform: "translateY(40%)",
|
||||
opacity: "70%",
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h2", {
|
||||
base: {
|
||||
fontSize: theme.font.size["2xl"],
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
fontFamily: theme.font.family.heading
|
||||
}
|
||||
})
|
||||
|
||||
const Subtitle = styled("h2", {
|
||||
base: {
|
||||
fontSize: theme.font.size["base"],
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
}
|
||||
})
|
||||
|
||||
const Button = styled("button", {
|
||||
base: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
cursor: "not-allowed",
|
||||
padding: "10px 20px",
|
||||
gap: theme.space["2"],
|
||||
borderRadius: theme.space["2"],
|
||||
backgroundColor: theme.color.background.d100,
|
||||
border: `1px solid ${theme.color.gray.d400}`
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonText = styled("span", {
|
||||
base: {
|
||||
fontSize: theme.font.size["lg"],
|
||||
fontWeight: theme.font.weight.medium,
|
||||
fontFamily: theme.font.family.heading,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonIcon = styled("svg", {
|
||||
base: {
|
||||
height: 28,
|
||||
width: 28,
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonContainer = styled("div", {
|
||||
base: {
|
||||
flexDirection: "column",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
position: "relative"
|
||||
}
|
||||
})
|
||||
|
||||
const bgRotate = keyframes({
|
||||
'to': { transform: 'rotate(1turn)' },
|
||||
});
|
||||
|
||||
const shake = keyframes({
|
||||
"0%": {
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
"50%": {
|
||||
transform: "translateX(10px)",
|
||||
},
|
||||
"100%": {
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
});
|
||||
|
||||
const Hr = styled("hr", {
|
||||
base: {
|
||||
border: 0,
|
||||
backgroundColor: theme.color.gray.d400,
|
||||
width: "100%",
|
||||
height: 1,
|
||||
}
|
||||
const opacity = keyframes({
|
||||
"0%": { opacity: 1 },
|
||||
"100%": { opacity: 0 }
|
||||
})
|
||||
|
||||
const Plan = {
|
||||
Free: 'free',
|
||||
Pro: 'pro',
|
||||
Family: 'family',
|
||||
} as const;
|
||||
|
||||
const schema = v.object({
|
||||
planType: v.pipe(
|
||||
v.enum(Plan, "Choose a valid plan"),
|
||||
),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(2, 'Use 2 characters at minimum.'),
|
||||
v.maxLength(32, 'Use 32 characters at maximum.'),
|
||||
),
|
||||
slug: v.pipe(
|
||||
v.string(),
|
||||
v.regex(nameRegex, "Use a URL friendly name."),
|
||||
v.minLength(2, 'Use 2 characters at minimum.'),
|
||||
v.maxLength(48, 'Use 48 characters at maximum.'),
|
||||
)
|
||||
})
|
||||
|
||||
// const Details = styled("details", {
|
||||
// base: {
|
||||
// overflow: "hidden",
|
||||
// transition: "max-height .2s ease"
|
||||
// }
|
||||
// })
|
||||
|
||||
// const Summary = styled("summary", {
|
||||
// base: {
|
||||
// userSelect: "none",
|
||||
// cursor: "pointer",
|
||||
// listStyle: "none"
|
||||
// }
|
||||
// })
|
||||
|
||||
// const SVG = styled("svg", {
|
||||
// base: {
|
||||
// color: theme.color.gray.d900,
|
||||
// width: 20,
|
||||
// height: 20,
|
||||
// marginRight: theme.space[2]
|
||||
// }
|
||||
// })
|
||||
|
||||
// const Subtitle = styled("p", {
|
||||
// base: {
|
||||
// color: theme.color.gray.d900,
|
||||
// fontSize: theme.font.size.sm,
|
||||
// fontWeight: theme.font.weight.regular,
|
||||
// lineHeight: "1rem"
|
||||
// }
|
||||
// })
|
||||
|
||||
const UrlParent = styled("div", {
|
||||
const QRContainer = styled("div", {
|
||||
base: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
}
|
||||
})
|
||||
|
||||
const UrlTitle = styled("span", {
|
||||
base: {
|
||||
borderWidth: 1,
|
||||
borderRight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderStyle: "solid",
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size.sm,
|
||||
padding: `0 ${theme.space[3]}`,
|
||||
height: theme.input.size.base,
|
||||
borderColor: theme.color.gray.d400,
|
||||
borderTopLeftRadius: theme.borderRadius,
|
||||
borderBottomLeftRadius: theme.borderRadius,
|
||||
borderRadius: 30,
|
||||
padding: 9,
|
||||
isolation: "isolate",
|
||||
":after": {
|
||||
content: "",
|
||||
zIndex: -1,
|
||||
inset: 10,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
borderRadius: 30,
|
||||
position: "absolute"
|
||||
}
|
||||
},
|
||||
variants: {
|
||||
login: {
|
||||
true: {
|
||||
":before": {
|
||||
content: "",
|
||||
backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.blue.d700} 10%,${theme.color.blue.d700} 25%,transparent 35%)`,
|
||||
animation: `${bgRotate} 2.25s linear infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
zIndex: -2,
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
position: "absolute"
|
||||
},
|
||||
}
|
||||
},
|
||||
error: {
|
||||
true: {
|
||||
animation: `${shake} 100ms ease 3`,
|
||||
":before": {
|
||||
content: "",
|
||||
inset: 1,
|
||||
background: theme.color.red.d700,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
animation: `${opacity} 3s ease`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
}
|
||||
}
|
||||
},
|
||||
success: {
|
||||
true: {
|
||||
animation: `${shake} 100ms ease 3`,
|
||||
// ":before": {
|
||||
// content: "",
|
||||
// backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.green.d700} 10%,${theme.color.green.d700} 25%,transparent 35%)`,
|
||||
// animation: `${bgRotate} 2.25s linear infinite`,
|
||||
// width: "200%",
|
||||
// height: "200%",
|
||||
// zIndex: -2,
|
||||
// top: "-50%",
|
||||
// left: "-50%",
|
||||
// position: "absolute"
|
||||
// },
|
||||
":before": {
|
||||
content: "",
|
||||
inset: 1,
|
||||
background: theme.color.teal.d700,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
animation: `${opacity} 1.1s ease infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const QRBg = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
margin: 5,
|
||||
borderRadius: 27
|
||||
}
|
||||
})
|
||||
|
||||
const QRWrapper = styled("div", {
|
||||
base: {
|
||||
height: "max-content",
|
||||
width: "max-content",
|
||||
backgroundColor: theme.color.d1000.gray,
|
||||
position: "relative",
|
||||
textWrap: "balance",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
},
|
||||
variants: {
|
||||
error: {
|
||||
true: {
|
||||
filter: "blur(3px)",
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadBtn = styled("button", {
|
||||
base: {
|
||||
background: "none",
|
||||
border: "none",
|
||||
width: 50,
|
||||
height: 50,
|
||||
position: "absolute",
|
||||
borderRadius: 25,
|
||||
zIndex: 5,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
cursor: "pointer",
|
||||
color: theme.color.blue.d700,
|
||||
transition: "color 200ms",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
":before": {
|
||||
zIndex: 3,
|
||||
content: "",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
transition: "opacity 200ms",
|
||||
background: "#FFF"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRRealoadContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
isolation: "isolate",
|
||||
":before": {
|
||||
background: `conic-gradient( from 90deg, currentColor 10%, #FFF 80% )`,
|
||||
inset: 3,
|
||||
borderRadius: 16,
|
||||
position: "absolute",
|
||||
content: "",
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadSvg = styled("svg", {
|
||||
base: {
|
||||
zIndex: 2,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
display: "block"
|
||||
}
|
||||
})
|
||||
|
||||
const LogoContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
color: theme.color.gray.d100
|
||||
}
|
||||
})
|
||||
|
||||
const LogoIcon = styled("svg", {
|
||||
base: {
|
||||
zIndex: 6,
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
overflow: "hidden",
|
||||
borderRadius: 17,
|
||||
}
|
||||
})
|
||||
|
||||
const Divider = styled("hr", {
|
||||
base: {
|
||||
height: "100%",
|
||||
backgroundColor: theme.color.gray.d400,
|
||||
width: 2,
|
||||
border: "none",
|
||||
margin: "0 20px",
|
||||
padding: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const CardWrapper = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
height: "max-content",
|
||||
flexDirection: "row",
|
||||
display: "flex",
|
||||
alignItems: "start",
|
||||
justifyContent: "start",
|
||||
top: "25vh"
|
||||
}
|
||||
})
|
||||
|
||||
const Footer = styled("div", {
|
||||
base: {
|
||||
flexDirection: "column",
|
||||
display: "flex",
|
||||
gap: 10
|
||||
}
|
||||
})
|
||||
|
||||
const Soon = styled("div", {
|
||||
base: {
|
||||
borderRadius: ".375rem",
|
||||
padding: "2px 4px",
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontSize: ".625rem",
|
||||
color: theme.color.blue.d900,
|
||||
backgroundColor: theme.color.blue.d400,
|
||||
textTransform: "uppercase",
|
||||
marginLeft: 5
|
||||
}
|
||||
})
|
||||
|
||||
const Link = styled("a", {
|
||||
base: {
|
||||
fontSize: theme.font.size["base"],
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
textDecoration: "underline",
|
||||
textUnderlineOffset: 2
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders a form for creating a new team with validated fields for team name, slug, and plan type.
|
||||
*
|
||||
* Submits the form data to the API to create the team, displays validation errors, and navigates to the new team's page upon success.
|
||||
*
|
||||
* @remark If the chosen team slug is already taken, an error message is shown for the slug field.
|
||||
*/
|
||||
export function CreateTeamComponent() {
|
||||
const [form, { Form, Field }] = createForm({
|
||||
validate: valiForm(schema),
|
||||
});
|
||||
|
||||
const nav = useNavigate();
|
||||
const auth = useOpenAuth();
|
||||
const account = useAccount();
|
||||
|
||||
const [challengeUrl, setChallengeUrl] = createSignal<string | null>(null);
|
||||
const [timedOut, setTimedOut] = createSignal(false);
|
||||
const [errorMsg, setErrorMsg] = createSignal<string | null>("");
|
||||
const [loginSuccess, setLoginSuccess] = createSignal(false);
|
||||
|
||||
// bump this to reconnect
|
||||
const [retryCount, setRetryCount] = createSignal(0);
|
||||
|
||||
let currentStream: EventSource | null = null;
|
||||
|
||||
const connectStream = async () => {
|
||||
// clear previous state
|
||||
setChallengeUrl(null);
|
||||
setTimedOut(false);
|
||||
setErrorMsg(null);
|
||||
|
||||
if (currentStream) {
|
||||
currentStream.close();
|
||||
}
|
||||
|
||||
const token = await auth.access();
|
||||
const stream = new EventSource(
|
||||
`${import.meta.env.VITE_API_URL}/steam/login`,
|
||||
{
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
currentStream = stream;
|
||||
|
||||
// status
|
||||
// stream.addEventListener("status", (e) => {
|
||||
// // setStatus(JSON.parse(e.data).message);
|
||||
// });
|
||||
|
||||
// challenge URL
|
||||
stream.addEventListener("challenge_url", (e) => {
|
||||
setChallengeUrl(JSON.parse(e.data).url);
|
||||
});
|
||||
|
||||
// success
|
||||
stream.addEventListener("login_success", (e) => {
|
||||
setLoginSuccess(true);
|
||||
});
|
||||
|
||||
// timed out
|
||||
stream.addEventListener("timed_out", (e) => {
|
||||
setTimedOut(true);
|
||||
});
|
||||
|
||||
// server-side error
|
||||
stream.addEventListener("error", (e: any) => {
|
||||
// Network‐level errors also fire here
|
||||
try {
|
||||
const err = JSON.parse(e.data).message
|
||||
setErrorMsg(err);
|
||||
} catch {
|
||||
setErrorMsg("Connection error");
|
||||
}
|
||||
//Event source has inbuilt retry method,this is to prevent it from firing
|
||||
stream.close()
|
||||
});
|
||||
|
||||
// team slug
|
||||
stream.addEventListener("team_slug", async (e) => {
|
||||
await account.refresh(account.current.email)
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ }
|
||||
nav(`/${JSON.parse(e.data).username}`)
|
||||
});
|
||||
};
|
||||
|
||||
// kick it off on mount _and_ whenever retryCount changes
|
||||
createEffect(() => {
|
||||
// read retryCount so effect re-runs
|
||||
retryCount();
|
||||
connectStream();
|
||||
// ensure cleanup if component unmounts
|
||||
onCleanup(() => currentStream?.close());
|
||||
});
|
||||
|
||||
return (
|
||||
<FullScreen>
|
||||
<Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" >
|
||||
<Container style={{ "width": "100%", "max-width": "380px" }} horizontal="start" space="3" >
|
||||
<Text font="heading" spacing="none" size="3xl" weight="semibold">
|
||||
Create a Team
|
||||
</Text>
|
||||
<Text style={{ color: theme.color.gray.d900 }} size="sm">
|
||||
Choose something that your team mates will recognize
|
||||
</Text>
|
||||
<Hr />
|
||||
</Container>
|
||||
<Form style={{ width: "100%", "max-width": "380px" }}
|
||||
onSubmit={async (data) => {
|
||||
console.log("submitting");
|
||||
const result = await fetch(
|
||||
import.meta.env.VITE_API_URL + "/team",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${await auth.access()}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(form, "slug", "Team slug is already taken.");
|
||||
return;
|
||||
}
|
||||
await account.refresh(account.current.email);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
nav(`/${data.slug}`);
|
||||
}}
|
||||
>
|
||||
<FieldList>
|
||||
<Field type="string" name="name">
|
||||
{(field, props) => (
|
||||
<FormField
|
||||
label="Team Name"
|
||||
hint={
|
||||
field.error
|
||||
&& field.error
|
||||
}
|
||||
color={field.error ? "danger" : "primary"}
|
||||
>
|
||||
<Input
|
||||
{...props}
|
||||
autofocus
|
||||
placeholder="Jane Doe's Team"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Field type="string" name="slug">
|
||||
{(field, props) => (
|
||||
<FormField
|
||||
label="Team Slug"
|
||||
hint={
|
||||
field.error
|
||||
&& field.error
|
||||
}
|
||||
color={field.error ? "danger" : "primary"}
|
||||
>
|
||||
<UrlParent
|
||||
data-type='url'
|
||||
<Container
|
||||
vertical="start"
|
||||
horizontal="center"
|
||||
style={{ position: "fixed", height: "100%" }} >
|
||||
<CardWrapper>
|
||||
<Card >
|
||||
<Title>Connect your game library to get started.</Title>
|
||||
<ButtonContainer>
|
||||
<Button>
|
||||
<ButtonText>
|
||||
GOG.com
|
||||
<Soon>Soon</Soon>
|
||||
</ButtonText>
|
||||
<ButtonIcon preserveAspectRatio="xMidYMax meet" viewBox="0 0 34 31" width="24" height="24">
|
||||
<path fill="currentColor" d="M31,31H3a3,3,0,0,1-3-3V3A3,3,0,0,1,3,0H31a3,3,0,0,1,3,3V28A3,3,0,0,1,31,31ZM4,24.5A1.5,1.5,0,0,0,5.5,26H11V24H6.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5H11V18H5.5A1.5,1.5,0,0,0,4,19.5Zm8-18A1.5,1.5,0,0,0,10.5,5h-5A1.5,1.5,0,0,0,4,6.5v5A1.5,1.5,0,0,0,5.5,13H9V11H6.5a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,6.5,7h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H4v2h6.5A1.5,1.5,0,0,0,12,14.5Zm0,13v5A1.5,1.5,0,0,0,13.5,26h5A1.5,1.5,0,0,0,20,24.5v-5A1.5,1.5,0,0,0,18.5,18h-5A1.5,1.5,0,0,0,12,19.5Zm9-13A1.5,1.5,0,0,0,19.5,5h-5A1.5,1.5,0,0,0,13,6.5v5A1.5,1.5,0,0,0,14.5,13h5A1.5,1.5,0,0,0,21,11.5Zm9,0A1.5,1.5,0,0,0,28.5,5h-5A1.5,1.5,0,0,0,22,6.5v5A1.5,1.5,0,0,0,23.5,13H27V11H24.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H22v2h6.5A1.5,1.5,0,0,0,30,14.5ZM30,18H22.5A1.5,1.5,0,0,0,21,19.5V26h2V20.5a.5.5,0,0,1,.5-.5h1v6h2V20H28v6h2ZM18.5,11h-3a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v3A.5.5,0,0,1,18.5,11Zm-4,9h3a.5.5,0,0,1,.5.5v3a.5.5,0,0,1-.5.5h-3a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,14.5,20Z" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
<Button>
|
||||
<ButtonText>
|
||||
Epic Games
|
||||
<Soon>Soon</Soon>
|
||||
</ButtonText>
|
||||
<ButtonIcon xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
|
||||
<path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4 4 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4 4 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.7 1.7 0 0 1 .591.108a1.8 1.8 0 0 1 .49.299l-.452.546a1.3 1.3 0 0 0-.308-.195a.9.9 0 0 0-.363-.068a.7.7 0 0 0-.28.06a.7.7 0 0 0-.224.163a.8.8 0 0 0-.151.243a.8.8 0 0 0-.056.299v.008a.9.9 0 0 0 .056.31a.7.7 0 0 0 .157.245a.7.7 0 0 0 .238.16a.8.8 0 0 0 .303.058a.8.8 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2 2 0 0 1-.524.307a1.8 1.8 0 0 1-.683.123a1.6 1.6 0 0 1-.602-.107a1.5 1.5 0 0 1-.478-.3a1.4 1.4 0 0 1-.318-.455a1.4 1.4 0 0 1-.115-.58v-.008a1.4 1.4 0 0 1 .113-.57a1.5 1.5 0 0 1 .312-.46a1.4 1.4 0 0 1 .474-.309a1.6 1.6 0 0 1 .598-.111h.045zm11.963.008a2 2 0 0 1 .612.094a1.6 1.6 0 0 1 .507.277l-.386.546a1.6 1.6 0 0 0-.39-.205a1.2 1.2 0 0 0-.388-.07a.35.35 0 0 0-.208.052a.15.15 0 0 0-.07.127v.008a.16.16 0 0 0 .022.084a.2.2 0 0 0 .076.066a1 1 0 0 0 .147.06q.093.03.236.061a3 3 0 0 1 .43.122a1.3 1.3 0 0 1 .328.17a.7.7 0 0 1 .207.24a.74.74 0 0 1 .071.337v.008a.9.9 0 0 1-.081.382a.8.8 0 0 1-.229.285a1 1 0 0 1-.353.18a1.6 1.6 0 0 1-.46.061a2.2 2.2 0 0 1-.71-.116a1.7 1.7 0 0 1-.593-.346l.43-.514q.416.335.9.335a.46.46 0 0 0 .236-.05a.16.16 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.2.2 0 0 0-.073-.066a1 1 0 0 0-.143-.062a3 3 0 0 0-.233-.062a5 5 0 0 1-.413-.113a1.3 1.3 0 0 1-.331-.16a.7.7 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.9.9 0 0 1 .074-.359a.8.8 0 0 1 .214-.283a1 1 0 0 1 .34-.185a1.4 1.4 0 0 1 .448-.066zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
<Button>
|
||||
<ButtonText>
|
||||
Amazon Games
|
||||
<Soon>Soon</Soon>
|
||||
</ButtonText>
|
||||
<ButtonIcon xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 291.29 134.46" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M50.38,59.78c1.09-3.68,1-8.31,1-13.08V12.56c0-1.64.4-6.32-.25-7.29s-3.15-.75-4.9-.75c-5,0-7.22-.69-7.67,4.08l-.19.13c-3.92-3-7.65-5.85-14.84-5.72l-2.39.19a22.76,22.76,0,0,0-4.08,1A19.69,19.69,0,0,0,6.31,15a36.62,36.62,0,0,0-2.08,7.36,23.87,23.87,0,0,0-.38,7.54c.38,2.36.15,4.42.63,6.48,1.74,7.39,5.21,13.15,11.57,15.9a20.21,20.21,0,0,0,11.13,1.39A21,21,0,0,0,34.35,51l2.7-2h.06A22.54,22.54,0,0,1,37,55.75c-1.12,6.39-3,8.54-9.37,9.68a18,18,0,0,1-5.41.13l-5.28-.69L9.2,63a1.69,1.69,0,0,0-1.26,1.07,40.2,40.2,0,0,0,.25,7.8c.89,1.48,3.75,2.07,5.54,2.64,6,1.91,15.69,2.83,22.19.57C43.36,72.52,48.07,67.51,50.38,59.78ZM37.17,22.87V40.41a15.23,15.23,0,0,1-4.33,2.14c-10.59,3.32-14.59-4.12-14.59-13.89a25.33,25.33,0,0,1,1.13-8.87c.93-2.4,2.37-4.5,4.72-5.47.84-.34,1.85-.26,2.76-.63a21.18,21.18,0,0,1,7.8,1.2L37,16C37.57,17,37.17,21.31,37.17,22.87ZM79.74,56.32a25.65,25.65,0,0,0,8.36-3.21l3.33-2.45c.86,1.11.52,2.8,1.63,3.65s9.68,1.16,10.5,0,.44-3.67.44-5.41V26.46c0-4.37.33-9.26-.69-12.7C100.92,5.67,94.08,2.89,83.51,3l-5.66.37a62,62,0,0,0-9.56,2.08c-1.36.47-3.44.82-4,2.07s-.45,7.84.31,8.36c1.12.77,6.5-1,8-1.32,4.34-.94,14.24-1.9,16.66,1.2C91,18,90.71,22.37,90.67,26.39c-1,.24-2.72-.42-3.77-.63l-4.78-.5a18,18,0,0,0-5.28.19c-8.2,1.41-14,4.53-15.9,12.13C58,49.27,68.13,58.77,79.74,56.32ZM77.35,34.63c1.19-.7,2.67-.51,4.15-1.07,3.35,0,6.18.51,9,.63.51,1.12.14,6.83.12,8.55-2.39,3.17-12,6.33-15.27,1.82C73,41.23,74.57,36.26,77.35,34.63Zm38.53,16c0,1.75-.21,3.48.88,4.15.62.37,2.09.19,3,.19,2.09,0,9.28.44,10.06-.57,1-1.25.44-7.82.44-10.12V16.84a19.35,19.35,0,0,1,6.1-2.27c3.38-.79,7.86-.8,9.55,1.45,1.49,2,1.26,5.56,1.26,9.05v19c0,2.58-.58,9.79.88,10.69.9.54,5,.19,6.41.19s5.54.34,6.42-.32c1.18-.89.69-7.28.69-9.56q0-14.13.06-28.29c.48-.79,2.45-1.11,3.4-1.44,4.14-1.46,10.62-2.42,12.63,1.63,1,2.1.69,5.92.69,9V44.81c0,2.24-.5,8.33.44,9.56.55.71,1.83.57,3.08.57,1.88,0,9.33.33,10.19-.32,1.24-.94.75-4.74.75-6.85V28.22c0-8.24.64-15.75-3-20.44-6.52-8.5-23.71-3.95-30,1.45h-.25C157.15,5.18,153,2.9,146.44,3l-2.64.19a30.21,30.21,0,0,0-5.28,1.19,40.58,40.58,0,0,0-6.35,3l-3.08,1.89c-1.12-1.35-.44-3.54-2-4.46-.61-.37-8.67-.47-9.8-.19a2,2,0,0,0-1.07.69c-.66,1-.32,7.59-.32,9.49Zm96.32,2.13c6.17,3.87,17.31,4.71,26.09,2.52,2.21-.55,6.52-1.33,7.29-3.14a48.27,48.27,0,0,0,.12-7.55,1.83,1.83,0,0,0-.81-.94c-.79-.34-2,.24-2.77.44l-6.48,1.19a23.66,23.66,0,0,1-7.16.26,39.37,39.37,0,0,1-5-.7c-4.92-1.49-8.19-5.16-8.24-11.44,1.17-.53,5-.12,6.6-.12h16c2.3,0,6,.47,7.41-.57,1.89-1.41,1.75-10.85,1.14-13.89-2.07-10.3-8.28-16-20.75-15.78l-1.51.06-4.53.63c-4.86,1.22-9.05,3.46-11.75,6.85a25.69,25.69,0,0,0-3.71,6C201.68,22.42,201,33,203.08,40,204.76,45.59,207.71,49.93,212.2,52.73Zm3.7-32.56c1.13-3.25,3-5.62,6.29-6.66L225,13c7.46-.07,9.52,3.79,9.43,11.26-1,.46-4.25.12-5.66.12H215.21C214.8,23.33,215.58,21.1,215.9,20.17Zm77.65,13.2c-3-5.2-9.52-7.23-15.34-9.62-2.76-1.13-7.28-2.08-7.93-5.28-1.37-6.84,12.69-4.86,16.85-3.83,1.16.28,3.85,1.33,4.59.37s.38-3.29.38-4.77c0-1.23.16-2.8-.32-3.59-.72-1.21-2.61-1.55-4.08-2A36.6,36.6,0,0,0,276,3l-3.59.25A29.08,29.08,0,0,0,265.88,5a14.84,14.84,0,0,0-8,7.79c-2.23,5.52-.14,12.84,3.21,15.53,4,3.23,9.43,5.07,14.58,7.17,2.6,1.06,5.55,1.67,6.1,4.78,1.49,8.45-14.51,5.39-19.3,4.15-1-.27-4.16-1.34-5-.88-1.14.65-.69,3.85-.69,5.59,0,1-.15,2.42.25,3.08,1.2,2,7.83,3.26,10.75,3.84,11.6,2.3,21.92-1.62,25.65-8.93C295.3,43.59,295.64,37,293.55,33.37ZM252.81,83l-2.2.13a37.54,37.54,0,0,0-6.35.69,43.91,43.91,0,0,0-13.52,4.72c-1,.61-5,2.58-4.27,4.4.57,1.46,6.36.25,8.23.12,3.7-.25,5.51-.57,9-.56h6.41a35.9,35.9,0,0,1,5.73.37,8.52,8.52,0,0,1,3.45,1.64c1.46,1.25,1.19,5.49.69,7.48a139.33,139.33,0,0,1-5.78,18.86c-.41,1-3.64,7.3-.06,6.54,1.62-.35,4.9-4,5.91-5.22,5-6.39,8.15-13.75,10.5-23,.54-2.15,1.78-10.6.56-12.57C269.11,83.34,258.52,82.89,252.81,83ZM245,101l-5.72,2.51-9.49,3.58c-8.44,3.27-17.84,5.41-27.23,7.74l-11,2.07-12.95,1.7-4.15.31c-1.66.35-3.61.15-5.47.44a83.4,83.4,0,0,1-12.38.51l-9.37.06-6.73-.25-4.33-.25c-1-.2-2.18-.06-3.27-.26l-13.14-1.44c-3.89-.73-8.07-1-11.76-2l-3.08-.51L93.5,112.65c-8.16-2.55-16.27-4.54-23.89-7.48-8.46-3.27-17.29-6.84-24.77-11.26l-7.41-4.27c-1.35-.81-2.44-2-4.59-2-1.6.79-2.09,1.83-1,3.71a12.73,12.73,0,0,0,2.89,2.83l3.4,3.14c4.9,3.9,9.82,7.91,15.15,11.38,4.6,3,9.5,5.55,14.33,8.36l7.23,3.46c4.13,1.82,8.42,3.7,12.76,5.4l11.13,3.71c6,2,12.53,3,19,4.59l13.64,2,4.4.32,7.42.56h2.7a30.39,30.39,0,0,0,7.92.07l2.83-.07,3.46-.06,11.82-.94c5.3-1.18,10.88-1,15.9-2.52l11.57-2.82a195.36,195.36,0,0,0,20.31-7.11,144.13,144.13,0,0,0,23.63-12.57c2.56-1.72,6.18-3,6.86-6.6C250.75,101.43,247.63,100.27,245,101Z" transform="translate(-3.69 -3)" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
<Footer>
|
||||
<Link target="_blank" href="https://discord.gg/6um5K6jrYj" >Help I can't connect my account</Link>
|
||||
</Footer>
|
||||
</Card>
|
||||
<Divider />
|
||||
<Card
|
||||
style={{
|
||||
"--nestri-qr-dot-color": theme.color.gray.d100,
|
||||
"--nestri-body-background": theme.color.d1000.gray,
|
||||
"align-items": "center",
|
||||
}}>
|
||||
<QRContainer success={loginSuccess()} login={!loginSuccess() && !!challengeUrl() && !timedOut() && !errorMsg()} error={!loginSuccess() && (timedOut() || !!errorMsg())}>
|
||||
<QRBg />
|
||||
<QRWrapper error={loginSuccess() || timedOut() || !!errorMsg()}>
|
||||
<LogoContainer>
|
||||
<LogoIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<UrlTitle>
|
||||
nestri.io/
|
||||
</UrlTitle>
|
||||
<Input
|
||||
{...props}
|
||||
placeholder={
|
||||
getValue(form, "name")?.toString()
|
||||
.split(" ").join("-")
|
||||
.toLowerCase() || "janes-team"}
|
||||
/>
|
||||
</UrlParent>
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Field type="string" name="planType">
|
||||
{(field, props) => (
|
||||
<FormField
|
||||
label="Plan Type"
|
||||
hint={
|
||||
field.error
|
||||
&& field.error
|
||||
}
|
||||
color={field.error ? "danger" : "primary"}
|
||||
>
|
||||
<Select
|
||||
{...props}
|
||||
required
|
||||
value={field.value}
|
||||
badges={[
|
||||
{ label: "Free", color: "gray" },
|
||||
{ label: "Pro", color: "blue" },
|
||||
{ label: "Family", color: "purple" },
|
||||
]}
|
||||
options={[
|
||||
{ label: "I'll be playing by myself", value: 'free' },
|
||||
{ label: "I'll be playing with 3 friends", value: 'pro' },
|
||||
{ label: "I'll be playing with 5 family members", value: 'family' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
{/* <Details>
|
||||
<Summary>
|
||||
<div style={{ "display": "flex", "align-items": "center" }}>
|
||||
<SVG xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M8.59 16.59L13.17 12L8.59 7.41L10 6l6 6l-6 6z" /></SVG>
|
||||
<Subtitle>
|
||||
Continuing will start a 14-day Pro plan trial.
|
||||
</Subtitle>
|
||||
</div>
|
||||
</Summary>
|
||||
</Details> */}
|
||||
<Button color="brand" disabled={form.submitting} >
|
||||
<Show when={form.submitting} fallback="Create">
|
||||
Creating…
|
||||
</Show>
|
||||
</Button>
|
||||
</FieldList>
|
||||
</Form>
|
||||
</Container>
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
|
||||
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</LogoIcon>
|
||||
</LogoContainer>
|
||||
{(challengeUrl()
|
||||
&& !timedOut()
|
||||
&& !loginSuccess()
|
||||
&& !errorMsg()) ? (<QRCode
|
||||
uri={challengeUrl() as string}
|
||||
size={180}
|
||||
ecl="H"
|
||||
clearArea={true}
|
||||
/>) : (<QRCode
|
||||
uri={"https://nestri.io"}
|
||||
size={180}
|
||||
ecl="H"
|
||||
clearArea={true}
|
||||
/>)}
|
||||
|
||||
</QRWrapper>
|
||||
{(!loginSuccess() && timedOut() || errorMsg()) && (
|
||||
<QRReloadBtn onClick={() => setRetryCount((c) => c + 1)}>
|
||||
<QRRealoadContainer>
|
||||
<QRReloadSvg
|
||||
aria-hidden="true"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM24.5001 8.74263C25.0834 8.74263 25.5563 9.21551 25.5563 9.79883V14.5997C25.5563 15.183 25.0834 15.6559 24.5001 15.6559H19.6992C19.1159 15.6559 18.643 15.183 18.643 14.5997C18.643 14.0164 19.1159 13.5435 19.6992 13.5435H21.8378L20.071 11.8798C20.0632 11.8724 20.0555 11.865 20.048 11.8574C19.1061 10.915 17.8835 10.3042 16.5643 10.1171C15.2452 9.92999 13.9009 10.1767 12.7341 10.82C11.5674 11.4634 10.6413 12.4685 10.0955 13.684C9.54968 14.8994 9.41368 16.2593 9.70801 17.5588C10.0023 18.8583 10.711 20.0269 11.7273 20.8885C12.7436 21.7502 14.0124 22.2582 15.3425 22.336C16.6726 22.4138 17.9919 22.0572 19.1017 21.3199C19.5088 21.0495 19.8795 20.7333 20.2078 20.3793C20.6043 19.9515 21.2726 19.9262 21.7004 20.3228C22.1282 20.7194 22.1534 21.3876 21.7569 21.8154C21.3158 22.2912 20.8176 22.7161 20.2706 23.0795C18.7793 24.0702 17.0064 24.5493 15.2191 24.4448C13.4318 24.3402 11.7268 23.6576 10.3612 22.4998C8.9956 21.3419 8.0433 19.7716 7.6478 18.0254C7.2523 16.2793 7.43504 14.4519 8.16848 12.8186C8.90192 11.1854 10.1463 9.83471 11.7142 8.97021C13.282 8.10572 15.0884 7.77421 16.861 8.02565C18.6282 8.27631 20.2664 9.09278 21.5304 10.3525L23.4439 12.1544V9.79883C23.4439 9.21551 23.9168 8.74263 24.5001 8.74263Z" fill="currentColor" />
|
||||
</QRReloadSvg>
|
||||
</QRRealoadContainer>
|
||||
</QRReloadBtn>
|
||||
)}
|
||||
</QRContainer>
|
||||
<ButtonContainer>
|
||||
<Title>{loginSuccess() ?
|
||||
"Login successful" :
|
||||
(timedOut() || !!errorMsg()) ?
|
||||
"Login timed out" :
|
||||
"Scan to connect Steam"
|
||||
}</Title>
|
||||
<Subtitle>{
|
||||
loginSuccess() ?
|
||||
"Just a minute while we create your team" :
|
||||
(timedOut() || !!errorMsg()) ?
|
||||
"Failed to connect Steam. Please try again." :
|
||||
"On your mobile phone, open the Stream App to scan this code"}</Subtitle>
|
||||
</ButtonContainer>
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</Container>
|
||||
<LogoFooter >
|
||||
<Logo viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} >
|
||||
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
|
||||
<path
|
||||
fill="url(#paint1)"
|
||||
pathLength="1"
|
||||
stroke="url(#paint1)"
|
||||
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
|
||||
<stop stop-color="white"></stop>
|
||||
<stop offset="1" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</Logo>
|
||||
</LogoFooter>
|
||||
</FullScreen>
|
||||
)
|
||||
}
|
||||
@@ -401,7 +401,6 @@ export function HomeRoute() {
|
||||
<>
|
||||
<Header>
|
||||
<FullScreen >
|
||||
<QrCodeComponent />
|
||||
{/* <LastPlayedWrapper>
|
||||
<LastPlayedFader />
|
||||
<LogoBackgroundImage />
|
||||
|
||||
@@ -33,22 +33,12 @@ export function useStorage() {
|
||||
}
|
||||
|
||||
import { createEffect } from "solid-js";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Account } from "@nestri/core/account/index";
|
||||
import { createInitializedContext } from "../common/context";
|
||||
|
||||
type Storage = {
|
||||
accounts: Record<string, {
|
||||
id: string
|
||||
name: string;
|
||||
email: string
|
||||
avatarUrl?: string
|
||||
teams: Team.Info[];
|
||||
discriminator: number
|
||||
polarCustomerID: string;
|
||||
steamAccounts: Steam.Info[];
|
||||
}>
|
||||
accounts: Record<string, Account.Info>
|
||||
}
|
||||
|
||||
export const { use: useAccount, provider: AccountProvider } = createInitializedContext("AccountContext", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import QRCodeUtil from 'qrcode';
|
||||
import { createMemo, JSXElement } from "solid-js"
|
||||
import { createMemo, type JSXElement } from "solid-js"
|
||||
|
||||
const generateMatrix = (
|
||||
value: string,
|
||||
|
||||
26
patches/steam-session@1.9.3.patch
Normal file
26
patches/steam-session@1.9.3.patch
Normal file
@@ -0,0 +1,26 @@
|
||||
diff --git a/dist/AuthenticationClient.js b/dist/AuthenticationClient.js
|
||||
index a7144534a4c27b25e63804aeb4ae87a1f9c7a4e0..068250690a7b6bc4c2c5270cd603f7190a925956 100644
|
||||
--- a/dist/AuthenticationClient.js
|
||||
+++ b/dist/AuthenticationClient.js
|
||||
@@ -314,7 +314,7 @@ class AuthenticationClient extends events_1.EventEmitter {
|
||||
cookie: 'mobileClient=android; mobileClientVersion=777777 3.0.0'
|
||||
},
|
||||
deviceDetails: {
|
||||
- device_friendly_name: 'Galaxy S22',
|
||||
+ device_friendly_name: 'Nestri Mobile',
|
||||
platform_type: EAuthTokenPlatformType_1.default.MobileApp,
|
||||
os_type: EOSType_1.default.AndroidUnknown,
|
||||
gaming_device_type: 528 // dunno
|
||||
diff --git a/src/AuthenticationClient.ts b/src/AuthenticationClient.ts
|
||||
index 9e1c5bcf8b36d304d313c5b35795b4b543da27dd..17bce1acc689c7210060e5cea196ca63e973355b 100644
|
||||
--- a/src/AuthenticationClient.ts
|
||||
+++ b/src/AuthenticationClient.ts
|
||||
@@ -414,7 +414,7 @@ export default class AuthenticationClient extends EventEmitter {
|
||||
cookie: 'mobileClient=android; mobileClientVersion=777777 3.0.0'
|
||||
},
|
||||
deviceDetails: {
|
||||
- device_friendly_name: 'Galaxy S22',
|
||||
+ device_friendly_name: 'Nestri Mobile',
|
||||
platform_type: EAuthTokenPlatformType.MobileApp,
|
||||
os_type: EOSType.AndroidUnknown,
|
||||
gaming_device_type: 528 // dunno
|
||||
12
sst-env.d.ts
vendored
12
sst-env.d.ts
vendored
@@ -32,10 +32,6 @@ declare module "sst" {
|
||||
"type": "sst.aws.Aurora"
|
||||
"username": string
|
||||
}
|
||||
"DatabaseMigrator": {
|
||||
"name": string
|
||||
"type": "sst.aws.Function"
|
||||
}
|
||||
"DiscordClientID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -90,6 +86,10 @@ declare module "sst" {
|
||||
"endpoint": string
|
||||
"type": "sst.aws.Realtime"
|
||||
}
|
||||
"SteamEncryptionKey": {
|
||||
"type": "random.index/randomString.RandomString"
|
||||
"value": string
|
||||
}
|
||||
"Storage": {
|
||||
"name": string
|
||||
"type": "sst.aws.Bucket"
|
||||
@@ -107,10 +107,6 @@ declare module "sst" {
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
"ZeroPermissions": {
|
||||
"name": string
|
||||
"type": "sst.aws.Function"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user