From a1a12eee41889e152eba1f023dfc43ad1328abcf Mon Sep 17 00:00:00 2001 From: nexpid Date: Wed, 4 Mar 2026 16:04:23 +0100 Subject: [PATCH] feat(SongSpotlight): support refresh tokens (#870) --- .../songSpotlight.desktop/lib/api.ts | 66 +++++++++++-------- .../songSpotlight.desktop/lib/oauth2.tsx | 14 +++- .../lib/stores/AuthorizationStore.ts | 39 ++++++++--- .../songSpotlight.desktop/settings.tsx | 5 +- .../ui/settings/index.tsx | 6 +- .../ui/songs/CollapsedProfileSongs.tsx | 7 +- 6 files changed, 91 insertions(+), 46 deletions(-) diff --git a/src/equicordplugins/songSpotlight.desktop/lib/api.ts b/src/equicordplugins/songSpotlight.desktop/lib/api.ts index 868f79132..9bad09bca 100644 --- a/src/equicordplugins/songSpotlight.desktop/lib/api.ts +++ b/src/equicordplugins/songSpotlight.desktop/lib/api.ts @@ -9,7 +9,6 @@ import { showToast, Toasts, UserStore } from "@webpack/common"; import { useAuthorizationStore } from "./stores/AuthorizationStore"; import { useSongStore } from "./stores/SongStore"; -import { logger } from "./utils"; export const apiConstants = { api: "https://dc.songspotlight.nexpid.xyz/", @@ -20,16 +19,36 @@ export const apiConstants = { songLimit: 6, }; -export async function authFetch(_url: string | URL, options?: RequestInit) { - const url = new URL(_url); +let refreshPromise: Promise | undefined; +async function refreshAccessToken() { + const token = useAuthorizationStore.getState().getToken(); + if (!token) return false; + return refreshPromise ??= fetch(new URL("api/auth/refresh", apiConstants.api), { + method: "POST", + headers: { + "X-Refresh-Token": token.refresh, + }, + body: token.access, + }).then(async res => { + if (!res.ok) return false; + + const access = await res.text(); + useAuthorizationStore.getState().setToken(access, token.refresh); + return true; + }).finally(() => refreshPromise = undefined); +} + +export async function authFetch(url: string | URL, options?: RequestInit, retried = false) { + url = new URL(url); try { + const token = useAuthorizationStore.getState().getToken(); const res = await fetch(url, { ...options, headers: { ...options?.headers, - Authorization: useAuthorizationStore.getState().getToken()!, - }, + Authorization: token?.access, + } as HeadersInit, }); if (res.ok) return res; @@ -37,36 +56,29 @@ export async function authFetch(_url: string | URL, options?: RequestInit) { // not modified if (res.status === 304) return null; + const text = await res.text(); + // unauthorized if (res.status === 401) { - useAuthorizationStore.getState().deleteToken(); + const retry = !retried && await refreshAccessToken(); + if (retry) return await authFetch(url, options, true); + else { + useAuthorizationStore.getState().deleteTokens(); + showToast("You have been signed out from Song Spotlight. Please sign in again.", Toasts.Type.FAILURE); + } + } else { + showToast( + !text.includes("") && res.status >= 400 && res.status <= 599 + ? `Song Spotlight: ${text}` + : `Song Spotlight fetch error at ${url.pathname}`, + Toasts.Type.FAILURE, + ); } - const text = await res.text(); - showToast( - !text.includes("") && res.status >= 400 && res.status <= 599 - ? `Song Spotlight: ${text}` - : `Song Spotlight fetch error at ${url.pathname}`, - Toasts.Type.FAILURE, - ); - - logger.error( - "Got an authFetch response error", - options?.method ?? "GET", - url.toString(), - res.status, - text, - ); throw new Error(text); } catch (error) { showToast(`Song Spotlight: ${error}`, Toasts.Type.FAILURE); - logger.error( - "Got an authFetch request error", - options?.method ?? "GET", - url.toString(), - error, - ); throw error; } } diff --git a/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx b/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx index 3ff3bcdf9..8780a8df0 100644 --- a/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx +++ b/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx @@ -30,15 +30,23 @@ export function presentOAuth2Modal() { const url = new URL(location); url.searchParams.append("whois", "equicord"); - const token = await (await authFetch(url))?.text(); - if (!token) return; + const res = await authFetch(url); + if (!res) throw "Response wasn't ok"; - useAuthorizationStore.getState().setToken(token); + const access = await res.text(); + if (!access) throw "Access token is missing"; + + const refresh = res.headers.get("X-Refresh-Token"); + // STUB uncomment this once API is fully rolled out + // if (!refresh) throw "Refresh token is missing"; + + useAuthorizationStore.getState().setToken(access, refresh || ""); getData(); showToast("Successfully authorized!", Toasts.Type.SUCCESS); } catch (error) { logger.error("Got an error during OAuth2", error); + if (typeof error === "string") showToast(error, Toasts.Type.FAILURE); } }} /> diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts index 48e1469d5..368166764 100644 --- a/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts @@ -10,11 +10,16 @@ import { UserStore, zustandCreate, zustandPersist } from "@webpack/common"; import { PersistedZustandStore, ZustandDefinition } from "./zustand"; +export interface Token { + access: string; + refresh: string; +} + interface AuthorizationState { - tokens: Record; - getToken(): string | undefined; - setToken(token: string): void; - deleteToken(): void; + tokens: Record; + getToken(): Token | undefined; + setToken(access: string, refresh: string): void; + deleteTokens(): void; isAuthorized(): boolean; } @@ -26,17 +31,18 @@ export const useAuthorizationStore: PersistedZustandStore = getToken() { return get().tokens[UserStore.getCurrentUser()?.id]; }, - setToken(token) { + setToken(access, refresh) { const userId = UserStore.getCurrentUser()?.id; if (userId) { set({ - tokens: Object.assign(get().tokens, { - [userId]: token, - }), + tokens: { + ...get().tokens, + [userId]: { access, refresh }, + }, }); } }, - deleteToken() { + deleteTokens() { set({ tokens: {} }); }, isAuthorized() { @@ -45,11 +51,24 @@ export const useAuthorizationStore: PersistedZustandStore = })) as ZustandDefinition, { name: "songspotlight-auth", + version: 1, + migrate(persisted: any, version: number) { + if (version === 0) { + persisted.tokens = Object.fromEntries( + Object.entries(persisted.tokens).map(([userId, access]) => [userId, { + access, + refresh: "", + }]), + ); + } + + return persisted; + }, storage: { async getItem(name: string) { return (await DataStore.get(name)) ?? null; }, - async setItem(name: string, value: string) { + async setItem(name: string, value: unknown) { return await DataStore.set(name, value); }, async removeItem(name: string) { diff --git a/src/equicordplugins/songSpotlight.desktop/settings.tsx b/src/equicordplugins/songSpotlight.desktop/settings.tsx index 2819a7adb..97af8cc3f 100644 --- a/src/equicordplugins/songSpotlight.desktop/settings.tsx +++ b/src/equicordplugins/songSpotlight.desktop/settings.tsx @@ -7,6 +7,7 @@ import { definePluginSettings } from "@api/Settings"; import { makeRange, OptionType } from "@utils/types"; +import { apiConstants } from "./lib/api"; import Settings from "./ui/settings"; export default definePluginSettings({ @@ -18,8 +19,8 @@ export default definePluginSettings({ profileSongsLimit: { type: OptionType.SLIDER, description: "How many songs are shown when initially clicking on a user", - default: 6, - markers: makeRange(1, 6), + default: apiConstants.songLimit, + markers: makeRange(1, apiConstants.songLimit), }, manager: { type: OptionType.COMPONENT, diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx index ef42841de..7a36d6f73 100644 --- a/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx @@ -85,7 +85,7 @@ interface SettingsProps { } export default function Settings({ templateData }: SettingsProps) { - const { isAuthorized, deleteToken } = useAuthorizationStore(); + const { isAuthorized, deleteTokens } = useAuthorizationStore(); const { self } = useSongStore(); const ticked = useRef(false); @@ -172,7 +172,7 @@ export default function Settings({ templateData }: SettingsProps) {