Compare commits
15 Commits
fix/start-
...
3f8a877671
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f8a877671 | |||
| 34c4072c5b | |||
| 222b0b60f7 | |||
| 4b4cc3145f | |||
| 417e52d261 | |||
| 12852e034d | |||
| f1e28780f0 | |||
| daa6c04f76 | |||
| bd3d99e801 | |||
| 7dacb7a53a | |||
| d81202f905 | |||
| 9758bb296b | |||
| 6fa02a34a3 | |||
| ccf56f767f | |||
| 7f58bad245 |
@@ -20,11 +20,12 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
network_mode: host
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://localhost:8080
|
- WEB_SERVER_URL=http://host.docker.internal:8080
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default tseslint.config({
|
|
||||||
extends: [
|
|
||||||
// Remove ...tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
// other options...
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default tseslint.config({
|
|
||||||
plugins: {
|
|
||||||
// Add the react-x and react-dom plugins
|
|
||||||
'react-x': reactX,
|
|
||||||
'react-dom': reactDom,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// other rules...
|
|
||||||
// Enable its recommended typescript rules
|
|
||||||
...reactX.configs['recommended-typescript'].rules,
|
|
||||||
...reactDom.configs.recommended.rules,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
@@ -9,6 +9,20 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<script>
|
||||||
|
// Apply the stored theme before first paint so a dark session never
|
||||||
|
// flashes light. Mirrors the logic in src/theme/ThemeProvider.tsx.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("theme");
|
||||||
|
var dark = stored === "dark" ||
|
||||||
|
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.dataset.theme = "light";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script src="/env-config.js"></script>
|
<script src="/env-config.js"></script>
|
||||||
<title>The VoD System</title>
|
<title>The VoD System</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { GoogleOAuthProvider } from '@react-oauth/google';
|
|||||||
import {config} from "./config.ts";
|
import {config} from "./config.ts";
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
import NotFound from "./pages/NotFound.tsx";
|
||||||
import AuthProvider from "./auth/AuthProvider.tsx";
|
import AuthProvider from "./auth/AuthProvider.tsx";
|
||||||
|
import ThemeProvider from "./theme/ThemeProvider.tsx";
|
||||||
|
import RequireAuth from "./auth/RequireAuth.tsx";
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -27,26 +29,32 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
<Route element={<MainLayout />}>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route element={<MainLayout />}>
|
||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/my-vods" element={<MyVods />} />
|
|
||||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
<Route path="/my-vods" element={<MyVods />} />
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||||
</Route>
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Routes>
|
<Route path="/stream" element={<StreamControl />} />
|
||||||
</Router>
|
</Route>
|
||||||
</AuthProvider>
|
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ type AuthContextValue = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Exchange a Google credential for a session, then load the user. */
|
/** Exchange a Google credential for a session, then load the user. */
|
||||||
login: (googleCredential: string) => Promise<void>;
|
login: (googleCredential: string) => Promise<void>;
|
||||||
/** End the session and clear the user. */
|
/** End the session and clear the user. The local session is cleared even if the server call fails. */
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
/** Re-fetch the current user (e.g. after a profile change). */
|
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,66 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { User } from "../utils/types.ts";
|
import type { User } from "../utils/types.ts";
|
||||||
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
||||||
import { AuthContext } from "./AuthContext.ts";
|
import { AuthContext } from "./AuthContext.ts";
|
||||||
|
import {
|
||||||
|
refreshSession,
|
||||||
|
registerSessionRefreshedHandler,
|
||||||
|
registerUnauthorizedHandler,
|
||||||
|
} from "../utils/api/client.ts";
|
||||||
|
|
||||||
|
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
|
||||||
|
const REFRESH_MARGIN_MS = 60_000;
|
||||||
|
/** How soon to try again when a scheduled refresh fails because the network is down. */
|
||||||
|
const RETRY_DELAY_MS = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the single source of truth for the authenticated user. Everything that
|
* Owns the single source of truth for the authenticated user. Everything that
|
||||||
* needs to know who is logged in — the layout, the top bar, protected pages —
|
* needs to know who is logged in — the layout, the top bar, protected pages —
|
||||||
* reads it through the `useAuth` hook rather than fetching the user itself.
|
* reads it through the `useAuth` hook rather than fetching the user itself.
|
||||||
|
*
|
||||||
|
* It also keeps the session alive: the access token is refreshed shortly
|
||||||
|
* before it expires, rather than waiting for a request to fail. That matters
|
||||||
|
* for <video> elements, which load media straight from the API and can't
|
||||||
|
* retry on a 401 the way apiFetch does.
|
||||||
*/
|
*/
|
||||||
const AuthProvider = ({ children }: { children: ReactNode }) => {
|
const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
|
||||||
|
const refreshDueRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const cancelScheduledRefresh = useCallback(() => {
|
||||||
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
refreshDueRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const endSession = useCallback(() => {
|
||||||
|
cancelScheduledRefresh();
|
||||||
|
setUser(null);
|
||||||
|
}, [cancelScheduledRefresh]);
|
||||||
|
|
||||||
|
const scheduleRefreshIn = useCallback((delayMs: number) => {
|
||||||
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||||
|
refreshDueRef.current = Date.now() + delayMs;
|
||||||
|
refreshTimerRef.current = setTimeout(() => {
|
||||||
|
refreshSession()
|
||||||
|
.then((refreshed) => { if (!refreshed) endSession(); })
|
||||||
|
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
|
||||||
|
}, delayMs);
|
||||||
|
}, [endSession]);
|
||||||
|
|
||||||
|
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
|
||||||
|
// reports the new access token lifetime here, so the timer always tracks
|
||||||
|
// the live cookie.
|
||||||
|
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
|
||||||
|
const lifetimeMs = accessTokenExpiresIn * 1000;
|
||||||
|
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
|
||||||
|
}, [scheduleRefreshIn]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setUser(await getUser());
|
setUser(await getUser());
|
||||||
@@ -23,18 +71,50 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh().finally(() => setLoading(false));
|
registerUnauthorizedHandler(endSession);
|
||||||
|
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
|
||||||
|
}, [endSession, scheduleRefreshBeforeExpiry]);
|
||||||
|
|
||||||
|
// Restore the session on page load. Refreshing first means a returning
|
||||||
|
// user whose access token expired while they were away is logged straight
|
||||||
|
// back in, and gives us the expiry to schedule the next refresh against.
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSession()
|
||||||
|
.catch((error) => console.error("Failed to refresh session:", error))
|
||||||
|
.then(refresh)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Timers are throttled in background tabs and paused while the machine
|
||||||
|
// sleeps, so catch up as soon as the user comes back.
|
||||||
|
useEffect(() => {
|
||||||
|
const refreshIfDue = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const due = refreshDueRef.current;
|
||||||
|
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", refreshIfDue);
|
||||||
|
window.addEventListener("online", refreshIfDue);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", refreshIfDue);
|
||||||
|
window.removeEventListener("online", refreshIfDue);
|
||||||
|
};
|
||||||
|
}, [scheduleRefreshIn]);
|
||||||
|
|
||||||
|
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
|
||||||
|
|
||||||
const login = useCallback(async (googleCredential: string) => {
|
const login = useCallback(async (googleCredential: string) => {
|
||||||
await apiLogin(googleCredential);
|
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential));
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [refresh]);
|
}, [refresh, scheduleRefreshBeforeExpiry]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
// Drop the local session even if the server call fails, so the user
|
||||||
|
// is never left looking logged in after clicking log out.
|
||||||
|
endSession();
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
setUser(null);
|
}, [endSession]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
||||||
|
|||||||
14
frontend/src/auth/RequireAuth.tsx
Normal file
14
frontend/src/auth/RequireAuth.tsx
Normal file
@@ -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 <Navigate to="/" replace state={{ from: location }} />;
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequireAuth;
|
||||||
18
frontend/src/components/CardHeader.tsx
Normal file
18
frontend/src/components/CardHeader.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ElementType } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: ElementType;
|
||||||
|
title: string;
|
||||||
|
accent?: "primary" | "accent";
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardHeader = ({ icon: Icon, title, accent = "primary" }: Props) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${accent === "primary" ? "bg-terracotta/10" : "bg-olive/10"}`}>
|
||||||
|
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CardHeader;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X, Sun, Moon } from 'lucide-react';
|
||||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import MenuButton from "./buttons/MenuButton.tsx";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
|
import { useTheme } from "../theme/useTheme.ts";
|
||||||
|
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -25,6 +26,7 @@ const Topbar = ({
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, login, logout } = useAuth();
|
const { user, login, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
if (!response.credential) {
|
if (!response.credential) {
|
||||||
@@ -38,6 +40,8 @@ const Topbar = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
.then(() => navigate("/"))
|
.then(() => navigate("/"))
|
||||||
@@ -52,7 +56,16 @@ const Topbar = ({
|
|||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
{ user ? (
|
<div className="flex items-center gap-1">
|
||||||
|
<MenuButton
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={themeLabel}
|
||||||
|
aria-label={themeLabel}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun size={20}/> : <Moon size={20}/>}
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
{ user ? (
|
||||||
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
||||||
<img
|
<img
|
||||||
className={"w-8 h-8 rounded-full inline-block"}
|
className={"w-8 h-8 rounded-full inline-block"}
|
||||||
@@ -78,13 +91,15 @@ const Topbar = ({
|
|||||||
className={"text-error font-medium"} />
|
className={"text-error font-medium"} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
) :
|
) :
|
||||||
(
|
(
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
shape={"pill"}
|
shape={"pill"}
|
||||||
|
theme={theme === "dark" ? "filled_black" : "outline"}
|
||||||
useOneTap={false}
|
useOneTap={false}
|
||||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
173
frontend/src/components/stream/MarkerList.tsx
Normal file
173
frontend/src/components/stream/MarkerList.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Bookmark, Plus, X } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Box from "../Box.tsx";
|
||||||
|
import CardHeader from "../CardHeader.tsx";
|
||||||
|
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
||||||
|
import type { Marker } from "../../utils/types.ts";
|
||||||
|
import { formatLocalTime } from "../../utils/utils.ts";
|
||||||
|
|
||||||
|
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
|
|
||||||
|
type RailSegment = "none" | "plain" | "lit";
|
||||||
|
|
||||||
|
const railClass = (segment: RailSegment) => clsx(
|
||||||
|
"w-px",
|
||||||
|
segment === "lit" && "bg-terracotta",
|
||||||
|
segment === "plain" && "bg-inactive-strong/60",
|
||||||
|
segment === "none" && "bg-transparent"
|
||||||
|
);
|
||||||
|
|
||||||
|
type MarkerRowProps = {
|
||||||
|
marker: Marker;
|
||||||
|
role: "start" | "end" | "between" | null;
|
||||||
|
/** Rail segments above and below the dot — "lit" inside the chosen section. */
|
||||||
|
railAbove: RailSegment;
|
||||||
|
railBelow: RailSegment;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkerRow = ({ marker, role, railAbove, railBelow, onSelect, onDelete }: MarkerRowProps) => (
|
||||||
|
<li className="group relative flex gap-3">
|
||||||
|
{/* Timeline rail: a dot per marker, with the segment between the chosen
|
||||||
|
start and end lit so the selected section reads as one run. */}
|
||||||
|
<div className="flex w-2.5 flex-col items-center flex-shrink-0">
|
||||||
|
<span className={clsx("h-3.5 flex-shrink-0", railClass(railAbove))} />
|
||||||
|
<span className={clsx(
|
||||||
|
"h-2.5 w-2.5 rounded-full border-2 flex-shrink-0 transition-colors duration-150",
|
||||||
|
role === "start" && "border-terracotta bg-terracotta",
|
||||||
|
role === "end" && "border-olive bg-olive",
|
||||||
|
role === "between" && "border-terracotta bg-card",
|
||||||
|
role === null && "border-inactive-strong bg-card"
|
||||||
|
)} />
|
||||||
|
<span className={clsx("flex-1", railClass(railBelow))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The rail spans the full row, so the breathing room goes on the button —
|
||||||
|
the line stays continuous while neighbouring rings no longer touch. */}
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={clsx(
|
||||||
|
"min-w-0 flex-1 rounded-md my-0.5 py-2 pl-2.5 pr-7 text-left transition-colors duration-150",
|
||||||
|
role === "start" && "bg-selected ring-1 ring-terracotta",
|
||||||
|
role === "end" && "bg-selected ring-1 ring-olive",
|
||||||
|
(role === "between" || role === null) && "hover:bg-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="truncate text-sm font-medium text-text-strong">{marker.message}</p>
|
||||||
|
<p className="font-data text-[11px] text-muted tabular-nums">
|
||||||
|
{formatLocalTime(marker.timestamp)}
|
||||||
|
{role === "start" && <span className="font-medium text-terracotta"> · start</span>}
|
||||||
|
{role === "end" && <span className="font-medium text-olive"> · end</span>}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Delete marker"
|
||||||
|
aria-label={`Delete marker ${marker.message}`}
|
||||||
|
className="absolute right-1 top-2.5 flex h-5 w-5 items-center justify-center rounded-md text-muted opacity-0 transition duration-150 hover:bg-hover hover:text-error focus-visible:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Newest first — the list renders them in the order given. */
|
||||||
|
markers: Marker[];
|
||||||
|
startMarkerId: string;
|
||||||
|
endMarkerId: string;
|
||||||
|
error: string | null;
|
||||||
|
/** Resolves true once the marker exists, so the input only clears on success. */
|
||||||
|
onAdd: (message: string) => Promise<boolean>;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkerList = ({ markers, startMarkerId, endMarkerId, error, onAdd, onSelect, onDelete, className }: Props) => {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!message.trim() || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
if (await onAdd(message.trim())) setMessage("");
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Either of the two may have been picked first, so the lit run is bounded by
|
||||||
|
// whichever indices come first/last rather than by start/end.
|
||||||
|
const startIndex = markers.findIndex((m) => String(m.id) === startMarkerId);
|
||||||
|
const endIndex = markers.findIndex((m) => String(m.id) === endMarkerId);
|
||||||
|
const hasRange = startIndex !== -1 && endIndex !== -1;
|
||||||
|
const rangeFrom = hasRange ? Math.min(startIndex, endIndex) : -1;
|
||||||
|
const rangeTo = hasRange ? Math.max(startIndex, endIndex) : -1;
|
||||||
|
// The rail segment sitting between marker `i` and the one after it.
|
||||||
|
const railSegment = (i: number): RailSegment => {
|
||||||
|
if (i < 0 || i >= markers.length - 1) return "none";
|
||||||
|
return hasRange && i >= rangeFrom && i < rangeTo ? "lit" : "plain";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={clsx("p-5 flex flex-col gap-4", className)}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<CardHeader icon={Bookmark} title="Markers" accent="accent" />
|
||||||
|
{markers.length > 0 && (
|
||||||
|
<span className="font-data text-[11px] text-muted tabular-nums">{markers.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
|
placeholder="What just happened?"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={busy || !message.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
Add
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{markers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Nothing marked yet. Markers stack up here, newest first — pick two to save the section between them.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
/* py-0.5 keeps the scroll container from clipping the selection
|
||||||
|
ring on the first and last rows. */
|
||||||
|
<ul className="-mr-1 flex max-h-[26rem] flex-col overflow-y-auto py-0.5 pr-1">
|
||||||
|
{markers.map((m, i) => (
|
||||||
|
<MarkerRow
|
||||||
|
key={m.id}
|
||||||
|
marker={m}
|
||||||
|
role={String(m.id) === startMarkerId ? "start"
|
||||||
|
: String(m.id) === endMarkerId ? "end"
|
||||||
|
: hasRange && i > rangeFrom && i < rangeTo ? "between"
|
||||||
|
: null}
|
||||||
|
railAbove={railSegment(i - 1)}
|
||||||
|
railBelow={railSegment(i)}
|
||||||
|
onSelect={() => onSelect(m.id)}
|
||||||
|
onDelete={() => onDelete(m.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkerList;
|
||||||
@@ -65,7 +65,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="absolute top-1.5 left-1.5 bg-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
<span className="absolute top-1.5 left-1.5 bg-media-backdrop/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||||
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
|
className="bg-media-backdrop/75 hover:bg-media-backdrop/90 text-on-dark rounded p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Drive the `dark:` variant off the same data-theme attribute the toggle sets,
|
||||||
|
instead of Tailwind's default prefers-color-scheme. Without this a stray
|
||||||
|
`dark:` utility would disagree with an explicit light/dark choice. */
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
||||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
@@ -12,6 +17,10 @@
|
|||||||
--color-fields: #F4F0E8;
|
--color-fields: #F4F0E8;
|
||||||
--color-dropzone: #F9F6EF;
|
--color-dropzone: #F9F6EF;
|
||||||
|
|
||||||
|
/* Letterbox behind video. Dark in both themes — it is a cinema surface,
|
||||||
|
not an ink colour — so `text-on-dark` always reads on top of it. */
|
||||||
|
--color-media-backdrop: #2B2620;
|
||||||
|
|
||||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||||
--color-text-primary: #2B2620;
|
--color-text-primary: #2B2620;
|
||||||
--color-text-strong: #35302A;
|
--color-text-strong: #35302A;
|
||||||
@@ -44,6 +53,55 @@
|
|||||||
--color-inactive-strong: #C4BAA9;
|
--color-inactive-strong: #C4BAA9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode. Only token values change here: every component already styles
|
||||||
|
itself with the semantic utilities above, so the whole UI follows from this
|
||||||
|
one block. `--color-on-dark` is deliberately absent — it is the ink on the
|
||||||
|
media backdrop, which stays dark in both themes. */
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
|
||||||
|
--color-background: #0E0E10;
|
||||||
|
--color-sidebar: #09090B;
|
||||||
|
--color-card: #17171A;
|
||||||
|
--color-fields: #121215;
|
||||||
|
--color-dropzone: #141417;
|
||||||
|
|
||||||
|
--color-media-backdrop: #000000;
|
||||||
|
|
||||||
|
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||||
|
--color-text-primary: #F4F4F5;
|
||||||
|
--color-text-strong: #E3E3E7;
|
||||||
|
--color-text-body: #BFBFC6;
|
||||||
|
--color-text-secondary: #94949D;
|
||||||
|
--color-muted: #76767F;
|
||||||
|
--color-faint-mono: #6A6A73;
|
||||||
|
|
||||||
|
/* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
|
||||||
|
rather than deepens ─────────────────────────────────────────────────── */
|
||||||
|
--color-terracotta: #E07A4C;
|
||||||
|
--color-terracotta-hover: #F08F62;
|
||||||
|
--color-terracotta-light: #F0A177;
|
||||||
|
--color-terracotta-disabled: #5E463A;
|
||||||
|
--color-olive: #A3B678;
|
||||||
|
|
||||||
|
/* ── Status ──────────────────────────────────────────────────────────── */
|
||||||
|
--color-live: #F2604A;
|
||||||
|
--color-error: #F2604A;
|
||||||
|
|
||||||
|
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
|
||||||
|
sits on them flips dark ─────────────────────────────────────────────── */
|
||||||
|
--color-on-accent: #17100B;
|
||||||
|
|
||||||
|
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
||||||
|
--color-hairline: #232327;
|
||||||
|
--color-hover: #26262B;
|
||||||
|
--color-selected: #2A1F18;
|
||||||
|
--color-ring: #33333A;
|
||||||
|
--color-inactive: #2C2C32;
|
||||||
|
--color-inactive-strong: #55555E;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -52,6 +110,7 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -66,6 +125,16 @@ body {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Google Identity Services renders its sign-in button in a cross-origin iframe
|
||||||
|
whose document is light-scheme with a transparent background. Left to inherit
|
||||||
|
our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
|
||||||
|
white canvas behind it instead — framing the pill in a white box on every dark
|
||||||
|
page. Pinning the iframe back to light keeps it transparent; the button's own
|
||||||
|
colours come from the `theme` prop in Topbar.tsx. */
|
||||||
|
iframe[src*="accounts.google.com/gsi/"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
||||||
defaults; the #id beats those class selectors regardless of import order. */
|
defaults; the #id beats those class selectors regardless of import order. */
|
||||||
#range-slider {
|
#range-slider {
|
||||||
@@ -87,4 +156,4 @@ body {
|
|||||||
|
|
||||||
#range-slider .range-slider__thumb:focus-visible {
|
#range-slider .range-slider__thumb:focus-visible {
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
|
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
|||||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||||
import { compress } from "../utils/api/media"
|
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 type { VideoMetadata } from "../utils/types.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
import {config} from "../config.ts";
|
|
||||||
|
|
||||||
const API_URL = config.apiUrl;
|
|
||||||
|
|
||||||
const ClipEdit = () => {
|
const ClipEdit = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -111,14 +108,14 @@ const ClipEdit = () => {
|
|||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!uploadedId) return;
|
if (!uploadedId) return;
|
||||||
|
|
||||||
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
|
let blob: Blob;
|
||||||
|
try {
|
||||||
if (!response.ok) {
|
blob = await downloadJob(uploadedId);
|
||||||
console.error('Download failed');
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|
||||||
@@ -135,7 +132,7 @@ const ClipEdit = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-text-primary"}
|
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-text-primary rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-media-backdrop rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||||
onMouseMove={showControls}
|
onMouseMove={showControls}
|
||||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
import { Radio, Check, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
|
import CardHeader from "../components/CardHeader.tsx";
|
||||||
|
import MarkerList from "../components/stream/MarkerList.tsx";
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||||
@@ -9,7 +11,7 @@ import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"
|
|||||||
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||||
import { getJob } from "../utils/api/jobs.ts";
|
import { getJob } from "../utils/api/jobs.ts";
|
||||||
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
||||||
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
import { formatLocalDate, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
||||||
@@ -22,15 +24,6 @@ type TrackedJob = {
|
|||||||
errorOutput: string | null;
|
errorOutput: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-terracotta/10" : "bg-olive/10")}>
|
|
||||||
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
@@ -63,11 +56,8 @@ const StreamControl = () => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||||
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
||||||
const [now, setNow] = useState(Date.now());
|
|
||||||
|
|
||||||
const [markers, setMarkers] = useState<Marker[]>([]);
|
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||||
const [markerMessage, setMarkerMessage] = useState("");
|
|
||||||
const [markerBusy, setMarkerBusy] = useState(false);
|
|
||||||
const [markerError, setMarkerError] = useState<string | null>(null);
|
const [markerError, setMarkerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [clipDuration, setClipDuration] = useState(30);
|
const [clipDuration, setClipDuration] = useState(30);
|
||||||
@@ -103,16 +93,10 @@ const StreamControl = () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [user, streamStatus?.id]);
|
}, [user, streamStatus?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!streamDetails?.startDate) return;
|
|
||||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [streamDetails?.startDate]);
|
|
||||||
|
|
||||||
const refreshMarkers = () => {
|
const refreshMarkers = () => {
|
||||||
getMarkers()
|
getMarkers()
|
||||||
.then((data) => setMarkers(data
|
.then((data) => setMarkers(data
|
||||||
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
|
.sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,18 +133,15 @@ const StreamControl = () => {
|
|||||||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMarker = async () => {
|
const handleAddMarker = async (message: string) => {
|
||||||
if (!markerMessage.trim() || markerBusy) return;
|
|
||||||
setMarkerBusy(true);
|
|
||||||
setMarkerError(null);
|
setMarkerError(null);
|
||||||
try {
|
try {
|
||||||
await createMarker(markerMessage.trim());
|
await createMarker(message);
|
||||||
setMarkerMessage("");
|
|
||||||
refreshMarkers();
|
refreshMarkers();
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
||||||
} finally {
|
return false;
|
||||||
setMarkerBusy(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,7 +160,7 @@ const StreamControl = () => {
|
|||||||
const handleSaveByMarkers = async () => {
|
const handleSaveByMarkers = async () => {
|
||||||
setMarkerSaveError(null);
|
setMarkerSaveError(null);
|
||||||
if (!startMarkerId || !endMarkerId) {
|
if (!startMarkerId || !endMarkerId) {
|
||||||
setMarkerSaveError("Tap two markers above to pick a start and end");
|
setMarkerSaveError("Pick a start and an end marker from the timeline");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -210,7 +191,7 @@ const StreamControl = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChipClick = (id: number) => {
|
const handleSelectMarker = (id: number) => {
|
||||||
const idStr = String(id);
|
const idStr = String(id);
|
||||||
setMarkerSaveError(null);
|
setMarkerSaveError(null);
|
||||||
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
||||||
@@ -228,10 +209,6 @@ const StreamControl = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = streamDetails?.startDate
|
|
||||||
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
||||||
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
||||||
|
|
||||||
@@ -259,97 +236,30 @@ const StreamControl = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isStreaming && elapsed ? (
|
<Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
|
|
||||||
<p className={labelClass}>elapsed</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Radio size={28} className="text-inactive" />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<>
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
|
||||||
{/* Quick mark toolbar */}
|
{/* Marker timeline. First on narrow screens, where marking
|
||||||
<Box className="p-4 flex flex-col gap-3">
|
something as it happens beats the action forms for urgency. */}
|
||||||
<div className="flex items-center gap-3">
|
<MarkerList
|
||||||
<Bookmark size={18} className="text-olive flex-shrink-0" />
|
className="lg:order-last lg:sticky lg:top-6"
|
||||||
<input
|
markers={markers}
|
||||||
type="text"
|
startMarkerId={startMarkerId}
|
||||||
value={markerMessage}
|
endMarkerId={endMarkerId}
|
||||||
onChange={(e) => setMarkerMessage(e.target.value)}
|
error={markerError}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
|
onAdd={handleAddMarker}
|
||||||
placeholder="What just happened?"
|
onSelect={handleSelectMarker}
|
||||||
className={inputClass}
|
onDelete={handleDeleteMarker}
|
||||||
/>
|
/>
|
||||||
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
|
||||||
Add Marker
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
{markerError && <p className="text-sm text-error">{markerError}</p>}
|
|
||||||
|
|
||||||
{markers.length > 0 && (
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
|
||||||
{markers.map((m) => {
|
|
||||||
const isStart = startMarkerId === String(m.id);
|
|
||||||
const isEnd = endMarkerId === String(m.id);
|
|
||||||
return (
|
|
||||||
<div key={m.id} className="group relative flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => handleChipClick(m.id)}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
|
||||||
isStart && "border-terracotta bg-selected ring-1 ring-terracotta",
|
|
||||||
isEnd && "border-olive bg-selected ring-1 ring-olive",
|
|
||||||
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
|
|
||||||
<span className="font-data text-[11px] text-muted">
|
|
||||||
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
|
||||||
{isStart && <span className="text-terracotta font-medium"> · start</span>}
|
|
||||||
{isEnd && <span className="text-olive font-medium"> · end</span>}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
|
||||||
title="Delete marker"
|
|
||||||
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-muted hover:bg-error text-on-accent transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Forefront actions */}
|
{/* Forefront actions */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
<div className="flex flex-col gap-5">
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
|
||||||
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className={labelClass}>Duration (seconds)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={clipDuration}
|
|
||||||
onChange={(e) => setClipDuration(Number(e.target.value))}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
|
||||||
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
|
||||||
{clipError && <p className="text-sm text-error">{clipError}</p>}
|
|
||||||
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||||
{markers.length < 2 ? (
|
{markers.length < 2 ? (
|
||||||
<p className="text-sm text-muted">Add at least two markers above to save a section between them.</p>
|
<p className="text-sm text-muted">Add at least two markers to save a section between them.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -374,19 +284,37 @@ const StreamControl = () => {
|
|||||||
)}
|
)}
|
||||||
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Box className="p-5 flex flex-col gap-3">
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
<CardHeader icon={Activity} title="Recent actions" />
|
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
||||||
{jobs.length === 0 ? (
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
<label className={labelClass}>Duration (seconds)</label>
|
||||||
) : (
|
<input
|
||||||
<div className="flex flex-col gap-3">
|
type="number"
|
||||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
min={1}
|
||||||
|
value={clipDuration}
|
||||||
|
onChange={(e) => setClipDuration(Number(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
||||||
</Box>
|
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
</>
|
{clipError && <p className="text-sm text-error">{clipError}</p>}
|
||||||
|
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="p-5 flex flex-col gap-3">
|
||||||
|
<CardHeader icon={Activity} title="Recent actions" />
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
23
frontend/src/theme/ThemeContext.ts
Normal file
23
frontend/src/theme/ThemeContext.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
/** What the user chose. "system" defers to the OS setting. */
|
||||||
|
type ThemePreference = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
/** The theme actually on screen, once "system" has been resolved. */
|
||||||
|
type ResolvedTheme = "light" | "dark";
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
/** The stored choice, which may be "system". */
|
||||||
|
preference: ThemePreference;
|
||||||
|
/** The theme currently applied to the document. */
|
||||||
|
theme: ResolvedTheme;
|
||||||
|
/** Store a new preference and apply it immediately. */
|
||||||
|
setPreference: (preference: ThemePreference) => void;
|
||||||
|
/** Flip between light and dark, pinning the result as an explicit choice. */
|
||||||
|
toggleTheme: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export { ThemeContext };
|
||||||
|
export type { ThemeContextValue, ThemePreference, ResolvedTheme };
|
||||||
71
frontend/src/theme/ThemeProvider.tsx
Normal file
71
frontend/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ThemeContext } from "./ThemeContext.ts";
|
||||||
|
import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
|
||||||
|
|
||||||
|
/** Shared with the pre-paint script in index.html — keep the two in step. */
|
||||||
|
const STORAGE_KEY = "theme";
|
||||||
|
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
const readPreference = (): ThemePreference => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage blocked (private browsing, cookies disabled) — follow the OS.
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSystemTheme = (): ResolvedTheme =>
|
||||||
|
window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the single source of truth for the active theme. Nothing reads colours
|
||||||
|
* from here: the palette lives in index.css and switches on the `data-theme`
|
||||||
|
* attribute this provider writes, so components keep using the semantic
|
||||||
|
* utilities (`bg-card`, `text-text-primary`) and follow along for free.
|
||||||
|
*/
|
||||||
|
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [preference, setPreferenceState] = useState<ThemePreference>(readPreference);
|
||||||
|
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
|
||||||
|
|
||||||
|
// Keep following the OS while the preference is "system".
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia(DARK_QUERY);
|
||||||
|
const onChange = (event: MediaQueryListEvent) =>
|
||||||
|
setSystemTheme(event.matches ? "dark" : "light");
|
||||||
|
|
||||||
|
query.addEventListener("change", onChange);
|
||||||
|
return () => query.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const setPreference = useCallback((next: ThemePreference) => {
|
||||||
|
setPreferenceState(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Storage blocked — the choice still applies for this session.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setPreference(theme === "dark" ? "light" : "dark");
|
||||||
|
}, [theme, setPreference]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ preference, theme, setPreference, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeProvider;
|
||||||
14
frontend/src/theme/useTheme.ts
Normal file
14
frontend/src/theme/useTheme.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { ThemeContext } from "./ThemeContext.ts";
|
||||||
|
import type { ThemeContextValue } from "./ThemeContext.ts";
|
||||||
|
|
||||||
|
/** Access the centralised theme state. Must be used within a <ThemeProvider>. */
|
||||||
|
const useTheme = (): ThemeContextValue => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useTheme };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {config} from "../../config.ts";
|
import {config} from "../../config.ts";
|
||||||
|
import type { APIResponse } from "../types.ts";
|
||||||
|
|
||||||
const API_URL = config.apiUrl;
|
const API_URL = config.apiUrl;
|
||||||
|
|
||||||
@@ -6,9 +7,87 @@ class AuthError extends Error {
|
|||||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
|
let onUnauthorized: (() => void) | null = null;
|
||||||
const response = await fetch(thumbnailUrl, { credentials: 'include' });
|
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
|
||||||
return response.ok;
|
let refreshInFlight: Promise<boolean> | null = null;
|
||||||
};
|
|
||||||
|
|
||||||
export { API_URL, AuthError, isThumbnailAvailable };
|
export function registerUnauthorizedHandler(handler: () => void) {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
|
||||||
|
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
|
||||||
|
onSessionRefreshed = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges the refresh token cookie for new access and refresh token cookies.
|
||||||
|
* Concurrent callers share a single request, so a burst of 401s only rotates
|
||||||
|
* the refresh token once.
|
||||||
|
*
|
||||||
|
* @returns true if the session was refreshed, false if it has ended (no valid
|
||||||
|
* refresh token). Throws on network errors so a dropped connection isn't
|
||||||
|
* mistaken for being logged out.
|
||||||
|
*/
|
||||||
|
function refreshSession(): Promise<boolean> {
|
||||||
|
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestRefresh(): Promise<boolean> {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return false;
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a request against the API, sending session cookies. An expired
|
||||||
|
* access token is refreshed and the request retried once, so callers only see
|
||||||
|
* an AuthError when the session has really ended — at which point the
|
||||||
|
* registered unauthorized handler drops the UI 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<Response> {
|
||||||
|
const send = () => fetch(url, { ...options, credentials: 'include' });
|
||||||
|
|
||||||
|
let response = await send();
|
||||||
|
|
||||||
|
if (response.status == 401 && await refreshSession()) {
|
||||||
|
response = await send();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status == 401) {
|
||||||
|
onUnauthorized?.();
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged in, but not allowed to access this resource: don't end the session.
|
||||||
|
if (response.status == 403) {
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
let message = text;
|
||||||
|
try { message = JSON.parse(text).message ?? text; } catch { /* not JSON, use raw text */ }
|
||||||
|
throw new Error(message || `Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await apiFetchRaw(url, options);
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : (undefined as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession };
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
import type { APIResponse, Clip } from "../types.ts";
|
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<Clip[]> => {
|
const getClips = async (): Promise<Clip[]> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(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;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(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;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,31 +21,20 @@ const getClipById = async (id: string): Promise<Clip | null> => {
|
|||||||
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
||||||
|
|
||||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(data),
|
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}`);
|
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteClip = async (id: number): Promise<void> => {
|
const deleteClip = async (id: number): Promise<void> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
if (result?.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||||
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}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { APIResponse, JobResponse } from "../types.ts";
|
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<JobResponse> => {
|
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/jobs/${uuid}`);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getJob };
|
const downloadJob = async (uuid: string): Promise<Blob> => {
|
||||||
|
const response = await apiFetchRaw(API_URL + `/api/v1/jobs/${uuid}/download`);
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getJob, downloadJob };
|
||||||
|
|||||||
@@ -1,40 +1,28 @@
|
|||||||
import type { APIResponse, Marker } from "../types.ts";
|
import type { APIResponse, Marker } from "../types.ts";
|
||||||
import { API_URL } from "./client.ts";
|
import { API_URL, apiFetch } from "./client.ts";
|
||||||
|
|
||||||
const getMarkers = async (): Promise<Marker[]> => {
|
const getMarkers = async (): Promise<Marker[]> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers');
|
||||||
if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMarker = async (message: string): Promise<Marker> => {
|
const createMarker = async (message: string): Promise<Marker> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/markers', {
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ message }),
|
body: JSON.stringify({ message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
if (result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
|
||||||
if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMarker = async (id: number): Promise<void> => {
|
const deleteMarker = async (id: number): Promise<void> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/markers/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
if (result?.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
|
||||||
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}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getMarkers, createMarker, deleteMarker };
|
export { getMarkers, createMarker, deleteMarker };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIResponse, VideoMetadata } from "../types.ts";
|
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<string> => {
|
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -13,58 +13,48 @@ const compress = async (file: File, options: VideoMetadata): Promise<string> =>
|
|||||||
if (options.height) formData.append('height', options.height.toString());
|
if (options.height) formData.append('height', options.height.toString());
|
||||||
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
||||||
|
|
||||||
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/compress', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
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}`);
|
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
|
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/media/save', {
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ startTime, endTime, title, description }),
|
body: JSON.stringify({ startTime, endTime, title, description }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
||||||
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
|
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/media/save/markers', {
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save/markers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
|
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
||||||
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
|
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/media/clip', {
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/clip', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ duration, title, description }),
|
body: JSON.stringify({ duration, title, description }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
if (result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
|
||||||
if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
return result.data.uuid;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
|
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<boolean> => {
|
const getStreamStatus = async (): Promise<boolean> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
try {
|
||||||
if (!response.ok) return false;
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
|
||||||
const result: APIResponse = await response.json();
|
return result.status === 'success' && result.data?.isStreaming === true;
|
||||||
return result.status === 'success' && result.data?.isStreaming === true;
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentStream = async (): Promise<StreamStatus> => {
|
const getCurrentStream = async (): Promise<StreamStatus> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
|
||||||
if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
|
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/stream/history/${userId}`);
|
||||||
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import type { APIResponse, User } from "../types.ts";
|
import type { APIResponse, User } from "../types.ts";
|
||||||
import { API_URL } from "./client.ts";
|
import { API_URL } from "./client.ts";
|
||||||
|
|
||||||
const login = async (googleToken: string): Promise<string> => {
|
// These deliberately use fetch directly rather than apiFetch: they run when
|
||||||
|
// there may be no session at all, and apiFetch's refresh-and-retry on 401
|
||||||
|
// would either loop or log the user out from under the auth provider.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
|
||||||
|
* cookies; only the access token lifetime is returned.
|
||||||
|
*
|
||||||
|
* @returns seconds until the access token expires
|
||||||
|
*/
|
||||||
|
const login = async (googleToken: string): Promise<number> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -14,9 +24,10 @@ const login = async (googleToken: string): Promise<string> => {
|
|||||||
const result: APIResponse = await response.json();
|
const result: APIResponse = await response.json();
|
||||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||||
|
|
||||||
return result.data.token;
|
return result.data.accessTokenExpiresIn;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Revokes the session and clears the auth cookies. */
|
||||||
const logout = async (): Promise<void> => {
|
const logout = async (): Promise<void> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -24,9 +35,6 @@ const logout = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
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 getUser = async (): Promise<User | null> => {
|
const getUser = async (): Promise<User | null> => {
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
import type { APIResponse, Vod } from "../types.ts";
|
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<Vod[]> => {
|
const getVods = async (): Promise<Vod[]> => {
|
||||||
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(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;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVodById = async (id: string): Promise<Vod | null> => {
|
const getVodById = async (id: string): Promise<Vod | null> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
|
const result = await apiFetch<APIResponse>(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;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,31 +21,20 @@ const getVodById = async (id: string): Promise<Vod | null> => {
|
|||||||
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
||||||
|
|
||||||
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(data),
|
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}`);
|
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteVod = async (id: number): Promise<void> => {
|
const deleteVod = async (id: number): Promise<void> => {
|
||||||
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
|
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
||||||
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}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOption
|
|||||||
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clock time only, no date — markers within a stream are all from the same day,
|
||||||
|
// so the date would just be noise beside them.
|
||||||
|
function formatLocalTime(dateString: string): string {
|
||||||
|
return stringToDate(dateString).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
function dateToTimeAgo(date: Date): string {
|
function dateToTimeAgo(date: Date): string {
|
||||||
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
const units: [number, string][] = [
|
const units: [number, string][] = [
|
||||||
@@ -40,4 +46,4 @@ function dateToTimeAgo(date: Date): string {
|
|||||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM tiangolo/nginx-rtmp
|
FROM tiangolo/nginx-rtmp:latest-2026-09-07
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update -o Acquire::Check-Valid-Until=false && \
|
||||||
apt-get install -y --no-install-recommends gettext-base && \
|
apt-get install -y --no-install-recommends gettext-base && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
10
pom.xml
10
pom.xml
@@ -90,6 +90,16 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Database Migration Tools -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.ddf.vodsystem;
|
package com.ddf.vodsystem;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableConfigurationProperties(AuthProperties.class)
|
||||||
public class VodSystemApplication {
|
public class VodSystemApplication {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.ddf.vodsystem.configuration;
|
package com.ddf.vodsystem.configuration;
|
||||||
|
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
@@ -24,7 +26,7 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
||||||
* installed, and per-endpoint authorization rules.
|
* installed, {@code 401} for unauthenticated requests, and per-endpoint authorization rules.
|
||||||
*
|
*
|
||||||
* @param http the HTTP security builder to configure
|
* @param http the HTTP security builder to configure
|
||||||
* @return the built {@link SecurityFilterChain}
|
* @return the built {@link SecurityFilterChain}
|
||||||
@@ -41,11 +43,14 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
|
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
|
||||||
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
|
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
|
||||||
.requestMatchers("/api/v1/media/**").authenticated()
|
.requestMatchers("/api/v1/media/**").authenticated()
|
||||||
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
|
.requestMatchers("/api/v1/users/me").authenticated()
|
||||||
.requestMatchers("/api/v1/users/login").permitAll()
|
// authenticated by the refresh token cookie, not the (possibly expired) access token
|
||||||
|
.requestMatchers("/api/v1/users/login", "/api/v1/users/refresh", "/api/v1/users/logout").permitAll()
|
||||||
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
// 401 rather than the default 403, so clients know to refresh their session
|
||||||
|
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ddf.vodsystem.configuration;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class TimeConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Clock clock() {
|
||||||
|
return Clock.systemUTC();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.SessionResponse;
|
||||||
import com.ddf.vodsystem.controllers.dto.Token;
|
import com.ddf.vodsystem.controllers.dto.Token;
|
||||||
|
import com.ddf.vodsystem.dto.TokenPackage;
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.services.UserService;
|
import com.ddf.vodsystem.services.UserService;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseCookie;
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -19,12 +23,18 @@ import java.util.Optional;
|
|||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@Value("${jwt.expiration}")
|
private final Duration jwtExpiration;
|
||||||
private long jwtExpiration;
|
private final Duration refreshTokenExpiration;
|
||||||
private static final String SUCCESS = "success";
|
private static final String SUCCESS = "success";
|
||||||
|
|
||||||
public UserController(UserService userService) {
|
private static final String ACCESS_COOKIE_PATH = "/";
|
||||||
|
private static final String REFRESH_COOKIE_PATH = "/api/v1/users";
|
||||||
|
|
||||||
|
public UserController(UserService userService,
|
||||||
|
AuthProperties props) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.jwtExpiration = props.expiration().jwt();
|
||||||
|
this.refreshTokenExpiration = props.expiration().refreshToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,59 +57,110 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates a user from a Google ID token and issues a session JWT.
|
* Authenticates a user from a Google ID token and starts a session.
|
||||||
* <p>
|
* <p>
|
||||||
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
|
* Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken}
|
||||||
* returned in the response body.
|
* (path {@code /api/v1/users}). Neither token is returned in the response body.
|
||||||
*
|
*
|
||||||
* @param token the payload carrying the Google ID token to verify
|
* @param token the payload carrying the Google ID token to verify
|
||||||
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
|
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
|
||||||
* @param response the response the session cookie is written to
|
* @param response the response the cookies are written to
|
||||||
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
|
* @return {@code 200 OK} wrapping the access token lifetime as a {@link SessionResponse}
|
||||||
|
* @throws NotAuthenticated if the Google ID token is rejected
|
||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<SessionResponse>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String jwt = userService.login(token.token());
|
TokenPackage tokenPackage = userService.login(token.token());
|
||||||
|
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
|
||||||
.httpOnly(true)
|
|
||||||
.maxAge(jwtExpiration / 1000)
|
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
|
||||||
.secure(request.isSecure())
|
|
||||||
.path("/")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
response.addHeader("Set-Cookie", cookie.toString());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
|
new APIResponse<>(SUCCESS, "Logged in successfully", sessionResponse())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs the user out by clearing the session cookie.
|
* Exchanges the {@code refreshToken} cookie for a new refresh token and JWT, replacing both cookies.
|
||||||
*
|
*
|
||||||
* @param response the response the expired cookie is written to
|
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
|
||||||
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
|
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
|
||||||
|
* @param response the response the new cookies are written to
|
||||||
|
* @return {@code 200 OK} wrapping the new access token lifetime as a {@link SessionResponse}
|
||||||
|
* @throws NotAuthenticated if the refresh token is missing or rejected
|
||||||
|
*/
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<APIResponse<SessionResponse>> refresh(
|
||||||
|
@CookieValue(name = "refreshToken", required = false) String refreshToken,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
TokenPackage tokenPackage = userService.refresh(refreshToken);
|
||||||
|
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Refreshed tokens successfully", sessionResponse())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the user out by revoking the session tied to the {@code refreshToken} cookie and clearing
|
||||||
|
* both auth cookies. Succeeds even if no refresh token cookie is present.
|
||||||
|
*
|
||||||
|
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
|
||||||
|
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
|
||||||
|
* @param response the response the expired cookies are written to
|
||||||
* @return {@code 200 OK} confirming logout
|
* @return {@code 200 OK} confirming logout
|
||||||
*/
|
*/
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
|
public ResponseEntity<APIResponse<Void>> logout(
|
||||||
HttpServletRequest request) {
|
@CookieValue(name = "refreshToken", required = false) String refreshToken,
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
HttpServletRequest request,
|
||||||
.httpOnly(true)
|
HttpServletResponse response) {
|
||||||
.maxAge(0)
|
userService.logout(refreshToken);
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO);
|
||||||
.secure(request.isSecure())
|
|
||||||
.path("/")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
response.addHeader("Set-Cookie", cookie.toString());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
new APIResponse<>(SUCCESS, "Logged out successfully", null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private SessionResponse sessionResponse() {
|
||||||
|
return new SessionResponse(jwtExpiration.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCookies(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
String accessToken,
|
||||||
|
String refreshToken) {
|
||||||
|
writeCookies(request, response, accessToken, jwtExpiration, refreshToken, refreshTokenExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void writeCookies(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
String accessToken,
|
||||||
|
Duration accessTokenMaxAge,
|
||||||
|
String refreshToken,
|
||||||
|
Duration refreshTokenMaxAge) {
|
||||||
|
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken)
|
||||||
|
.httpOnly(true)
|
||||||
|
.maxAge(accessTokenMaxAge)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.secure(request.isSecure())
|
||||||
|
.path(ACCESS_COOKIE_PATH)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken)
|
||||||
|
.httpOnly(true)
|
||||||
|
.maxAge(refreshTokenMaxAge)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.secure(request.isSecure())
|
||||||
|
.path(REFRESH_COOKIE_PATH)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session metadata returned after login or refresh. The tokens themselves are only ever sent as
|
||||||
|
* HttpOnly cookies; this tells the client when to refresh them.
|
||||||
|
*
|
||||||
|
* @param accessTokenExpiresIn seconds until the access token cookie expires
|
||||||
|
*/
|
||||||
|
public record SessionResponse(
|
||||||
|
long accessTokenExpiresIn
|
||||||
|
) {}
|
||||||
9
src/main/java/com/ddf/vodsystem/dto/GoogleUser.java
Normal file
9
src/main/java/com/ddf/vodsystem/dto/GoogleUser.java
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
|
public record GoogleUser(
|
||||||
|
String googleId,
|
||||||
|
String email,
|
||||||
|
String name,
|
||||||
|
String profilePictureUrl
|
||||||
|
) {
|
||||||
|
}
|
||||||
7
src/main/java/com/ddf/vodsystem/dto/TokenPackage.java
Normal file
7
src/main/java/com/ddf/vodsystem/dto/TokenPackage.java
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
|
public record TokenPackage (
|
||||||
|
String refreshToken,
|
||||||
|
String accessToken
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.ddf.vodsystem.dto.properties;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@ConfigurationProperties(prefix = "app.security")
|
||||||
|
public record AuthProperties (
|
||||||
|
@NotBlank @Size(min = 32) String jwtSecret,
|
||||||
|
@Valid @NotNull Expiration expiration
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param jwt
|
||||||
|
* @param refreshToken
|
||||||
|
* @param tokenFamily
|
||||||
|
* @param gracePeriod
|
||||||
|
*/
|
||||||
|
public record Expiration (
|
||||||
|
@NotNull Duration jwt,
|
||||||
|
@NotNull Duration refreshToken,
|
||||||
|
@NotNull Duration tokenFamily,
|
||||||
|
@NotNull Duration gracePeriod
|
||||||
|
) {}
|
||||||
|
}
|
||||||
37
src/main/java/com/ddf/vodsystem/entities/RefreshToken.java
Normal file
37
src/main/java/com/ddf/vodsystem/entities/RefreshToken.java
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ddf.vodsystem.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "refresh_tokens")
|
||||||
|
@Data
|
||||||
|
public class RefreshToken {
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", nullable = false, unique = true)
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "family_id", nullable = false)
|
||||||
|
private TokenFamily tokenFamily;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", unique = true, nullable = false, length = 32)
|
||||||
|
private byte[] tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "used_at")
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
31
src/main/java/com/ddf/vodsystem/entities/TokenFamily.java
Normal file
31
src/main/java/com/ddf/vodsystem/entities/TokenFamily.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ddf.vodsystem.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "token_families")
|
||||||
|
@Data
|
||||||
|
public class TokenFamily {
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", nullable = false, unique = true)
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ddf.vodsystem.repositories;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.entities.RefreshToken;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
|
||||||
|
Optional<RefreshToken> findByHash(byte[] tokenHash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ddf.vodsystem.repositories;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.entities.TokenFamily;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> {
|
||||||
|
/**
|
||||||
|
* Deletes families that can no longer be refreshed: revoked, expired, or with every refresh
|
||||||
|
* token expired. Their refresh tokens are removed by the {@code ON DELETE CASCADE} foreign key.
|
||||||
|
*
|
||||||
|
* @return the number of families deleted
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM TokenFamily f
|
||||||
|
WHERE f.revokedAt IS NOT NULL
|
||||||
|
OR f.expiresAt <= ?1
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM RefreshToken r
|
||||||
|
WHERE r.tokenFamily = f AND r.expiresAt > ?1
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
int deleteDead(Instant now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
|
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||||
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class GoogleAuthConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the verifier used to validate Google ID tokens issued for this application's client ID.
|
||||||
|
*
|
||||||
|
* @param googleClientId the OAuth client ID that tokens must be issued for
|
||||||
|
* @return the configured {@link GoogleIdTokenVerifier}
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public GoogleIdTokenVerifier googleIdTokenVerifier(
|
||||||
|
@Value("${spring.security.oauth2.client.registration.google.client-id}") String googleClientId) {
|
||||||
|
return new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
|
||||||
|
.setAudience(Collections.singletonList(googleClientId))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.GoogleUser;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GoogleVerifierService {
|
||||||
|
private final GoogleIdTokenVerifier verifier;
|
||||||
|
|
||||||
|
public GoogleVerifierService(GoogleIdTokenVerifier verifier) {
|
||||||
|
this.verifier = verifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GoogleUser verify(String idToken) {
|
||||||
|
GoogleIdToken token;
|
||||||
|
try {
|
||||||
|
token = verifier.verify(idToken);
|
||||||
|
} catch (GeneralSecurityException | IOException e) {
|
||||||
|
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
throw new NotAuthenticated("Invalid ID token");
|
||||||
|
}
|
||||||
|
|
||||||
|
String googleId = token.getPayload().getSubject();
|
||||||
|
|
||||||
|
if (googleId == null) {
|
||||||
|
throw new NotAuthenticated("Google ID does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) {
|
||||||
|
throw new NotAuthenticated("Email not verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GoogleUser(
|
||||||
|
googleId,
|
||||||
|
token.getPayload().getEmail(),
|
||||||
|
(String) token.getPayload().get("name"),
|
||||||
|
(String) token.getPayload().get("picture")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
Cookie[] cookies = request.getCookies();
|
Cookie[] cookies = request.getCookies();
|
||||||
if (cookies != null) {
|
if (cookies != null) {
|
||||||
jwt = Arrays.stream(cookies)
|
jwt = Arrays.stream(cookies)
|
||||||
.filter(cookie -> "token".equals(cookie.getName()))
|
.filter(cookie -> "accessToken".equals(cookie.getName()))
|
||||||
.map(Cookie::getValue)
|
.map(Cookie::getValue)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm;
|
|||||||
import com.auth0.jwt.exceptions.JWTDecodeException;
|
import com.auth0.jwt.exceptions.JWTDecodeException;
|
||||||
import com.auth0.jwt.exceptions.TokenExpiredException;
|
import com.auth0.jwt.exceptions.TokenExpiredException;
|
||||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.oauth2.jwt.JwtException;
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -20,10 +21,9 @@ public class JwtService {
|
|||||||
private static final String USER_ID_CLAIM = "userId";
|
private static final String USER_ID_CLAIM = "userId";
|
||||||
private static final String ISSUER = "vodsystem";
|
private static final String ISSUER = "vodsystem";
|
||||||
|
|
||||||
public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey,
|
public JwtService(AuthProperties props) {
|
||||||
@Value("${jwt.expiration}") long jwtExpiration) {
|
this.jwtExpiration = props.expiration().jwt().toMillis();
|
||||||
this.jwtExpiration = jwtExpiration;
|
this.algorithm = Algorithm.HMAC256(props.jwtSecret());
|
||||||
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
248
src/main/java/com/ddf/vodsystem/security/TokenService.java
Normal file
248
src/main/java/com/ddf/vodsystem/security/TokenService.java
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
|
import com.ddf.vodsystem.entities.RefreshToken;
|
||||||
|
import com.ddf.vodsystem.entities.TokenFamily;
|
||||||
|
import com.ddf.vodsystem.entities.User;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
|
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
|
||||||
|
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues, rotates and revokes opaque refresh tokens.
|
||||||
|
* <p>
|
||||||
|
* Each login starts a {@link TokenFamily}; every refresh token issued for that session belongs to it.
|
||||||
|
* Only the SHA-256 hash of a token is stored. Presenting an already-used token after the grace
|
||||||
|
* period is treated as theft and revokes the whole family.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class TokenService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TokenService.class);
|
||||||
|
private static final SecureRandom RNG = new SecureRandom();
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
private final TokenFamilyRepository tokenFamilyRepository;
|
||||||
|
private final RefreshTokenRepository refreshTokenRepository;
|
||||||
|
|
||||||
|
private final Duration tokenFamilyExpiration;
|
||||||
|
private final Duration refreshTokenExpiration;
|
||||||
|
private final Duration refreshGracePeriod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tokenFamilyRepository stores token families (one per login session)
|
||||||
|
* @param refreshTokenRepository stores hashed refresh tokens
|
||||||
|
* @param props supplies token family lifetime, refresh token lifetime and reuse grace period
|
||||||
|
* @param clock time source for all timestamps and expiry checks
|
||||||
|
*/
|
||||||
|
public TokenService (
|
||||||
|
TokenFamilyRepository tokenFamilyRepository,
|
||||||
|
RefreshTokenRepository refreshTokenRepository,
|
||||||
|
AuthProperties props,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.tokenFamilyRepository = tokenFamilyRepository;
|
||||||
|
this.refreshTokenRepository = refreshTokenRepository;
|
||||||
|
this.clock = clock;
|
||||||
|
|
||||||
|
this.refreshTokenExpiration = props.expiration().refreshToken();
|
||||||
|
this.tokenFamilyExpiration = props.expiration().tokenFamily();
|
||||||
|
this.refreshGracePeriod = props.expiration().gracePeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new session for a user by creating a token family and its first refresh token.
|
||||||
|
*
|
||||||
|
* @param user the user the session belongs to
|
||||||
|
* @return the raw refresh token as a 64-character hex string; only its hash is persisted
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String createSession(User user) {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
|
||||||
|
TokenFamily tokenFamily = new TokenFamily();
|
||||||
|
tokenFamily.setUser(user);
|
||||||
|
tokenFamily.setCreatedAt(now);
|
||||||
|
tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis()));
|
||||||
|
tokenFamilyRepository.save(tokenFamily);
|
||||||
|
|
||||||
|
byte[] token = generateRandomBytes(32);
|
||||||
|
|
||||||
|
createRefreshToken(
|
||||||
|
token,
|
||||||
|
tokenFamily
|
||||||
|
);
|
||||||
|
|
||||||
|
return HEX.formatHex(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges a refresh token for a new one in the same family.
|
||||||
|
* <p>
|
||||||
|
* The presented token is marked used and a new token is issued. Presenting a used token again
|
||||||
|
* within the grace period returns another new token without extending the grace period. Presenting
|
||||||
|
* it after the grace period revokes the family; this revocation is committed even though the
|
||||||
|
* method throws.
|
||||||
|
*
|
||||||
|
* @param hexToken the raw refresh token as a hex string
|
||||||
|
* @return the owning user's ID and the new raw refresh token
|
||||||
|
* @throws NotAuthenticated if the token is missing, malformed, unknown, expired, belongs to a revoked
|
||||||
|
* or expired family, or is reused after the grace period
|
||||||
|
*/
|
||||||
|
@Transactional(noRollbackFor = NotAuthenticated.class)
|
||||||
|
public Rotation rotate(String hexToken) {
|
||||||
|
if (hexToken == null || hexToken.isBlank()) {
|
||||||
|
throw new NotAuthenticated("Missing refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] hash;
|
||||||
|
try {
|
||||||
|
hash = sha256(HEX.parseHex(hexToken));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new NotAuthenticated("Malformed refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
|
||||||
|
// Check Token is real
|
||||||
|
RefreshToken refreshToken = refreshTokenRepository.findByHash(hash)
|
||||||
|
.orElseThrow(() -> new NotAuthenticated("No such refresh token"));
|
||||||
|
|
||||||
|
// Find Token Family
|
||||||
|
TokenFamily tokenFamily = refreshToken.getTokenFamily();
|
||||||
|
|
||||||
|
if (tokenFamily.getRevokedAt() != null) {
|
||||||
|
throw new NotAuthenticated("Token family has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse Check
|
||||||
|
if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) {
|
||||||
|
tokenFamily.setRevokedAt(now);
|
||||||
|
tokenFamilyRepository.save(tokenFamily);
|
||||||
|
throw new NotAuthenticated("Refresh token already being used");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry Check
|
||||||
|
if (
|
||||||
|
now.isAfter(refreshToken.getExpiresAt()) ||
|
||||||
|
now.isAfter(tokenFamily.getExpiresAt())
|
||||||
|
) {
|
||||||
|
throw new NotAuthenticated("Refresh token or family expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate old token, unless we're within the grace period, otherwise the grace gets extended
|
||||||
|
if (refreshToken.getUsedAt() == null) {
|
||||||
|
refreshToken.setUsedAt(now);
|
||||||
|
refreshTokenRepository.save(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] newRawToken = generateRandomBytes(32);
|
||||||
|
createRefreshToken(
|
||||||
|
newRawToken,
|
||||||
|
tokenFamily
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes a refresh token and its whole token family, ending the session. Does nothing if the
|
||||||
|
* token is missing or unknown, and leaves existing revocation timestamps unchanged.
|
||||||
|
*
|
||||||
|
* @param hexToken the raw refresh token as a hex string, may be {@code null}
|
||||||
|
* @throws NotAuthenticated if the token is not valid hex
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void revoke(String hexToken) {
|
||||||
|
if (hexToken == null || hexToken.isBlank()) return;
|
||||||
|
|
||||||
|
byte[] hash;
|
||||||
|
try {
|
||||||
|
hash = sha256(HEX.parseHex(hexToken));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new NotAuthenticated("Malformed refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RefreshToken> found = refreshTokenRepository.findByHash(hash);
|
||||||
|
if (found.isEmpty()) return;
|
||||||
|
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
RefreshToken refreshToken = found.get();
|
||||||
|
TokenFamily family = refreshToken.getTokenFamily();
|
||||||
|
|
||||||
|
if (family.getRevokedAt() == null) {
|
||||||
|
family.setRevokedAt(now);
|
||||||
|
tokenFamilyRepository.save(family);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken.getRevokedAt() == null) {
|
||||||
|
refreshToken.setRevokedAt(now);
|
||||||
|
refreshTokenRepository.save(refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled cleanup that deletes token families that can no longer be refreshed, along with all
|
||||||
|
* their refresh tokens.
|
||||||
|
* <p>
|
||||||
|
* Runs hourly. Used tokens in live families are kept, since reuse detection needs them.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 3_600_000)
|
||||||
|
@Transactional
|
||||||
|
public void deleteDeadSessions() {
|
||||||
|
int deleted = tokenFamilyRepository.deleteDead(Instant.now(clock));
|
||||||
|
if (deleted > 0) logger.info("Deleted {} dead token families", deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a successful {@link #rotate(String)}.
|
||||||
|
*
|
||||||
|
* @param userId ID of the user who owns the token family
|
||||||
|
* @param newRefreshToken the newly issued raw refresh token as a hex string
|
||||||
|
*/
|
||||||
|
public record Rotation(Long userId, String newRefreshToken) {}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] in) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(in);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] generateRandomBytes(int length) {
|
||||||
|
byte[] bytes = new byte[length];
|
||||||
|
RNG.nextBytes(bytes);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private RefreshToken createRefreshToken(
|
||||||
|
byte[] token,
|
||||||
|
TokenFamily tokenFamily
|
||||||
|
) {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
|
||||||
|
RefreshToken refreshToken = new RefreshToken();
|
||||||
|
refreshToken.setTokenFamily(tokenFamily);
|
||||||
|
refreshToken.setTokenHash(sha256(token));
|
||||||
|
refreshToken.setCreatedAt(now);
|
||||||
|
refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
|
||||||
|
return refreshTokenRepository.save(refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ public class MediaService {
|
|||||||
onSuccess.run();
|
onSuccess.run();
|
||||||
}).exceptionally(ex -> {
|
}).exceptionally(ex -> {
|
||||||
job.setState(JobState.FAILED);
|
job.setState(JobState.FAILED);
|
||||||
job.setErrorOutput(ex.getMessage());
|
job.setErrorOutput("FFMPEG failed to save stream segments");
|
||||||
logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
|
logger.error("Stream save job {} failed: {}", job.getUuid(), ex.getMessage());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,44 @@
|
|||||||
package com.ddf.vodsystem.services;
|
package com.ddf.vodsystem.services;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.GoogleUser;
|
||||||
|
import com.ddf.vodsystem.dto.TokenPackage;
|
||||||
import com.ddf.vodsystem.entities.User;
|
import com.ddf.vodsystem.entities.User;
|
||||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
import com.ddf.vodsystem.repositories.UserRepository;
|
import com.ddf.vodsystem.repositories.UserRepository;
|
||||||
|
import com.ddf.vodsystem.security.GoogleVerifierService;
|
||||||
import com.ddf.vodsystem.security.JwtService;
|
import com.ddf.vodsystem.security.JwtService;
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
import com.ddf.vodsystem.security.TokenService;
|
||||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
import org.slf4j.Logger;
|
||||||
import com.google.api.client.json.JsonFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import com.google.api.client.json.gson.GsonFactory;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
private final GoogleIdTokenVerifier verifier;
|
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
|
||||||
|
|
||||||
|
private final GoogleVerifierService googleVerifierService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
|
||||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
private final TokenService tokenService;
|
||||||
|
|
||||||
public UserService(UserRepository userRepository,
|
public UserService(UserRepository userRepository,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
@Value("${google.client.id}") String googleClientId) {
|
GoogleVerifierService googleVerifierService,
|
||||||
|
TokenService tokenService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
this.googleVerifierService = googleVerifierService;
|
||||||
NetHttpTransport transport = new NetHttpTransport();
|
|
||||||
JsonFactory jsonFactory = new GsonFactory();
|
|
||||||
|
|
||||||
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
|
|
||||||
.setAudience(Collections.singletonList(googleClientId))
|
|
||||||
.build();
|
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
|
this.tokenService = tokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,24 +65,58 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
|
* Verifies a Google ID token, creates or updates the matching user, and starts a new session.
|
||||||
|
* <p>
|
||||||
|
* A new user gets a username equal to their email, the default role and a random stream key.
|
||||||
|
* Email, name and profile picture are refreshed from Google on every login.
|
||||||
*
|
*
|
||||||
* @param idToken the Google ID token to verify
|
* @param idToken the Google ID token to verify
|
||||||
* @return a signed JWT for the authenticated user
|
* @return a new refresh token and a signed JWT access token for the user
|
||||||
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
* @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified
|
||||||
*/
|
*/
|
||||||
public String login(String idToken) {
|
@Transactional
|
||||||
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
public TokenPackage login(String idToken) {
|
||||||
String googleId = googleIdToken.getPayload().getSubject();
|
GoogleUser googleUser = googleVerifierService.verify(idToken);
|
||||||
|
User user = createOrUpdateGoogleUser(googleUser);
|
||||||
|
|
||||||
if (googleId == null) {
|
String refreshToken = tokenService.createSession(user);
|
||||||
throw new NotAuthenticated("Invalid ID token");
|
String jwt = jwtService.generateToken(user.getId());
|
||||||
|
|
||||||
|
logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
|
||||||
|
|
||||||
|
return new TokenPackage(refreshToken, jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a refresh token and issues a new JWT access token for its owner.
|
||||||
|
*
|
||||||
|
* @param refreshToken the raw refresh token as a hex string
|
||||||
|
* @return the new refresh token and a signed JWT access token
|
||||||
|
* @throws NotAuthenticated if the refresh token is rejected; see {@link TokenService#rotate(String)}
|
||||||
|
*/
|
||||||
|
public TokenPackage refresh(String refreshToken) {
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(refreshToken);
|
||||||
|
String jwt = jwtService.generateToken(rotation.userId());
|
||||||
|
|
||||||
|
logger.debug("New refresh and access token issued for user ID {}", rotation.userId());
|
||||||
|
|
||||||
|
return new TokenPackage(rotation.newRefreshToken(), jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the session tied to a refresh token by revoking its token family.
|
||||||
|
* Already-issued JWT access tokens stay valid until they expire.
|
||||||
|
*
|
||||||
|
* Does nothing if the token is missing, malformed or unknown.
|
||||||
|
*
|
||||||
|
* @param refreshToken the raw refresh token as a hex string, may be {@code null}
|
||||||
|
*/
|
||||||
|
public void logout(String refreshToken) {
|
||||||
|
try {
|
||||||
|
tokenService.revoke(refreshToken);
|
||||||
|
} catch (NotAuthenticated e) {
|
||||||
|
logger.info("Malformed or missing token on logout()");
|
||||||
}
|
}
|
||||||
|
|
||||||
User googleUser = getGoogleUser(googleIdToken);
|
|
||||||
User user = createOrUpdateUser(googleUser);
|
|
||||||
|
|
||||||
return jwtService.generateToken(user.getId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,51 +129,30 @@ public class UserService {
|
|||||||
return userRepository.findByStreamKey(streamKey);
|
return userRepository.findByStreamKey(streamKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private User createOrUpdateUser(User user) {
|
private User createOrUpdateGoogleUser(GoogleUser googleUser) {
|
||||||
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
|
Optional<User> existingUser = userRepository.findByGoogleId(googleUser.googleId());
|
||||||
|
User user;
|
||||||
|
|
||||||
if (existingUser.isEmpty()) {
|
if (existingUser.isEmpty()) {
|
||||||
|
user = new User();
|
||||||
|
user.setGoogleId(googleUser.googleId());
|
||||||
|
user.setUsername(googleUser.email());
|
||||||
user.setRole(0);
|
user.setRole(0);
|
||||||
user.setCreatedAt(Instant.now());
|
user.setCreatedAt(Instant.now());
|
||||||
user.setStreamKey(generateStreamKey());
|
user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24)));
|
||||||
return userRepository.saveAndFlush(user);
|
} else {
|
||||||
|
user = existingUser.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
User existing = existingUser.get();
|
user.setEmail(googleUser.email());
|
||||||
existing.setEmail(user.getEmail());
|
user.setName(googleUser.name());
|
||||||
existing.setName(user.getName());
|
user.setProfilePictureUrl(googleUser.profilePictureUrl());
|
||||||
existing.setProfilePictureUrl(user.getProfilePictureUrl());
|
return userRepository.save(user);
|
||||||
existing.setUsername(user.getUsername());
|
|
||||||
return userRepository.saveAndFlush(existing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private User getGoogleUser(GoogleIdToken idToken) {
|
private byte[] generateRandomBytes(int length) {
|
||||||
String googleId = idToken.getPayload().getSubject();
|
byte[] bytes = new byte[length];
|
||||||
String email = idToken.getPayload().getEmail();
|
|
||||||
String name = (String) idToken.getPayload().get("name");
|
|
||||||
String profilePictureUrl = (String) idToken.getPayload().get("picture");
|
|
||||||
|
|
||||||
User user = new User();
|
|
||||||
user.setGoogleId(googleId);
|
|
||||||
user.setEmail(email);
|
|
||||||
user.setName(name);
|
|
||||||
user.setUsername(email);
|
|
||||||
user.setProfilePictureUrl(profilePictureUrl);
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private GoogleIdToken getGoogleIdToken(String idToken) {
|
|
||||||
try {
|
|
||||||
return verifier.verify(idToken);
|
|
||||||
} catch (GeneralSecurityException | IOException e) {
|
|
||||||
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generateStreamKey() {
|
|
||||||
byte[] bytes = new byte[24];
|
|
||||||
SECURE_RANDOM.nextBytes(bytes);
|
SECURE_RANDOM.nextBytes(bytes);
|
||||||
return HexFormat.of().formatHex(bytes);
|
return bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ public class CommandRunner {
|
|||||||
public void setProgress(String line, ProgressTracker progress, float length) {
|
public void setProgress(String line, ProgressTracker progress, float length) {
|
||||||
Matcher matcher = timePattern.matcher(line);
|
Matcher matcher = timePattern.matcher(line);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
|
float timeInSeconds = Float.parseFloat(matcher.group(1)) / 1_000_000f;
|
||||||
progress.setProgress(timeInMs / length);
|
float fraction = Math.clamp(timeInSeconds / length, 0f, 1f);
|
||||||
|
progress.setProgress(fraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import java.util.Comparator;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,13 +43,14 @@ public class StreamActionsService {
|
|||||||
* A resolved section of a stream, ready to be muxed by {@link #saveSection}.
|
* A resolved section of a stream, ready to be muxed by {@link #saveSection}.
|
||||||
*
|
*
|
||||||
* @param segments the segments to concatenate, in playback order; never empty
|
* @param segments the segments to concatenate, in playback order; never empty
|
||||||
* @param trimOffset seconds to trim from the start of {@code segments.getFirst()}
|
* @param trimOffset seconds between the start of {@code segments.getFirst()} and
|
||||||
* so playback begins at the requested moment, or {@code 0}
|
* the requested start, or {@code 0} when the first segment
|
||||||
* when the first segment already starts there
|
* already starts there. Segments are copied whole, so this is
|
||||||
* @param duration seconds of footage to keep, measured from the trim point.
|
* lead-in the section carries before the requested moment, not
|
||||||
* When the requested start fell in a recording gap and the
|
* footage that gets trimmed away
|
||||||
* leading segment was dropped, this is measured from the
|
* @param duration seconds of footage to keep, measured from the start of
|
||||||
* later real start so the section still ends where requested
|
* {@code segments.getFirst()} so the section ends at the
|
||||||
|
* requested end time with the lead-in included
|
||||||
*/
|
*/
|
||||||
public record SegmentSelection(List<Path> segments, float trimOffset, float duration) {}
|
public record SegmentSelection(List<Path> segments, float trimOffset, float duration) {}
|
||||||
|
|
||||||
@@ -78,8 +80,6 @@ public class StreamActionsService {
|
|||||||
long firstMs = parseTimestampMs(segments.getFirst());
|
long firstMs = parseTimestampMs(segments.getFirst());
|
||||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
|
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f);
|
||||||
|
|
||||||
// A first segment shorter than the trim offset means the requested start
|
|
||||||
// lands in a gap after it; drop it and begin at the next recorded footage.
|
|
||||||
if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) {
|
if (trimOffset > 0 && probeDurationSeconds(segments.getFirst()) < trimOffset) {
|
||||||
segments.removeFirst();
|
segments.removeFirst();
|
||||||
if (segments.isEmpty()) {
|
if (segments.isEmpty()) {
|
||||||
@@ -89,18 +89,19 @@ public class StreamActionsService {
|
|||||||
trimOffset = 0f;
|
trimOffset = 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measure duration from where playback actually begins so a dropped
|
|
||||||
// leading segment shortens the section rather than overrunning endTime.
|
|
||||||
long effectiveStartMs = firstMs + (long) (trimOffset * 1000);
|
|
||||||
float duration = (endTime.toEpochMilli() - effectiveStartMs) / 1000f;
|
|
||||||
|
|
||||||
|
float duration = (endTime.toEpochMilli() - firstMs) / 1000f;
|
||||||
return new SegmentSelection(segments, trimOffset, duration);
|
return new SegmentSelection(segments, trimOffset, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Muxes a {@link SegmentSelection} to {@code outputFile}, trimming the leading
|
* Muxes a {@link SegmentSelection} to {@code outputFile}, keeping
|
||||||
* segment to {@code selection.trimOffset()} and keeping {@code selection.duration()}
|
* {@code selection.duration()} seconds of footage.
|
||||||
* seconds of footage.
|
* <p>
|
||||||
|
* Segments are concatenated byte-wise through ffmpeg's {@code concat:} protocol,
|
||||||
|
* which preserves the continuous timestamps nginx writes across fragments. The
|
||||||
|
* leading segment is copied whole rather than trimmed, so playback begins up to
|
||||||
|
* {@code selection.trimOffset()} seconds before the requested moment.
|
||||||
* <p>
|
* <p>
|
||||||
* Runs asynchronously; failures (process-launch errors, ffmpeg failures,
|
* Runs asynchronously; failures (process-launch errors, ffmpeg failures,
|
||||||
* interruption) complete the returned future exceptionally rather than being
|
* interruption) complete the returned future exceptionally rather than being
|
||||||
@@ -131,37 +132,19 @@ public class StreamActionsService {
|
|||||||
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
"trimOffset must be >= 0 and duration must be > 0 (got " + trimOffset + ", " + duration + ")"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Path headFragment = null;
|
|
||||||
Path concatList = null;
|
|
||||||
try {
|
try {
|
||||||
// Only the first segment is trimmed, so re-encode just that fragment and
|
String concatInput = "concat:" + segments.stream()
|
||||||
// stream-copy the rest. A zero offset means the first segment already
|
.map(p -> p.toAbsolutePath().toString())
|
||||||
// starts at the requested point, so re-encoding it would be wasted work.
|
.collect(Collectors.joining("|"));
|
||||||
List<Path> fragments;
|
|
||||||
if (trimOffset > 0) {
|
|
||||||
headFragment = Files.createTempFile("reencode-section-", ".ts");
|
|
||||||
encodeHead(segments.getFirst(), trimOffset, headFragment);
|
|
||||||
|
|
||||||
fragments = new ArrayList<>();
|
|
||||||
fragments.add(headFragment);
|
|
||||||
fragments.addAll(segments.subList(1, segments.size()));
|
|
||||||
} else {
|
|
||||||
fragments = segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The concat demuxer (unlike the concat: byte protocol) rebases each
|
|
||||||
// fragment's timestamps to run continuously across the joins, absorbing
|
|
||||||
// the discontinuity between the PTS-0 head and the copied fragments.
|
|
||||||
concatList = writeConcatList(fragments);
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
List<String> command = List.of(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
"-f", "concat",
|
"-progress", "pipe:1",
|
||||||
"-safe", "0",
|
"-i", concatInput,
|
||||||
"-i", concatList.toAbsolutePath().toString(),
|
|
||||||
"-t", String.valueOf(duration),
|
"-t", String.valueOf(duration),
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
|
"-avoid_negative_ts", "make_zero",
|
||||||
outputFile.toAbsolutePath().toString()
|
outputFile.toAbsolutePath().toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -180,9 +163,6 @@ public class StreamActionsService {
|
|||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
logger.error("Failed to save section to '{}': {}", outputFile, e.toString());
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
} finally {
|
|
||||||
deleteTempQuietly(headFragment);
|
|
||||||
deleteTempQuietly(concatList);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,52 +237,4 @@ public class StreamActionsService {
|
|||||||
throw new IOException("Failed to probe segment duration: " + segment, e);
|
throw new IOException("Failed to probe segment duration: " + segment, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void encodeHead(Path firstSegment, float trimOffset, Path head)
|
|
||||||
throws IOException, InterruptedException {
|
|
||||||
|
|
||||||
List<String> command = List.of(
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i", firstSegment.toAbsolutePath().toString(),
|
|
||||||
"-ss", String.valueOf(trimOffset),
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-preset", "veryfast",
|
|
||||||
"-crf", "20",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", "160k",
|
|
||||||
"-muxdelay", "0",
|
|
||||||
"-muxpreload", "0",
|
|
||||||
"-f", "mpegts",
|
|
||||||
head.toAbsolutePath().toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug("Re-encoding head fragment '{}' from {}s", firstSegment, trimOffset);
|
|
||||||
commandRunner.run(command, line -> { /* head pass is short; no progress reporting */ });
|
|
||||||
|
|
||||||
if (!Files.exists(head) || Files.size(head) == 0) {
|
|
||||||
throw new IOException("Head re-encode produced no output — trimOffset " + trimOffset
|
|
||||||
+ "s may exceed the duration of " + firstSegment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path writeConcatList(List<Path> fragments) throws IOException {
|
|
||||||
Path listFile = Files.createTempFile("concat-list-", ".txt");
|
|
||||||
List<String> lines = fragments.stream()
|
|
||||||
.map(p -> "file '" + p.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
|
||||||
.toList();
|
|
||||||
Files.write(listFile, lines);
|
|
||||||
return listFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteTempQuietly(Path tempFile) {
|
|
||||||
if (tempFile != null) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(tempFile);
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to delete temp file '{}': {}", tempFile, e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ spring.docker.compose.file=docker-compose.dev.yml
|
|||||||
media.temp.inputs=./videos/inputs
|
media.temp.inputs=./videos/inputs
|
||||||
media.temp.outputs=./videos/outputs
|
media.temp.outputs=./videos/outputs
|
||||||
media.users=${MEDIA_DATA}
|
media.users=${MEDIA_DATA}
|
||||||
media.streams=${STREAM_DATA}
|
media.streams=${STREAM_DATA}
|
||||||
|
|
||||||
|
logging.level.com.ddf.vodsystem=DEBUG
|
||||||
@@ -10,17 +10,22 @@ spring.datasource.url=${POSTGRES_URL}
|
|||||||
spring.datasource.username=${POSTGRES_USER}
|
spring.datasource.username=${POSTGRES_USER}
|
||||||
spring.datasource.password=${POSTGRES_PASSWORD}
|
spring.datasource.password=${POSTGRES_PASSWORD}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
spring.jpa.show-sql=false
|
spring.jpa.show-sql=false
|
||||||
spring.sql.init.mode=always
|
|
||||||
spring.sql.init.schema-locations=classpath:db/schema.sql
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migrations
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
|
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
|
||||||
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
|
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
|
||||||
spring.security.oauth2.client.registration.google.scope=profile,email
|
spring.security.oauth2.client.registration.google.scope=profile,email
|
||||||
jwt.secret.key=${JWT_SECRET_KEY}
|
app.security.jwt-secret=${JWT_SECRET_KEY}
|
||||||
jwt.expiration=3600000
|
|
||||||
|
app.security.expiration.jwt=5m
|
||||||
|
app.security.expiration.refresh-token=7d
|
||||||
|
app.security.expiration.token-family=90d
|
||||||
|
app.security.expiration.grace-period=5s
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
frontend.url=${FRONTEND_URL}
|
frontend.url=${FRONTEND_URL}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
-- DROP TABLE IF EXISTS streams;
|
-- Users Table
|
||||||
-- DROP TABLE IF EXISTS clips;
|
|
||||||
-- DROP TABLE IF EXISTS users;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
google_id VARCHAR(64),
|
google_id VARCHAR(64),
|
||||||
@@ -14,6 +11,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
stream_key VARCHAR(64) NOT NULL UNIQUE
|
stream_key VARCHAR(64) NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- Streams Table
|
||||||
CREATE TABLE IF NOT EXISTS streams (
|
CREATE TABLE IF NOT EXISTS streams (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT,
|
user_id BIGINT,
|
||||||
@@ -24,6 +23,7 @@ CREATE TABLE IF NOT EXISTS streams (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Clips Table
|
||||||
CREATE TABLE IF NOT EXISTS clips (
|
CREATE TABLE IF NOT EXISTS clips (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS clips (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- VoDs Table
|
||||||
CREATE TABLE IF NOT EXISTS vods (
|
CREATE TABLE IF NOT EXISTS vods (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
@@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS vods (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Markers Table
|
||||||
CREATE TABLE IF NOT EXISTS markers (
|
CREATE TABLE IF NOT EXISTS markers (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
27
src/main/resources/db/migrations/V2__refresh_tokens.sql
Normal file
27
src/main/resources/db/migrations/V2__refresh_tokens.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS token_families
|
||||||
|
(
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_families_user_id
|
||||||
|
ON token_families (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens
|
||||||
|
(
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
family_id BIGINT NOT NULL REFERENCES token_families (id) ON DELETE CASCADE,
|
||||||
|
token_hash BYTEA NOT NULL UNIQUE, -- unique automatically creates a B-tree index
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id
|
||||||
|
ON refresh_tokens (family_id);
|
||||||
628
src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java
Normal file
628
src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
|
import com.ddf.vodsystem.entities.RefreshToken;
|
||||||
|
import com.ddf.vodsystem.entities.TokenFamily;
|
||||||
|
import com.ddf.vodsystem.entities.User;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
|
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
|
||||||
|
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Captor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class MutableClock extends Clock {
|
||||||
|
private Instant now;
|
||||||
|
|
||||||
|
MutableClock(Instant start) {
|
||||||
|
this.now = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
void advance(Duration d) {
|
||||||
|
now = now.plus(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Instant instant() { return now; }
|
||||||
|
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||||
|
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TokenServiceTest {
|
||||||
|
@Mock private TokenFamilyRepository tokenFamilyRepository;
|
||||||
|
@Mock private RefreshTokenRepository refreshTokenRepository;
|
||||||
|
|
||||||
|
@Captor ArgumentCaptor<TokenFamily> familyCaptor;
|
||||||
|
@Captor ArgumentCaptor<RefreshToken> tokenCaptor;
|
||||||
|
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef";
|
||||||
|
|
||||||
|
AuthProperties authProperties;
|
||||||
|
TokenService tokenService;
|
||||||
|
MutableClock clock;
|
||||||
|
|
||||||
|
User createUser(Long id, String username, String email) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setEmail(email);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
|
||||||
|
authProperties = new AuthProperties(
|
||||||
|
"a-secret",
|
||||||
|
new AuthProperties.Expiration(
|
||||||
|
Duration.ofSeconds(10),
|
||||||
|
Duration.ofSeconds(10),
|
||||||
|
Duration.ofSeconds(10),
|
||||||
|
Duration.ofSeconds(5)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
tokenService = new TokenService(
|
||||||
|
tokenFamilyRepository,
|
||||||
|
refreshTokenRepository,
|
||||||
|
authProperties,
|
||||||
|
clock
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("createSession()")
|
||||||
|
class createSession {
|
||||||
|
@Test
|
||||||
|
void createSession_createsTokenFamily() {
|
||||||
|
User user = createUser(1L, "user", "user@gmail.com");
|
||||||
|
|
||||||
|
tokenService.createSession(user);
|
||||||
|
|
||||||
|
verify(tokenFamilyRepository).save(familyCaptor.capture());
|
||||||
|
verify(refreshTokenRepository).save(tokenCaptor.capture());
|
||||||
|
|
||||||
|
TokenFamily tokenFamily = familyCaptor.getValue();
|
||||||
|
RefreshToken refreshToken = tokenCaptor.getValue();
|
||||||
|
|
||||||
|
assertSame(tokenFamily.getUser(), user);
|
||||||
|
assertSame(refreshToken.getTokenFamily(), tokenFamily);
|
||||||
|
assertEquals(
|
||||||
|
tokenFamily.getExpiresAt(),
|
||||||
|
clock.instant().plusMillis(authProperties.expiration().tokenFamily().toMillis())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_savesRefreshTokenCreatedAtAndExpires() {
|
||||||
|
User user = createUser(1L, "user", "user");
|
||||||
|
|
||||||
|
tokenService.createSession(user);
|
||||||
|
|
||||||
|
verify(refreshTokenRepository).save(tokenCaptor.capture());
|
||||||
|
RefreshToken refreshToken = tokenCaptor.getValue();
|
||||||
|
|
||||||
|
assertEquals(refreshToken.getCreatedAt(), clock.instant());
|
||||||
|
assertEquals(
|
||||||
|
refreshToken.getExpiresAt(),
|
||||||
|
clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_returnsHexTokenOf64Characters() {
|
||||||
|
User user = createUser(1L, "user", "user");
|
||||||
|
|
||||||
|
String token = tokenService.createSession(user);
|
||||||
|
|
||||||
|
assertEquals(64, token.length());
|
||||||
|
assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_doesNotStoreRawToken() {
|
||||||
|
User user = createUser(1L, "user", "user");
|
||||||
|
|
||||||
|
String token = tokenService.createSession(user);
|
||||||
|
|
||||||
|
verify(refreshTokenRepository).save(tokenCaptor.capture());
|
||||||
|
RefreshToken refreshToken = tokenCaptor.getValue();
|
||||||
|
|
||||||
|
assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate
|
||||||
|
@Nested
|
||||||
|
@DisplayName("rotate()")
|
||||||
|
class rotate {
|
||||||
|
// Input val
|
||||||
|
@Test
|
||||||
|
void nullToken_throwsNotAuthenticated() {
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> tokenService.rotate(null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankToken_throwsNotAuthenticated() {
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> tokenService.rotate("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid cases
|
||||||
|
@Test
|
||||||
|
void unknownToken_throwsNotAuthenticated() {
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(
|
||||||
|
Optional.empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> tokenService.rotate(rawToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokedFamily_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
tokenFamily.setRevokedAt(Instant.now(clock));
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
|
||||||
|
clock.advance(Duration.ofMinutes(1));
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(
|
||||||
|
Optional.of(refreshToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> tokenService.rotate(rawToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Happy Path
|
||||||
|
@Test
|
||||||
|
void unusedToken_returnsUserIdAndRefreshToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
|
.thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
assertEquals(1L, rotation.userId());
|
||||||
|
assertNotEquals(rawToken, rotation.newRefreshToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unsedToken_savesRefreshToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
|
.thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
|
||||||
|
|
||||||
|
RefreshToken newToken = tokenCaptor.getAllValues().stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
|
||||||
|
.orElseThrow(() -> new AssertionError("no unused token was saved"));
|
||||||
|
|
||||||
|
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
|
||||||
|
assertEquals(refreshToken.getTokenFamily().getUser(), newToken.getTokenFamily().getUser()); // same owner
|
||||||
|
assertEquals(refreshToken.getTokenFamily(), newToken.getTokenFamily()); // same rotation family, if you model one
|
||||||
|
assertNull(newToken.getUsedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ununsedToken_savesOldTokenWithUsedAt() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
|
.thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
|
||||||
|
|
||||||
|
RefreshToken savedToken = tokenCaptor.getAllValues().stream()
|
||||||
|
.filter(t -> t.getUsedAt() != null)
|
||||||
|
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
|
||||||
|
.orElseThrow(() -> new AssertionError("used_at field not set in any token"));
|
||||||
|
|
||||||
|
assertEquals(savedToken.getUsedAt(), clock.instant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unusedToken_doesNotRevokeFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
|
.thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
|
||||||
|
RefreshToken newToken = tokenCaptor.getAllValues().stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
|
||||||
|
.orElseThrow(() -> new AssertionError("no unused token was saved"));
|
||||||
|
assertNull(newToken.getTokenFamily().getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unusedToken_newTokenIsDifferentFromOldToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setTokenHash(rawToken.getBytes());
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
|
.thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
assertNotEquals(rawToken, rotation.newRefreshToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace period checks
|
||||||
|
@Test
|
||||||
|
void usedWithinGrace_returnsNewToken() {
|
||||||
|
Instant usedAt = Instant.now(clock);
|
||||||
|
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setTokenHash(rawToken.getBytes());
|
||||||
|
refreshToken.setUsedAt(usedAt);
|
||||||
|
|
||||||
|
when(refreshTokenRepository.findByHash(any()))
|
||||||
|
.thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
// within grace: 3s since usedAt, grace is 5s, and nothing has expired yet
|
||||||
|
clock.advance(Duration.ofSeconds(3));
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
assertEquals(1L, rotation.userId());
|
||||||
|
assertNotEquals(rawToken, rotation.newRefreshToken());
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
|
||||||
|
// grace is not extended by a replay, and only the new token is written
|
||||||
|
assertEquals(usedAt, refreshToken.getUsedAt());
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, times(1)).save(tokenCaptor.capture());
|
||||||
|
RefreshToken newToken = tokenCaptor.getValue();
|
||||||
|
assertNull(newToken.getUsedAt());
|
||||||
|
assertSame(tokenFamily, newToken.getTokenFamily());
|
||||||
|
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAtExactGraceBoundary_returnsNewToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(authProperties.expiration().gracePeriod());
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
assertEquals(1L, rotation.userId());
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse after grace period
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_revokesFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(tokenFamilyRepository).save(familyCaptor.capture());
|
||||||
|
assertSame(tokenFamily, familyCaptor.getValue());
|
||||||
|
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_doesNotIssueNewToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usedAfterGrace_subsequentRotateRejectedWithoutResaving() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
verify(tokenFamilyRepository, times(1)).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry
|
||||||
|
@Test
|
||||||
|
void expiredToken_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setExpiresAt(clock.instant().minusSeconds(1));
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredFamily_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
tokenFamily.setExpiresAt(clock.instant().minusSeconds(1));
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredToken_doesNotSaveOrRevoke() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(11));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
assertNull(refreshToken.getUsedAt());
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenAtExactExpiry_returnsNewToken() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(10));
|
||||||
|
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||||
|
|
||||||
|
assertEquals(1L, rotation.userId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordering
|
||||||
|
@Test
|
||||||
|
void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
Instant revokedAt = clock.instant();
|
||||||
|
tokenFamily.setRevokedAt(revokedAt);
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(6));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertEquals(revokedAt, tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokedAndExpiredFamily_throwsNotAuthenticated() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
tokenFamily.setRevokedAt(clock.instant());
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(11));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredAndUsedAfterGrace_revokesFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(11));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository).save(tokenFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredWithinGrace_doesNotRevokeFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setExpiresAt(clock.instant().plusSeconds(2));
|
||||||
|
refreshToken.setUsedAt(clock.instant());
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofSeconds(3));
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
assertNull(tokenFamily.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownToken_doesNotTouchRepositoriesFurther() {
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
verifyNoInteractions(tokenFamilyRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("revoke()")
|
||||||
|
class revoke {
|
||||||
|
@Test
|
||||||
|
void knownToken_revokesTokenAndFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
tokenService.revoke(rawToken);
|
||||||
|
|
||||||
|
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||||
|
assertEquals(clock.instant(), refreshToken.getRevokedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knownToken_savesTokenAndFamily() {
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
tokenService.revoke(rawToken);
|
||||||
|
|
||||||
|
verify(tokenFamilyRepository).save(tokenFamily);
|
||||||
|
verify(refreshTokenRepository).save(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knownToken_looksUpBySingleSha256Hash() throws Exception {
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
tokenService.revoke(rawToken);
|
||||||
|
|
||||||
|
byte[] expectedHash = MessageDigest.getInstance("SHA-256").digest(HEX.parseHex(rawToken));
|
||||||
|
verify(refreshTokenRepository).findByHash(expectedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alreadyRevoked_keepsOriginalTimestampsAndDoesNotSave() {
|
||||||
|
Instant revokedAt = clock.instant();
|
||||||
|
TokenFamily tokenFamily = activeFamily();
|
||||||
|
tokenFamily.setRevokedAt(revokedAt);
|
||||||
|
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||||
|
refreshToken.setRevokedAt(revokedAt);
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||||
|
|
||||||
|
clock.advance(Duration.ofMinutes(1));
|
||||||
|
tokenService.revoke(rawToken);
|
||||||
|
|
||||||
|
assertEquals(revokedAt, tokenFamily.getRevokedAt());
|
||||||
|
assertEquals(revokedAt, refreshToken.getRevokedAt());
|
||||||
|
verify(tokenFamilyRepository, never()).save(any());
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownToken_doesNothing() {
|
||||||
|
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
tokenService.revoke(rawToken);
|
||||||
|
|
||||||
|
verify(refreshTokenRepository, never()).save(any());
|
||||||
|
verifyNoInteractions(tokenFamilyRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullToken_doesNothing() {
|
||||||
|
tokenService.revoke(null);
|
||||||
|
|
||||||
|
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankToken_doesNothing() {
|
||||||
|
tokenService.revoke("");
|
||||||
|
|
||||||
|
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedToken_throwsNotAuthenticated() {
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> tokenService.revoke("not-hex")
|
||||||
|
);
|
||||||
|
|
||||||
|
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenFamily activeFamily() {
|
||||||
|
TokenFamily tokenFamily = new TokenFamily();
|
||||||
|
tokenFamily.setId(1L);
|
||||||
|
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||||
|
tokenFamily.setExpiresAt(clock.instant().plusSeconds(10));
|
||||||
|
return tokenFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RefreshToken tokenIn(TokenFamily tokenFamily) {
|
||||||
|
RefreshToken refreshToken = new RefreshToken();
|
||||||
|
refreshToken.setTokenFamily(tokenFamily);
|
||||||
|
refreshToken.setExpiresAt(clock.instant().plusSeconds(10));
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/test/java/com/ddf/vodsystem/services/UserServiceTest.java
Normal file
295
src/test/java/com/ddf/vodsystem/services/UserServiceTest.java
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package com.ddf.vodsystem.services;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.GoogleUser;
|
||||||
|
import com.ddf.vodsystem.dto.TokenPackage;
|
||||||
|
import com.ddf.vodsystem.entities.User;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
|
import com.ddf.vodsystem.repositories.UserRepository;
|
||||||
|
import com.ddf.vodsystem.security.GoogleVerifierService;
|
||||||
|
import com.ddf.vodsystem.security.JwtService;
|
||||||
|
import com.ddf.vodsystem.security.TokenService;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Captor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServiceTest {
|
||||||
|
@Mock private GoogleVerifierService googleVerifierService;
|
||||||
|
@Mock private JwtService jwtService;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
@Mock private TokenService tokenService;
|
||||||
|
|
||||||
|
@Captor ArgumentCaptor<User> userCaptor;
|
||||||
|
|
||||||
|
private static final String ID_TOKEN = "google-id-token";
|
||||||
|
private static final String REFRESH_TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef";
|
||||||
|
private static final String NEW_REFRESH_TOKEN = "cafebabecafebabecafebabecafebabe";
|
||||||
|
private static final String JWT = "header.payload.signature";
|
||||||
|
private static final String GOOGLE_ID = "google-id";
|
||||||
|
|
||||||
|
UserService userService;
|
||||||
|
|
||||||
|
User createUser(Long id, String username, String email) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setEmail(email);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
GoogleUser googleUser() {
|
||||||
|
return new GoogleUser(
|
||||||
|
GOOGLE_ID,
|
||||||
|
"user@example.com",
|
||||||
|
"My User",
|
||||||
|
"https://lol.com/1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userService = new UserService(
|
||||||
|
userRepository,
|
||||||
|
jwtService,
|
||||||
|
googleVerifierService,
|
||||||
|
tokenService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurityContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("getUserById()")
|
||||||
|
class getUserById {
|
||||||
|
@Test
|
||||||
|
void existingId_returnsUser() {
|
||||||
|
User user = createUser(1L, "user", "user@example.com");
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
Optional<User> result = userService.getUserById(1L);
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertSame(user, result.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownId_returnsEmpty() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertTrue(userService.getUserById(1L).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("getLoggedInUser()")
|
||||||
|
class getLoggedInUser {
|
||||||
|
@Test
|
||||||
|
void authenticatedUserPrincipal_returnsUser() {
|
||||||
|
User user = createUser(1L, "user", "user@example.com");
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(user, null, List.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
Optional<User> result = userService.getLoggedInUser();
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertSame(user, result.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noAuthentication_returnsEmpty() {
|
||||||
|
assertTrue(userService.getLoggedInUser().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticated_returnsEmpty() {
|
||||||
|
User user = createUser(1L, "user", "user@example.com");
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(user, null)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(userService.getLoggedInUser().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonUserPrincipal_returnsEmpty() {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken("anonymous", null, List.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(userService.getLoggedInUser().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("login()")
|
||||||
|
class login {
|
||||||
|
@Test
|
||||||
|
void newUser_savesUserToRepo() {
|
||||||
|
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
|
||||||
|
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
userService.login(ID_TOKEN);
|
||||||
|
|
||||||
|
verify(userRepository).save(userCaptor.capture());
|
||||||
|
User saved = userCaptor.getValue();
|
||||||
|
assertEquals(GOOGLE_ID, saved.getGoogleId());
|
||||||
|
assertEquals("user@example.com", saved.getEmail());
|
||||||
|
assertEquals("user@example.com", saved.getUsername());
|
||||||
|
assertEquals("My User", saved.getName());
|
||||||
|
assertEquals("https://lol.com/1", saved.getProfilePictureUrl());
|
||||||
|
assertEquals(0, saved.getRole());
|
||||||
|
assertNotNull(saved.getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newUser_generatesHexStreamKeyOf48Characters() {
|
||||||
|
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
|
||||||
|
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
userService.login(ID_TOKEN);
|
||||||
|
|
||||||
|
verify(userRepository).save(userCaptor.capture());
|
||||||
|
String streamKey = userCaptor.getValue().getStreamKey();
|
||||||
|
assertEquals(48, streamKey.length());
|
||||||
|
assertTrue(streamKey.matches("[0-9a-f]+"), () -> "not hex: " + streamKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingUser_updatesUserInfo() {
|
||||||
|
User existingUser = createUser(1L, "old-username", "old@example.com");
|
||||||
|
existingUser.setGoogleId(GOOGLE_ID);
|
||||||
|
existingUser.setName("Old Name");
|
||||||
|
existingUser.setProfilePictureUrl("https://lol.com/old");
|
||||||
|
existingUser.setStreamKey("123");
|
||||||
|
|
||||||
|
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
|
||||||
|
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.of(existingUser));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
userService.login(ID_TOKEN);
|
||||||
|
|
||||||
|
verify(userRepository).save(userCaptor.capture());
|
||||||
|
User saved = userCaptor.getValue();
|
||||||
|
assertSame(existingUser, saved);
|
||||||
|
assertEquals(1L, saved.getId());
|
||||||
|
assertEquals(GOOGLE_ID, saved.getGoogleId());
|
||||||
|
assertEquals("user@example.com", saved.getEmail());
|
||||||
|
assertEquals("My User", saved.getName());
|
||||||
|
assertEquals("https://lol.com/1", saved.getProfilePictureUrl());
|
||||||
|
assertEquals("old-username", saved.getUsername());
|
||||||
|
assertEquals("123", saved.getStreamKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validToken_returnsRefreshTokenAndJwt() {
|
||||||
|
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
|
||||||
|
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tokenService.createSession(any())).thenReturn(REFRESH_TOKEN);
|
||||||
|
when(jwtService.generateToken(any())).thenReturn(JWT);
|
||||||
|
|
||||||
|
TokenPackage tokenPackage = userService.login(ID_TOKEN);
|
||||||
|
|
||||||
|
assertEquals(REFRESH_TOKEN, tokenPackage.refreshToken());
|
||||||
|
assertEquals(JWT, tokenPackage.accessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validToken_createsSessionForSavedUser() {
|
||||||
|
User savedUser = createUser(42L, "user@example.com", "user@example.com");
|
||||||
|
|
||||||
|
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
|
||||||
|
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
|
||||||
|
when(userRepository.save(any())).thenReturn(savedUser);
|
||||||
|
|
||||||
|
userService.login(ID_TOKEN);
|
||||||
|
|
||||||
|
verify(tokenService).createSession(savedUser);
|
||||||
|
verify(jwtService).generateToken(42L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidToken_throwsNotAuthenticated() {
|
||||||
|
when(googleVerifierService.verify(ID_TOKEN))
|
||||||
|
.thenThrow(new NotAuthenticated("Invalid ID token"));
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> userService.login(ID_TOKEN)
|
||||||
|
);
|
||||||
|
|
||||||
|
verifyNoInteractions(userRepository, tokenService, jwtService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("refresh()")
|
||||||
|
class refresh {
|
||||||
|
@Test
|
||||||
|
void validToken_returnsRotatedTokenAndJwt() {
|
||||||
|
when(tokenService.rotate(REFRESH_TOKEN))
|
||||||
|
.thenReturn(new TokenService.Rotation(1L, NEW_REFRESH_TOKEN));
|
||||||
|
when(jwtService.generateToken(1L)).thenReturn(JWT);
|
||||||
|
|
||||||
|
TokenPackage tokenPackage = userService.refresh(REFRESH_TOKEN);
|
||||||
|
|
||||||
|
assertEquals(NEW_REFRESH_TOKEN, tokenPackage.refreshToken());
|
||||||
|
assertEquals(JWT, tokenPackage.accessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidToken_throwsNotAuthenticated() {
|
||||||
|
when(tokenService.rotate(REFRESH_TOKEN))
|
||||||
|
.thenThrow(new NotAuthenticated("Invalid refresh token"));
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
NotAuthenticated.class,
|
||||||
|
() -> userService.refresh(REFRESH_TOKEN)
|
||||||
|
);
|
||||||
|
|
||||||
|
verifyNoInteractions(jwtService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("logout()")
|
||||||
|
class logout {
|
||||||
|
@Test
|
||||||
|
void revokesRefreshToken() {
|
||||||
|
userService.logout(REFRESH_TOKEN);
|
||||||
|
|
||||||
|
verify(tokenService).revoke(REFRESH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotIssueTokens() {
|
||||||
|
userService.logout(REFRESH_TOKEN);
|
||||||
|
|
||||||
|
verify(tokenService, never()).createSession(any());
|
||||||
|
verify(tokenService, never()).rotate(any());
|
||||||
|
verifyNoInteractions(jwtService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,23 +57,6 @@ class StreamActionsServiceTest {
|
|||||||
when(metadataService.getVideoMetadata(any())).thenReturn(CompletableFuture.completedFuture(meta));
|
when(metadataService.getVideoMetadata(any())).thenReturn(CompletableFuture.completedFuture(meta));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs the two-arg {@link CommandRunner#run} so the head re-encode pass
|
|
||||||
* actually writes a non-empty file at its output path — {@code encodeHead}
|
|
||||||
* verifies its output exists and is non-empty before continuing. The concat
|
|
||||||
* pass (no {@code mpegts} muxer) is left untouched. Returns {@code output}
|
|
||||||
* for both invocations.
|
|
||||||
*/
|
|
||||||
private void stubRunnerProducingHead(CommandOutput output) throws IOException, InterruptedException {
|
|
||||||
when(commandRunner.run(anyList(), any())).thenAnswer(inv -> {
|
|
||||||
List<String> cmd = inv.getArgument(0);
|
|
||||||
if (cmd.contains("mpegts")) {
|
|
||||||
Files.writeString(Path.of(cmd.getLast()), "x");
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// selectSegments: missing / invalid directories
|
// selectSegments: missing / invalid directories
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -247,8 +230,10 @@ class StreamActionsServiceTest {
|
|||||||
assertThat(selection.segments()).extracting(p -> p.getFileName().toString())
|
assertThat(selection.segments()).extracting(p -> p.getFileName().toString())
|
||||||
.containsExactly("30.ts", "33.ts");
|
.containsExactly("30.ts", "33.ts");
|
||||||
assertThat(selection.trimOffset()).isEqualTo(1.5f);
|
assertThat(selection.trimOffset()).isEqualTo(1.5f);
|
||||||
// effective start equals the requested 31.5s, so duration runs to endTime: 40 - 31.5
|
// 30.ts is copied whole, so the section starts at 30s rather than the
|
||||||
assertThat(selection.duration()).isEqualTo(8.5f);
|
// requested 31.5s; duration runs from there to endTime: 40 - 30. The
|
||||||
|
// 1.5s trimOffset is carried as lead-in, not cut away.
|
||||||
|
assertThat(selection.duration()).isEqualTo(10f);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -355,7 +340,7 @@ class StreamActionsServiceTest {
|
|||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
CommandOutput commandOutput = new CommandOutput();
|
||||||
|
|
||||||
stubRunnerProducingHead(commandOutput);
|
when(commandRunner.run(anyList(), any())).thenReturn(commandOutput);
|
||||||
|
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(
|
||||||
new StreamActionsService.SegmentSelection(List.of(segmentA, segmentB), 5.0f, 12.0f), output, progress);
|
new StreamActionsService.SegmentSelection(List.of(segmentA, segmentB), 5.0f, 12.0f), output, progress);
|
||||||
@@ -363,24 +348,29 @@ class StreamActionsServiceTest {
|
|||||||
assertThat(future.get()).isSameAs(commandOutput);
|
assertThat(future.get()).isSameAs(commandOutput);
|
||||||
assertThat(progress.isComplete()).isTrue();
|
assertThat(progress.isComplete()).isTrue();
|
||||||
|
|
||||||
// With a non-zero trim offset saveSection issues two ffmpeg passes: the
|
// The section is one stream-copy pass over the byte-concatenated segments:
|
||||||
// head re-encode (trims the first segment via -ss) followed by the
|
// the concat: protocol joins them without rebasing the timestamps nginx
|
||||||
// concat-demuxer stream copy (-f concat / -t / -c copy).
|
// wrote across fragments, which is what keeps the joins seamless.
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
Mockito.verify(commandRunner, Mockito.times(2)).run(captor.capture(), any());
|
Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any());
|
||||||
List<String> headCommand = captor.getAllValues().get(0);
|
List<String> command = captor.getValue();
|
||||||
List<String> concatCommand = captor.getAllValues().get(1);
|
|
||||||
|
|
||||||
assertThat(headCommand).containsSequence("-ss", "5.0");
|
assertThat(command).containsSequence(
|
||||||
assertThat(concatCommand).containsSequence("-f", "concat");
|
"-i", "concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath());
|
||||||
assertThat(concatCommand).containsSequence("-t", "12.0");
|
assertThat(command).containsSequence("-t", "12.0");
|
||||||
assertThat(concatCommand).containsSequence("-c", "copy");
|
assertThat(command).containsSequence("-c", "copy");
|
||||||
assertThat(concatCommand).endsWith(output.toAbsolutePath().toString());
|
assertThat(command).containsSequence("-avoid_negative_ts", "make_zero");
|
||||||
|
assertThat(command).endsWith(output.toAbsolutePath().toString());
|
||||||
|
|
||||||
|
// The leading segment is copied whole, so nothing seeks and the concat
|
||||||
|
// demuxer (-f concat) is deliberately not used.
|
||||||
|
assertThat(command).doesNotContain("-ss");
|
||||||
|
assertThat(command).doesNotContainSequence("-f", "concat");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveSection_zeroTrimOffset_skipsHeadReencodeAndRunsSingleConcatPass() throws Exception {
|
void saveSection_zeroTrimOffset_buildsTheSameSingleCopyPass() throws Exception {
|
||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
CommandOutput commandOutput = new CommandOutput();
|
CommandOutput commandOutput = new CommandOutput();
|
||||||
@@ -394,14 +384,14 @@ class StreamActionsServiceTest {
|
|||||||
assertThat(future.get()).isSameAs(commandOutput);
|
assertThat(future.get()).isSameAs(commandOutput);
|
||||||
assertThat(progress.isComplete()).isTrue();
|
assertThat(progress.isComplete()).isTrue();
|
||||||
|
|
||||||
// trimOffset 0 means the first segment already starts at the requested point:
|
// trimOffset no longer changes the command shape: with or without lead-in
|
||||||
// no head re-encode pass, just a single concat-demuxer copy (no -ss).
|
// the section is the same single copy pass over the joined segments.
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any());
|
Mockito.verify(commandRunner, Mockito.times(1)).run(captor.capture(), any());
|
||||||
List<String> command = captor.getValue();
|
List<String> command = captor.getValue();
|
||||||
|
|
||||||
assertThat(command).containsSequence("-f", "concat");
|
assertThat(command).containsSequence("-i", "concat:/tmp/a.ts|/tmp/b.ts");
|
||||||
assertThat(command).containsSequence("-t", "10.0");
|
assertThat(command).containsSequence("-t", "10.0");
|
||||||
assertThat(command).doesNotContain("-ss");
|
assertThat(command).doesNotContain("-ss");
|
||||||
}
|
}
|
||||||
@@ -411,8 +401,7 @@ class StreamActionsServiceTest {
|
|||||||
Path output = Path.of("/tmp/out/clip.mp4");
|
Path output = Path.of("/tmp/out/clip.mp4");
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
// With a non-zero trim offset the head re-encode is the first ffmpeg pass;
|
// The copy pass is the only ffmpeg invocation, so its failure must surface.
|
||||||
// its failure must surface.
|
|
||||||
when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom"));
|
when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom"));
|
||||||
|
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(
|
||||||
@@ -442,8 +431,8 @@ class StreamActionsServiceTest {
|
|||||||
void saveSection_nullOutputFile_completesExceptionally() {
|
void saveSection_nullOutputFile_completesExceptionally() {
|
||||||
ProgressTracker progress = new ProgressTracker();
|
ProgressTracker progress = new ProgressTracker();
|
||||||
|
|
||||||
// trimOffset 0 skips the head pass; building the concat command dereferences
|
// Building the command dereferences the null output path, so no ffmpeg
|
||||||
// the null output path, so no ffmpeg pass ever runs.
|
// pass ever runs.
|
||||||
var future = service.saveSection(
|
var future = service.saveSection(
|
||||||
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 0f, 10f), null, progress);
|
new StreamActionsService.SegmentSelection(List.of(Path.of("/tmp/a.ts")), 0f, 10f), null, progress);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user