🔄 refactor(steam): Migrate to Steam OpenID authentication and official Web API (#282)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added support for managing multiple Steam profiles per user, including
a new profiles page with avatar selection and profile management.
- Introduced a streamlined Steam authentication flow using a popup
window, replacing the previous QR code and team-based login.
- Added utilities for Steam image handling and metadata, including
avatar preloading and static Steam metadata mappings.
  - Enhanced OpenID verification for Steam login.
- Added new image-related events and expanded event handling for Steam
account updates and image processing.

- **Improvements**
- Refactored the account structure from teams to profiles, updating
related UI, context, and storage.
- Updated API headers and authentication logic to use Steam IDs instead
of team IDs.
- Expanded game metadata with new fields for categories, franchises, and
social links.
- Improved library and category schemas for richer game and profile
data.
- Simplified and improved Steam API client methods for fetching user
info, friends, and game libraries using Steam Web API.
- Updated queue processing to handle individual game updates and publish
image events.
- Adjusted permissions and queue configurations for better message
handling and dead-letter queue support.
  - Improved slug creation and rating estimation utilities.

- **Bug Fixes**
- Fixed avatar image loading to display higher quality images after
initial load.

- **Removals**
- Removed all team, member, and credential management functionality and
related database schemas.
  - Eliminated the QR code-based login and related UI components.
  - Deleted legacy team and member database tables and related code.
- Removed encryption utilities and deprecated secret keys in favor of
new secret management.

- **Chores**
- Updated dependencies and internal configuration for new features and
schema changes.
- Cleaned up unused code and updated database migrations for new data
structures.
- Adjusted import orders and removed unused imports across multiple
modules.
- Added new resource declarations and updated service link
configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-06-02 09:22:18 +03:00
committed by GitHub
parent ae364f69bd
commit c0194ecef4
71 changed files with 8268 additions and 2134 deletions

View File

@@ -0,0 +1,23 @@
ALTER TABLE "steam_account_credentials" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "steam_account_credentials" CASCADE;--> statement-breakpoint
ALTER TABLE "game_libraries" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
ALTER TABLE "teams" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
ALTER TABLE "steam_accounts" DROP CONSTRAINT "idx_steam_username";--> statement-breakpoint
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk";
--> statement-breakpoint
ALTER TABLE "teams" DROP CONSTRAINT "teams_owner_id_users_id_fk";
--> statement-breakpoint
ALTER TABLE "teams" DROP CONSTRAINT "teams_slug_steam_accounts_username_fk";
--> statement-breakpoint
DROP INDEX "idx_team_slug";--> statement-breakpoint
DROP INDEX "idx_game_libraries_owner_id";--> statement-breakpoint
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_base_game_id_owner_id_pk";--> statement-breakpoint
ALTER TABLE "game_libraries" ALTER COLUMN "last_played" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_owner_steam_id_pk" PRIMARY KEY("base_game_id","owner_steam_id");--> statement-breakpoint
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_steam_id");--> statement-breakpoint
ALTER TABLE "game_libraries" DROP COLUMN "time_acquired";--> statement-breakpoint
ALTER TABLE "game_libraries" DROP COLUMN "is_family_shared";--> statement-breakpoint
ALTER TABLE "steam_accounts" DROP COLUMN "username";--> statement-breakpoint
ALTER TABLE "teams" DROP COLUMN "slug";

View File

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

View File

@@ -0,0 +1,6 @@
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
DROP TYPE "public"."category_type";--> statement-breakpoint
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer', 'categorie', 'franchise');--> statement-breakpoint
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";--> statement-breakpoint
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "base_games" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "base_games" ADD COLUMN "links" text[];

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,41 @@
"when": 1747202158003,
"tag": "0019_charming_namorita",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1747795508868,
"tag": "0020_vengeful_wallop",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1747975397543,
"tag": "0021_real_skreet",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748099972605,
"tag": "0022_clean_living_lightning",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1748411845939,
"tag": "0023_flawless_steel_serpent",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1748414049463,
"tag": "0024_damp_cerise",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,12 @@
import { z } from "zod";
import { fn } from "../utils";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { Examples } from "../examples";
import { createEvent } from "../event";
import { eq, isNull, and } from "drizzle-orm";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum } from "./base-game.sql";
import { ImageTypeEnum } from "../images/images.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction";
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
export namespace BaseGame {
export const Info = z.object({
@@ -31,7 +30,7 @@ export namespace BaseGame {
description: "The initial public release date of the game on Steam",
example: Examples.BaseGame.releaseDate
}),
description: z.string().openapi({
description: z.string().nullable().openapi({
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
example: Examples.BaseGame.description
}),
@@ -39,6 +38,12 @@ export namespace BaseGame {
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
example: Examples.BaseGame.score
}),
links: Links
.nullable()
.openapi({
description: "The social links of this game",
example: Examples.BaseGame.links
}),
primaryGenre: z.string().nullable().openapi({
description: "The main category or genre that best represents the game's content and gameplay style",
example: Examples.BaseGame.primaryGenre
@@ -50,7 +55,7 @@ export namespace BaseGame {
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
example: Examples.BaseGame.compatibility
})
}),
}).openapi({
ref: "BaseGame",
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
@@ -61,9 +66,27 @@ export namespace BaseGame {
export const Events = {
New: createEvent(
"new_game.added",
"new_image.save",
z.object({
appID: Info.shape.id,
url: z.string().url(),
type: z.enum(ImageTypeEnum.enumValues)
}),
),
NewBoxArt: createEvent(
"new_box_art_image.save",
z.object({
appID: Info.shape.id,
logoUrl: z.string().url(),
backgroundUrl: z.string().url(),
}),
),
NewHeroArt: createEvent(
"new_hero_art_image.save",
z.object({
appID: Info.shape.id,
backdropUrl: z.string().url(),
screenshots: z.string().url().array(),
}),
),
};
@@ -72,6 +95,21 @@ export namespace BaseGame {
Info,
(input) =>
createTransaction(async (tx) => {
const result = await tx
.select()
.from(baseGamesTable)
.where(
and(
eq(baseGamesTable.id, input.id),
isNull(baseGamesTable.timeDeleted)
)
)
.limit(1)
.execute()
.then(rows => rows.at(0))
if (result) return result.id
await tx
.insert(baseGamesTable)
.values(input)
@@ -82,10 +120,6 @@ export namespace BaseGame {
}
})
await afterTx(async () => {
await bus.publish(Resource.Bus, Events.New, { appID: input.id })
})
return input.id
})
)
@@ -116,6 +150,7 @@ export namespace BaseGame {
name: input.name,
slug: input.slug,
size: input.size,
links: input.links,
score: input.score,
description: input.description,
releaseDate: input.releaseDate,

View File

@@ -1,7 +1,8 @@
import { timestamps } from "../drizzle/types";
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer"])
// Intentional grammatical error on category
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer", "categorie", "franchise"])
export const categoriesTable = pgTable(
"categories",

View File

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

View File

@@ -1,159 +1,172 @@
import type {
Shot,
AppInfo,
GameTagsResponse,
SteamApiResponse,
GameDetailsResponse,
SteamAppDataResponse,
ImageInfo,
ImageType,
Shot
SteamAccount,
GameTagsResponse,
GameDetailsResponse,
SteamAppDataResponse,
SteamOwnedGamesResponse,
SteamPlayerBansResponse,
SteamFriendsListResponse,
SteamPlayerSummaryResponse,
SteamStoreResponse,
} from "./types";
import { z } from "zod";
import pLimit from 'p-limit';
import SteamID from "steamid";
import { fn } from "../utils";
import { Resource } from "sst";
import { Steam } from "./steam";
import { Utils } from "./utils";
import SteamCommunity from "steamcommunity";
import { Credentials } from "../credentials";
import type CSteamUser from "steamcommunity/classes/CSteamUser";
const requestLimit = pLimit(10); // max concurrent requests
import { ImageTypeEnum } from "../images/images.sql";
export namespace Client {
export const getUserLibrary = fn(
Credentials.Info.shape.accessToken,
async (accessToken) =>
await Utils.fetchApi<SteamApiResponse>(`https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${accessToken}&family_groupid=0&include_excluded=true&include_free=true&include_non_games=false&include_own=true`)
z.string(),
async (steamID) =>
await Utils.fetchApi<SteamOwnedGamesResponse>(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&include_appinfo=1&format=json&include_played_free_games=1&skip_unvetted_apps=0`)
)
export const getFriendsList = fn(
Credentials.Info.shape.cookies,
async (cookies): Promise<CSteamUser[]> => {
const community = new SteamCommunity();
community.setCookies(cookies);
const allFriends = await new Promise<Record<string, any>>((resolve, reject) => {
community.getFriendsList((err, friends) => {
if (err) {
return reject(new Error(`Could not get friends list: ${err.message}`));
}
resolve(friends);
});
});
const friendIds = Object.keys(allFriends);
const userPromises: Promise<CSteamUser>[] = friendIds.map(id =>
requestLimit(() => new Promise<CSteamUser>((resolve, reject) => {
const sid = new SteamID(id);
community.getSteamUser(sid, (err, user) => {
if (err) {
return reject(new Error(`Could not get steam user info for ${id}: ${err.message}`));
}
resolve(user);
});
}))
);
const settled = await Promise.allSettled(userPromises)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[getFriendsList] failed:", (r as PromiseRejectedResult).reason));
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<CSteamUser>).value);
}
z.string(),
async (steamID) =>
await Utils.fetchApi<SteamFriendsListResponse>(`https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&relationship=friend`)
);
export const getUserInfo = fn(
Credentials.Info.pick({ cookies: true, steamID: true }),
async (input) =>
new Promise((resolve, reject) => {
const community = new SteamCommunity()
community.setCookies(input.cookies);
const steamID = new SteamID(input.steamID);
community.getSteamUser(steamID, async (err, user) => {
if (err) {
reject(`Could not get steam user info: ${err.message}`)
z.string().array(),
async (steamIDs) => {
const [userInfo, banInfo, profileInfo] = await Promise.all([
Utils.fetchApi<SteamPlayerSummaryResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
Utils.fetchApi<SteamPlayerBansResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
Utils.fetchProfilesInfo(steamIDs)
])
// Create a map of bans by steamID for fast lookup
const bansBySteamID = new Map(
banInfo.players.map((b) => [b.SteamId, b])
);
// Map userInfo.players to your desired output using Promise.allSettled
// to prevent one error from closing down the whole pipeline
const steamAccounts = await Promise.allSettled(
userInfo.response.players.map(async (player) => {
const ban = bansBySteamID.get(player.steamid);
const info = profileInfo.get(player.steamid);
if (!info) {
throw new Error(`[userInfo] profile info missing for ${player.steamid}`)
}
if ('error' in info) {
throw new Error(`error handling profile info for: ${player.steamid}:${info.error}`)
} else {
resolve(user)
return {
id: player.steamid,
name: player.personaname,
realName: player.realname ?? null,
steamMemberSince: new Date(player.timecreated * 1000),
avatarHash: player.avatarhash,
limitations: {
isLimited: info.isLimited,
privacyState: info.privacyState,
isVacBanned: ban?.VACBanned ?? false,
tradeBanState: ban?.EconomyBan ?? "none",
visibilityState: player.communityvisibilitystate,
},
lastSyncedAt: new Date(),
profileUrl: player.profileurl,
};
}
})
}) as Promise<CSteamUser>
)
);
steamAccounts
.filter(result => result.status === 'rejected')
.forEach(result => console.warn('[userInfo] failed:', (result as PromiseRejectedResult).reason))
return steamAccounts.filter(result => result.status === "fulfilled").map(result => (result as PromiseFulfilledResult<SteamAccount>).value)
})
export const getAppInfo = fn(
z.string(),
async (appid) => {
const [infoData, tagsData, details] = await Promise.all([
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
Utils.fetchApi<GameTagsResponse>("https://store.steampowered.com/actions/ajaxgetstoretags"),
Utils.fetchApi<GameDetailsResponse>(
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
),
]);
try {
const info = await Promise.all([
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
Utils.fetchApi<SteamStoreResponse>(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?key=${Resource.SteamApiKey.value}&input_json={"ids":[{"appid":"${appid}"}],"context":{"language":"english","country_code":"US","steam_realm":"1"},"data_request":{"include_assets":true,"include_release":true,"include_platforms":true,"include_all_purchase_options":true,"include_screenshots":true,"include_trailers":true,"include_ratings":true,"include_tag_count":"40","include_reviews":true,"include_basic_info":true,"include_supported_languages":true,"include_full_description":true,"include_included_items":true,"include_assets_without_overrides":true,"apply_user_filters":true,"include_links":true}}`),
]);
const tags = tagsData.tags;
const game = infoData.data[appid];
// Guard against an empty string - When there are no genres, Steam returns an empty string
const genres = details.strGenres ? Utils.parseGenres(details.strGenres) : [];
const cmd = info[0].data[appid]
const store = info[1].response.store_items[0]
const controllerTag = game.common.controller_support ?
Utils.createTag(`${Utils.capitalise(game.common.controller_support)} Controller Support`) :
Utils.createTag(`Unknown Controller Support`)
if (!cmd) {
throw new Error(`App data not found for appid: ${appid}`)
}
const compatibilityTag = Utils.createTag(`${Utils.capitalise(Utils.compatibilityType(game.common.steam_deck_compatibility?.category))} Compatibility`)
if (!store || store.success !== 1) {
throw new Error(`Could not get store information or appid: ${appid}`)
}
const controller = (game.common.controller_support === "partial" || game.common.controller_support === "full") ? game.common.controller_support : "unknown";
const appInfo: AppInfo = {
genres,
gameid: game.appid,
name: game.common.name.trim(),
size: Utils.getPublicDepotSizes(game.depots!),
slug: Utils.createSlug(game.common.name.trim()),
description: Utils.cleanDescription(details.strDescription),
controllerSupport: controller,
releaseDate: new Date(Number(game.common.steam_release_date) * 1000),
primaryGenre: (!!game?.common.genres && !!details.strGenres) ? Utils.getPrimaryGenre(
genres,
game.common.genres!,
game.common.primary_genre!
) : null,
developers: game.common.associations ?
Array.from(
Utils.getAssociationsByTypeWithSlug(
game.common.associations!,
"developer"
)
) : [],
publishers: game.common.associations ?
Array.from(
Utils.getAssociationsByTypeWithSlug(
game.common.associations!,
"publisher"
)
) : [],
compatibility: Utils.compatibilityType(game.common.steam_deck_compatibility?.category),
tags: [
...(game?.common.store_tags ?
Utils.mapGameTags(
tags,
game.common.store_tags!,
) : []),
controllerTag,
compatibilityTag
],
score: Utils.getRating(
details.ReviewSummary.cRecommendationsPositive,
details.ReviewSummary.cRecommendationsNegative
),
};
const tags = store.tagids
.map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
.filter((name): name is string => typeof name === 'string')
return appInfo
const publishers = store.basic_info.publishers
.map(i => i.name)
const developers = store.basic_info.developers
.map(i => i.name)
const franchises = store.basic_info.franchises
?.map(i => i.name)
const genres = cmd?.common.genres &&
Object.keys(cmd?.common.genres)
.map(id => Steam.genres[id.toString() as keyof typeof Steam.genres])
.filter((name): name is string => typeof name === 'string')
const categories = [
...(store.categories?.controller_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? []),
...(store.categories?.supported_player_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? [])
].filter((name): name is string => typeof name === 'string')
const assetUrls = Utils.getAssetUrls(cmd?.common.library_assets_full, appid, cmd?.common.header_image.english);
const screenshots = store.screenshots.all_ages_screenshots?.map(i => `https://shared.cloudflare.steamstatic.com/store_item_assets/${i.filename}`) ?? [];
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${cmd?.common.icon}.jpg`;
const data: AppInfo = {
id: appid,
name: cmd?.common.name.trim(),
tags: Utils.createType(tags, "tag"),
images: { screenshots, icon, ...assetUrls },
size: Utils.getPublicDepotSizes(cmd?.depots!),
slug: Utils.createSlug(cmd?.common.name.trim()),
publishers: Utils.createType(publishers, "publisher"),
developers: Utils.createType(developers, "developer"),
categories: Utils.createType(categories, "categorie"),
links: store.links ? store.links.map(i => i.url) : null,
genres: genres ? Utils.createType(genres, "genre") : [],
franchises: franchises ? Utils.createType(franchises, "franchise") : [],
description: store.basic_info.short_description ? Utils.cleanDescription(store.basic_info.short_description) : null,
controllerSupport: cmd?.common.controller_support ?? "unknown" as any,
releaseDate: new Date(Number(cmd?.common.steam_release_date) * 1000),
primaryGenre: !!cmd?.common.primary_genre && Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] ? Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] : null,
compatibility: store?.platforms.steam_os_compat_category ? Utils.compatibilityType(store?.platforms.steam_os_compat_category.toString() as any).toLowerCase() : "unknown" as any,
score: Utils.estimateRatingFromSummary(store.reviews.summary_filtered.review_count, store.reviews.summary_filtered.percent_positive)
}
return data
} catch (err) {
console.log(`Error handling: ${appid}`)
throw err
}
}
)
export const getImages = fn(
export const getImageUrls = fn(
z.string(),
async (appid) => {
const [appData, details] = await Promise.all([
@@ -167,18 +180,49 @@ export namespace Client {
if (!game) throw new Error('Game info missing');
// 2. Prepare URLs
const screenshotUrls = Utils.getScreenshotUrls(details.rgScreenshots || []);
const screenshots = Utils.getScreenshotUrls(details.rgScreenshots || []);
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
const iconUrl = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
//2.5 Get the backdrop buffer and use it to get the best screenshot
const baselineBuffer = await Utils.fetchBuffer(assetUrls.backdrop);
return { screenshots, icon, ...assetUrls }
}
)
// 3. Download screenshot buffers in parallel
export const getImageInfo = fn(
z.object({
type: z.enum(ImageTypeEnum.enumValues),
url: z.string()
}),
async (input) =>
Utils.fetchBuffer(input.url)
.then(buf => Utils.getImageMetadata(buf))
.then(meta => ({ ...meta, position: 0, sourceUrl: input.url, type: input.type } as ImageInfo))
)
export const createBoxArt = fn(
z.object({
backgroundUrl: z.string(),
logoUrl: z.string(),
}),
async (input) =>
Utils.createBoxArtBuffer(input.logoUrl, input.backgroundUrl)
.then(buf => Utils.getImageMetadata(buf))
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
)
export const createHeroArt = fn(
z.object({
screenshots: z.string().array(),
backdropUrl: z.string()
}),
async (input) => {
// Download screenshot buffers in parallel
const shots: Shot[] = await Promise.all(
screenshotUrls.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
input.screenshots.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
);
const baselineBuffer = await Utils.fetchBuffer(input.backdropUrl);
// 4. Score screenshots (or pick single)
const scores =
shots.length === 1
@@ -204,37 +248,69 @@ export namespace Client {
);
}
// 5b. Asset images
for (const [type, url] of Object.entries({ ...assetUrls, icon: iconUrl })) {
if (!url || type === "backdrop") continue;
tasks.push(
Utils.fetchBuffer(url)
.then(buf => Utils.getImageMetadata(buf))
.then(meta => ({ ...meta, position: 0, sourceUrl: url, type: type as ImageType } as ImageInfo))
);
}
// 5c. Backdrop
tasks.push(
Utils.getImageMetadata(baselineBuffer)
.then(meta => ({ ...meta, position: 0, sourceUrl: assetUrls.backdrop, type: "backdrop" as const } as ImageInfo))
)
// 5d. Box art
tasks.push(
Utils.createBoxArtBuffer(game.library_assets_full, appid)
.then(buf => Utils.getImageMetadata(buf))
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
);
const settled = await Promise.allSettled(tasks)
const settled = await Promise.allSettled(tasks);
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[getImages] failed:", (r as PromiseRejectedResult).reason));
.forEach(r => console.warn("[getHeroArt] failed:", (r as PromiseRejectedResult).reason));
// 6. Await all and return
// Await all and return
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
}
)
/**
* Verifies a Steam OpenID response by sending a request back to Steam
* with mode=check_authentication
*/
export async function verifyOpenIDResponse(params: URLSearchParams): Promise<string | null> {
try {
// Create a new URLSearchParams with all the original parameters
const verificationParams = new URLSearchParams();
// Copy all parameters from the original request
for (const [key, value] of params.entries()) {
verificationParams.append(key, value);
}
// Change mode to check_authentication for verification
verificationParams.set('openid.mode', 'check_authentication');
// Send verification request to Steam
const verificationResponse = await fetch('https://steamcommunity.com/openid/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: verificationParams.toString()
});
const responseText = await verificationResponse.text();
// Check if verification was successful
if (!responseText.includes('is_valid:true')) {
console.error('OpenID verification failed: Invalid response from Steam', responseText);
return null;
}
// Extract steamID from the claimed_id
const claimedId = params.get('openid.claimed_id');
if (!claimedId) {
console.error('OpenID verification failed: Missing claimed_id');
return null;
}
// Extract the Steam ID from the claimed_id
const steamID = claimedId.split('/').pop();
if (!steamID || !/^\d+$/.test(steamID)) {
console.error('OpenID verification failed: Invalid steamID format', steamID);
return null;
}
return steamID;
} catch (error) {
console.error('OpenID verification error:', error);
return null;
}
}
}

View File

@@ -0,0 +1,544 @@
export namespace Steam {
//Source: https://github.com/woctezuma/steam-api/blob/master/data/genres.json
export const genres = {
"1": "Action",
"2": "Strategy",
"3": "RPG",
"4": "Casual",
"9": "Racing",
"18": "Sports",
"23": "Indie",
"25": "Adventure",
"28": "Simulation",
"29": "Massively Multiplayer",
"37": "Free to Play",
"50": "Accounting",
"51": "Animation & Modeling",
"52": "Audio Production",
"53": "Design & Illustration",
"54": "Education",
"55": "Photo Editing",
"56": "Software Training",
"57": "Utilities",
"58": "Video Production",
"59": "Web Publishing",
"60": "Game Development",
"70": "Early Access",
"71": "Sexual Content",
"72": "Nudity",
"73": "Violent",
"74": "Gore",
"80": "Movie",
"81": "Documentary",
"82": "Episodic",
"83": "Short",
"84": "Tutorial",
"85": "360 Video"
}
//Source: https://github.com/woctezuma/steam-api/blob/master/data/categories.json
export const categories = {
"1": "Multi-player",
"2": "Single-player",
"6": "Mods (require HL2)",
"7": "Mods (require HL1)",
"8": "Valve Anti-Cheat enabled",
"9": "Co-op",
"10": "Demos",
"12": "HDR available",
"13": "Captions available",
"14": "Commentary available",
"15": "Stats",
"16": "Includes Source SDK",
"17": "Includes level editor",
"18": "Partial Controller Support",
"19": "Mods",
"20": "MMO",
"21": "Downloadable Content",
"22": "Steam Achievements",
"23": "Steam Cloud",
"24": "Shared/Split Screen",
"25": "Steam Leaderboards",
"27": "Cross-Platform Multiplayer",
"28": "Full controller support",
"29": "Steam Trading Cards",
"30": "Steam Workshop",
"31": "VR Support",
"32": "Steam Turn Notifications",
"33": "Native Steam Controller",
"35": "In-App Purchases",
"36": "Online PvP",
"37": "Shared/Split Screen PvP",
"38": "Online Co-op",
"39": "Shared/Split Screen Co-op",
"40": "SteamVR Collectibles",
"41": "Remote Play on Phone",
"42": "Remote Play on Tablet",
"43": "Remote Play on TV",
"44": "Remote Play Together",
"45": "Cloud Gaming",
"46": "Cloud Gaming (NVIDIA)",
"47": "LAN PvP",
"48": "LAN Co-op",
"49": "PvP",
"50": "Additional High-Quality Audio",
"51": "Steam Workshop",
"52": "Tracked Controller Support",
"53": "VR Supported",
"54": "VR Only"
}
// Source: https://files.catbox.moe/96bty7.json
export const tags = {
"9": "Strategy",
"19": "Action",
"21": "Adventure",
"84": "Design & Illustration",
"87": "Utilities",
"113": "Free to Play",
"122": "RPG",
"128": "Massively Multiplayer",
"492": "Indie",
"493": "Early Access",
"597": "Casual",
"599": "Simulation",
"699": "Racing",
"701": "Sports",
"784": "Video Production",
"809": "Photo Editing",
"872": "Animation & Modeling",
"1027": "Audio Production",
"1036": "Education",
"1038": "Web Publishing",
"1445": "Software Training",
"1616": "Trains",
"1621": "Music",
"1625": "Platformer",
"1628": "Metroidvania",
"1638": "Dog",
"1643": "Building",
"1644": "Driving",
"1645": "Tower Defense",
"1646": "Hack and Slash",
"1647": "Western",
"1649": "GameMaker",
"1651": "Satire",
"1654": "Relaxing",
"1659": "Zombies",
"1662": "Survival",
"1663": "FPS",
"1664": "Puzzle",
"1665": "Match 3",
"1666": "Card Game",
"1667": "Horror",
"1669": "Moddable",
"1670": "4X",
"1671": "Superhero",
"1673": "Aliens",
"1674": "Typing",
"1676": "RTS",
"1677": "Turn-Based",
"1678": "War",
"1680": "Heist",
"1681": "Pirates",
"1684": "Fantasy",
"1685": "Co-op",
"1687": "Stealth",
"1688": "Ninja",
"1693": "Classic",
"1695": "Open World",
"1697": "Third Person",
"1698": "Point & Click",
"1702": "Crafting",
"1708": "Tactical",
"1710": "Surreal",
"1714": "Psychedelic",
"1716": "Roguelike",
"1717": "Hex Grid",
"1718": "MOBA",
"1719": "Comedy",
"1720": "Dungeon Crawler",
"1721": "Psychological Horror",
"1723": "Action RTS",
"1730": "Sokoban",
"1732": "Voxel",
"1733": "Unforgiving",
"1734": "Fast-Paced",
"1736": "LEGO",
"1738": "Hidden Object",
"1741": "Turn-Based Strategy",
"1742": "Story Rich",
"1743": "Fighting",
"1746": "Basketball",
"1751": "Comic Book",
"1752": "Rhythm",
"1753": "Skateboarding",
"1754": "MMORPG",
"1755": "Space",
"1756": "Great Soundtrack",
"1759": "Perma Death",
"1770": "Board Game",
"1773": "Arcade",
"1774": "Shooter",
"1775": "PvP",
"1777": "Steampunk",
"3796": "Based On A Novel",
"3798": "Side Scroller",
"3799": "Visual Novel",
"3810": "Sandbox",
"3813": "Real Time Tactics",
"3814": "Third-Person Shooter",
"3834": "Exploration",
"3835": "Post-apocalyptic",
"3839": "First-Person",
"3841": "Local Co-Op",
"3843": "Online Co-Op",
"3854": "Lore-Rich",
"3859": "Multiplayer",
"3871": "2D",
"3877": "Precision Platformer",
"3878": "Competitive",
"3916": "Old School",
"3920": "Cooking",
"3934": "Immersive",
"3942": "Sci-fi",
"3952": "Gothic",
"3955": "Character Action Game",
"3959": "Roguelite",
"3964": "Pixel Graphics",
"3965": "Epic",
"3968": "Physics",
"3978": "Survival Horror",
"3987": "Historical",
"3993": "Combat",
"4004": "Retro",
"4018": "Vampire",
"4026": "Difficult",
"4036": "Parkour",
"4046": "Dragons",
"4057": "Magic",
"4064": "Thriller",
"4085": "Anime",
"4094": "Minimalist",
"4102": "Combat Racing",
"4106": "Action-Adventure",
"4115": "Cyberpunk",
"4136": "Funny",
"4137": "Transhumanism",
"4145": "Cinematic",
"4150": "World War II",
"4155": "Class-Based",
"4158": "Beat 'em up",
"4161": "Real-Time",
"4166": "Atmospheric",
"4168": "Military",
"4172": "Medieval",
"4175": "Realistic",
"4182": "Singleplayer",
"4184": "Chess",
"4190": "Addictive",
"4191": "3D",
"4195": "Cartoony",
"4202": "Trading",
"4231": "Action RPG",
"4234": "Short",
"4236": "Loot",
"4242": "Episodic",
"4252": "Stylized",
"4255": "Shoot 'Em Up",
"4291": "Spaceships",
"4295": "Futuristic",
"4305": "Colorful",
"4325": "Turn-Based Combat",
"4328": "City Builder",
"4342": "Dark",
"4345": "Gore",
"4364": "Grand Strategy",
"4376": "Assassin",
"4400": "Abstract",
"4434": "JRPG",
"4474": "CRPG",
"4486": "Choose Your Own Adventure",
"4508": "Co-op Campaign",
"4520": "Farming",
"4559": "Quick-Time Events",
"4562": "Cartoon",
"4598": "Alternate History",
"4604": "Dark Fantasy",
"4608": "Swordplay",
"4637": "Top-Down Shooter",
"4667": "Violent",
"4684": "Wargame",
"4695": "Economy",
"4700": "Movie",
"4711": "Replay Value",
"4726": "Cute",
"4736": "2D Fighter",
"4747": "Character Customization",
"4754": "Politics",
"4758": "Twin Stick Shooter",
"4777": "Spectacle fighter",
"4791": "Top-Down",
"4821": "Mechs",
"4835": "6DOF",
"4840": "4 Player Local",
"4845": "Capitalism",
"4853": "Political",
"4878": "Parody",
"4885": "Bullet Hell",
"4947": "Romance",
"4975": "2.5D",
"4994": "Naval Combat",
"5030": "Dystopian",
"5055": "eSports",
"5094": "Narration",
"5125": "Procedural Generation",
"5153": "Kickstarter",
"5154": "Score Attack",
"5160": "Dinosaurs",
"5179": "Cold War",
"5186": "Psychological",
"5228": "Blood",
"5230": "Sequel",
"5300": "God Game",
"5310": "Games Workshop",
"5348": "Mod",
"5350": "Family Friendly",
"5363": "Destruction",
"5372": "Conspiracy",
"5379": "2D Platformer",
"5382": "World War I",
"5390": "Time Attack",
"5395": "3D Platformer",
"5407": "Benchmark",
"5411": "Beautiful",
"5432": "Programming",
"5502": "Hacking",
"5537": "Puzzle Platformer",
"5547": "Arena Shooter",
"5577": "RPGMaker",
"5608": "Emotional",
"5611": "Mature",
"5613": "Detective",
"5652": "Collectathon",
"5673": "Modern",
"5708": "Remake",
"5711": "Team-Based",
"5716": "Mystery",
"5727": "Baseball",
"5752": "Robots",
"5765": "Gun Customization",
"5794": "Science",
"5796": "Bullet Time",
"5851": "Isometric",
"5900": "Walking Simulator",
"5914": "Tennis",
"5923": "Dark Humor",
"5941": "Reboot",
"5981": "Mining",
"5984": "Drama",
"6041": "Horses",
"6052": "Noir",
"6129": "Logic",
"6214": "Birds",
"6276": "Inventory Management",
"6310": "Diplomacy",
"6378": "Crime",
"6426": "Choices Matter",
"6506": "3D Fighter",
"6621": "Pinball",
"6625": "Time Manipulation",
"6650": "Nudity",
"6691": "1990's",
"6702": "Mars",
"6730": "PvE",
"6815": "Hand-drawn",
"6869": "Nonlinear",
"6910": "Naval",
"6915": "Martial Arts",
"6948": "Rome",
"6971": "Multiple Endings",
"7038": "Golf",
"7107": "Real-Time with Pause",
"7108": "Party",
"7113": "Crowdfunded",
"7178": "Party Game",
"7208": "Female Protagonist",
"7250": "Linear",
"7309": "Skiing",
"7328": "Bowling",
"7332": "Base Building",
"7368": "Local Multiplayer",
"7423": "Sniper",
"7432": "Lovecraftian",
"7478": "Illuminati",
"7481": "Controller",
"7556": "Dice",
"7569": "Grid-Based Movement",
"7622": "Offroad",
"7702": "Narrative",
"7743": "1980s",
"7782": "Cult Classic",
"7918": "Dwarf",
"7926": "Artificial Intelligence",
"7948": "Soundtrack",
"8013": "Software",
"8075": "TrackIR",
"8093": "Minigames",
"8122": "Level Editor",
"8253": "Music-Based Procedural Generation",
"8369": "Investigation",
"8461": "Well-Written",
"8666": "Runner",
"8945": "Resource Management",
"9130": "Hentai",
"9157": "Underwater",
"9204": "Immersive Sim",
"9271": "Trading Card Game",
"9541": "Demons",
"9551": "Dating Sim",
"9564": "Hunting",
"9592": "Dynamic Narration",
"9803": "Snow",
"9994": "Experience",
"10235": "Life Sim",
"10383": "Transportation",
"10397": "Memes",
"10437": "Trivia",
"10679": "Time Travel",
"10695": "Party-Based RPG",
"10808": "Supernatural",
"10816": "Split Screen",
"11014": "Interactive Fiction",
"11095": "Boss Rush",
"11104": "Vehicular Combat",
"11123": "Mouse only",
"11333": "Villain Protagonist",
"11634": "Vikings",
"12057": "Tutorial",
"12095": "Sexual Content",
"12190": "Boxing",
"12286": "Warhammer 40K",
"12472": "Management",
"13070": "Solitaire",
"13190": "America",
"13276": "Tanks",
"13382": "Archery",
"13577": "Sailing",
"13782": "Experimental",
"13906": "Game Development",
"14139": "Turn-Based Tactics",
"14153": "Dungeons & Dragons",
"14720": "Nostalgia",
"14906": "Intentionally Awkward Controls",
"15045": "Flight",
"15172": "Conversation",
"15277": "Philosophical",
"15339": "Documentary",
"15564": "Fishing",
"15868": "Motocross",
"15954": "Silent Protagonist",
"16094": "Mythology",
"16250": "Gambling",
"16598": "Space Sim",
"16689": "Time Management",
"17015": "Werewolves",
"17305": "Strategy RPG",
"17337": "Lemmings",
"17389": "Tabletop",
"17770": "Asynchronous Multiplayer",
"17894": "Cats",
"17927": "Pool",
"18594": "FMV",
"19568": "Cycling",
"19780": "Submarine",
"19995": "Dark Comedy",
"21006": "Underground",
"21491": "Demo Available",
"21725": "Tactical RPG",
"21978": "VR",
"22602": "Agriculture",
"22955": "Mini Golf",
"24003": "Word Game",
"24904": "NSFW",
"25085": "Touch-Friendly",
"26921": "Political Sim",
"27758": "Voice Control",
"28444": "Snowboarding",
"29363": "3D Vision",
"29482": "Souls-like",
"29855": "Ambient",
"30358": "Nature",
"30927": "Fox",
"31275": "Text-Based",
"31579": "Otome",
"32322": "Deckbuilding",
"33572": "Mahjong",
"35079": "Job Simulator",
"42089": "Jump Scare",
"42329": "Coding",
"42804": "Action Roguelike",
"44868": "LGBTQ+",
"47827": "Wrestling",
"49213": "Rugby",
"51306": "Foreign",
"56690": "On-Rails Shooter",
"61357": "Electronic Music",
"65443": "Adult Content",
"71389": "Spelling",
"87918": "Farming Sim",
"91114": "Shop Keeper",
"92092": "Jet",
"96359": "Skating",
"97376": "Cozy",
"102530": "Elf",
"117648": "8-bit Music",
"123332": "Bikes",
"129761": "ATV",
"143739": "Electronic",
"150626": "Gaming",
"158638": "Cricket",
"176981": "Battle Royale",
"180368": "Faith",
"189941": "Instrumental Music",
"198631": "Mystery Dungeon",
"198913": "Motorbike",
"220585": "Colony Sim",
"233824": "Feature Film",
"252854": "BMX",
"255534": "Automation",
"323922": "Musou",
"324176": "Hockey",
"337964": "Rock Music",
"348922": "Steam Machine",
"353880": "Looter Shooter",
"363767": "Snooker",
"379975": "Clicker",
"454187": "Traditional Roguelike",
"552282": "Wholesome",
"603297": "Hardware",
"615955": "Idler",
"620519": "Hero Shooter",
"745697": "Social Deduction",
"769306": "Escape Room",
"776177": "360 Video",
"791774": "Card Battler",
"847164": "Volleyball",
"856791": "Asymmetric VR",
"916648": "Creature Collector",
"922563": "Roguevania",
"1003823": "Profile Features Limited",
"1023537": "Boomer Shooter",
"1084988": "Auto Battler",
"1091588": "Roguelike Deckbuilder",
"1100686": "Outbreak Sim",
"1100687": "Automobile Sim",
"1100688": "Medical Sim",
"1100689": "Open World Survival Craft",
"1199779": "Extraction Shooter",
"1220528": "Hobby Sim",
"1254546": "Football (Soccer)",
"1254552": "Football (American)",
"1368160": "AI Content Disclosed",
}
}

View File

@@ -160,9 +160,9 @@ export interface AppConfig {
export interface AppDepots {
branches: AppDepotBranches;
privatebranches: Record<string, AppDepotBranches>;
[depotId: string]: DepotEntry
| AppDepotBranches
| Record<string, AppDepotBranches>;
[depotId: string]: DepotEntry
| AppDepotBranches
| Record<string, AppDepotBranches>;
}
@@ -284,16 +284,27 @@ export type GenreType = {
export interface AppInfo {
name: string;
slug: string;
images: {
logo: string;
backdrop: string;
poster: string;
banner: string;
screenshots: string[];
icon: string;
}
links: string[] | null;
score: number;
gameid: string;
id: string;
releaseDate: Date;
description: string;
description: string | null;
compatibility: "low" | "mid" | "high" | "unknown";
controllerSupport: "partial" | "full" | "unknown";
primaryGenre: string | null;
size: { downloadSize: number; sizeOnDisk: number };
tags: Array<{ name: string; slug: string; type: "tag" }>;
genres: Array<{ type: "genre"; name: string; slug: string }>;
categories: Array<{ name: string; slug: string; type: "categorie" }>;
franchises: Array<{ name: string; slug: string; type: "franchise" }>;
developers: Array<{ name: string; slug: string; type: "developer" }>;
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
}
@@ -341,4 +352,249 @@ export interface Shot {
export interface RankedShot {
url: string;
score: number;
}
}
export interface SteamPlayerSummaryResponse {
response: {
players: SteamPlayerSummary[];
};
}
export interface SteamPlayerSummary {
steamid: string;
communityvisibilitystate: number;
profilestate?: number;
personaname: string;
profileurl: string;
avatar: string;
avatarmedium: string;
avatarfull: string;
avatarhash: string;
lastlogoff?: number;
personastate: number;
realname?: string;
primaryclanid?: string;
timecreated: number;
personastateflags?: number;
loccountrycode?: string;
}
export interface SteamPlayerBansResponse {
players: SteamPlayerBan[];
}
export interface SteamPlayerBan {
SteamId: string;
CommunityBanned: boolean;
VACBanned: boolean;
NumberOfVACBans: number;
DaysSinceLastBan: number;
NumberOfGameBans: number;
EconomyBan: 'none' | 'probation' | 'banned'; // Enum based on known possible values
}
export type SteamAccount = {
id: string;
name: string;
realName: string | null;
steamMemberSince: Date;
avatarHash: string;
limitations: {
isLimited: boolean;
tradeBanState: 'none' | 'probation' | 'banned';
isVacBanned: boolean;
visibilityState: number;
privacyState: 'public' | 'private' | 'friendsonly';
};
profileUrl: string;
lastSyncedAt: Date;
};
export interface SteamFriendsListResponse {
friendslist: {
friends: SteamFriend[];
};
}
export interface SteamFriend {
steamid: string;
relationship: 'friend'; // could expand this if Steam ever adds more types
friend_since: number; // Unix timestamp (seconds)
}
export interface SteamOwnedGamesResponse {
response: {
game_count: number;
games: SteamOwnedGame[];
};
}
export interface SteamOwnedGame {
appid: number;
name: string;
playtime_forever: number;
img_icon_url: string;
playtime_windows_forever?: number;
playtime_mac_forever?: number;
playtime_linux_forever?: number;
playtime_deck_forever?: number;
rtime_last_played?: number; // Unix timestamp
content_descriptorids?: number[];
playtime_disconnected?: number;
has_community_visible_stats?: boolean;
}
/**
* The shape of the parsed Steam profile information.
*/
export interface ProfileInfo {
steamID64: string;
isLimited: boolean;
privacyState: 'public' | 'private' | 'friendsonly' | string;
visibility: string;
}
export interface SteamStoreResponse {
response: {
store_items: SteamStoreItem[];
};
}
export interface SteamStoreItem {
item_type: number;
id: number;
success: number;
visible: boolean;
name: string;
store_url_path: string;
appid: number;
type: number;
tagids: number[];
categories: {
supported_player_categoryids?: number[];
feature_categoryids?: number[];
controller_categoryids?: number[];
};
reviews: {
summary_filtered: {
review_count: number;
percent_positive: number;
review_score: number;
review_score_label: string;
};
};
basic_info: {
short_description?: string;
publishers: SteamCreator[];
developers: SteamCreator[];
franchises?: SteamCreator[];
};
tags: {
tagid: number;
weight: number;
}[];
assets: SteamAssets;
assets_without_overrides: SteamAssets;
release: {
steam_release_date: number;
};
platforms: {
windows: boolean;
mac: boolean;
steamos_linux: boolean;
vr_support: Record<string, never>;
steam_deck_compat_category?: number;
steam_os_compat_category?: number;
};
best_purchase_option: PurchaseOption;
purchase_options: PurchaseOption[];
screenshots: {
all_ages_screenshots: {
filename: string;
ordinal: number;
}[];
};
trailers: {
highlights: Trailer[];
};
supported_languages: SupportedLanguage[];
full_description: string;
links?: {
link_type: number;
url: string;
}[];
}
export interface SteamCreator {
name: string;
creator_clan_account_id: number;
}
export interface SteamAssets {
asset_url_format: string;
main_capsule: string;
small_capsule: string;
header: string;
page_background: string;
hero_capsule: string;
hero_capsule_2x: string;
library_capsule: string;
library_capsule_2x: string;
library_hero: string;
library_hero_2x: string;
community_icon: string;
page_background_path: string;
raw_page_background: string;
}
export interface PurchaseOption {
packageid?: number;
bundleid?: number;
purchase_option_name: string;
final_price_in_cents: string;
original_price_in_cents: string;
formatted_final_price: string;
formatted_original_price: string;
discount_pct: number;
active_discounts: ActiveDiscount[];
user_can_purchase_as_gift: boolean;
hide_discount_pct_for_compliance: boolean;
included_game_count: number;
bundle_discount_pct?: number;
price_before_bundle_discount?: string;
formatted_price_before_bundle_discount?: string;
}
export interface ActiveDiscount {
discount_amount: string;
discount_description: string;
discount_end_date: number;
}
export interface Trailer {
trailer_name: string;
trailer_url_format: string;
trailer_category: number;
trailer_480p: TrailerFile[];
trailer_max: TrailerFile[];
microtrailer: TrailerFile[];
screenshot_medium: string;
screenshot_full: string;
trailer_base_id: number;
all_ages: boolean;
}
export interface TrailerFile {
filename: string;
type: string;
}
export interface SupportedLanguage {
elanguage: number;
eadditionallanguage: number;
supported: boolean;
full_audio: boolean;
subtitles: boolean;
}

View File

@@ -9,6 +9,7 @@ import type {
CompareResult,
RankedShot,
Shot,
ProfileInfo,
} from "./types";
import crypto from 'crypto';
import pLimit from 'p-limit';
@@ -18,6 +19,7 @@ import { LRUCache } from 'lru-cache';
import sanitizeHtml from 'sanitize-html';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import { parseStringPromise } from "xml2js";
import sharp, { type Metadata } from 'sharp';
import AbortController from 'abort-controller';
import fetch, { RequestInit } from 'node-fetch';
@@ -90,29 +92,21 @@ export namespace Utils {
// --- Optimized Box Art creation ---
export async function createBoxArtBuffer(
assets: LibraryAssetsFull,
appid: number | string,
logoUrl: string,
backgroundUrl: string,
logoPercent = 0.9
): Promise<Buffer> {
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
const pick = (key: string) => {
const set = assets[key];
const path = set?.image2x?.english || set?.image?.english;
if (!path) throw new Error(`Missing asset for ${key}`);
return `${base}/${path}`;
};
const [bgBuf, logoBuf] = await Promise.all([
downloadLimit(() =>
fetchBuffer(pick('library_hero'))
fetchBuffer(backgroundUrl)
.catch(error => {
console.error(`Failed to download hero image for ${appid}:`, error);
console.error(`Failed to download hero image from ${backgroundUrl}:`, error);
throw new Error(`Failed to create box art: hero image unavailable`);
}),
),
downloadLimit(() => fetchBuffer(pick('library_logo'))
downloadLimit(() => fetchBuffer(logoUrl)
.catch(error => {
console.error(`Failed to download logo image for ${appid}:`, error);
console.error(`Failed to download logo image from ${logoUrl}:`, error);
throw new Error(`Failed to create box art: logo image unavailable`);
}),
),
@@ -182,9 +176,11 @@ export namespace Utils {
export function createSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\s -]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.normalize("NFKD") // Normalize to decompose accented characters
.replace(/[^\p{L}\p{N}\s-]/gu, '') // Keep Unicode letters, numbers, spaces, and hyphens
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
.trim();
}
@@ -328,16 +324,26 @@ export namespace Utils {
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
switch (type) {
case "1":
return "low";
return "high";
case "2":
return "mid";
case "3":
return "high";
return "low";
default:
return "unknown";
}
}
export function estimateRatingFromSummary(
reviewCount: number,
percentPositive: number
): number {
const positiveVotes = Math.round((percentPositive / 100) * reviewCount);
const negativeVotes = reviewCount - positiveVotes;
return getRating(positiveVotes, negativeVotes);
}
export function mapGameTags<
T extends string = "tag"
>(
@@ -353,6 +359,20 @@ export namespace Utils {
return result;
}
export function createType<
T extends "developer" | "publisher" | "franchise" | "tag" | "categorie" | "genre"
>(
names: string[],
type: T
) {
return names
.map(name => ({
type,
name: name.trim(),
slug: createSlug(name.trim())
}));
}
/**
* Create a tag object with name, slug, and type
* @typeparam T Literal type of the `type` field (defaults to 'tag')
@@ -380,17 +400,39 @@ export namespace Utils {
.toLowerCase();
}
function isDepotEntry(e: any): e is DepotEntry {
return (
e != null &&
typeof e === 'object' &&
'manifests' in e &&
e.manifests != null &&
typeof e.manifests.public?.download === 'string'
);
}
export function getPublicDepotSizes(depots: AppDepots) {
const sum = { download: 0, size: 0 };
for (const key in depots) {
let download = 0;
let size = 0;
for (const key of Object.keys(depots)) {
if (key === 'branches' || key === 'privatebranches') continue;
const entry = depots[key] as DepotEntry;
if ('manifests' in entry && entry.manifests.public) {
sum.download += Number(entry.manifests.public.download);
sum.size += Number(entry.manifests.public.size);
if (!isDepotEntry(entry)) {
continue;
}
const dl = Number(entry.manifests.public.download);
const sz = Number(entry.manifests.public.size);
if (!Number.isFinite(dl) || !Number.isFinite(sz)) {
console.warn(`[getPublicDepotSizes] non-numeric size for depot ${key}`);
continue;
}
download += dl;
size += sz;
}
return { downloadSize: sum.download, sizeOnDisk: sum.size };
return { downloadSize: download, sizeOnDisk: size };
}
export function parseGenres(str: string): GenreType[] {
@@ -419,4 +461,64 @@ export namespace Utils {
return cleaned.trim()
}
/**
* Fetches and parses a single Steam community profile XML.
* @param steamIdOrVanity - The 64-bit SteamID or vanity name.
* @returns Promise resolving to ProfileInfo.
*/
export async function fetchProfileInfo(
steamIdOrVanity: string
): Promise<ProfileInfo> {
const isNumericId = /^\d+$/.test(steamIdOrVanity);
const path = isNumericId ? `profiles/${steamIdOrVanity}` : `id/${steamIdOrVanity}`;
const url = `https://steamcommunity.com/${path}/?xml=1`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${steamIdOrVanity}: HTTP ${response.status}`);
}
const xml = await response.text();
const { profile } = await parseStringPromise(xml, {
explicitArray: false,
trim: true,
mergeAttrs: true
}) as { profile: any };
// Extract fields (fall back to limitedAccount tag if needed)
const limitedFlag = profile.isLimitedAccount ?? profile.limitedAccount;
const isLimited = limitedFlag === '1';
return {
isLimited,
steamID64: profile.steamID64,
privacyState: profile.privacyState,
visibility: profile.visibilityState
};
}
/**
* Batch-fetches multiple Steam profiles in parallel.
* @param idsOrVanities - Array of SteamID64 strings or vanity names.
* @returns Promise resolving to a record mapping each input to its ProfileInfo or an error.
*/
export async function fetchProfilesInfo(
idsOrVanities: string[]
): Promise<Map<string, ProfileInfo | { error: string }>> {
const results = await Promise.all(
idsOrVanities.map(async (input) => {
try {
const info = await fetchProfileInfo(input);
return { input, result: info };
} catch (err) {
return { input, result: { error: (err as Error).message } };
}
})
);
return new Map(
results.map(({ input, result }) => [input, result] as [string, ProfileInfo | { error: string }])
);
}
}

View File

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

View File

@@ -1,25 +0,0 @@
import { steamTable } from "../steam/steam.sql";
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
import { encryptedText, ulid, timestamps, utc } from "../drizzle/types";
export const steamCredentialsTable = pgTable(
"steam_account_credentials",
{
...timestamps,
id: ulid("id").notNull(),
steamID: varchar("steam_id", { length: 255 })
.notNull()
.references(() => steamTable.id, {
onDelete: "cascade"
}),
refreshToken: encryptedText("refresh_token")
.notNull(),
expiry: utc("expiry").notNull(),
username: varchar("username", { length: 255 }).notNull(),
},
(table) => [
primaryKey({
columns: [table.steamID, table.id]
})
]
)

View File

@@ -1,93 +0,0 @@
import { z } from "zod";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { createEvent } from "../event";
import { createID, fn } from "../utils";
import { eq, and, isNull, gt } from "drizzle-orm";
import { createSelectSchema } from "drizzle-zod";
import { steamCredentialsTable } from "./credentials.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Credentials {
export const Info = createSelectSchema(steamCredentialsTable)
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
.extend({
accessToken: z.string(),
cookies: z.string().array()
})
export type Info = z.infer<typeof Info>;
export const Events = {
New: createEvent(
"new_credentials.added",
z.object({
steamID: Info.shape.steamID,
}),
),
};
export const create = fn(
Info
.omit({ accessToken: true, cookies: true, expiry: true })
.partial({ id: true }),
(input) => {
const part = input.refreshToken.split('.')[1] as string
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
return createTransaction(async (tx) => {
const id = input.id ?? createID("credentials")
await tx
.insert(steamCredentialsTable)
.values({
id,
steamID: input.steamID,
username: input.username,
refreshToken: input.refreshToken,
expiry: new Date(payload.exp * 1000),
})
await afterTx(async () =>
await bus.publish(Resource.Bus, Events.New, { steamID: input.steamID })
);
return id
})
});
export const fromSteamID = fn(
Info.shape.steamID,
(steamID) =>
useTransaction(async (tx) => {
const now = new Date()
const credential = await tx
.select()
.from(steamCredentialsTable)
.where(
and(
eq(steamCredentialsTable.steamID, steamID),
isNull(steamCredentialsTable.timeDeleted),
gt(steamCredentialsTable.expiry, now)
)
)
.execute()
.then(rows => rows.at(0));
if (!credential) return null;
return serialize(credential);
})
);
export function serialize(
input: typeof steamCredentialsTable.$inferSelect,
) {
return {
id: input.id,
expiry: input.expiry,
steamID: input.steamID,
username: input.username,
refreshToken: input.refreshToken,
};
}
}

View File

@@ -1,5 +1,4 @@
import { Token } from "../utils";
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
export const ulid = (name: string) => char(name, { length: 26 + 4 });
@@ -33,19 +32,6 @@ export const utc = (name: string) =>
// mode: "date"
});
export const encryptedText =
customType<{ data: string; driverData: string; }>({
dataType() {
return 'text';
},
fromDriver(val) {
return Token.decrypt(val);
},
toDriver(val) {
return Token.encrypt(val);
},
});
export const timestamps = {
timeCreated: utc("time_created").notNull().defaultNow(),
timeUpdated: utc("time_updated").notNull().defaultNow(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,122 +0,0 @@
import { z } from "zod";
import { Actor } from "../actor";
import { Common } from "../common";
import { Examples } from "../examples";
import { createID, fn } from "../utils";
import { and, eq, isNull } from "drizzle-orm"
import { memberTable, RoleEnum } from "./member.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Member {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Member.id,
}),
teamID: z.string().openapi({
description: "Associated team identifier for this membership",
example: Examples.Member.teamID
}),
role: z.enum(RoleEnum.enumValues).openapi({
description: "Assigned permission role within the team",
example: Examples.Member.role
}),
steamID: z.string().openapi({
description: "Steam platform identifier for Steam account integration",
example: Examples.Member.steamID
}),
userID: z.string().nullable().openapi({
description: "Optional associated user account identifier",
example: Examples.Member.userID
}),
})
.openapi({
ref: "Member",
description: "Team membership entity defining user roles and platform connections",
example: Examples.Member,
});
export type Info = z.infer<typeof Info>;
export const create = fn(
Info
.partial({
id: true,
userID: true,
teamID: true
}),
(input) =>
createTransaction(async (tx) => {
const id = input.id ?? createID("member");
await tx.insert(memberTable).values({
id,
role: input.role,
userID: input.userID,
steamID: input.steamID,
teamID: input.teamID ?? Actor.teamID(),
})
return id;
}),
);
export const fromTeamID = fn(
Info.shape.teamID,
(teamID) =>
useTransaction((tx) =>
tx
.select()
.from(memberTable)
.where(
and(
eq(memberTable.userID, Actor.userID()),
eq(memberTable.teamID, teamID),
isNull(memberTable.timeDeleted)
)
)
.execute()
.then(rows => rows.map(serialize).at(0))
)
)
export const fromUserID = fn(
z.string(),
(userID) =>
useTransaction((tx) =>
tx
.select()
.from(memberTable)
.where(
and(
eq(memberTable.userID, userID),
eq(memberTable.teamID, Actor.teamID()),
isNull(memberTable.timeDeleted)
)
)
.execute()
.then(rows => rows.map(serialize).at(0))
)
)
/**
* Converts a raw member database row into a standardized {@link Member.Info} object.
*
* @param input - The database row representing a member.
* @returns The member information formatted as a {@link Member.Info} object.
*/
export function serialize(
input: typeof memberTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
role: input.role,
userID: input.userID,
teamID: input.teamID,
steamID: input.steamID
};
}
}

View File

@@ -1,33 +0,0 @@
import { isNotNull } from "drizzle-orm";
import { userTable } from "../user/user.sql";
import { steamTable } from "../steam/steam.sql";
import { timestamps, teamID, ulid } from "../drizzle/types";
import { bigint, pgEnum, pgTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/pg-core";
export const RoleEnum = pgEnum("member_role", ["child", "adult"])
export const memberTable = pgTable(
"members",
{
...teamID,
...timestamps,
userID: ulid("user_id")
.references(() => userTable.id, {
onDelete: "cascade"
}),
steamID: varchar("steam_id", { length: 255 })
.notNull()
.references(() => steamTable.id, {
onDelete: "cascade",
onUpdate: "restrict"
}),
role: RoleEnum("role").notNull(),
},
(table) => [
primaryKey({ columns: [table.id, table.teamID] }),
uniqueIndex("idx_member_steam_id").on(table.teamID, table.steamID),
uniqueIndex("idx_member_user_id")
.on(table.teamID, table.userID)
.where(isNotNull(table.userID))
],
);

View File

@@ -1,15 +1,12 @@
import { z } from "zod";
import { fn } from "../utils";
import { Resource } from "sst";
import { Actor } from "../actor";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createEvent } from "../event";
import { Examples } from "../examples";
import { createEvent } from "../event";
import { eq, and, isNull, desc } from "drizzle-orm";
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { teamTable } from "../team/team.sql";
import { createTransaction, useTransaction } from "../drizzle/transaction";
export namespace Steam {
export const Info = z
@@ -34,14 +31,6 @@ export namespace Steam {
description: "The steam community url of this account",
example: Examples.SteamAccount.profileUrl
}),
username: z.string()
.regex(/^[a-z0-9]{1,32}$/, "The Steam username is not slug friendly")
.nullable()
.openapi({
description: "The unique username of this account",
example: Examples.SteamAccount.username
})
.default("unknown"),
realName: z.string().nullable().openapi({
description: "The real name behind of this Steam account",
example: Examples.SteamAccount.realName
@@ -76,7 +65,7 @@ export namespace Steam {
"steam_account.created",
z.object({
steamID: Info.shape.id,
userID: Info.shape.userID
userID: Info.shape.userID,
}),
),
Updated: createEvent(
@@ -94,9 +83,9 @@ export namespace Steam {
useUser: z.boolean(),
})
.partial({
useUser: true,
userID: true,
status: true,
useUser: true,
lastSyncedAt: true
}),
(input) =>
@@ -107,8 +96,8 @@ export namespace Steam {
.from(steamTable)
.where(
and(
eq(steamTable.id, input.id),
isNull(steamTable.timeDeleted)
isNull(steamTable.timeDeleted),
eq(steamTable.id, input.id)
)
)
.execute()
@@ -129,7 +118,6 @@ export namespace Steam {
avatarHash: input.avatarHash,
limitations: input.limitations,
status: input.status ?? "offline",
username: input.username ?? "unknown",
steamMemberSince: input.steamMemberSince,
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
})
@@ -151,8 +139,8 @@ export namespace Steam {
.partial({
userID: true
}),
(input) =>
useTransaction(async (tx) => {
async (input) =>
createTransaction(async (tx) => {
const userID = input.userID ?? Actor.userID()
await tx
.update(steamTable)
@@ -160,6 +148,12 @@ export namespace Steam {
userID
})
.where(eq(steamTable.id, input.steamID));
// await afterTx(async () =>
// bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
// );
return input.steamID
})
)
@@ -177,6 +171,26 @@ export namespace Steam {
)
)
export const confirmOwnerShip = fn(
z.string().min(1),
(userID) =>
useTransaction((tx) =>
tx
.select()
.from(steamTable)
.where(
and(
eq(steamTable.userID, userID),
eq(steamTable.id, Actor.steamID()),
isNull(steamTable.timeDeleted)
)
)
.orderBy(desc(steamTable.timeCreated))
.execute()
.then((rows) => rows.map(serialize).at(0))
)
)
export const fromSteamID = fn(
z.string(),
(steamID) =>
@@ -208,15 +222,14 @@ export namespace Steam {
return {
id: input.id,
name: input.name,
userID: input.userID,
status: input.status,
username: input.username,
userID: input.userID,
realName: input.realName,
profileUrl: input.profileUrl,
avatarHash: input.avatarHash,
limitations: input.limitations,
lastSyncedAt: input.lastSyncedAt,
steamMemberSince: input.steamMemberSince,
profileUrl: input.profileUrl ? `https://steamcommunity.com/id/${input.profileUrl}` : null,
};
}

View File

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

View File

@@ -1,188 +0,0 @@
import { z } from "zod";
import { Steam } from "../steam";
import { Actor } from "../actor";
import { Common } from "../common";
import { teamTable } from "./team.sql";
import { Examples } from "../examples";
import { and, eq, isNull } from "drizzle-orm";
import { steamTable } from "../steam/steam.sql";
import { createID, fn, Invite } from "../utils";
import { memberTable } from "../member/member.sql";
import { groupBy, pipe, values, map } from "remeda";
import { createTransaction, useTransaction, type Transaction } from "../drizzle/transaction";
export namespace Team {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
slug: z.string().regex(/^[a-z0-9-]{1,32}$/, "Use a URL friendly name.").openapi({
description: "URL-friendly unique username (lowercase alphanumeric with hyphens)",
example: Examples.Team.slug
}),
name: z.string().openapi({
description: "Display name of the team",
example: Examples.Team.name
}),
ownerID: z.string().openapi({
description: "Unique identifier of the team owner",
example: Examples.Team.ownerID
}),
maxMembers: z.number().openapi({
description: "Maximum allowed team members based on subscription tier",
example: Examples.Team.maxMembers
}),
inviteCode: z.string().openapi({
description: "Unique invitation code used for adding new team members",
example: Examples.Team.inviteCode
}),
members: Steam.Info.array().openapi({
description: "All the team members in this team",
example: Examples.Team.members
})
})
.openapi({
ref: "Team",
description: "Team entity containing core team information and settings",
example: Examples.Team,
});
export type Info = z.infer<typeof Info>;
/**
* Generates a unique team invite code
* @param length The length of the invite code
* @param maxAttempts Maximum number of attempts to generate a unique code
* @returns A promise resolving to a unique invite code
*/
async function createUniqueTeamInviteCode(
tx: Transaction,
length: number = 8,
maxAttempts: number = 5
): Promise<string> {
let attempts = 0;
while (attempts < maxAttempts) {
const code = Invite.generateCode(length);
const teams =
await tx
.select()
.from(teamTable)
.where(eq(teamTable.inviteCode, code))
.execute()
if (teams.length === 0) {
return code;
}
attempts++;
}
// If we've exceeded max attempts, add timestamp to ensure uniqueness
const timestampSuffix = Date.now().toString(36).slice(-4);
const baseCode = Invite.generateCode(length - 4);
return baseCode + timestampSuffix;
}
export const create = fn(
Info
.omit({ members: true })
.partial({
id: true,
inviteCode: true,
maxMembers: true,
ownerID: true
}),
async (input) =>
createTransaction(async (tx) => {
const inviteCode = await createUniqueTeamInviteCode(tx)
const id = input.id ?? createID("team");
await tx
.insert(teamTable)
.values({
id,
inviteCode,
slug: input.slug,
name: input.name,
ownerID: input.ownerID ?? Actor.userID(),
maxMembers: input.maxMembers ?? 1,
})
.onConflictDoUpdate({
target: [teamTable.slug],
set: {
timeDeleted: null
}
})
return id;
})
)
export const list = () =>
useTransaction(async (tx) =>
tx
.select({
steam_accounts: steamTable,
teams: teamTable
})
.from(teamTable)
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
.innerJoin(steamTable, eq(memberTable.steamID, steamTable.id))
.where(
and(
eq(memberTable.userID, Actor.userID()),
isNull(memberTable.timeDeleted),
isNull(steamTable.timeDeleted),
isNull(teamTable.timeDeleted),
),
)
.execute()
.then((rows) => serialize(rows))
)
export const fromSlug = fn(
Info.shape.slug,
(slug) =>
useTransaction((tx) =>
tx
.select()
.from(teamTable)
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
.innerJoin(steamTable, eq(memberTable.steamID, steamTable.id))
.where(
and(
eq(memberTable.userID, Actor.userID()),
isNull(memberTable.timeDeleted),
isNull(steamTable.timeDeleted),
isNull(teamTable.timeDeleted),
eq(teamTable.slug, slug),
)
)
.then((rows) => serialize(rows).at(0))
)
)
export function serialize(
input: { teams: typeof teamTable.$inferSelect; steam_accounts: typeof steamTable.$inferSelect | null }[]
): z.infer<typeof Info>[] {
return pipe(
input,
groupBy((row) => row.teams.id),
values(),
map((group) => ({
id: group[0].teams.id,
slug: group[0].teams.slug,
name: group[0].teams.name,
ownerID: group[0].teams.ownerID,
maxMembers: group[0].teams.maxMembers,
inviteCode: group[0].teams.inviteCode,
members: group.map(i => i.steam_accounts)
.filter((c): c is typeof steamTable.$inferSelect => Boolean(c))
.map((item) => Steam.serialize(item))
})),
)
}
}

View File

@@ -1,35 +0,0 @@
import { timestamps, id, ulid } from "../drizzle/types";
import {
varchar,
pgTable,
bigint,
unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { userTable } from "../user/user.sql";
import { steamTable } from "../steam/steam.sql";
export const teamTable = pgTable(
"teams",
{
...id,
...timestamps,
name: varchar("name", { length: 255 }).notNull(),
ownerID: ulid("owner_id")
.notNull()
.references(() => userTable.id, {
onDelete: "cascade"
}),
inviteCode: varchar("invite_code", { length: 10 }).notNull(),
slug: varchar("slug", { length: 255 })
.notNull()
.references(() => steamTable.username, {
onDelete: "cascade"
}),
maxMembers: bigint("max_members", { mode: "number" }).notNull(),
},
(team) => [
uniqueIndex("idx_team_slug").on(team.slug),
unique("idx_team_invite_code").on(team.inviteCode)
]
);

View File

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

View File

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

View File

@@ -1,58 +0,0 @@
import { z } from 'zod';
import { fn } from './fn';
import crypto from 'crypto';
import { Resource } from 'sst';
// This is a 32-character random ASCII string
const rawKey = Resource.SteamEncryptionKey.value;
// Turn it into exactly 32 bytes via UTF-8
const key = Buffer.from(rawKey, 'utf8');
if (key.length !== 32) {
throw new Error(
`SteamEncryptionKey must be exactly 32 bytes; got ${key.length}`
);
}
const ENCRYPTION_IV_LENGTH = 12; // 96 bits for GCM
export namespace Token {
export const encrypt = fn(
z.string().min(4),
(token) => {
const iv = crypto.randomBytes(ENCRYPTION_IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([
cipher.update(token, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return ['v1', iv.toString('hex'), tag.toString('hex'), ciphertext.toString('hex')].join(':');
});
export const decrypt = fn(
z.string().min(4),
(data) => {
const [version, ivHex, tagHex, ciphertextHex] = data.split(':');
if (version !== 'v1' || !ivHex || !tagHex || !ciphertextHex) {
throw new Error('Invalid token format');
}
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ciphertext = Buffer.from(ciphertextHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return plaintext.toString('utf8');
});
}

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,19 @@
import { z } from "zod";
import { Hono } from "hono";
import crypto from 'crypto';
import { Resource } from "sst";
import { streamSSE } from "hono/streaming";
import { Actor } from "@nestri/core/actor";
import SteamCommunity from "steamcommunity";
import { describeRoute } from "hono-openapi";
import { Team } from "@nestri/core/team/index";
import { User } from "@nestri/core/user/index";
import { Examples } from "@nestri/core/examples";
import { Steam } from "@nestri/core/steam/index";
import { Member } from "@nestri/core/member/index";
import { getCookie, setCookie } from "hono/cookie";
import { Client } from "@nestri/core/client/index";
import { Friend } from "@nestri/core/friend/index";
import { Library } from "@nestri/core/library/index";
import { chunkArray } from "@nestri/core/utils/helper";
import { ErrorResponses, validator, Result } from "./utils";
import { Credentials } from "@nestri/core/credentials/index";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
import { ErrorResponses, validator, Result, notPublic } from "./utils";
const sqs = new SQSClient({});
@@ -45,273 +42,217 @@ export namespace SteamApi {
429: ErrorResponses[429],
}
}),
notPublic,
async (c) =>
c.json({
data: await Steam.list()
})
)
.get("/login",
.get("/callback/:id",
validator(
"param",
z.object({
id: z.string().openapi({
description: "ID of the user to login",
example: Examples.User.id,
}),
}),
),
async (c) => {
const cookieID = getCookie(c, "user_id");
const userID = c.req.valid("param").id;
if (!cookieID || cookieID !== userID) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"You should not be here"
);
}
const currentUser = await User.fromID(userID);
if (!currentUser) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} not found`
)
}
const params = new URL(c.req.url).searchParams;
// Verify OpenID response and get steamID
const steamID = await Client.verifyOpenIDResponse(params);
// If verification failed, return error
if (!steamID) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Invalid OpenID authentication response"
);
}
const user = (await Client.getUserInfo([steamID]))[0];
if (!user) {
throw new VisibleError(
"internal",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
"Steam user data is missing"
);
}
const wasAdded = await Steam.create({ ...user, userID });
if (!wasAdded) {
// Update the owner of the Steam account
await Steam.updateOwner({ userID, steamID })
}
c.executionCtx.waitUntil((async () => {
try {
// Get friends info
const friends = await Client.getFriendsList(steamID);
const friendSteamIDs = friends.friendslist.friends.map(f => f.steamid);
// Steam API has a limit of requesting 100 friends at a go
const friendChunks = chunkArray(friendSteamIDs, 100);
const settled = await Promise.allSettled(
friendChunks.map(async (friendIDs) => {
const friendsInfo = await Client.getUserInfo(friendIDs)
return await Promise.all(
friendsInfo.map(async (friend) => {
const wasAdded = await Steam.create(friend);
if (!wasAdded) {
console.log(`Friend ${friend.id} already exists`)
}
await Friend.add({ friendSteamID: friend.id, steamID })
return friend.id
})
)
})
)
settled
.filter(result => result.status === 'rejected')
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
const prod = (Resource.App.stage === "production" || Resource.App.stage === "dev")
const friendIDs = [
steamID,
...(prod ? settled
.filter(result => result.status === "fulfilled")
.map(f => f.value)
.flat() : [])
]
await Promise.all(
friendIDs.map(async (currentSteamID) => {
// Get user library
const gameLibrary = await Client.getUserLibrary(currentSteamID);
const queryLib = await Promise.allSettled(
gameLibrary.response.games.map(async (game) => {
await Actor.provide(
"steam",
{
steamID: currentSteamID,
},
async () => {
const payload = await Library.Events.Queue.create({
appID: game.appid,
lastPlayed: game.rtime_last_played ? new Date(game.rtime_last_played * 1000) : null,
totalPlaytime: game.playtime_forever
});
await sqs.send(
new SendMessageCommand({
QueueUrl: Resource.LibraryQueue.url,
// Prevent bombarding Steam with requests at the same time
DelaySeconds: 10,
MessageBody: JSON.stringify(payload),
})
)
}
)
})
)
queryLib
.filter(i => i.status === "rejected")
.forEach(e => console.warn(`[pushUserLib]: Failed to push user library to queue: ${e.reason}`))
})
)
} catch (error: any) {
console.error(`Failed to process Steam data for user ${userID}:`, error);
}
})())
return c.html(
`
<script>
window.location.href = "about:blank";
window.close()
</script>
`
)
}
)
.get("/popup/:id",
describeRoute({
tags: ["Steam"],
summary: "Login to Steam using QR code",
description: "Login to Steam using a QR code sent using Server Sent Events",
summary: "Login to Steam",
description: "Login to Steam in a popup",
responses: {
400: ErrorResponses[400],
429: ErrorResponses[429],
}
}),
validator(
"header",
"param",
z.object({
"accept": z.string()
.refine((v) =>
v.toLowerCase()
.includes("text/event-stream")
)
.openapi({
description: "Client must accept Server Sent Events",
example: "text/event-stream"
})
})
id: z.string().openapi({
description: "ID of the user to login",
example: Examples.User.id,
}),
}),
),
(c) => {
const currentUser = Actor.user()
async (c) => {
const userID = c.req.valid("param").id;
return streamSSE(c, async (stream) => {
const user = await User.fromID(userID);
if (!user) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} not found`
)
}
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
setCookie(c, "user_id", user.id);
session.loginTimeout = 30000; //30 seconds is typically when the url expires
const returnUrl = `${new URL(c.req.url).origin}/steam/callback/${userID}`
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "connected to steam" })
})
const params = new URLSearchParams({
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.mode': 'checkid_setup',
'openid.return_to': returnUrl,
'openid.realm': new URL(returnUrl).origin,
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
'user_id': user.id
});
const challenge = await session.startWithQR();
await stream.writeSSE({
event: 'challenge_url',
data: JSON.stringify({ url: challenge.qrChallengeUrl })
})
return new Promise((resolve, reject) => {
session.on('remoteInteraction', async () => {
await stream.writeSSE({
event: 'remote_interaction',
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
})
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
})
});
session.on('timeout', async () => {
console.log('This login attempt has timed out.');
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Your session timed out" }),
})
await stream.writeSSE({
event: 'timed_out',
data: JSON.stringify({ success: false }),
})
await stream.close()
reject("Authentication timed out")
});
session.on('error', async (err) => {
// This should ordinarily not happen. This only happens in case there's some kind of unexpected error while
// polling, e.g. the network connection goes down or Steam chokes on something.
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Recieved an error while authenticating" }),
})
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message: err.message }),
})
await stream.close()
reject(err.message)
});
session.on('authenticated', async () => {
await stream.writeSSE({
event: 'status',
data: JSON.stringify({ message: "Login successful" })
})
await stream.writeSSE({
event: 'login_success',
data: JSON.stringify({ success: true, })
})
const username = session.accountName;
const accessToken = session.accessToken;
const refreshToken = session.refreshToken;
const steamID = session.steamID.toString();
const cookies = await session.getWebCookies();
// Get user information
const community = new SteamCommunity();
community.setCookies(cookies);
const user = await Client.getUserInfo({ steamID, cookies })
const wasAdded =
await Steam.create({
username,
id: steamID,
name: user.name,
realName: user.realName,
userID: currentUser.userID,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState),
tradeBanState: user.tradeBanState.toLowerCase() as any,
}
})
// Does not matter if the user is already there or has just been created, just store the credentials
await Credentials.create({ refreshToken, steamID, username })
let teamID: string | undefined
if (wasAdded) {
const rawFirst = (user.name ?? username).trim().split(/\s+/)[0] ?? username;
const firstName = rawFirst
.charAt(0) // first character
.toUpperCase() // make it uppercase
+ rawFirst
.slice(1) // rest of the string
.toLowerCase();
// create a team
teamID = await Team.create({
slug: username,
name: firstName,
ownerID: currentUser.userID,
})
// Add us as a member
await Actor.provide(
"system",
{ teamID },
async () =>
await Member.create({
role: "adult",
userID: currentUser.userID,
steamID
})
)
} else {
// Update the owner of the Steam account
await Steam.updateOwner({ userID: currentUser.userID, steamID })
const t = await Actor.provide(
"user",
currentUser,
async () => {
// Get the team associated with this username
const team = await Team.fromSlug(username);
// This should never happen
if (!team) throw Error(`Is Nestri okay???, we could not find the team with this slug ${username}`)
teamID = team.id
return team.id
}
)
console.log("t",t)
console.log("teamID",teamID)
}
await stream.writeSSE({
event: 'team_slug',
data: JSON.stringify({ username })
})
// Get game library in the background
c.executionCtx.waitUntil((async () => {
const games = await Client.getUserLibrary(accessToken);
// Get a batch of 5 games each
const apps = games?.response?.apps || [];
if (apps.length === 0) {
console.info("[SteamApi] Is Steam okay? No games returned for user:", { steamID });
return
}
const chunkedGames = chunkArray(apps, 5);
// Get the batches to the queue
const processQueue = chunkedGames.map(async (chunk) => {
const myGames = chunk.map(i => {
return {
appID: i.appid,
totalPlaytime: i.rt_playtime,
isFamilyShareable: i.exclude_reason === 0,
lastPlayed: new Date(i.rt_last_played * 1000),
timeAcquired: new Date(i.rt_time_acquired * 1000),
isFamilyShared: !i.owner_steamids.includes(steamID) && i.exclude_reason === 0,
}
})
if (teamID) {
const deduplicationId = crypto
.createHash('md5')
.update(`${teamID}_${chunk.map(g => g.appid).join(',')}`)
.digest('hex');
await Actor.provide(
"member",
{
teamID,
steamID,
userID: currentUser.userID
},
async () => {
const payload = await Library.Events.Queue.create(myGames);
await sqs.send(
new SendMessageCommand({
MessageGroupId: teamID,
QueueUrl: Resource.LibraryQueue.url,
MessageBody: JSON.stringify(payload),
MessageDeduplicationId: deduplicationId,
})
)
}
)
}
})
const settled = await Promise.allSettled(processQueue)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.error("[LibraryQueue] enqueue failed:", (r as PromiseRejectedResult).reason));
})())
await stream.close();
resolve();
})
})
})
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
}
)
}

View File

@@ -1,91 +0,0 @@
import { z } from "zod"
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { Team } from "@nestri/core/team/index";
import { Examples } from "@nestri/core/examples";
import { ErrorResponses, Result, validator } from "./utils";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
export namespace TeamApi {
export const route = new Hono()
.get("/",
describeRoute({
tags: ["Team"],
summary: "List user teams",
description: "List the current user's team details",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.array().openapi({
description: "All team information",
example: [Examples.Team]
})
),
},
},
description: "All team details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
async (c) =>
c.json({
data: await Team.list()
})
)
.get("/:slug",
describeRoute({
tags: ["Team"],
summary: "Get team by slug",
description: "Get the current user's team details, by its slug",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.openapi({
description: "Team details",
example: Examples.Team
})
),
},
},
description: "Team details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
validator(
"param",
z.object({
slug: z.string().openapi({
description: "SLug of the team to get",
example: Examples.Team.slug,
}),
}),
),
async (c) => {
const teamSlug = c.req.valid("param").slug
const team = await Team.fromSlug(teamSlug)
if (!team) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Team ${teamSlug} not found`
)
}
return c.json({
data: team
})
}
)
}

View File

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

View File

@@ -1,117 +1,238 @@
import "zod-openapi/extend";
import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Actor } from "@nestri/core/actor";
import { Game } from "@nestri/core/game/index";
import { Steam } from "@nestri/core/steam/index";
import { Client } from "@nestri/core/client/index";
import { Images } from "@nestri/core/images/index";
import { Friend } from "@nestri/core/friend/index";
import { Library } from "@nestri/core/library/index";
import { BaseGame } from "@nestri/core/base-game/index";
import { Credentials } from "@nestri/core/credentials/index";
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
import { Categories } from "@nestri/core/categories/index";
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({});
export const handler = bus.subscriber(
[Credentials.Events.New, BaseGame.Events.New],
[
BaseGame.Events.New,
Steam.Events.Updated,
Steam.Events.Created,
BaseGame.Events.NewBoxArt,
BaseGame.Events.NewHeroArt,
],
async (event) => {
console.log(event.type, event.properties, event.metadata);
switch (event.type) {
case "new_credentials.added": {
const input = event.properties
const credentials = await Credentials.fromSteamID(input.steamID)
if (credentials) {
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
case "new_image.save": {
const input = event.properties;
const image = await Client.getImageInfo({ url: input.url, type: input.type });
session.refreshToken = credentials.refreshToken;
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
const cookies = await session.getWebCookies();
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
const friends = await Client.getFriendsList(cookies);
const putFriends = friends.map(async (user) => {
const wasAdded =
await Steam.create({
id: user.steamID.toString(),
name: user.name,
realName: user.realName,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
tradeBanState: user.tradeBanState.toLowerCase() as any,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState)
}
})
if (!wasAdded) {
console.log(`Steam user ${user.steamID.toString()} already exists`)
}
await Friend.add({ friendSteamID: user.steamID.toString(), steamID: input.steamID })
})
const settled = await Promise.allSettled(putFriends);
settled
.filter(result => result.status === 'rejected')
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
break;
}
case "new_game.added": {
const input = event.properties
// Get images and save to s3
const images = await Client.getImages(input.appID);
(await Promise.allSettled(
images.map(async (image) => {
// Put the images into the db
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
})
))
.filter(i => i.status === "rejected")
.forEach(r => console.warn("[createImages] failed:", (r as PromiseRejectedResult).reason));
break;
}
case "new_box_art_image.save": {
const input = event.properties;
const image = await Client.createBoxArt(input);
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
break;
}
case "new_hero_art_image.save": {
const input = event.properties;
const images = await Client.createHeroArt(input);
const settled =
await Promise.allSettled(
images.map(async (image) => {
await Images.create({
type: image.type,
imageHash: image.hash,
baseGameID: input.appID,
position: image.position,
fileSize: image.fileSize,
sourceUrl: image.sourceUrl,
dimensions: image.dimensions,
extractedColor: image.averageColor,
});
try {
//Check whether the image already exists
await s3.send(
new HeadObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
})
);
} catch (e) {
// Save to s3 because it doesn't already exist
await s3.send(
new PutObjectCommand({
Bucket: Resource.Storage.name,
Key: `images/${image.hash}`,
Body: image.buffer,
...(image.format && { ContentType: `image/${image.format}` }),
Metadata: {
type: image.type,
appID: input.appID,
}
})
)
}
})
)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.warn("[processHeroArt] failed:", (r as PromiseRejectedResult).reason));
break;
}
// case "steam_account.updated":
// case "steam_account.created": {
// //Get user library and commit it to the db
// const steamID = event.properties.steamID;
// await Actor.provide(
// event.metadata.actor.type,
// event.metadata.actor.properties,
// async () => {
// //Get user library
// const gameLibrary = await Client.getUserLibrary(steamID);
// const myLibrary = new Map(gameLibrary.response.games.map(g => [g.appid, g]))
// const queryLib = await Promise.allSettled(
// gameLibrary.response.games.map(async (game) => {
// return await Client.getAppInfo(game.appid.toString())
// })
// )
// queryLib
// .filter(i => i.status === "rejected")
// .forEach(e => console.warn(`[getAppInfo]: Failed to get game metadata: ${e.reason}`))
// const gameInfo = queryLib.filter(i => i.status === "fulfilled").map(f => f.value)
// const queryGames = gameInfo.map(async (game) => {
// await BaseGame.create(game);
// const allCategories = [...game.tags, ...game.genres, ...game.publishers, ...game.developers];
// const uniqueCategories = Array.from(
// new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
// );
// const gameSettled = await Promise.allSettled(
// uniqueCategories.map(async (cat) => {
// //Use a single db transaction to get or set the category
// await Categories.create({
// type: cat.type, slug: cat.slug, name: cat.name
// })
// // Use a single db transaction to get or create the game
// await Game.create({ baseGameID: game.id, categorySlug: cat.slug, categoryType: cat.type })
// })
// )
// gameSettled
// .filter(r => r.status === "rejected")
// .forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason));
// const currentGameInLibrary = myLibrary.get(parseInt(game.id))
// if (currentGameInLibrary) {
// await Library.add({
// baseGameID: game.id,
// lastPlayed: currentGameInLibrary.rtime_last_played ? new Date(currentGameInLibrary.rtime_last_played * 1000) : null,
// totalPlaytime: currentGameInLibrary.playtime_forever,
// })
// } else {
// throw new Error(`Game is not in library, but was found in app info:${game.id}`)
// }
// })
// const settled = await Promise.allSettled(queryGames);
// settled
// .filter(i => i.status === "rejected")
// .forEach(e => console.warn(`[gameCreate]: Failed to create game: ${e.reason}`))
// })
// break;
// }
}
},
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { createSignal, type JSX, onMount } from "solid-js";
type SteamAvatarProps = {
avatarHash: string;
alt?: string;
class?: string;
style?: string | JSX.CSSProperties;
};
export default function SteamAvatar(props: SteamAvatarProps) {
const smallUrl = `https://avatars.cloudflare.steamstatic.com/${props.avatarHash}.jpg`;
const fullUrl = `https://avatars.cloudflare.steamstatic.com/${props.avatarHash}_full.jpg`;
const [src, setSrc] = createSignal(smallUrl);
onMount(() => {
const img = new Image();
img.src = fullUrl;
img.onload = () => setSrc(fullUrl);
});
return (
<img
src={src()}
alt={props.alt ?? "Steam Avatar"}
class={props.class}
style={{
"height": "100%",
"width": "100%",
"object-fit": "cover",
...typeof props.style === "string" ? {} : props.style,
}}
/>
);
}

View File

@@ -1,22 +1,17 @@
import { EventSource } from 'eventsource'
import { QRCode } from "../ui/custom-qr";
import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme";
import { useNavigate } from "@solidjs/router";
import { keyframes } from "@macaron-css/core";
import { useOpenAuth } from "@openauthjs/solid";
import { useAccount } from "../providers/account";
import { createEffect, createSignal, onCleanup } from "solid-js";
import { Container, Screen as FullScreen } from "@nestri/www/ui/layout";
import { useNavigate } from "@solidjs/router";
const Card = styled("div", {
base: {
padding: `10px 20px`,
maxWidth: 360,
gap: 40,
maxWidth: 400,
width: "100%",
position: "relative",
display: "flex",
gap: 20,
padding: `10px 20px`,
position: "relative",
flexDirection: "column",
justifyContent: "center",
}
@@ -48,33 +43,45 @@ const Logo = styled("svg", {
}
})
const Title = styled("h2", {
const Title = styled("h1", {
base: {
fontSize: theme.font.size["2xl"],
lineHeight: "2rem",
textWrap: "balance",
letterSpacing: "-0.029375rem",
fontSize: theme.font.size["4xl"],
fontFamily: theme.font.family.heading,
fontWeight: theme.font.weight.semibold,
fontFamily: theme.font.family.heading
}
})
const Subtitle = styled("h2", {
base: {
fontSize: theme.font.size["base"],
fontWeight: theme.font.weight.regular,
color: theme.color.gray.d900,
}
})
const Button = styled("button", {
base: {
display: "flex",
justifyContent: "space-between",
justifyContent: "center",
alignItems: "center",
cursor: "not-allowed",
padding: "10px 20px",
gap: theme.space["2"],
cursor: "pointer",
padding: "0px 14px",
gap: 10,
height: 48,
borderRadius: theme.space["2"],
backgroundColor: theme.color.background.d100,
border: `1px solid ${theme.color.gray.d400}`
},
variants: {
comingSoon: {
true: {
justifyContent: "space-between",
padding: "10px 20px",
gap: theme.space["2"],
cursor: "not-allowed",
}
},
steamBtn: {
true: {
color: "#FFF",
backgroundColor: "#2D73FF"
}
}
}
})
@@ -86,7 +93,7 @@ const ButtonText = styled("span", {
position: "relative",
display: "flex",
alignItems: "center"
}
},
})
const ButtonIcon = styled("svg", {
@@ -105,230 +112,6 @@ const ButtonContainer = styled("div", {
}
})
const bgRotate = keyframes({
'to': { transform: 'rotate(1turn)' },
});
const shake = keyframes({
"0%": {
transform: "translateX(0)",
},
"50%": {
transform: "translateX(10px)",
},
"100%": {
transform: "translateX(0)",
},
});
const opacity = keyframes({
"0%": { opacity: 1 },
"100%": { opacity: 0 }
})
const QRContainer = styled("div", {
base: {
position: "relative",
display: "flex",
overflow: "hidden",
justifyContent: "center",
alignItems: "center",
borderRadius: 30,
padding: 9,
isolation: "isolate",
":after": {
content: "",
zIndex: -1,
inset: 10,
backgroundColor: theme.color.background.d100,
borderRadius: 30,
position: "absolute"
}
},
variants: {
login: {
true: {
":before": {
content: "",
backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.blue.d700} 10%,${theme.color.blue.d700} 25%,transparent 35%)`,
animation: `${bgRotate} 2.25s linear infinite`,
width: "200%",
height: "200%",
zIndex: -2,
top: "-50%",
left: "-50%",
position: "absolute"
},
}
},
error: {
true: {
animation: `${shake} 100ms ease 3`,
":before": {
content: "",
inset: 1,
background: theme.color.red.d700,
opacity: 0,
position: "absolute",
animation: `${opacity} 3s ease`,
width: "200%",
height: "200%",
}
}
},
success: {
true: {
animation: `${shake} 100ms ease 3`,
// ":before": {
// content: "",
// backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.green.d700} 10%,${theme.color.green.d700} 25%,transparent 35%)`,
// animation: `${bgRotate} 2.25s linear infinite`,
// width: "200%",
// height: "200%",
// zIndex: -2,
// top: "-50%",
// left: "-50%",
// position: "absolute"
// },
":before": {
content: "",
inset: 1,
background: theme.color.teal.d700,
opacity: 0,
position: "absolute",
animation: `${opacity} 1.1s ease infinite`,
width: "200%",
height: "200%",
}
}
}
}
})
const QRBg = styled("div", {
base: {
backgroundColor: theme.color.background.d200,
position: "absolute",
inset: 0,
margin: 5,
borderRadius: 27
}
})
const QRWrapper = styled("div", {
base: {
height: "max-content",
width: "max-content",
backgroundColor: theme.color.d1000.gray,
position: "relative",
textWrap: "balance",
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
borderRadius: 22,
padding: 20,
},
variants: {
error: {
true: {
filter: "blur(3px)",
}
}
}
})
const QRReloadBtn = styled("button", {
base: {
background: "none",
border: "none",
width: 50,
height: 50,
position: "absolute",
borderRadius: 25,
zIndex: 5,
right: 2,
bottom: 2,
cursor: "pointer",
color: theme.color.blue.d700,
transition: "color 200ms",
overflow: "hidden",
display: "flex",
justifyContent: "center",
alignItems: "center",
":before": {
zIndex: 3,
content: "",
position: "absolute",
inset: 0,
opacity: 0,
transition: "opacity 200ms",
background: "#FFF"
}
}
})
const QRRealoadContainer = styled("div", {
base: {
position: "absolute",
inset: 0,
isolation: "isolate",
":before": {
background: `conic-gradient( from 90deg, currentColor 10%, #FFF 80% )`,
inset: 3,
borderRadius: 16,
position: "absolute",
content: "",
zIndex: 1
}
}
})
const QRReloadSvg = styled("svg", {
base: {
zIndex: 2,
width: "100%",
height: "100%",
position: "relative",
display: "block"
}
})
const LogoContainer = styled("div", {
base: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
color: theme.color.gray.d100
}
})
const LogoIcon = styled("svg", {
base: {
zIndex: 6,
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%,-50%)",
overflow: "hidden",
borderRadius: 17,
}
})
const Divider = styled("hr", {
base: {
height: "100%",
backgroundColor: theme.color.gray.d400,
width: 2,
border: "none",
margin: "0 20px",
padding: 0,
}
})
const CardWrapper = styled("div", {
base: {
width: "100%",
@@ -338,7 +121,7 @@ const CardWrapper = styled("div", {
display: "flex",
alignItems: "start",
justifyContent: "start",
top: "25vh"
top: "16vh"
}
})
@@ -374,97 +157,109 @@ const Link = styled("a", {
}
})
export function CreateTeamComponent() {
const Divider = styled("div", {
base: {
display: "flex",
whiteSpace: "nowrap",
textAlign: "center",
":before": {
width: "100%",
content: "",
borderTop: `1px solid ${theme.color.gray.d500}`,
alignSelf: "center"
},
":after": {
width: "100%",
content: "",
borderTop: `1px solid ${theme.color.gray.d500}`,
alignSelf: "center"
}
}
})
const DividerText = styled("span", {
base: {
margin: "0px 10px",
fontSize: theme.font.size["xs"],
color: theme.color.gray.d900,
lineHeight: "20px",
textOverflow: "ellipsis"
}
})
export function NewProfile() {
const nav = useNavigate();
const auth = useOpenAuth();
const account = useAccount();
const [challengeUrl, setChallengeUrl] = createSignal<string | null>(null);
const [timedOut, setTimedOut] = createSignal(false);
const [errorMsg, setErrorMsg] = createSignal<string | null>("");
const [loginSuccess, setLoginSuccess] = createSignal(false);
const openPopup = () => {
const BASE_URL = import.meta.env.VITE_API_URL;
// bump this to reconnect
const [retryCount, setRetryCount] = createSignal(0);
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
let currentStream: EventSource | null = null;
const createDesktopWindow = (authUrl: string) => {
const config = {
width: 700,
height: 700,
features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no"
};
const connectStream = async () => {
// clear previous state
setChallengeUrl(null);
setTimedOut(false);
setErrorMsg(null);
const top = window.top!.outerHeight / 2 + window.top!.screenY - (config.height / 2);
const left = window.top!.outerWidth / 2 + window.top!.screenX - (config.width / 2);
if (currentStream) {
currentStream.close();
return window.open(
authUrl,
'Steam Popup',
`width=${config.width},height=${config.height},left=${left},top=${top},${config.features}`
);
};
const monitorAuthWindow = (
targetWindow: Window,
{ timeoutMs = 3 * 60 * 1000, pollInterval = 250 } = {}
) => {
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(async() => {
await cleanup();
reject(new Error("Authentication timed out"));
}, timeoutMs);
const poll = setInterval(async () => {
if (targetWindow.closed) {
await cleanup();
resolve(); // Auth window closed by user
}
}, pollInterval);
async function cleanup() {
clearTimeout(timeout);
clearInterval(poll);
if (!targetWindow.closed) {
try {
targetWindow.location.href = "about:blank";
targetWindow.close();
} catch {
// Ignore cross-origin issues
}
}
await account.refresh(account.current.id)
nav("/profiles")
window.focus();
}
});
};
const authUrl = `${BASE_URL}/steam/popup/${account.current.id}`;
const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl);
if (!newWindow) {
throw new Error('Failed to open authentication window');
}
const token = await auth.access();
const stream = new EventSource(
`${import.meta.env.VITE_API_URL}/steam/login`,
{
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
Authorization: `Bearer ${token}`,
},
}),
}
);
currentStream = stream;
// status
// stream.addEventListener("status", (e) => {
// // setStatus(JSON.parse(e.data).message);
// });
// challenge URL
stream.addEventListener("challenge_url", (e) => {
setChallengeUrl(JSON.parse(e.data).url);
});
// success
stream.addEventListener("login_success", (e) => {
setLoginSuccess(true);
});
// timed out
stream.addEventListener("timed_out", (e) => {
setTimedOut(true);
});
// server-side error
stream.addEventListener("error", (e: any) => {
// Networklevel errors also fire here
try {
const err = JSON.parse(e.data).message
setErrorMsg(err);
} catch {
setErrorMsg("Connection error");
}
//Event source has inbuilt retry method,this is to prevent it from firing
stream.close()
});
// team slug
stream.addEventListener("team_slug", async (e) => {
await account.refresh(account.current.id)
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ }
nav(`/${JSON.parse(e.data).username}`)
});
};
// kick it off on mount _and_ whenever retryCount changes
createEffect(() => {
// read retryCount so effect re-runs
retryCount();
connectStream();
// ensure cleanup if component unmounts
onCleanup(() => currentStream?.close());
});
return monitorAuthWindow(newWindow);
}
return (
<FullScreen>
@@ -474,9 +269,28 @@ export function CreateTeamComponent() {
style={{ position: "fixed", height: "100%" }} >
<CardWrapper>
<Card >
<Title>Connect your game library to get started.</Title>
<Title>Connect your game library to get started</Title>
<ButtonContainer>
<Button>
<Button onClick={openPopup} steamBtn>
<ButtonIcon
xmlns="http://www.w3.org/2000/svg"
width={32}
height={32}
viewBox="0 0 16 16"
>
<g fill="currentColor">
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
</g>
</ButtonIcon>
<ButtonText>
Continue with Steam
</ButtonText>
</Button>
<Divider>
<DividerText>Or link</DividerText>
</Divider>
<Button comingSoon>
<ButtonText>
GOG.com
<Soon>Soon</Soon>
@@ -485,7 +299,7 @@ export function CreateTeamComponent() {
<path fill="currentColor" d="M31,31H3a3,3,0,0,1-3-3V3A3,3,0,0,1,3,0H31a3,3,0,0,1,3,3V28A3,3,0,0,1,31,31ZM4,24.5A1.5,1.5,0,0,0,5.5,26H11V24H6.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5H11V18H5.5A1.5,1.5,0,0,0,4,19.5Zm8-18A1.5,1.5,0,0,0,10.5,5h-5A1.5,1.5,0,0,0,4,6.5v5A1.5,1.5,0,0,0,5.5,13H9V11H6.5a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,6.5,7h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H4v2h6.5A1.5,1.5,0,0,0,12,14.5Zm0,13v5A1.5,1.5,0,0,0,13.5,26h5A1.5,1.5,0,0,0,20,24.5v-5A1.5,1.5,0,0,0,18.5,18h-5A1.5,1.5,0,0,0,12,19.5Zm9-13A1.5,1.5,0,0,0,19.5,5h-5A1.5,1.5,0,0,0,13,6.5v5A1.5,1.5,0,0,0,14.5,13h5A1.5,1.5,0,0,0,21,11.5Zm9,0A1.5,1.5,0,0,0,28.5,5h-5A1.5,1.5,0,0,0,22,6.5v5A1.5,1.5,0,0,0,23.5,13H27V11H24.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H22v2h6.5A1.5,1.5,0,0,0,30,14.5ZM30,18H22.5A1.5,1.5,0,0,0,21,19.5V26h2V20.5a.5.5,0,0,1,.5-.5h1v6h2V20H28v6h2ZM18.5,11h-3a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v3A.5.5,0,0,1,18.5,11Zm-4,9h3a.5.5,0,0,1,.5.5v3a.5.5,0,0,1-.5.5h-3a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,14.5,20Z" />
</ButtonIcon>
</Button>
<Button>
<Button comingSoon>
<ButtonText>
Epic Games
<Soon>Soon</Soon>
@@ -494,7 +308,7 @@ export function CreateTeamComponent() {
<path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4 4 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4 4 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.7 1.7 0 0 1 .591.108a1.8 1.8 0 0 1 .49.299l-.452.546a1.3 1.3 0 0 0-.308-.195a.9.9 0 0 0-.363-.068a.7.7 0 0 0-.28.06a.7.7 0 0 0-.224.163a.8.8 0 0 0-.151.243a.8.8 0 0 0-.056.299v.008a.9.9 0 0 0 .056.31a.7.7 0 0 0 .157.245a.7.7 0 0 0 .238.16a.8.8 0 0 0 .303.058a.8.8 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2 2 0 0 1-.524.307a1.8 1.8 0 0 1-.683.123a1.6 1.6 0 0 1-.602-.107a1.5 1.5 0 0 1-.478-.3a1.4 1.4 0 0 1-.318-.455a1.4 1.4 0 0 1-.115-.58v-.008a1.4 1.4 0 0 1 .113-.57a1.5 1.5 0 0 1 .312-.46a1.4 1.4 0 0 1 .474-.309a1.6 1.6 0 0 1 .598-.111h.045zm11.963.008a2 2 0 0 1 .612.094a1.6 1.6 0 0 1 .507.277l-.386.546a1.6 1.6 0 0 0-.39-.205a1.2 1.2 0 0 0-.388-.07a.35.35 0 0 0-.208.052a.15.15 0 0 0-.07.127v.008a.16.16 0 0 0 .022.084a.2.2 0 0 0 .076.066a1 1 0 0 0 .147.06q.093.03.236.061a3 3 0 0 1 .43.122a1.3 1.3 0 0 1 .328.17a.7.7 0 0 1 .207.24a.74.74 0 0 1 .071.337v.008a.9.9 0 0 1-.081.382a.8.8 0 0 1-.229.285a1 1 0 0 1-.353.18a1.6 1.6 0 0 1-.46.061a2.2 2.2 0 0 1-.71-.116a1.7 1.7 0 0 1-.593-.346l.43-.514q.416.335.9.335a.46.46 0 0 0 .236-.05a.16.16 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.2.2 0 0 0-.073-.066a1 1 0 0 0-.143-.062a3 3 0 0 0-.233-.062a5 5 0 0 1-.413-.113a1.3 1.3 0 0 1-.331-.16a.7.7 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.9.9 0 0 1 .074-.359a.8.8 0 0 1 .214-.283a1 1 0 0 1 .34-.185a1.4 1.4 0 0 1 .448-.066zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z" />
</ButtonIcon>
</Button>
<Button>
<Button comingSoon>
<ButtonText>
Amazon Games
<Soon>Soon</Soon>
@@ -508,77 +322,6 @@ export function CreateTeamComponent() {
<Link target="_blank" href="https://discord.gg/6um5K6jrYj" >Help I can't connect my account</Link>
</Footer>
</Card>
<Divider />
<Card
style={{
"--nestri-qr-dot-color": theme.color.gray.d100,
"--nestri-body-background": theme.color.d1000.gray,
"align-items": "center",
}}>
<QRContainer success={loginSuccess()} login={!loginSuccess() && !!challengeUrl() && !timedOut() && !errorMsg()} error={!loginSuccess() && (timedOut() || !!errorMsg())}>
<QRBg />
<QRWrapper error={loginSuccess() || timedOut() || !!errorMsg()}>
<LogoContainer>
<LogoIcon
xmlns="http://www.w3.org/2000/svg"
width={32}
height={32}
viewBox="0 0 16 16"
>
<g fill="currentColor">
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
</g>
</LogoIcon>
</LogoContainer>
{(challengeUrl()
&& !timedOut()
&& !loginSuccess()
&& !errorMsg()) ? (<QRCode
uri={challengeUrl() as string}
size={180}
ecl="H"
clearArea={true}
/>) : (<QRCode
uri={"https://nestri.io"}
size={180}
ecl="H"
clearArea={true}
/>)}
</QRWrapper>
{(!loginSuccess() && timedOut() || errorMsg()) && (
<QRReloadBtn onClick={() => setRetryCount((c) => c + 1)}>
<QRRealoadContainer>
<QRReloadSvg
aria-hidden="true"
width="32"
height="32"
viewBox="0 0 32 32"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM24.5001 8.74263C25.0834 8.74263 25.5563 9.21551 25.5563 9.79883V14.5997C25.5563 15.183 25.0834 15.6559 24.5001 15.6559H19.6992C19.1159 15.6559 18.643 15.183 18.643 14.5997C18.643 14.0164 19.1159 13.5435 19.6992 13.5435H21.8378L20.071 11.8798C20.0632 11.8724 20.0555 11.865 20.048 11.8574C19.1061 10.915 17.8835 10.3042 16.5643 10.1171C15.2452 9.92999 13.9009 10.1767 12.7341 10.82C11.5674 11.4634 10.6413 12.4685 10.0955 13.684C9.54968 14.8994 9.41368 16.2593 9.70801 17.5588C10.0023 18.8583 10.711 20.0269 11.7273 20.8885C12.7436 21.7502 14.0124 22.2582 15.3425 22.336C16.6726 22.4138 17.9919 22.0572 19.1017 21.3199C19.5088 21.0495 19.8795 20.7333 20.2078 20.3793C20.6043 19.9515 21.2726 19.9262 21.7004 20.3228C22.1282 20.7194 22.1534 21.3876 21.7569 21.8154C21.3158 22.2912 20.8176 22.7161 20.2706 23.0795C18.7793 24.0702 17.0064 24.5493 15.2191 24.4448C13.4318 24.3402 11.7268 23.6576 10.3612 22.4998C8.9956 21.3419 8.0433 19.7716 7.6478 18.0254C7.2523 16.2793 7.43504 14.4519 8.16848 12.8186C8.90192 11.1854 10.1463 9.83471 11.7142 8.97021C13.282 8.10572 15.0884 7.77421 16.861 8.02565C18.6282 8.27631 20.2664 9.09278 21.5304 10.3525L23.4439 12.1544V9.79883C23.4439 9.21551 23.9168 8.74263 24.5001 8.74263Z" fill="currentColor" />
</QRReloadSvg>
</QRRealoadContainer>
</QRReloadBtn>
)}
</QRContainer>
<ButtonContainer>
<Title>{loginSuccess() ?
"Login successful" :
(timedOut() || !!errorMsg()) ?
"Login timed out" :
"Scan to connect Steam"
}</Title>
<Subtitle>{
loginSuccess() ?
"Just a minute while we create your team" :
(timedOut() || !!errorMsg()) ?
"Failed to connect Steam. Please try again." :
"On your mobile phone, open the Steam App to scan this code"}</Subtitle>
</ButtonContainer>
</Card>
</CardWrapper>
</Container>
<LogoFooter >

View File

@@ -3,7 +3,7 @@ import { A } from "@solidjs/router";
import { Text } from "@nestri/www/ui/text";
import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme";
import { Header } from "@nestri/www/pages/team/header";
import { Header } from "@nestri/www/pages/steam/header";
import { Screen as FullScreen, Container } from "@nestri/www/ui/layout";
const NotAllowedDesc = styled("div", {

View File

@@ -0,0 +1,179 @@
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { styled } from "@macaron-css/solid";
import { keyframes } from "@macaron-css/core";
import { useAccount } from "../providers/account";
import SteamAvatar from "../components/profile-picture";
import { Container, Screen as FullScreen, theme } from "@nestri/www/ui";
const Background = styled("div", {
base: {
position: "fixed",
zIndex: "-1",
inset: 0,
":after": {
inset: 0,
content: "",
userSelect: "none",
position: "absolute",
pointerEvents: "none",
background: `linear-gradient(0deg,${theme.color.background.d200} 30%,transparent),linear-gradient(0deg,${theme.color.background.d200} 30%,transparent)`
}
}
})
const gradient = keyframes({
"0%": {
backgroundPosition: "0% 50%",
},
"50%": {
backgroundPosition: "100% 50%",
},
"100%": {
backgroundPosition: "0% 50%",
},
})
const BackgroundImage = styled("div", {
base: {
width: "100%",
height: "70%",
position: "relative",
filter: "saturate(120%)",
backgroundSize: "300% 100%",
backgroundPosition: "0% 0%",
backgroundRepeat: "repeat-x",
animation: `${gradient} 35s linear 0s infinite`,
backgroundImage: "linear-gradient(120deg, rgb(232,23,98) 1.26%, rgb(30,134,248) 18.6%, rgb(91,108,255) 34.56%, rgb(52,199,89) 49.76%, rgb(245,197,5) 64.87%, rgb(236,62,62) 85.7%)",
}
})
const Wrapper = styled("div", {
base: {
margin: "100px 0",
textAlign: "center",
justifyContent: "center",
display: "flex",
flexDirection: "column",
width: "100%",
maxWidth: 700,
}
})
const Title = styled("h1", {
base: {
fontSize: "50px",
fontFamily: theme.font.family.heading,
letterSpacing: "-0.515px",
}
})
const Profiles = styled("div", {
base: {
// width: "100%",
gridTemplateColumns: "repeat(auto-fit, minmax(150px, auto))",
display: "grid",
columnGap: 12,
rowGap: 10,
margin: "100px 0",
alignItems: "center",
justifyContent: "center"
}
})
const Profile = styled("div", {
base: {
width: 150,
}
})
const ProfilePicture = styled("div", {
base: {
width: 150,
height: 150,
cursor: "pointer",
borderRadius: 75,
overflow: "hidden",
border: `6px solid ${theme.color.gray.d700}`,
transition: "all 200ms ease",
":hover": {
transform: "scale(1.07)",
borderColor: theme.color.blue.d700
}
}
})
const ProfileName = styled("div", {
base: {
margin: "20px 0",
lineHeight: "1.25em",
color: theme.color.gray.d900,
transition: "all 300ms ease",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: theme.font.size.lg
}
})
const NewButton = styled(A, {
base: {
display: "flex",
justifyContent: "center",
textDecoration: "none",
alignItems: "center",
cursor: "pointer",
color: "inherit",
padding: "0px 14px",
gap: 10,
width: "max-content",
alignSelf: "center",
height: 48,
borderRadius: theme.space["3"],
transition: "all .2s ease",
border: `1px solid ${theme.color.gray.d400}`,
backgroundColor: theme.color.background.d100,
":hover": {
transform: "scale(1.02)",
borderColor: theme.color.blue.d700
}
}
})
export function ProfilesRoute() {
const account = useAccount()
return (
<FullScreen>
<Container
vertical="center"
horizontal="center"
style={{ position: "fixed", height: "100%", width: "100%" }} >
<Background>
<BackgroundImage />
</Background>
<Wrapper>
<Title>
Who's playing?
</Title>
<Profiles>
<For each={account.current.profiles}>
{(profile) => (
<Profile>
<ProfilePicture>
<SteamAvatar avatarHash={profile.avatarHash} />
</ProfilePicture>
<ProfileName>{profile.name}</ProfileName>
</Profile>
)}
</For>
</Profiles>
<NewButton href="/new" >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none"><path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" /><path fill="currentColor" d="M6 7a5 5 0 1 1 10 0A5 5 0 0 1 6 7m-1.178 7.672C6.425 13.694 8.605 13 11 13q.671 0 1.316.07a1 1 0 0 1 .72 1.557A5.97 5.97 0 0 0 12 18c0 .92.207 1.79.575 2.567a1 1 0 0 1-.89 1.428L11 22c-2.229 0-4.335-.14-5.913-.558c-.785-.208-1.524-.506-2.084-.956C2.41 20.01 2 19.345 2 18.5c0-.787.358-1.523.844-2.139c.494-.625 1.177-1.2 1.978-1.69ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1" /></g></svg>
Add Steam account
</NewButton>
</Wrapper>
</Container>
</FullScreen>
)
}

View File

@@ -1,6 +1,6 @@
import { FullScreen, theme } from "@nestri/www/ui";
import { styled } from "@macaron-css/solid";
import { Header } from "@nestri/www/pages/team/header";
import { Header } from "@nestri/www/pages/steam/header";
import { Modal } from "@nestri/www/ui/modal";
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
import { Text } from "@nestri/www/ui/text"

View File

@@ -0,0 +1,61 @@
import { LibraryRoute } from "./library";
import { useOpenAuth } from "@openauthjs/solid";
import { Route, useParams } from "@solidjs/router";
import { ApiProvider } from "@nestri/www/providers/api";
import { SteamContext } from "@nestri/www/providers/context";
import { createEffect, createMemo, Match, Switch } from "solid-js";
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
import { useAccount, useStorage } from "@nestri/www/providers/account";
export const SteamRoute = (
<Route
// component={(props) => {
// const params = useParams();
// const account = useAccount();
// const storage = useStorage();
// const openauth = useOpenAuth();
// const team = createMemo(() =>
// account.current.teams.find(
// (item) => item.id === params.steamID,
// ),
// );
// createEffect(() => {
// const t = team();
// if (!t) return;
// storage.set("steam", t.id);
// });
// createEffect(() => {
// const steamID = params.steamID;
// for (const item of Object.values(account.all)) {
// for (const profile of item.profiles) {
// if (profile.id === steamID && item.id !== openauth.subject!.id) {
// openauth.switch(item.id);
// }
// }
// }
// })
// return (
// <Switch>
// <Match when={!team()}>
// {/* TODO: Add a public page for (other) teams */}
// <NotAllowed header />
// </Match>
// <Match when={team()}>
// <TeamContext.Provider value={() => team()!}>
// <ApiProvider>
// {props.children}
// </ApiProvider>
// </TeamContext.Provider>
// </Match>
// </Switch>
// )
// }}
>
<Route path="library" component={LibraryRoute} />
<Route path="*" component={() => <NotFound header />} />
</Route>
)

View File

@@ -1,7 +1,7 @@
import { For } from "solid-js";
import { styled } from "@macaron-css/solid";
import { FullScreen, theme } from "@nestri/www/ui";
import { Header } from "@nestri/www/pages/team/header";
import { Header } from "@nestri/www/pages/steam/header";
const Container = styled("div", {
base: {

View File

@@ -1,63 +0,0 @@
import { HomeRoute } from "./home";
import { LibraryRoute } from "./library";
import { useOpenAuth } from "@openauthjs/solid";
import { Route, useParams } from "@solidjs/router";
import { ApiProvider } from "@nestri/www/providers/api";
import { TeamContext } from "@nestri/www/providers/context";
import { createEffect, createMemo, Match, Switch } from "solid-js";
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
import { useAccount, useStorage } from "@nestri/www/providers/account";
export const TeamRoute = (
<Route
component={(props) => {
const params = useParams();
const account = useAccount();
const storage = useStorage();
const openauth = useOpenAuth();
const team = createMemo(() =>
account.current.teams.find(
(item) => item.slug === params.teamSlug,
),
);
createEffect(() => {
const t = team();
if (!t) return;
storage.set("team", t.id);
});
createEffect(() => {
const teamSlug = params.teamSlug;
for (const item of Object.values(account.all)) {
for (const team of item.teams) {
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
openauth.switch(item.email);
}
}
}
})
return (
<Switch>
<Match when={!team()}>
{/* TODO: Add a public page for (other) teams */}
<NotAllowed header />
</Match>
<Match when={team()}>
<TeamContext.Provider value={() => team()!}>
<ApiProvider>
{props.children}
</ApiProvider>
</TeamContext.Provider>
</Match>
</Switch>
)
}}
>
<Route path="" component={HomeRoute} />
<Route path="library" component={LibraryRoute} />
<Route path="*" component={() => <NotFound header />} />
</Route>
)

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { Team } from "@nestri/core/team/index";
import { Steam } from "@nestri/core/steam/index";
import { Accessor, createContext, useContext } from "solid-js";
export const TeamContext = createContext<Accessor<Team.Info>>();
export const SteamContext = createContext<Accessor<Steam.Info>>();
export function useTeam() {
const context = useContext(TeamContext);
if (!context) throw new Error("No team context");
export function useSteam() {
const context = useContext(SteamContext);
if (!context) throw new Error("No steam context");
return context;
}

View File

@@ -1,175 +0,0 @@
import QRCodeUtil from 'qrcode';
import { createMemo, type JSXElement } from "solid-js"
const generateMatrix = (
value: string,
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
) => {
const arr = Array.prototype.slice.call(
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
0
);
const sqrt = Math.sqrt(arr.length);
return arr.reduce(
(rows, key, index) =>
(index % sqrt === 0
? rows.push([key])
: rows[rows.length - 1].push(key)) && rows,
[]
);
};
type Props = {
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
size?: number;
uri: string;
clearArea?: boolean;
image?: HTMLImageElement;
imageBackground?: string;
};
/**
* Renders an SVG element displaying a QR code generated from a URI.
*
* This component creates a QR code matrix based on the provided URI and error correction level, then renders
* the QR code using SVG elements. It highlights finder patterns and conditionally renders QR code dots,
* while optionally embedding a logo in the center with a specified background and an adjustable clear area.
*
* @param ecl - The error correction level for the QR code (defaults to 'M').
* @param size - The overall size (in pixels) of the QR code, including margins (defaults to 200).
* @param uri - The URI to encode into the QR code.
* @param clearArea - When true, reserves extra space in the QR code for an embedded logo.
* @param image - An optional JSX element to render as a central logo within the QR code.
* @param imageBackground - The background color for the logo area (defaults to 'transparent').
*
* @returns An SVG element representing the generated QR code.
*/
export function QRCode({
ecl = 'M',
size: sizeProp = 200,
uri,
clearArea = false,
image,
imageBackground = 'transparent',
}: Props) {
const logoSize = clearArea ? 38 : 0;
const size = sizeProp - 10 * 2;
const dots = createMemo(() => {
const dots: JSXElement[] = [];
const matrix = generateMatrix(uri, ecl);
const cellSize = size / matrix.length;
let qrList = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
];
qrList.forEach(({ x, y }) => {
const x1 = (matrix.length - 7) * cellSize * x;
const y1 = (matrix.length - 7) * cellSize * y;
for (let i = 0; i < 3; i++) {
dots.push(
<rect
id={`${i}-${x}-${y}`}
fill={
i % 2 !== 0
? 'var(--nestri-qr-background, var(--nestri-body-background))'
: 'var(--nestri-qr-dot-color)'
}
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
width={cellSize * (7 - i * 2)}
height={cellSize * (7 - i * 2)}
x={x1 + cellSize * i}
y={y1 + cellSize * i}
/>
);
}
});
if (image) {
const x1 = (matrix.length - 7) * cellSize * 1;
const y1 = (matrix.length - 7) * cellSize * 1;
dots.push(
<>
<rect
fill={imageBackground}
rx={(0 - 2) * -5 + 2}
ry={(0 - 2) * -5 + 2}
width={cellSize * (7 - 0 * 2)}
height={cellSize * (7 - 0 * 2)}
x={x1 + cellSize * 0}
y={y1 + cellSize * 0}
/>
<foreignObject
width={cellSize * (7 - 0 * 2)}
height={cellSize * (7 - 0 * 2)}
x={x1 + cellSize * 0}
y={y1 + cellSize * 0}
>
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
{image}
</div>
</foreignObject>
</>
);
}
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
row.forEach((_: any, j: number) => {
if (matrix[i][j]) {
// Do not render dots under position squares
if (
!(
(i < 7 && j < 7) ||
(i > matrix.length - 8 && j < 7) ||
(i < 7 && j > matrix.length - 8)
)
) {
if (
image ||
!(
i > matrixMiddleStart &&
i < matrixMiddleEnd &&
j > matrixMiddleStart &&
j < matrixMiddleEnd
)
) {
dots.push(
<circle
id={`circle-${i}-${j}`}
cx={i * cellSize + cellSize / 2}
cy={j * cellSize + cellSize / 2}
fill="var(--nestri-qr-dot-color)"
r={cellSize / 3}
/>
);
}
}
}
});
});
return dots;
}, [ecl, size, uri]);
return (
<svg
height={size}
width={size}
viewBox={`0 0 ${size} ${size}`}
style={{
width: `${size}px`,
height: `${size}px`,
}}
>
<rect fill="transparent" height={size} width={size} />
{dots()}
</svg>
);
}

View File

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