From fc71648c957ac2dfdf01cb2d46205fe3ff9c5357 Mon Sep 17 00:00:00 2001 From: saebyn Date: Sun, 5 Jan 2025 15:37:39 -0800 Subject: [PATCH] Move twitch login to backend API call, rather than exposing access token in URL in browser Fixes #311 --- .env.defaults | 1 - .vscode/tasks.json | 15 +- src/App.tsx | 8 +- src/api.ts | 107 +++++++++++++ src/components/atoms/TwitchCCLSelect.tsx | 7 +- .../atoms/TwitchCategoryAutocomplete.tsx | 7 +- .../atoms/TwitchCategoryAutocompleteInput.tsx | 2 +- src/components/atoms/TwitchOAuthButton.tsx | 72 +++------ .../atoms/TwitchTokenLivenessChecker.tsx | 50 ------- src/components/molecules/AdManager.tsx | 14 +- .../molecules/ChatDialog.stories.tsx | 14 +- src/components/molecules/StreamInfoEditor.tsx | 10 +- .../OTIOExporter/mediaClipSequence.ts | 2 +- .../organisms/SRTExporter/ExportButton.tsx | 3 +- src/components/organisms/TasksDrawer/List.tsx | 2 +- .../organisms/TasksDrawer/useTasks.ts | 2 +- .../Timeline/DensityLine.stories.tsx | 4 +- .../Timeline/SegmentSelector.stories.tsx | 10 +- .../Timeline/StreamTimeline.stories.tsx | 2 +- src/components/pages/ProfilePage.tsx | 6 +- src/components/pages/TwitchCallbackPage.tsx | 76 +++++----- src/hooks/useProfile.ts | 32 +++- src/ra/Layout.tsx | 7 +- src/ra/dataProvider/index.ts | 6 +- src/ra/dataProvider/restDataProvider.ts | 12 +- src/ra/dataProvider/twitchDataProvider.ts | 60 ++++++++ .../dataProvider/twitchVideosDataProvider.ts | 42 ------ .../stream_plans/StreamPlansCalendar.tsx | 2 +- src/resources/streams/Create.tsx | 1 - .../{twitch_streams => twitch}/List.tsx | 0 .../{twitch_streams => twitch}/index.tsx | 0 src/scheduling/findNextStream.ts | 5 +- src/scheduling/generateEventsForDay.test.ts | 77 +++++----- src/types.ts | 11 ++ src/utilities/csrf.ts | 12 -- src/utilities/twitch.ts | 140 +++++++++--------- 36 files changed, 466 insertions(+), 355 deletions(-) create mode 100644 src/api.ts delete mode 100644 src/components/atoms/TwitchTokenLivenessChecker.tsx create mode 100644 src/ra/dataProvider/twitchDataProvider.ts delete mode 100644 src/ra/dataProvider/twitchVideosDataProvider.ts rename src/resources/{twitch_streams => twitch}/List.tsx (100%) rename src/resources/{twitch_streams => twitch}/index.tsx (100%) delete mode 100644 src/utilities/csrf.ts diff --git a/.env.defaults b/.env.defaults index bf36ee2..79cef4c 100644 --- a/.env.defaults +++ b/.env.defaults @@ -6,7 +6,6 @@ VITE_REDIRECT_URI="http://localhost:5173/auth-callback" VITE_LOGOUT_URI="http://localhost:5173/login" VITE_TWITCH_CLIENT_ID=??? -VITE_TWITCH_REDIRECT_URI="http://localhost:5173/twitch-callback" VITE_API_URL="http://localhost:5173/api/" WEBSOCKET_URL="ws://localhost:5173" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4ef2385..cfa69b3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -6,7 +6,9 @@ "script": "lint:vscode", "problemMatcher": { "owner": "biome", - "fileLocation": ["absolute"], + "fileLocation": [ + "absolute" + ], "pattern": { "regexp": "^::(error|warning) title=(.+),file=(.+),line=(\\d+),endLine=(\\d+),col=(\\d+),endColumn=(\\d+)::(.+)$", "severity": 1, @@ -28,6 +30,15 @@ "problemMatcher": [], "label": "npm: format", "detail": "biome format" + }, + { + "type": "npm", + "script": "typecheck", + "problemMatcher": [ + "$tsc" + ], + "label": "npm: typecheck", + "detail": "tsc --project tsconfig.json --noEmit" } ] -} +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 843ea14..5713536 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { Route, RouterProvider, createBrowserRouter } from 'react-router-dom'; import episodes from '@/resources/episodes'; import streamPlans, { StreamPlansCalendar } from '@/resources/stream_plans'; import streams from '@/resources/streams'; -import twitch_streams from '@/resources/twitch_streams'; +import twitch from '@/resources/twitch'; import video_clips from '@/resources/video_clips'; import GlobalStyles from '@mui/material/GlobalStyles'; @@ -79,7 +79,11 @@ function App() { - + diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..e3194cb --- /dev/null +++ b/src/api.ts @@ -0,0 +1,107 @@ +import type { TwitchAccess } from '@/types'; +import { userManager } from '@/utilities/auth'; +import type { + TwitchAuthRequest, + TwitchCallbackRequest, +} from 'glowing-telegram-types/src/types'; + +const { VITE_API_URL: baseApiUrl } = import.meta.env; + +export async function authenticatedFetch( + url: string, + options: RequestInit = {}, +): Promise { + const user = await userManager.getUser(); + + if (!user) { + throw new Error('User not found'); + } + + const token = user.id_token; + + if (token === undefined) { + throw new Error('User not authenticated'); + } + + return fetch(url, { + ...options, + headers: { + Authorization: token, + Accept: 'application/json', + ...options.headers, + }, + }); +} + +export async function fetchTwitchAccessToken(): Promise { + const url = new URL('auth/twitch/token', baseApiUrl); + + try { + const res = await authenticatedFetch(url.toString()); + + const data = await res.json(); + + return { + valid: true, + id: data.broadcaster_id, + accessToken: data.access_token, + }; + } catch (error) { + return { + id: 'twitchToken', + valid: false, + }; + } +} + +export async function generateAuthorizeUri( + provider: 'twitch', + scopes: string[], +): Promise { + const url = new URL(`auth/${provider}/url`, baseApiUrl); + + const body: TwitchAuthRequest = { + scopes, + redirect_uri: window.location.href, + }; + + const res = await authenticatedFetch(url.toString(), { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await res.json(); + + return data.url; +} + +export async function handleOAuthCallback( + provider: 'twitch', + code: string, + state: string, +): Promise { + const body: TwitchCallbackRequest = { + code, + state, + scope: [], + }; + + const res = await authenticatedFetch( + new URL(`auth/${provider}/callback`, baseApiUrl).toString(), + { + method: 'POST', + body: JSON.stringify(body), + redirect: 'manual', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + const data = await res.json(); + + return data.url; +} diff --git a/src/components/atoms/TwitchCCLSelect.tsx b/src/components/atoms/TwitchCCLSelect.tsx index 79be684..204e189 100644 --- a/src/components/atoms/TwitchCCLSelect.tsx +++ b/src/components/atoms/TwitchCCLSelect.tsx @@ -43,6 +43,11 @@ function TwitchCCLSelect({ useEffect(() => { const abortController = new AbortController(); + if (!profile.twitch?.accessToken) { + setError(new Error('Missing Twitch access token')); + return; + } + setLoading(true); getContentClassificationLabels(getLocale(), profile.twitch.accessToken, { @@ -62,7 +67,7 @@ function TwitchCCLSelect({ return () => { abortController.abort(); }; - }, [profile.twitch.accessToken, getLocale]); + }, [profile.twitch?.accessToken, getLocale]); return ( diff --git a/src/components/atoms/TwitchCategoryAutocomplete.tsx b/src/components/atoms/TwitchCategoryAutocomplete.tsx index 14be8d3..d9871b4 100644 --- a/src/components/atoms/TwitchCategoryAutocomplete.tsx +++ b/src/components/atoms/TwitchCategoryAutocomplete.tsx @@ -39,6 +39,11 @@ function TwitchCategoryAutocomplete({ return; } + if (!profile.twitch?.accessToken) { + setError(new Error('Missing Twitch access token')); + return; + } + const abortController = new AbortController(); setLoading(true); @@ -61,7 +66,7 @@ function TwitchCategoryAutocomplete({ setLoading(false); } }, FETCH_CATEGORIES_DEBOUNCE_TIME), - [profile.twitch.accessToken], + [profile.twitch?.accessToken], ); useEffect(() => { diff --git a/src/components/atoms/TwitchCategoryAutocompleteInput.tsx b/src/components/atoms/TwitchCategoryAutocompleteInput.tsx index 1e381f1..0e2d9fb 100644 --- a/src/components/atoms/TwitchCategoryAutocompleteInput.tsx +++ b/src/components/atoms/TwitchCategoryAutocompleteInput.tsx @@ -18,7 +18,7 @@ function TwitchCategoryAutocompleteInput({ category={field.value === '' ? null : field.value} onChange={field.onChange} profile={profile} - label={label} + label={label || ''} /> ); } diff --git a/src/components/atoms/TwitchOAuthButton.tsx b/src/components/atoms/TwitchOAuthButton.tsx index fd0d3ef..1264bd3 100644 --- a/src/components/atoms/TwitchOAuthButton.tsx +++ b/src/components/atoms/TwitchOAuthButton.tsx @@ -1,9 +1,6 @@ -import { getCsrfToken } from '@/utilities/csrf'; -import { generateAuthorizeUri } from '@/utilities/twitch'; import CheckIcon from '@mui/icons-material/Check'; -import Alert from '@mui/material/Alert'; import Button from '@mui/material/Button'; -import { LoadingIndicator, useTranslate, useUpdate } from 'react-admin'; +import { useDataProvider, useTranslate } from 'react-admin'; interface TwitchOAuthButtonProps { tokens: { @@ -19,64 +16,29 @@ const scopes = [ function TwitchOAuthButton({ tokens }: TwitchOAuthButtonProps) { const translate = useTranslate(); - const [update, { isPending, isError }] = useUpdate(); + const dataprovider = useDataProvider(); - const csrfToken = getCsrfToken(); - const url = generateAuthorizeUri(csrfToken, scopes); - - const handleDisconnect = () => { - update('profile', { - id: 'my-profile', - data: { twitch: { accessToken: null } }, - }); - }; - - const handleConnect = () => { + const handleConnect = async () => { + const url = await dataprovider.generateAuthorizeUri('twitch', scopes); window.location.href = url; }; - if (isPending) { - return ; - } - - if (isError) { - return ( - - {translate('gt.profile.errorSaving', { - _: 'There was an error saving the profile', - })} - - ); - } - - // if the user is connected, show the reauthorize button return ( <> {tokens.accessToken && ( - <> - - - + )} {!tokens.accessToken && ( diff --git a/src/components/atoms/TwitchTokenLivenessChecker.tsx b/src/components/atoms/TwitchTokenLivenessChecker.tsx deleted file mode 100644 index 110a922..0000000 --- a/src/components/atoms/TwitchTokenLivenessChecker.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import useProfile from '@/hooks/useProfile'; -import { validateAccessToken } from '@/utilities/twitch'; -import { useEffect } from 'react'; -import { useUpdate } from 'react-admin'; - -function TwitchTokenLivenessChecker() { - const { profile } = useProfile(); - const [update] = useUpdate(); - - const twitchToken = profile?.twitch?.accessToken; - - const clearToken = () => { - console.log('clearing twitch oauth token'); - update('profile', { - id: 'my-profile', - data: { twitch: { accessToken: null } }, - }); - }; - - useEffect(() => { - const abortController = new AbortController(); - - if (!twitchToken) { - return; - } - - const interval = setInterval( - async () => { - try { - // check if the token is still valid - await validateAccessToken(twitchToken, { - signal: abortController.signal, - }); - } catch (error) { - clearToken(); - } - }, - 1000 * 60 * 60, - ); // check every hour - - return () => { - abortController.abort(); - clearInterval(interval); - }; - }); - - return null; -} - -export default TwitchTokenLivenessChecker; diff --git a/src/components/molecules/AdManager.tsx b/src/components/molecules/AdManager.tsx index 73a250b..2efb5df 100644 --- a/src/components/molecules/AdManager.tsx +++ b/src/components/molecules/AdManager.tsx @@ -29,6 +29,10 @@ function AdManager({ profile }: AdManagerProps) { useEffect(() => { const abortController = new AbortController(); + if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) { + return; + } + getAdSchedule(profile.twitch.broadcasterId, profile.twitch.accessToken, { signal: abortController.signal, }) @@ -36,9 +40,13 @@ function AdManager({ profile }: AdManagerProps) { .catch((e) => setError(e)); return () => abortController.abort(); - }, [profile.twitch.accessToken, profile.twitch.broadcasterId]); + }, [profile.twitch?.accessToken, profile.twitch?.broadcasterId]); const handleSnooze = async () => { + if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) { + return; + } + setIsPending(true); try { const data = await snoozeNextAd( @@ -55,6 +63,10 @@ function AdManager({ profile }: AdManagerProps) { }; const handleStartCommercial = async () => { + if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) { + return; + } + setIsPending(true); try { await startCommercial( diff --git a/src/components/molecules/ChatDialog.stories.tsx b/src/components/molecules/ChatDialog.stories.tsx index 6dc0245..5d8315d 100644 --- a/src/components/molecules/ChatDialog.stories.tsx +++ b/src/components/molecules/ChatDialog.stories.tsx @@ -8,11 +8,11 @@ const meta = { tags: ['autodocs'], argTypes: { open: { control: 'boolean' }, - onChat: { control: 'function' }, - onChange: { control: 'function' }, - job: { control: 'string' }, - transcript: { control: 'string' }, - context: { control: 'string' }, + onChat: {}, + onChange: {}, + job: { control: 'text' }, + transcript: { control: 'text' }, + context: { control: 'text' }, }, args: { open: true, @@ -28,7 +28,7 @@ export default meta; type Story = StoryObj; -export const Empty: Story = {}; +export const Empty: Story = {} as Story; export const WithMessages: Story = { args: { @@ -45,4 +45,4 @@ export const WithMessages: Story = { ]; }), }, -}; +} as Story; diff --git a/src/components/molecules/StreamInfoEditor.tsx b/src/components/molecules/StreamInfoEditor.tsx index 9f94261..104be07 100644 --- a/src/components/molecules/StreamInfoEditor.tsx +++ b/src/components/molecules/StreamInfoEditor.tsx @@ -47,6 +47,10 @@ function StreamInfoEditor({ useEffect(() => { const abortController = new AbortController(); + if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) { + return; + } + getChannelInformation( profile.twitch.broadcasterId, profile.twitch.accessToken, @@ -63,13 +67,17 @@ function StreamInfoEditor({ return () => { abortController.abort(); }; - }, [profile.twitch.accessToken, profile.twitch.broadcasterId, reloadCount]); + }, [profile.twitch?.accessToken, profile.twitch?.broadcasterId, reloadCount]); const handleRefresh = () => { setReloadCount((count) => count + 1); }; const handleSave = async () => { + if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) { + return; + } + setIsPending(true); try { await modifyChannelInformation( diff --git a/src/components/organisms/OTIOExporter/mediaClipSequence.ts b/src/components/organisms/OTIOExporter/mediaClipSequence.ts index d5559d8..d1f6741 100644 --- a/src/components/organisms/OTIOExporter/mediaClipSequence.ts +++ b/src/components/organisms/OTIOExporter/mediaClipSequence.ts @@ -1,4 +1,4 @@ -import type { VideoClip } from '../types'; +import type { VideoClip } from '@/types'; import { FPS } from './constants'; import type { ConvertedCut, InternalTrack } from './types'; diff --git a/src/components/organisms/SRTExporter/ExportButton.tsx b/src/components/organisms/SRTExporter/ExportButton.tsx index 6ce18fd..d032fc2 100644 --- a/src/components/organisms/SRTExporter/ExportButton.tsx +++ b/src/components/organisms/SRTExporter/ExportButton.tsx @@ -1,8 +1,7 @@ import DownloadIcon from '@mui/icons-material/Download'; +import type { Episode } from '@/types'; import { Button, useGetOne, useRecordContext } from 'react-admin'; - -import type { Episode } from '../types'; import exportSRT from './exporter'; const ExportButton = () => { diff --git a/src/components/organisms/TasksDrawer/List.tsx b/src/components/organisms/TasksDrawer/List.tsx index d7e31f8..1395cb3 100644 --- a/src/components/organisms/TasksDrawer/List.tsx +++ b/src/components/organisms/TasksDrawer/List.tsx @@ -1,3 +1,4 @@ +import type { TaskStatus, TaskSummary } from '@/types'; import DoneIcon from '@mui/icons-material/Done'; import ErrorIcon from '@mui/icons-material/Error'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; @@ -18,7 +19,6 @@ import { blue, green, orange, red } from '@mui/material/colors'; import useTheme from '@mui/material/styles/useTheme'; import { type FC, useRef } from 'react'; import { LoadingIndicator } from 'react-admin'; -import type { TaskStatus, TaskSummary } from '../types'; import Notifications from './Notifications'; import useTasks from './useTasks'; diff --git a/src/components/organisms/TasksDrawer/useTasks.ts b/src/components/organisms/TasksDrawer/useTasks.ts index 18096d2..3dc0e93 100644 --- a/src/components/organisms/TasksDrawer/useTasks.ts +++ b/src/components/organisms/TasksDrawer/useTasks.ts @@ -1,5 +1,5 @@ +import type { TaskSummary } from '@/types'; import { useGetList, useStore } from 'react-admin'; -import type { TaskSummary } from '../types'; const useTasks = () => { const [hideViewed, setHideViewed] = useStore('hideViewedTasks', false); diff --git a/src/components/organisms/Timeline/DensityLine.stories.tsx b/src/components/organisms/Timeline/DensityLine.stories.tsx index c8ca625..7a17e68 100644 --- a/src/components/organisms/Timeline/DensityLine.stories.tsx +++ b/src/components/organisms/Timeline/DensityLine.stories.tsx @@ -8,8 +8,8 @@ const meta = { argTypes: { start: { control: 'number' }, end: { control: 'number' }, - data: { control: 'array' }, - color: { control: 'array' }, + data: { control: 'object' }, + color: { control: 'object' }, transitionMargin: { control: { type: 'range', diff --git a/src/components/organisms/Timeline/SegmentSelector.stories.tsx b/src/components/organisms/Timeline/SegmentSelector.stories.tsx index 965534a..dfd3e22 100644 --- a/src/components/organisms/Timeline/SegmentSelector.stories.tsx +++ b/src/components/organisms/Timeline/SegmentSelector.stories.tsx @@ -10,8 +10,8 @@ const meta = { argTypes: { boundsStart: { control: 'number' }, boundsEnd: { control: 'number' }, - segments: { control: 'array' }, - onUpdateSegment: { control: 'function' }, + segments: { control: 'object' }, + onUpdateSegment: {}, }, args: { onUpdateSegment: fn(), @@ -47,11 +47,11 @@ export const Interacting: Story = { render: (args) => { const [segments, setSegments] = useState(args.segments); - const onUpdateSegment = (id: number, segment: any) => { - console.log(`Segment ${id} updated:`, segment); + const onUpdateSegment = (segment: any) => { + console.log('Segment updated:', segment); setSegments((prevSegments) => prevSegments.map((prevSegment) => - prevSegment.id === id ? segment : prevSegment, + prevSegment.id === segment.id ? segment : prevSegment, ), ); }; diff --git a/src/components/organisms/Timeline/StreamTimeline.stories.tsx b/src/components/organisms/Timeline/StreamTimeline.stories.tsx index 7b99f7b..30b3ffd 100644 --- a/src/components/organisms/Timeline/StreamTimeline.stories.tsx +++ b/src/components/organisms/Timeline/StreamTimeline.stories.tsx @@ -15,4 +15,4 @@ type Story = StoryObj; export const Empty: Story = { args: {}, -}; +} as Story; diff --git a/src/components/pages/ProfilePage.tsx b/src/components/pages/ProfilePage.tsx index 29f4b70..e600e62 100644 --- a/src/components/pages/ProfilePage.tsx +++ b/src/components/pages/ProfilePage.tsx @@ -68,7 +68,11 @@ const ProfilePage = () => { title={translate('gt.profile.credentials', { _: 'Credentials' })} /> - + diff --git a/src/components/pages/TwitchCallbackPage.tsx b/src/components/pages/TwitchCallbackPage.tsx index 0a9281f..d32a86d 100644 --- a/src/components/pages/TwitchCallbackPage.tsx +++ b/src/components/pages/TwitchCallbackPage.tsx @@ -1,64 +1,62 @@ -import { getCsrfToken } from '@/utilities/csrf'; -import { parseReturnedData, validateAccessToken } from '@/utilities/twitch'; -import { useEffect } from 'react'; -import { LoadingIndicator, useUpdate } from 'react-admin'; -import { useNavigate } from 'react-router-dom'; +import { handleOAuthCallback } from '@/api'; +import { useEffect, useState } from 'react'; function TwitchCallbackPage() { - const [update, { isPending, isIdle, isError }] = useUpdate(); - const navigate = useNavigate(); + const searchParams = new URLSearchParams(window.location.search); + const error = searchParams.get('error'); + const errorDescription = searchParams.get('error_description'); + const code = searchParams.get('code'); + const state = searchParams.get('state'); - const csrfToken = getCsrfToken(); - const result = parseReturnedData(csrfToken, window.location); - - const accessToken = result.status === 'success' ? result.accessToken : null; + const [loading, setLoading] = useState(true); + const [finalError, setFinalError] = useState(null); useEffect(() => { - const abortController = new AbortController(); + if (error) { + setLoading(false); + return; + } + + setLoading(true); - if (accessToken) { - validateAccessToken(accessToken, { signal: abortController.signal }) - .then((info) => { - update('profile', { - id: 'my-profile', - data: { twitch: { accessToken, broadcasterId: info.user_id } }, - }); + if (code && state) { + console.log('Handling OAuth callback'); + handleOAuthCallback('twitch', code, state) + .catch((err) => { + setFinalError(err.message); + throw err; }) - .catch(() => { - update('profile', { - id: 'my-profile', - data: { twitch: { accessToken: null } }, - }); + .then((url) => { + window.location.href = url; }) - .then(() => { - navigate('/profile'); + .finally(() => { + setLoading(false); }); } + }, [code, state, error]); - return () => { - abortController.abort(); - }; - }, [accessToken, update, navigate]); - - if (isPending || isIdle) { - return ; + if (error) { + return ( +
+

Error

+

{errorDescription}

+
+ ); } - if (isError) { + if (loading) { return (
-

Error

-

There was an error saving the access token

+

Loading

); } - if (result.status === 'error') { + if (finalError) { return (

Error

-

{result.error}

-

{result.errorDescription}

+

{finalError}

); } diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts index 1a3e8eb..25a35fb 100644 --- a/src/hooks/useProfile.ts +++ b/src/hooks/useProfile.ts @@ -1,3 +1,4 @@ +import type { TwitchAccess } from '@/types'; import { useGetIdentity, useGetOne } from 'react-admin'; export interface Profile { @@ -8,7 +9,7 @@ export interface Profile { fullName: string; timezone: string; standardTags: string[]; - twitch: { + twitch?: { accessToken: string; broadcasterId: string; }; @@ -48,7 +49,21 @@ function useProfile(): Return { }, ); - if (!identity || isPending) { + const { + data: twitchToken, + isPending: twitchTokenIsPending, + error: twitchTokenError, + } = useGetOne( + 'twitch', + { + id: 'twitchToken', + }, + { + enabled: !!identity, + }, + ); + + if (!identity || isPending || twitchTokenIsPending) { return { isPending: true, error: undefined, @@ -61,6 +76,15 @@ function useProfile(): Return { return { error, status: 'error', profile: undefined, isPending: false }; } + if (twitchTokenError) { + return { + error: twitchTokenError, + status: 'error', + profile: undefined, + isPending: false, + }; + } + return { status: 'success', error: undefined, @@ -72,6 +96,10 @@ function useProfile(): Return { fullName: identity.fullName, avatar: identity.avatar, accessToken: identity.accessToken, + twitch: twitchToken.valid && { + accessToken: twitchToken.accessToken, + broadcasterId: twitchToken.id, + }, }, }; } diff --git a/src/ra/Layout.tsx b/src/ra/Layout.tsx index 344e8b9..e406e00 100644 --- a/src/ra/Layout.tsx +++ b/src/ra/Layout.tsx @@ -1,14 +1,9 @@ -import TwitchTokenLivenessChecker from '@/components/atoms/TwitchTokenLivenessChecker'; import type { FC } from 'react'; import { Layout } from 'react-admin'; import AppBar from './AppBar'; const MyLayout: FC<{ children?: React.ReactNode }> = ({ children }) => ( - - {children} - - - + {children} ); export default MyLayout; diff --git a/src/ra/dataProvider/index.ts b/src/ra/dataProvider/index.ts index e769bf5..ee12f73 100644 --- a/src/ra/dataProvider/index.ts +++ b/src/ra/dataProvider/index.ts @@ -3,7 +3,7 @@ import { type DataProvider, combineDataProviders } from 'react-admin'; import chatDataProvider from './aiChat'; import resourceMap from './resourceMap'; import restDataProvider from './restDataProvider'; -import twitchVideosDataProvider from './twitchVideosDataProvider'; +import twitchDataProvider from './twitchDataProvider'; const dataProvider = combineDataProviders((resource) => { if (resource === 'aiChat') { @@ -14,8 +14,8 @@ const dataProvider = combineDataProviders((resource) => { return restDataProvider; } - if (resource === 'twitch_streams') { - return twitchVideosDataProvider; + if (resource === 'twitch') { + return twitchDataProvider; } throw new Error(`Unknown resource: ${resource}`); diff --git a/src/ra/dataProvider/restDataProvider.ts b/src/ra/dataProvider/restDataProvider.ts index acca052..ac8596d 100644 --- a/src/ra/dataProvider/restDataProvider.ts +++ b/src/ra/dataProvider/restDataProvider.ts @@ -1,3 +1,4 @@ +import { authenticatedFetch } from '@/api'; import { userManager } from '@/utilities/auth'; import type { DataProvider, Identifier } from 'react-admin'; import { HttpError } from 'react-admin'; @@ -215,14 +216,6 @@ async function fetchResourceData( ): Promise { validateResource(resource); - const user = await userManager.getUser(); - - if (!user) { - throw new Error('User not found'); - } - - const token = user.id_token; - const url = getResourceUrl(resource, recordId, options?.relatedFieldName); const { signal, params, data } = options || {}; @@ -237,12 +230,11 @@ async function fetchResourceData( } } - const response = await fetch(url.toString(), { + const response = await authenticatedFetch(url.toString(), { body: data ? JSON.stringify(data) : undefined, method, signal, headers: { - Authorization: `${token}`, Accept: 'application/json', 'Content-Type': 'application/json', }, diff --git a/src/ra/dataProvider/twitchDataProvider.ts b/src/ra/dataProvider/twitchDataProvider.ts new file mode 100644 index 0000000..26ce392 --- /dev/null +++ b/src/ra/dataProvider/twitchDataProvider.ts @@ -0,0 +1,60 @@ +import { fetchTwitchAccessToken, generateAuthorizeUri } from '@/api'; +import { getVideos } from '@/utilities/twitch'; +import type { DataProvider, GetListParams } from 'react-admin'; + +const twitchDataProvider = { + cursorPage: 1, + cursor: '', + + async getOne() { + const accessToken = await fetchTwitchAccessToken(); + + return { + data: accessToken, + }; + }, + + async generateAuthorizeUri(_resource: string, scopes: string[]) { + return generateAuthorizeUri('twitch', scopes); + }, + + async getList( + this: { cursorPage: number; cursor: string }, + _resource: string, + params: GetListParams, + ) { + const accessToken = await fetchTwitchAccessToken(); + + if (!accessToken.valid) { + throw new Error('Invalid Twitch access token'); + } + + const page = params.pagination?.page || 1; + let cursor = null; + + if (page === this.cursorPage) { + cursor = this.cursor; + } + + const result = await getVideos( + accessToken.id, + accessToken.accessToken, + cursor, + ); + + if (page === this.cursorPage && result.pagination?.cursor) { + this.cursor = result.pagination?.cursor; + this.cursorPage = page + 1; + } + + return { + data: result.data, + pageInfo: { + hasNextPage: result.pagination?.cursor, + hasPreviousPage: false, + }, + }; + }, +} as unknown as DataProvider; + +export default twitchDataProvider; diff --git a/src/ra/dataProvider/twitchVideosDataProvider.ts b/src/ra/dataProvider/twitchVideosDataProvider.ts deleted file mode 100644 index 4e55b0f..0000000 --- a/src/ra/dataProvider/twitchVideosDataProvider.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { DataProvider, GetListParams } from 'react-admin'; - -const { VITE_API_URL: baseApiUrl } = import.meta.env; - -const twitchVideosDataProvider = { - cursorPage: 1, - cursor: '', - - async getList( - this: { cursorPage: number; cursor: string }, - _resource: string, - params: GetListParams, - ) { - const page = params.pagination?.page || 1; - let cursor = ''; - - if (page === this.cursorPage) { - cursor = this.cursor; - } - - const url = new URL('twitch/videos', baseApiUrl); - url.searchParams.append('after', cursor); - - const res = await fetch(url); - const result = await res.json(); - - if (page === this.cursorPage && result.pagination?.cursor) { - this.cursor = result.pagination?.cursor; - this.cursorPage = page + 1; - } - - return { - data: result.data, - pageInfo: { - hasNextPage: result.pagination?.cursor, - hasPreviousPage: false, - }, - }; - }, -} as unknown as DataProvider; - -export default twitchVideosDataProvider; diff --git a/src/resources/stream_plans/StreamPlansCalendar.tsx b/src/resources/stream_plans/StreamPlansCalendar.tsx index 05d66c3..d148d0a 100644 --- a/src/resources/stream_plans/StreamPlansCalendar.tsx +++ b/src/resources/stream_plans/StreamPlansCalendar.tsx @@ -222,7 +222,7 @@ function MonthCalendarView({ {event.startDatetime.toLocaleString(DateTime.TIME_SIMPLE)} {' '} - | {event.name} + | {event.title} {event.prep_notes} ))} diff --git a/src/resources/streams/Create.tsx b/src/resources/streams/Create.tsx index 68acce3..c6cdb20 100644 --- a/src/resources/streams/Create.tsx +++ b/src/resources/streams/Create.tsx @@ -1,5 +1,4 @@ import DescriptionInput from '@/components/atoms/DescriptionInput'; -import { TimeDurationInput } from '@/components/atoms/TimeDurationInput'; import TitleInput from '@/components/atoms/TitleInput'; import { Create, diff --git a/src/resources/twitch_streams/List.tsx b/src/resources/twitch/List.tsx similarity index 100% rename from src/resources/twitch_streams/List.tsx rename to src/resources/twitch/List.tsx diff --git a/src/resources/twitch_streams/index.tsx b/src/resources/twitch/index.tsx similarity index 100% rename from src/resources/twitch_streams/index.tsx rename to src/resources/twitch/index.tsx diff --git a/src/scheduling/findNextStream.ts b/src/scheduling/findNextStream.ts index f0c2d99..af35d36 100644 --- a/src/scheduling/findNextStream.ts +++ b/src/scheduling/findNextStream.ts @@ -1,5 +1,6 @@ +import type { Series } from 'glowing-telegram-types/src/types'; import type { DateTime } from 'luxon'; -import type { StreamEvent, StreamPlan } from './types'; +import type { StreamEvent } from './types'; import generateEventsForDay from './generateEventsForDay'; @@ -15,7 +16,7 @@ import generateEventsForDay from './generateEventsForDay'; function findNextStream( startDateTime: DateTime, daysToSearch: number, - plans: StreamPlan[], + plans: Series[], ): StreamEvent | null { // first check for the current day from the start time const currentDayEvents = generateEventsForDay(startDateTime, plans).filter( diff --git a/src/scheduling/generateEventsForDay.test.ts b/src/scheduling/generateEventsForDay.test.ts index ec0a2ad..e67887c 100644 --- a/src/scheduling/generateEventsForDay.test.ts +++ b/src/scheduling/generateEventsForDay.test.ts @@ -1,18 +1,19 @@ +import type { Series } from 'glowing-telegram-types/src/types'; import { DateTime } from 'luxon'; import { describe, expect, it } from 'vitest'; import generateEventsForDay from './generateEventsForDay'; -import type { StreamPlan } from './types'; describe('generateEventsForDay', () => { it('should return an event for yesterday when the plan is in an earlier timezone', () => { const date = DateTime.fromISO('2024-11-25T00:00:00.000-04:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', description: 'Description 1', + created_at: '2024-11-01T00:00:00.000-08:00', prep_notes: 'Prep notes 1', start_date: '2024-11-24', end_date: '2024-11-24', @@ -26,7 +27,7 @@ describe('generateEventsForDay', () => { start_time: '23:00', end_time: '23:30', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; @@ -34,8 +35,8 @@ describe('generateEventsForDay', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', startDatetime: DateTime.fromISO('2024-11-25T03:00:00.000-04:00', { setZone: true, }), @@ -50,10 +51,11 @@ describe('generateEventsForDay', () => { const date = DateTime.fromISO('2024-11-25T00:00:00.000-08:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', + created_at: '2024-11-01T00:00:00.000-08:00', description: 'Description 1', prep_notes: 'Prep notes 1', start_date: '2024-11-25', @@ -68,15 +70,15 @@ describe('generateEventsForDay', () => { start_time: '23:00', end_time: '23:30', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; const events = generateEventsForDay(date, plans); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', prep_notes: 'Prep notes 1', }); expect(events[0].startDatetime.toJSDate()).toEqual( @@ -88,11 +90,12 @@ describe('generateEventsForDay', () => { const date = DateTime.fromISO('2024-11-25T00:00:00.000-08:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', description: 'Description 1', + created_at: '2024-11-01T00:00:00.000-08:00', prep_notes: 'Prep notes 1', start_date: '2024-11-26', end_date: '2024-11-26', @@ -106,7 +109,7 @@ describe('generateEventsForDay', () => { start_time: '03:00', end_time: '23:30', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; @@ -119,11 +122,12 @@ describe('generateEventsForDay', () => { const date = DateTime.fromISO('2024-11-28T00:00:00.000-08:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', description: 'Description 1', + created_at: '2024-11-01T00:00:00.000-08:00', prep_notes: 'Prep notes 1', start_date: '2024-11-01', end_date: '2024-11-30', @@ -142,7 +146,7 @@ describe('generateEventsForDay', () => { start_time: '18:00', end_time: '21:00', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; @@ -155,11 +159,12 @@ describe('generateEventsForDay', () => { const date = DateTime.fromISO('2024-11-28T00:00:00.000-08:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', description: 'Description 1', + created_at: '2024-11-01T00:00:00.000-08:00', prep_notes: 'Prep notes 1', start_date: '2024-11-01', end_date: '2024-11-30', @@ -173,7 +178,7 @@ describe('generateEventsForDay', () => { start_time: '18:00', end_time: '21:00', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; @@ -186,11 +191,12 @@ describe('generateEventsForDay', () => { const date = DateTime.fromISO('2024-11-25T00:00:00.000-08:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', description: 'Description 1', + created_at: '2024-11-01T00:00:00.000-08:00', prep_notes: 'Prep notes 1', start_date: '2024-11-01', end_date: '2024-11-30', @@ -204,7 +210,7 @@ describe('generateEventsForDay', () => { start_time: '18:00', end_time: '21:00', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; @@ -217,10 +223,11 @@ describe('generateEventsForDay', () => { const date = DateTime.fromISO('2024-11-04T00:00:00.000-08:00', { setZone: true, }); - const plans: StreamPlan[] = [ + const plans: Series[] = [ { - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', + created_at: '2024-11-01T00:00:00.000-08:00', description: 'Description 1', prep_notes: 'Prep notes 1', start_date: '2024-11-01', @@ -235,7 +242,7 @@ describe('generateEventsForDay', () => { start_time: '18:00', end_time: '21:00', tags: ['tag1'], - category: { id: '1', name: 'category1', box_art_url: '' }, + twitch_category: { id: '1', name: 'category1', box_art_url: '' }, }, ]; @@ -243,8 +250,8 @@ describe('generateEventsForDay', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - id: 1, - name: 'Event 1', + id: '1', + title: 'Event 1', prep_notes: 'Prep notes 1', }); expect(events[0].startDatetime.toJSDate()).toEqual( diff --git a/src/types.ts b/src/types.ts index dea215e..c20ed3c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,3 +111,14 @@ interface FileEntry { export interface FindFilesResponse { entries: FileEntry[]; } + +export type TwitchAccess = + | { + accessToken: string; + id: string; + valid: true; + } + | { + id: string; + valid: false; + }; diff --git a/src/utilities/csrf.ts b/src/utilities/csrf.ts deleted file mode 100644 index 38247c3..0000000 --- a/src/utilities/csrf.ts +++ /dev/null @@ -1,12 +0,0 @@ -const CSRF_TOKEN_KEY = 'gt:csrfToken'; - -export function getCsrfToken(): string { - const cachedToken = sessionStorage.getItem(CSRF_TOKEN_KEY); - if (!cachedToken) { - const token = Math.random().toString(36).slice(2); - sessionStorage.setItem(CSRF_TOKEN_KEY, token); - return token; - } - - return cachedToken; -} diff --git a/src/utilities/twitch.ts b/src/utilities/twitch.ts index c6dbbdb..15a2e53 100644 --- a/src/utilities/twitch.ts +++ b/src/utilities/twitch.ts @@ -1,24 +1,6 @@ import { DateTime } from 'luxon'; -const { - VITE_TWITCH_CLIENT_ID: clientId, - VITE_TWITCH_REDIRECT_URI: redirectUri, -} = import.meta.env; - -export function generateAuthorizeUri( - csrfToken: string, - scope: string[], -): string { - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: 'token', - scope: scope.join(' '), - state: csrfToken, - }); - - return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`; -} +const { VITE_TWITCH_CLIENT_ID: clientId } = import.meta.env; interface ValidateAccessTokenResponse { client_id: string; @@ -44,53 +26,6 @@ export async function validateAccessToken( throw new Error('Invalid access token'); } -interface AuthorizeSuccess { - status: 'success'; - accessToken: string; -} - -interface AuthorizeError { - status: 'error'; - error: string; - errorDescription: string; -} - -export function parseReturnedData( - csrfToken: string, - location: Location, -): AuthorizeError | AuthorizeSuccess { - const params = new URLSearchParams(location.hash.slice(1)); - const searchParams = new URLSearchParams(location.search); - - if (searchParams.has('error')) { - if (searchParams.get('state') !== csrfToken) { - return { - status: 'error', - error: 'csrf_mismatch', - errorDescription: 'CSRF token mismatch', - }; - } - - return { - status: 'error', - error: searchParams.get('error') || 'unknown', - errorDescription: searchParams.get('error_description') || 'unknown', - }; - } - - if (params.get('state') !== csrfToken) { - return { - status: 'error', - error: 'csrf_mismatch', - errorDescription: 'CSRF token mismatch', - }; - } - return { - status: 'success', - accessToken: params.get('access_token') || '', - }; -} - export interface ContentClassificationLabel { id: string; is_enabled: boolean; @@ -453,3 +388,76 @@ export async function snoozeNextAd( throw new Error('Failed to snooze next ad'); } + +/** + * Get Videos + * + * Gets information about one or more published videos. You may get videos by + * ID, by user, or by game/category. + * + * You may apply several filters to get a subset of the videos. The filters are + * applied as an AND operation to each video. For example, if language is set + * to ‘de’ and game_id is set to 21779, the response includes only videos that + * show playing League of Legends by users that stream in German. The filters + * apply only if you get videos by user ID or game ID. + * + * Authorization + * + * Requires an app access token or user access token. + */ +export interface Video { + id: string; + stream_id: string; + user_id: string; + user_login: string; + user_name: string; + title: string; + description: string; + created_at: string; + published_at: string; + url: string; + thumbnail_url: string; + viewable: 'public'; + view_count: number; + language: string; + type: 'archive' | 'highlight' | 'upload'; + duration: string; + muted_segments: Array<{ + duration: number; + offset: number; + }>; +} + +export interface GetVideosResponse { + data: Video[]; + pagination: { + cursor?: string; + }; +} + +export async function getVideos( + broadcasterId: string, + accessToken: string, + after: string | null = null, + options: { signal?: AbortSignal } = {}, +): Promise { + let url = `https://api.twitch.tv/helix/videos?user_id=${encodeURIComponent(broadcasterId)}`; + + if (after) { + url += `&after=${encodeURIComponent(after)}`; + } + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Client-Id': clientId, + }, + signal: options.signal, + }); + + if (response.ok) { + return response.json(); + } + + throw new Error('Failed to get videos'); +}