Game Library: Multi-Platform Profile System + GOG Integration
- Neues Profile-System: User können Steam und GOG verknüpfen - GOG OAuth2 Login-Flow (auth.gog.com) - GOG API Service (gog.ts): Token-Management, Spieleliste, User-Info - Server: Profile-Datenmodell, Migration bestehender Steam-Users - Frontend: Login-Bar (Steam + GOG), Profile-Chips mit Platform-Badges - Cross-Platform Game-Merging mit Deduplizierung - Profile-Detail mit "GOG verknüpfen" Option Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89e655482b
commit
ee5c29dd7b
4 changed files with 859 additions and 189 deletions
288
server/src/plugins/game-library/gog.ts
Normal file
288
server/src/plugins/game-library/gog.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// GOG (Good Old Games) API service
|
||||
// Uses GOG OAuth2 for user authentication. All paginated requests include a
|
||||
// 500 ms pause between pages to respect rate-limits.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const GOG_CLIENT_ID = '46899977096215655';
|
||||
const GOG_CLIENT_SECRET =
|
||||
'9d85c43b1482497dbbce61f6e4aa173a433796eebd2c1f0f7f015c4c2e57571';
|
||||
const GOG_AUTH_URL = 'https://auth.gog.com/auth';
|
||||
const GOG_TOKEN_URL = 'https://auth.gog.com/token';
|
||||
const GOG_EMBED_URL = 'https://embed.gog.com';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GogTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number; // unix-ms
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface GogUserInfo {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface GogGame {
|
||||
gogId: number;
|
||||
title: string;
|
||||
image: string; // cover URL from GOG
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// ── OAuth helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the GOG OAuth authorization URL that the user should visit to grant
|
||||
* access. After approval, GOG will redirect to `redirectUri` with a `code`
|
||||
* query parameter.
|
||||
*/
|
||||
export function getGogAuthUrl(redirectUri: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOG_CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
layout: 'client2',
|
||||
});
|
||||
|
||||
return `${GOG_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for GOG access + refresh tokens.
|
||||
*/
|
||||
export async function exchangeGogCode(
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
): Promise<GogTokens> {
|
||||
console.log('[GOG] Exchanging authorization code for tokens...');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOG_CLIENT_ID,
|
||||
client_secret: GOG_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[GOG] Token exchange failed (${res.status}): ${text}`);
|
||||
throw new Error(`[GOG] Token exchange failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user_id: string;
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
const tokens: GogTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
userId: data.user_id,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[GOG] Tokens acquired for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a fresh set of tokens using an existing refresh token.
|
||||
*/
|
||||
export async function refreshGogToken(
|
||||
refreshToken: string,
|
||||
): Promise<GogTokens> {
|
||||
console.log('[GOG] Refreshing access token...');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOG_CLIENT_ID,
|
||||
client_secret: GOG_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const res = await fetch(`${GOG_TOKEN_URL}?${params.toString()}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[GOG] Token refresh failed (${res.status}): ${text}`);
|
||||
throw new Error(`[GOG] Token refresh failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user_id: string;
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
const tokens: GogTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
userId: data.user_id,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[GOG] Token refreshed for user ${tokens.userId}, expires in ${Math.round(data.expires_in / 3600)} hours`,
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── User info ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch basic user profile information from the GOG embed API.
|
||||
*/
|
||||
export async function fetchGogUserInfo(
|
||||
accessToken: string,
|
||||
): Promise<GogUserInfo> {
|
||||
console.log('[GOG] Fetching user info...');
|
||||
|
||||
const res = await fetch(`${GOG_EMBED_URL}/userData.json`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[GOG] User info request failed (${res.status}): ${text}`);
|
||||
throw new Error(`[GOG] User info request failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
userId: string;
|
||||
username: string;
|
||||
galaxyUserId: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
const userInfo: GogUserInfo = {
|
||||
userId: data.galaxyUserId || data.userId,
|
||||
username: data.username,
|
||||
avatarUrl: data.avatar ? `https:${data.avatar}` : '',
|
||||
};
|
||||
|
||||
console.log(`[GOG] User info fetched: ${userInfo.username}`);
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
// ── Game library ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the complete list of owned GOG games for the authenticated user.
|
||||
*
|
||||
* The GOG embed API paginates results (one page at a time). We iterate
|
||||
* through all pages with a 500 ms pause between requests to stay friendly.
|
||||
*/
|
||||
export async function fetchGogGames(
|
||||
accessToken: string,
|
||||
): Promise<GogGame[]> {
|
||||
console.log('[GOG] Fetching game library...');
|
||||
|
||||
const allGames: GogGame[] = [];
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const url = `${GOG_EMBED_URL}/account/getFilteredProducts?mediaType=1&page=${currentPage}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(
|
||||
`[GOG] Game library request failed on page ${currentPage} (${res.status}): ${text}`,
|
||||
);
|
||||
throw new Error(
|
||||
`[GOG] Game library request failed (${res.status}): ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
totalPages: number;
|
||||
products: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
slug: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
totalPages = data.totalPages;
|
||||
|
||||
for (const product of data.products) {
|
||||
allGames.push({
|
||||
gogId: product.id,
|
||||
title: product.title,
|
||||
image: product.image ? `https:${product.image}` : '',
|
||||
slug: product.slug,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[GOG] Page ${currentPage}/${totalPages} fetched (${data.products.length} games)`,
|
||||
);
|
||||
|
||||
currentPage++;
|
||||
|
||||
// Rate-limit: 500 ms between page requests
|
||||
if (currentPage <= totalPages) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
} while (currentPage <= totalPages);
|
||||
|
||||
console.log(`[GOG] Library complete: ${allGames.length} games total`);
|
||||
|
||||
return allGames;
|
||||
}
|
||||
|
||||
// ── Cover URL helper ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a GOG image base path into a full HTTPS cover URL.
|
||||
*
|
||||
* GOG images are returned as protocol-relative paths like
|
||||
* `//images-1.gog.com/xxx`. This helper ensures HTTPS and optionally
|
||||
* appends a size suffix (e.g. `_196.jpg`).
|
||||
*
|
||||
* @param imageBase The raw image path returned by the GOG API
|
||||
* @param size Optional size suffix to append (e.g. `_196.jpg`)
|
||||
*/
|
||||
export function gogCoverUrl(imageBase: string, size?: string): string {
|
||||
let url = imageBase;
|
||||
|
||||
// Ensure HTTPS prefix
|
||||
if (url.startsWith('//')) {
|
||||
url = `https:${url}`;
|
||||
} else if (!url.startsWith('http')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
// Append size suffix if provided
|
||||
if (size) {
|
||||
url = `${url}${size}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue