8 Commits

Author SHA1 Message Date
79a779ad7c fix(security): tampered token throws 500 error
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 1m24s
CI / Deploy to server (push) Successful in 7s
2026-10-17 22:46:04 +01:00
1923eab14e fix(clipping): rejects start point of zero
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 1m28s
CI / Deploy to server (push) Successful in 7s
2026-10-17 22:42:06 +01:00
cba1f9927e feat(frontend): segment clips by time range (#57)
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 41s
CI / Deploy to server (push) Successful in 7s
Resolves #41

Reviewed-on: #57
2026-10-17 22:21:59 +01:00
29a7573f5c fix(frontend): clips & vod pages briefly showing "No Clips" on load (#56)
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Successful in 42s
CI / Deploy to server (push) Successful in 7s
Resolves #42

Reviewed-on: #56
2026-10-17 22:16:19 +01:00
2818d33dd5 feat(frontend): mute dark mode colours to reduce cold feeling
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Successful in 1m48s
CI / Deploy to server (push) Successful in 16s
2026-10-17 22:05:48 +01:00
34b150b77e feat(frontend): refresh token handling (#54)
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Successful in 2m12s
CI / Deploy to server (push) Successful in 26s
Reviewed-on: #54
2026-10-16 20:57:02 +01:00
34c4072c5b test(security): user & token services tests (#53)
Some checks failed
CI / Maven unit tests (push) Successful in 15s
CI / Deploy to server (push) Has been cancelled
CI / Build and push images (push) Has been cancelled
Reviewed-on: #53
2026-10-16 20:56:22 +01:00
222b0b60f7 feat(security): add refresh token capability to API (#52)
Some checks failed
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been cancelled
CI / Deploy to server (push) Has been cancelled
## Add backend refresh tokens

Adds rotating server-side refresh tokens so the access token can be short-lived (5m) without forcing frequent re-login.

- New `POST /api/v1/users/refresh` exchanges the `refreshToken` cookie for a new refresh token and JWT
- Each login starts a token family; reusing a used token after the 5s grace period revokes the whole family
- Logout revokes the family; an hourly job deletes dead families
- Google ID token verification extracted into `GoogleVerifierService`; config moved to a validated `AuthProperties`
- Unauthenticated requests now return 401 instead of 403
- Migration `V2__refresh_tokens.sql` adds `token_families` and `refresh_tokens`

**Breaking changes**
- Cookie `token` renamed to `accessToken` (existing sessions are logged out)
- `/login` and `/refresh` no longer return the JWT in the body, only `{ accessTokenExpiresIn }`
- `jwt.secret.key` / `jwt.expiration` replaced by `app.security.*`; `JWT_SECRET_KEY` must be 32+ chars
- Frontend must call `/refresh` on 401 or before the access token expires

Reviewed-on: #52
2026-10-16 20:56:02 +01:00
7 changed files with 144 additions and 51 deletions

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { groupByTimeRange } from "../../utils/utils";
type props<T> = {
items: T[];
getDate: (item: T) => string;
renderItem: (item: T) => ReactNode;
}
const TimeGroupedGrid = <T,>({ items, getDate, renderItem }: props<T>) => {
return (
<div className="flex flex-col gap-8">
{groupByTimeRange(items, getDate).map((group) => (
<section key={group.label}>
<h2 className="text-xl font-heading text-text-primary mb-3">{group.label}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map(renderItem)}
</div>
</section>
))}
</div>
);
}
export default TimeGroupedGrid;

View File

@@ -56,50 +56,61 @@
/* Dark mode. Only token values change here: every component already styles /* Dark mode. Only token values change here: every component already styles
itself with the semantic utilities above, so the whole UI follows from this 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 one block. `--color-on-dark` is deliberately absent — it is the ink on the
media backdrop, which stays dark in both themes. */ media backdrop, which stays dark in both themes.
Soft charcoal rather than pure black, with only a trace of warmth so it
still sits with the paper tones of the light theme. Ink is a warm off-white
instead of #FFF, and terracotta becomes a gentle clay. Layering matches the
light theme: sidebar recessed, cards raised, fields inset into the card. */
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */ /* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #0E0E10; --color-background: #161615;
--color-sidebar: #09090B; --color-sidebar: #111111;
--color-card: #17171A; --color-card: #1F1F1E;
--color-fields: #121215; --color-fields: #1A1A19;
--color-dropzone: #141417; --color-dropzone: #1C1C1B;
--color-media-backdrop: #000000; --color-media-backdrop: #0B0B0B;
/* ── Ink ─────────────────────────────────────────────────────────────── */ /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F4F4F5; --color-text-primary: #F5F4EF;
--color-text-strong: #E3E3E7; --color-text-strong: #E8E6E0;
--color-text-body: #BFBFC6; --color-text-body: #C9C7C0;
--color-text-secondary: #94949D; --color-text-secondary: #A2A099;
--color-muted: #76767F; --color-muted: #87857F;
--color-faint-mono: #6A6A73; --color-faint-mono: #6C6B66;
/* ── Accents ── brightened to carry on a dark canvas, so hover now lifts /* ── Accents ── clay and sage; hover lightens rather than deepens ────── */
rather than deepens ─────────────────────────────────────────────────── */ --color-terracotta: #D97757;
--color-terracotta: #E07A4C; --color-terracotta-hover: #E28B6E;
--color-terracotta-hover: #F08F62; --color-terracotta-light: #BC6A4E;
--color-terracotta-light: #F0A177; --color-terracotta-disabled: #46372F;
--color-terracotta-disabled: #5E463A; --color-olive: #A7AF84;
--color-olive: #A3B678;
/* ── Status ──────────────────────────────────────────────────────────── */ /* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #F2604A; --color-live: #E0644E;
--color-error: #F2604A; --color-error: #E0644E;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that /* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */ sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #17100B; --color-on-accent: #1A1410;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */ /* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #232327; --color-hairline: #282827;
--color-hover: #26262B; --color-hover: #2A2A29;
--color-selected: #2A1F18; --color-selected: #2A2522;
--color-ring: #33333A; --color-ring: #3B3B3A;
--color-inactive: #2C2C32; --color-inactive: #333332;
--color-inactive-strong: #55555E; --color-inactive-strong: #5A5955;
}
/* Tailwind's shadows default to 10% black, which vanishes on a dark canvas.
`--tw-shadow-color` does not inherit, so it is set on the shadowed elements
themselves; a deeper warm-black keeps cards and menus visibly lifted. */
:root[data-theme="dark"] :where(.shadow-sm, .shadow-md) {
--tw-shadow-color: rgb(0 0 0 / 0.45);
} }
:root { :root {

View File

@@ -1,18 +1,22 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips"; import { getClips, deleteClip } from "../utils/api/clips";
import { stringToDate } from "../utils/utils";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getClips() getClips()
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err)); .catch((err) => setError(err))
.finally(() => setLoading(false));
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -28,8 +32,10 @@ const MyClips = () => {
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <TimeGroupedGrid
{clips.map((clip) => ( items={clips}
getDate={(clip) => clip.createdAt}
renderItem={(clip) => (
<VideoCard <VideoCard
key={clip.id} key={clip.id}
id={clip.id} id={clip.id}
@@ -39,10 +45,10 @@ const MyClips = () => {
onEdit={() => navigate(`/clips/${clip.id}/edit`)} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)} onDelete={() => handleDelete(clip.id)}
/> />
))} )}
</div> />
{clips.length === 0 && !error && ( {clips.length === 0 && !error && !loading && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No clips yet.</p> <p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="text-sm mt-1">Upload your first clip to get started.</p>

View File

@@ -1,18 +1,22 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods"; import { getVods, deleteVod } from "../utils/api/vods";
import { stringToDate } from "../utils/utils";
import type { Vod } from "../utils/types"; import type { Vod } from "../utils/types";
const MyVods = () => { const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]); const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getVods() getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods")); .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
.finally(() => setLoading(false));
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -28,8 +32,10 @@ const MyVods = () => {
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <TimeGroupedGrid
{vods.map((vod) => ( items={vods}
getDate={(vod) => vod.createdAt}
renderItem={(vod) => (
<VideoCard <VideoCard
key={vod.id} key={vod.id}
id={vod.id} id={vod.id}
@@ -42,10 +48,10 @@ const MyVods = () => {
onEdit={() => navigate(`/vods/${vod.id}/edit`)} onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)} onDelete={() => handleDelete(vod.id)}
/> />
))} )}
</div> />
{vods.length === 0 && !error && ( {vods.length === 0 && !error && !loading && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No VoDs yet.</p> <p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="text-sm mt-1">Your stream recordings will appear here.</p>

View File

@@ -46,4 +46,52 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`; return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime }; function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
// Weeks start on Monday.
function startOfWeek(date: Date): Date {
const day = startOfDay(date);
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
return day;
}
// Buckets a date relative to now. Anything before last month falls back to its
// month and year, so older items still group sensibly.
function timeRangeLabel(date: Date, now: Date = new Date()): string {
const today = startOfDay(now);
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const thisWeek = startOfWeek(now);
const lastWeek = new Date(thisWeek);
lastWeek.setDate(thisWeek.getDate() - 7);
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= thisWeek) return "This Week";
if (date >= lastWeek) return "Last Week";
if (date >= thisMonth) return "This Month";
if (date >= lastMonth) return "Last Month";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
type TimeGroup<T> = { label: string, items: T[] };
// Expects items already sorted newest first; groups keep that order.
function groupByTimeRange<T>(items: T[], getDateString: (item: T) => string): TimeGroup<T>[] {
const now = new Date();
const groups: TimeGroup<T>[] = [];
for (const item of items) {
const label = timeRangeLabel(stringToDate(getDateString(item)), now);
const last = groups[groups.length - 1];
if (last?.label === label) last.items.push(item);
else groups.push({ label, items: [item] });
}
return groups;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime, timeRangeLabel, groupByTimeRange };
export type { TimeGroup };

View File

@@ -11,7 +11,7 @@ public class ClipOptions {
private String description; private String description;
@NotNull @NotNull
@Positive @PositiveOrZero
private Float startPoint; private Float startPoint;
@NotNull @Positive @NotNull @Positive

View File

@@ -4,12 +4,9 @@ import java.util.Date;
import com.auth0.jwt.JWT; import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.exceptions.JWTVerificationException;
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 com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -60,7 +57,7 @@ public class JwtService {
} }
return jwt.getClaim(USER_ID_CLAIM).asLong(); return jwt.getClaim(USER_ID_CLAIM).asLong();
} catch (JwtException | IllegalArgumentException | TokenExpiredException | JWTDecodeException ignored) { } catch (JWTVerificationException | IllegalArgumentException ignored) {
return null; return null;
} }
} }