🔄 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

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