diff --git a/clients/desktop/src-tauri/src/bridge.js b/clients/desktop/src-tauri/src/bridge.js index b7f912a..8d1c9be 100644 --- a/clients/desktop/src-tauri/src/bridge.js +++ b/clients/desktop/src-tauri/src/bridge.js @@ -7,6 +7,7 @@ if (!T || !T.core) return; // running in a plain browser — no-op, the PWA behaves normally var invoke = T.core.invoke; window.__gcOpenGamingExternal = function (url) { return invoke("open_gaming_external", { url: url }); }; + window.__gcOpenProviderAuthorization = function (url) { return invoke("open_provider_authorization", { url: url }); }; window.__gcOpenTikTokPost = function (url) { return invoke("open_tiktok_post", { url: url }); }; window.__gcEpicLibrary = { list: function () { return invoke("epic_installed_games"); }, diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 56f34d6..01e5078 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -40,6 +40,21 @@ fn open_gaming_external(url: String) -> Result<(), String> { open_provider_browser(parsed) } +#[tauri::command] +fn open_provider_authorization(url: String) -> Result<(), String> { + let parsed = Url::parse(&url).map_err(|_| "Invalid authorization URL")?; + let destination = matches!((parsed.host_str(), parsed.path()), + (Some("www.epicgames.com"), "/id/authorize") | + (Some("www.tiktok.com"), "/v2/auth/authorize/")); + if parsed.scheme() != "https" || !destination || !parsed.username().is_empty() + || parsed.password().is_some() || parsed.port().is_some() || parsed.fragment().is_some() + || !parsed.query_pairs().any(|(k, v)| k == "response_type" && v == "code") + || !parsed.query_pairs().any(|(k, v)| k == "state" && !v.is_empty()) { + return Err("Invalid authorization URL".into()); + } + open_provider_browser(parsed) +} + #[tauri::command] fn open_tiktok_post(url: String) -> Result<(), String> { let parsed = Url::parse(&url).map_err(|_| "Invalid TikTok URL")?; @@ -518,6 +533,7 @@ pub fn run() { app_version, desktop_identity, open_gaming_external, + open_provider_authorization, open_tiktok_post, epic::epic_installed_games, epic::epic_launch_game, diff --git a/clients/ui/src/screens/app.ts b/clients/ui/src/screens/app.ts index e0642dc..1a02714 100644 --- a/clients/ui/src/screens/app.ts +++ b/clients/ui/src/screens/app.ts @@ -931,7 +931,7 @@ export function createApp(deps: AppDeps): App { return; } if (r.name === "social") { - swap("social", () => wrapInShell(createSocialScreen({ locale: i18n.locale, + swap("social", () => wrapInShell(createSocialScreen({ locale: i18n.locale, api, onBack: () => router.navigate("/settings") }), "settings")); return; } diff --git a/clients/ui/src/screens/gamer_mode_screen.ts b/clients/ui/src/screens/gamer_mode_screen.ts index d541cfb..e0d8b5b 100644 --- a/clients/ui/src/screens/gamer_mode_screen.ts +++ b/clients/ui/src/screens/gamer_mode_screen.ts @@ -37,6 +37,7 @@ export interface GamerModeScreenDeps { onOpenSteam?: () => void; onOpenFaceit?: () => void; onOpenEpic?: () => void; + epicConnection?: HTMLElement; onBack?: () => void; onUnlinkSteam?: () => void; onUnlinkFaceit?: () => void; @@ -264,7 +265,8 @@ export function createGamerModeScreen(deps: GamerModeScreenDeps): GamerModeScree card.append(rank, bar, button(linked ? copy.open : copy.link, deps.onOpenFaceit)); if (linked && deps.onUnlinkFaceit) card.append(button(copy.unlink, deps.onUnlinkFaceit)); } else { - card.append(node("p", "gc-gm-provider-hint", copy.epicHint), button(copy.store, deps.onOpenEpic)); + if (deps.epicConnection) card.append(deps.epicConnection); + else card.append(node("p", "gc-gm-provider-hint", copy.epicHint), button(copy.store, deps.onOpenEpic)); } return card; }; diff --git a/clients/ui/src/screens/gaming_screen.ts b/clients/ui/src/screens/gaming_screen.ts index 2a63fe9..21e43bb 100644 --- a/clients/ui/src/screens/gaming_screen.ts +++ b/clients/ui/src/screens/gaming_screen.ts @@ -3,6 +3,7 @@ import { createGamerModeScreen, type GamerModeScreen } from "./gamer_mode_screen import { gamingExternalUrl, gamingProfile, validateGamingState, type GamingSettings, type GamingState } from "./gaming_model.ts"; import { el } from "../dom.ts"; import { createEpicLibrary, type EpicLibraryPort } from "./epic_library.ts"; +import { createProviderConnection, type ProviderConnectionState } from "./provider_connection.ts"; import type { ApiLike } from "./api.ts"; import type { I18n } from "../i18n.ts"; @@ -27,6 +28,11 @@ export function createGamingScreen(deps: { let busy = false; let generation = 0; let fresh = false; + let epicState: ProviderConnectionState | null = null; + const epicConnection = createProviderConnection({ provider: "epic", locale: i18n.locale, api, + ...(deps.openExternal ? { openExternal: deps.openExternal } : {}), + onChange(next) { epicState = next; if (!destroyed) render(); }, + }); const open = async (url: string, provider: "steam" | "faceit" | "epic", auth = false): Promise => { const safe = gamingExternalUrl(url, provider, auth); @@ -87,7 +93,8 @@ export function createGamingScreen(deps: { const steam = state?.profile.steam; const faceit = state?.profile.faceit; view = createGamerModeScreen({ - profile: state ? gamingProfile(state, deps.self, api) : deps.self, + profile: { ...(state ? gamingProfile(state, deps.self, api) : deps.self), epic: { linked: !!epicState?.account } }, + epicConnection: epicConnection.root, locale: i18n.locale, initialEnabled: state?.settings.enabled ?? false, available: fresh, @@ -139,5 +146,6 @@ export function createGamingScreen(deps: { view?.destroy(); root.remove(); epicLibrary.destroy(); + epicConnection.destroy(); } }; } diff --git a/clients/ui/src/screens/provider_connection.ts b/clients/ui/src/screens/provider_connection.ts new file mode 100644 index 0000000..0ec15a4 --- /dev/null +++ b/clients/ui/src/screens/provider_connection.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { el } from "../dom.ts"; +import type { ApiLike } from "./api.ts"; + +export type AccountProvider = "epic" | "tiktok"; +export interface ProviderConnectionState { + available: boolean; + account: { id: string; name: string } | null; +} +export function providerBase(provider: AccountProvider): string { + return provider === "epic" ? "/v1/gaming/connect/epic" : "/v1/social/connect/tiktok"; +} +export function providerAuthorizationUrl(raw: string, provider: AccountProvider): string { + const url = new URL(raw); + const host = provider === "epic" ? "www.epicgames.com" : "www.tiktok.com"; + const path = provider === "epic" ? "/id/authorize" : "/v2/auth/authorize/"; + if (url.protocol !== "https:" || url.hostname !== host || url.pathname !== path || + url.username || url.password || url.port || url.hash || url.searchParams.get("response_type") !== "code" || + !url.searchParams.get("state")) throw new Error("Invalid authorization URL"); + return url.href; +} +export function createProviderConnection(deps: { + provider: AccountProvider; locale: string; api: ApiLike; + openExternal?(url: string): Promise | void; + onChange?(state: ProviderConnectionState | null): void; +}) { + const ru = deps.locale.startsWith("ru"); + const copy = (a: string, b: string) => ru ? a : b; + const name = deps.provider === "epic" ? "Epic Games" : "TikTok"; + const base = providerBase(deps.provider); + const root = el("div", { class: "gc-provider-connection" }); + const status = el("p", { role: "status", "aria-live": "polite" }); + const connect = el("button", { type: "button", class: "gc-btn" }, [copy("Подключить", "Connect")]) as HTMLButtonElement; + const refresh = el("button", { type: "button", class: "gc-btn" }, [copy("Проверить подключение", "Check connection")]) as HTMLButtonElement; + root.append(status, connect, refresh); + let state: ProviderConnectionState | null = null; + let busy = false; + let destroyed = false; + let waiting = false; + function paint(message?: string) { + status.textContent = message ?? (state?.account ? `${copy("Подключён", "Connected")}: ${state.account.name}` + : waiting ? copy(`Завершите вход в ${name} в браузере.`, `Finish signing in to ${name} in your browser.`) + : state?.available ? copy(`Войдите в ${name}, чтобы подтвердить свой аккаунт.`, `Sign in to ${name} to verify your account.`) + : copy(`Подключение ${name} ещё не настроено на сервере.`, `${name} connection has not been configured on this server yet.`)); + connect.textContent = state?.account ? copy("Отключить", "Disconnect") : copy("Подключить", "Connect"); + connect.disabled = busy || !state || (!state.available && !state.account); + refresh.disabled = busy; + } + async function read() { + const next = await deps.api.get(`${base}/status`); + if (!next || typeof next.available !== "boolean" || !(next.account === null || + (typeof next.account?.id === "string" && !!next.account.id && typeof next.account.name === "string"))) { + throw new Error("Invalid connection state"); + } + if (destroyed) return; + state = next; + if (next.account) waiting = false; + deps.onChange?.(next); + } + async function run(action: () => Promise) { + if (busy || destroyed) return; + busy = true; paint(copy("Проверка…", "Checking…")); + try { await action(); if (!destroyed) { busy = false; paint(); } } + catch { if (!destroyed) { busy = false; state = null; deps.onChange?.(null); + paint(copy("Не удалось проверить подключение. Повторите попытку.", "Could not verify the connection. Try again.")); } } + } + connect.addEventListener("click", () => { void run(async () => { + if (state?.account) { + await deps.api.delete(`${base}/connection`); waiting = false; + } else { + const result = await deps.api.post<{ url: string }>(`${base}/start`); + if (destroyed) return; + const url = providerAuthorizationUrl(result.url, deps.provider); + const native = (window as Window & { __gcOpenProviderAuthorization?: (url: string) => Promise }).__gcOpenProviderAuthorization; + if (deps.openExternal) await deps.openExternal(url); + else if (native) await native(url); + else window.open(url, "_blank", "noopener,noreferrer"); + waiting = true; + } + await read(); + }); }); + const wake = () => { if (document.visibilityState !== "hidden") void run(read); }; + refresh.addEventListener("click", wake); + window.addEventListener("focus", wake); + window.addEventListener("online", wake); + document.addEventListener("visibilitychange", wake); + const timer = setInterval(wake, 30_000); + void run(read); + return { root, destroy() { destroyed = true; clearInterval(timer); + window.removeEventListener("focus", wake); window.removeEventListener("online", wake); + document.removeEventListener("visibilitychange", wake); root.remove(); } }; +} diff --git a/clients/ui/src/screens/social_screen.ts b/clients/ui/src/screens/social_screen.ts index b0cc25b..7b4ce34 100644 --- a/clients/ui/src/screens/social_screen.ts +++ b/clients/ui/src/screens/social_screen.ts @@ -1,8 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { el } from "../dom.ts"; import { tiktokPost } from "./tiktok_model.ts"; +import type { ApiLike } from "./api.ts"; +import { createProviderConnection } from "./provider_connection.ts"; -export function createSocialScreen(deps: { locale: string; onBack(): void }) { +export function createSocialScreen(deps: { locale: string; api?: ApiLike; onBack(): void }) { const ru = deps.locale.startsWith("ru"); const copy = (a: string, b: string) => ru ? a : b; const root = el("section", { class: "gc-social-page" }); @@ -20,6 +22,8 @@ export function createSocialScreen(deps: { locale: string; onBack(): void }) { "Вставь полную ссылку на публичное видео или фото TikTok. Плеер загрузится с TikTok после нажатия «Смотреть».", "Paste the full link to a public TikTok video or photo. The TikTok player loads when you select Watch.")]), form, status, feed); + const connection = deps.api ? createProviderConnection({ provider: "tiktok", locale: deps.locale, api: deps.api }) : null; + if (connection) root.insertBefore(connection.root, form); let frame: HTMLIFrameElement | null = null; let timer: ReturnType | null = null; let destroyed = false; @@ -67,5 +71,5 @@ export function createSocialScreen(deps: { locale: string; onBack(): void }) { document.addEventListener("visibilitychange", visibility); return { root, destroy() { destroyed = true; stopTimer(); window.removeEventListener("message", onMessage); document.removeEventListener("visibilitychange", visibility); - form.removeEventListener("submit", onSubmit); frame?.remove(); frame = null; root.remove(); } }; + form.removeEventListener("submit", onSubmit); connection?.destroy(); frame?.remove(); frame = null; root.remove(); } }; } diff --git a/clients/ui/test/provider_connection.test.ts b/clients/ui/test/provider_connection.test.ts new file mode 100644 index 0000000..428ea97 --- /dev/null +++ b/clients/ui/test/provider_connection.test.ts @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createProviderConnection, providerAuthorizationUrl } from "../src/screens/provider_connection.ts"; +import type { ApiLike } from "../src/screens/api.ts"; +import { installDomStub, StubNode, settle, deferred } from "./dom_stub.ts"; +const all = (n: StubNode): StubNode[] => [n, ...n.children.flatMap(all)]; +const button = (root: HTMLElement, text: string) => all(root as unknown as StubNode).find(n => n.tag === "button" && n.textContent === text)!; +function env() { installDomStub(); Object.assign(globalThis, { window: { addEventListener() {}, removeEventListener() {} } }); } +test("provider authentication rejects alternate hosts, paths and missing state", () => { + assert.match(providerAuthorizationUrl("https://www.epicgames.com/id/authorize?response_type=code&state=one", "epic"), /epicgames/); + for (const url of ["https://www.tiktok.com/evil?response_type=code&state=a", "https://www.tiktok.com.evil.test/v2/auth/authorize/?response_type=code&state=a", + "https://www.tiktok.com/v2/auth/authorize/?response_type=code", "https://user@www.tiktok.com/v2/auth/authorize/?response_type=code&state=a"]) + assert.throws(() => providerAuthorizationUrl(url, "tiktok")); +}); +test("opening browser never claims connected; server identity controls link and unlink", async () => { + env(); let account: null | { id: string; name: string } = null; const calls: string[] = []; let opened = ""; + const api = { get: async (path: string) => { calls.push(path); return { available: true, account }; }, + post: async (path: string) => { calls.push(path); return { url: "https://www.epicgames.com/id/authorize?response_type=code&state=one" }; }, + delete: async (path: string) => { calls.push(path); account = null; } } as unknown as ApiLike; + const screen = createProviderConnection({ provider: "epic", locale: "en", api, openExternal: url => { opened = url; } }); + try { + await settle(); button(screen.root, "Connect").dispatch("click"); await settle(); + assert.match(opened, /epicgames/); assert.match(screen.root.textContent ?? "", /Finish signing in/); + assert.doesNotMatch(screen.root.textContent ?? "", /Connected:/); + account = { id: "verified-id", name: "Verified player" }; + button(screen.root, "Check connection").dispatch("click"); await settle(); + assert.match(screen.root.textContent ?? "", /Connected: Verified player/); + button(screen.root, "Disconnect").dispatch("click"); await settle(); + assert.ok(calls.includes("/v1/gaming/connect/epic/connection")); + assert.doesNotMatch(screen.root.textContent ?? "", /Connected:/); + } finally { screen.destroy(); } +}); +test("unconfigured TikTok stays social and cannot start authentication", async () => { + env(); const paths: string[] = []; + const api = { get: async (path: string) => { paths.push(path); return { available: false, account: null }; } } as unknown as ApiLike; + const screen = createProviderConnection({ provider: "tiktok", locale: "en", api }); + try { await settle(); assert.equal(button(screen.root, "Connect").disabled, true); + assert.deepEqual(paths, ["/v1/social/connect/tiktok/status"]); } finally { screen.destroy(); } +}); +test("destroying a view during start prevents late browser navigation", async () => { + env(); let opened = false; const pending = deferred<{ url: string }>(); + const api = { get: async () => ({ available: true, account: null }), post: () => pending.promise } as unknown as ApiLike; + const screen = createProviderConnection({ provider: "tiktok", locale: "en", api, openExternal() { opened = true; } }); + await settle(); button(screen.root, "Connect").dispatch("click"); screen.destroy(); + pending.resolve({ url: "https://www.tiktok.com/v2/auth/authorize/?response_type=code&state=one" }); + await settle(); assert.equal(opened, false); +});