From ceb89882b17ccc59254f4e392208a2a7299af44c Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 6 Sep 2026 23:23:23 +0100 Subject: [PATCH] add apiFetch to each endpoint and redirect to a non-auth required page --- frontend/src/App.tsx | 21 ++++++++++------- frontend/src/auth/AuthProvider.tsx | 8 +++---- frontend/src/auth/RequireAuth.tsx | 14 +++++++++++ frontend/src/pages/ClipEdit.tsx | 15 +++++------- frontend/src/utils/api/client.ts | 15 ++++++++++-- frontend/src/utils/api/clips.ts | 37 +++++++----------------------- frontend/src/utils/api/jobs.ts | 15 ++++++------ frontend/src/utils/api/markers.ts | 24 +++++-------------- frontend/src/utils/api/media.ts | 26 +++++++-------------- frontend/src/utils/api/stream.ts | 22 ++++++++---------- frontend/src/utils/api/users.ts | 16 +++++-------- frontend/src/utils/api/vods.ts | 37 +++++++----------------------- 12 files changed, 103 insertions(+), 147 deletions(-) create mode 100644 frontend/src/auth/RequireAuth.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6f589c..32b9884 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import {config} from "./config.ts"; import NotFound from "./pages/NotFound.tsx"; import AuthProvider from "./auth/AuthProvider.tsx"; import ThemeProvider from "./theme/ThemeProvider.tsx"; + import RequireAuth from "./auth/RequireAuth.tsx"; function App() { @@ -36,14 +37,18 @@ function App() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index eb6a398..b1ee12e 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -34,12 +34,12 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { const logout = useCallback(async () => { await apiLogout(); - setUser(null); - }, []); + await refresh(); + }, [refresh]); useEffect(() => { - registerUnauthorizedHandler(logout); - }, [logout]); + registerUnauthorizedHandler(() => setUser(null)); + }, []); return ( diff --git a/frontend/src/auth/RequireAuth.tsx b/frontend/src/auth/RequireAuth.tsx new file mode 100644 index 0000000..b3e6fb8 --- /dev/null +++ b/frontend/src/auth/RequireAuth.tsx @@ -0,0 +1,14 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "./useAuth"; + +const RequireAuth = () => { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return null; + if (!user) return ; + + return ; +}; + +export default RequireAuth; \ No newline at end of file diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index 3188ef3..383883e 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -6,13 +6,10 @@ import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ConfigBox from "../components/video/ConfigBox.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx"; import { compress } from "../utils/api/media" -import { getJob } from "../utils/api/jobs" +import { getJob, downloadJob } from "../utils/api/jobs" import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; -import {config} from "../config.ts"; - -const API_URL = config.apiUrl; const ClipEdit = () => { const location = useLocation(); @@ -111,14 +108,14 @@ const ClipEdit = () => { const handleDownload = async () => { if (!uploadedId) return; - const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' }); - - if (!response.ok) { - console.error('Download failed'); + let blob: Blob; + try { + blob = await downloadJob(uploadedId); + } catch (err) { + console.error('Download failed', err); return; } - const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/frontend/src/utils/api/client.ts b/frontend/src/utils/api/client.ts index abafd74..51d8337 100644 --- a/frontend/src/utils/api/client.ts +++ b/frontend/src/utils/api/client.ts @@ -12,7 +12,13 @@ export function registerUnauthorizedHandler(handler: () => void) { onUnauthorized = handler; } -async function apiFetch(url: string, options: RequestInit = {}): Promise { +/** + * Performs a request against the API, sending session cookies and funnelling + * expired/missing sessions through the registered unauthorized handler so the + * UI can drop back to a logged out state. Returns the raw response so callers + * that need something other than JSON (blobs, streams) can read it themselves. + */ +async function apiFetchRaw(url: string, options: RequestInit = {}): Promise { const response = await fetch(url, { ...options, credentials: 'include' @@ -30,8 +36,13 @@ async function apiFetch(url: string, options: RequestInit = {}): Promise { throw new Error(message || `Request failed: ${response.status}`); } + return response; +} + +async function apiFetch(url: string, options: RequestInit = {}): Promise { + const response = await apiFetchRaw(url, options); const text = await response.text(); return text ? JSON.parse(text) : (undefined as T); } -export { API_URL, AuthError }; +export { API_URL, AuthError, apiFetch, apiFetchRaw }; diff --git a/frontend/src/utils/api/clips.ts b/frontend/src/utils/api/clips.ts index d042029..e7051cf 100644 --- a/frontend/src/utils/api/clips.ts +++ b/frontend/src/utils/api/clips.ts @@ -1,27 +1,17 @@ import type { APIResponse, Clip } from "../types.ts"; -import { API_URL, AuthError } from "./client.ts"; +import { API_URL, apiFetch } from "./client.ts"; const getClips = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' }); + const result = await apiFetch(API_URL + '/api/v1/clips'); + if (result.status === 'error') throw new Error(`Failed to fetch clips: ${result.message}`); - if (!response.ok) { - const errorResult: APIResponse = await response.json(); - throw new Error(`Failed to fetch clips: ${errorResult.message}`); - } - - const result: APIResponse = await response.json(); return result.data; }; const getClipById = async (id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' }); + const result = await apiFetch(API_URL + `/api/v1/clips/${id}`); + if (result.status === 'error') throw new Error(`Failed to fetch clip: ${result.message}`); - if (!response.ok) { - if (response.status === 401 || response.status === 403) throw new AuthError(); - throw new Error(`Failed to fetch clip: ${response.status}`); - } - - const result: APIResponse = await response.json(); return result.data; }; @@ -31,31 +21,20 @@ const getClipById = async (id: string): Promise => { const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`; const patchClip = async (id: number, data: { title?: string; description?: string }): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}`, { + const result = await apiFetch(API_URL + `/api/v1/clips/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify(data), }); - if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`); - - const result: APIResponse = await response.json(); if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`); return result.data; }; const deleteClip = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}`, { - method: 'DELETE', - credentials: 'include', - }); - - if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`); - - const result: APIResponse = await response.json(); - if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`); + const result = await apiFetch(API_URL + `/api/v1/clips/${id}`, { method: 'DELETE' }); + if (result?.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`); }; export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip }; diff --git a/frontend/src/utils/api/jobs.ts b/frontend/src/utils/api/jobs.ts index 5f3bfe4..ebad568 100644 --- a/frontend/src/utils/api/jobs.ts +++ b/frontend/src/utils/api/jobs.ts @@ -1,15 +1,16 @@ import type { APIResponse, JobResponse } from "../types.ts"; -import { API_URL } from "./client.ts"; +import { API_URL, apiFetch, apiFetchRaw } from "./client.ts"; const getJob = async (uuid: string): Promise => { - const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' }); - - if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`); - - const result: APIResponse = await response.json(); + const result = await apiFetch(API_URL + `/api/v1/jobs/${uuid}`); if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`); return result.data; }; -export { getJob }; +const downloadJob = async (uuid: string): Promise => { + const response = await apiFetchRaw(API_URL + `/api/v1/jobs/${uuid}/download`); + return response.blob(); +}; + +export { getJob, downloadJob }; diff --git a/frontend/src/utils/api/markers.ts b/frontend/src/utils/api/markers.ts index 8eadd80..3dee8ef 100644 --- a/frontend/src/utils/api/markers.ts +++ b/frontend/src/utils/api/markers.ts @@ -1,40 +1,28 @@ import type { APIResponse, Marker } from "../types.ts"; -import { API_URL } from "./client.ts"; +import { API_URL, apiFetch } from "./client.ts"; const getMarkers = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' }); - if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`); - - const result: APIResponse = await response.json(); + const result = await apiFetch(API_URL + '/api/v1/markers'); if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`); return result.data; }; const createMarker = async (message: string): Promise => { - const response = await fetch(API_URL + '/api/v1/markers', { + const result = await apiFetch(API_URL + '/api/v1/markers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify({ message }), }); - const result: APIResponse = await response.json(); - if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`); + if (result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`); return result.data; }; const deleteMarker = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/markers/${id}`, { - method: 'DELETE', - credentials: 'include', - }); - - if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`); - - const result: APIResponse = await response.json(); - if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`); + const result = await apiFetch(API_URL + `/api/v1/markers/${id}`, { method: 'DELETE' }); + if (result?.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`); }; export { getMarkers, createMarker, deleteMarker }; diff --git a/frontend/src/utils/api/media.ts b/frontend/src/utils/api/media.ts index 5d8626c..5f8d049 100644 --- a/frontend/src/utils/api/media.ts +++ b/frontend/src/utils/api/media.ts @@ -1,5 +1,5 @@ import type { APIResponse, VideoMetadata } from "../types.ts"; -import { API_URL } from "./client.ts"; +import { API_URL, apiFetch } from "./client.ts"; const compress = async (file: File, options: VideoMetadata): Promise => { const formData = new FormData(); @@ -13,58 +13,48 @@ const compress = async (file: File, options: VideoMetadata): Promise => if (options.height) formData.append('height', options.height.toString()); if (options.fileSize) formData.append('fileSize', options.fileSize.toString()); - const response = await fetch(API_URL + '/api/v1/media/compress', { + const result = await apiFetch(API_URL + '/api/v1/media/compress', { method: 'POST', body: formData, - credentials: 'include', }); - if (!response.ok) throw new Error(`Compression failed: ${response.status}`); - - const result: APIResponse = await response.json(); if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`); return result.data.uuid; }; const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise => { - const response = await fetch(API_URL + '/api/v1/media/save', { + const result = await apiFetch(API_URL + '/api/v1/media/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify({ startTime, endTime, title, description }), }); - const result: APIResponse = await response.json(); - if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`); + if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`); return result.data.uuid; }; const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise => { - const response = await fetch(API_URL + '/api/v1/media/save/markers', { + const result = await apiFetch(API_URL + '/api/v1/media/save/markers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify({ startMarkerId, endMarkerId, title, description }), }); - const result: APIResponse = await response.json(); - if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`); + if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`); return result.data.uuid; }; const clipSection = async (duration: number, title?: string, description?: string): Promise => { - const response = await fetch(API_URL + '/api/v1/media/clip', { + const result = await apiFetch(API_URL + '/api/v1/media/clip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify({ duration, title, description }), }); - const result: APIResponse = await response.json(); - if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`); + if (result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`); return result.data.uuid; }; diff --git a/frontend/src/utils/api/stream.ts b/frontend/src/utils/api/stream.ts index 7cf5fc0..a27ab6d 100644 --- a/frontend/src/utils/api/stream.ts +++ b/frontend/src/utils/api/stream.ts @@ -1,28 +1,24 @@ import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts"; -import { API_URL } from "./client.ts"; +import { API_URL, apiFetch } from "./client.ts"; const getStreamStatus = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' }); - if (!response.ok) return false; - const result: APIResponse = await response.json(); - return result.status === 'success' && result.data?.isStreaming === true; + try { + const result = await apiFetch(API_URL + '/api/v1/stream/current'); + return result.status === 'success' && result.data?.isStreaming === true; + } catch { + return false; + } }; const getCurrentStream = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' }); - if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`); - - const result: APIResponse = await response.json(); + const result = await apiFetch(API_URL + '/api/v1/stream/current'); if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`); return result.data; }; const getStreamHistory = async (userId: number): Promise => { - const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' }); - if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`); - - const result: APIResponse = await response.json(); + const result = await apiFetch(API_URL + `/api/v1/stream/history/${userId}`); if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`); return result.data; diff --git a/frontend/src/utils/api/users.ts b/frontend/src/utils/api/users.ts index 73f4af1..506a454 100644 --- a/frontend/src/utils/api/users.ts +++ b/frontend/src/utils/api/users.ts @@ -1,6 +1,9 @@ import type { APIResponse, User } from "../types.ts"; -import { API_URL } from "./client.ts"; +import { API_URL, apiFetch } from "./client.ts"; +// login and getUser deliberately use fetch directly: they run when there may be +// no session at all, and getUser is what the unauthorized handler calls to +// refresh — routing them through apiFetch would loop on a 401. const login = async (googleToken: string): Promise => { const response = await fetch(API_URL + '/api/v1/users/login', { method: 'POST', @@ -18,15 +21,8 @@ const login = async (googleToken: string): Promise => { }; const logout = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/users/logout', { - method: 'POST', - credentials: 'include', - }); - - if (!response.ok) throw new Error(`Logout failed: ${response.status}`); - - const result: APIResponse = await response.json(); - if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`); + const result = await apiFetch(API_URL + '/api/v1/users/logout', { method: 'POST' }); + if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`); }; const getUser = async (): Promise => { diff --git a/frontend/src/utils/api/vods.ts b/frontend/src/utils/api/vods.ts index f4f3484..88fb94b 100644 --- a/frontend/src/utils/api/vods.ts +++ b/frontend/src/utils/api/vods.ts @@ -1,27 +1,17 @@ import type { APIResponse, Vod } from "../types.ts"; -import { API_URL, AuthError } from "./client.ts"; +import { API_URL, apiFetch } from "./client.ts"; const getVods = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' }); + const result = await apiFetch(API_URL + '/api/v1/vods'); + if (result.status === 'error') throw new Error(`Failed to fetch vods: ${result.message}`); - if (!response.ok) { - const errorResult: APIResponse = await response.json(); - throw new Error(`Failed to fetch vods: ${errorResult.message}`); - } - - const result: APIResponse = await response.json(); return result.data; }; const getVodById = async (id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' }); + const result = await apiFetch(API_URL + `/api/v1/vods/${id}`); + if (result.status === 'error') throw new Error(`Failed to fetch vod: ${result.message}`); - if (!response.ok) { - if (response.status === 401 || response.status === 403) throw new AuthError(); - throw new Error(`Failed to fetch vod: ${response.status}`); - } - - const result: APIResponse = await response.json(); return result.data; }; @@ -31,31 +21,20 @@ const getVodById = async (id: string): Promise => { const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`; const patchVod = async (id: number, data: { title?: string; description?: string }): Promise => { - const response = await fetch(API_URL + `/api/v1/vods/${id}`, { + const result = await apiFetch(API_URL + `/api/v1/vods/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify(data), }); - if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`); - - const result: APIResponse = await response.json(); if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`); return result.data; }; const deleteVod = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/vods/${id}`, { - method: 'DELETE', - credentials: 'include', - }); - - if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`); - - const result: APIResponse = await response.json(); - if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`); + const result = await apiFetch(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' }); + if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`); }; export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };