Compare commits

...
3 Commits
Author SHA1 Message Date
lobo b0c78d8dca docs(readme): add the architecture overview
Build, Test & Push Frontend / docker (push) Successful in 24s
Build, Test & Push Frontend / quality-check (push) Successful in 14s
2026-09-26 07:37:44 +02:00
lobo fa1bd59509 feat(play-next): pick games by mood 2026-09-26 07:37:16 +02:00
lobo 7d9cf907fc feat(security): enforce the Content Security Policy
The report-only run showed no violation in production.
2026-09-26 07:15:14 +02:00
7 changed files with 146 additions and 18 deletions

No files matched your search

+28 -2
View File
@@ -47,9 +47,36 @@ To Be Played is split into three repositories:
- react-hook-form + zod for forms, i18next for translations (`public/i18n`) - react-hook-form + zod for forms, i18next for translations (`public/i18n`)
- Vitest + React Testing Library + jsdom - Vitest + React Testing Library + jsdom
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy - Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
(report-only for now) and security headers and security headers
- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image) - CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image)
## Architecture
```mermaid
flowchart LR
Browser["Browser"] --> Proxy["Traefik (TLS)"]
Proxy --> Frontend["ToBePlayed-Frontend<br/>nginx, static files"]
Proxy --> Auth["ToBePlayed-Auth"]
Proxy --> Core["ToBePlayed-Core"]
Core -->|"JWKS"| Auth
Auth --> Db[("PostgreSQL")]
Core --> Db
Core --> Steam["Steam Web API"]
Core --> Igdb["IGDB API"]
Core -->|"OAuth token"| Twitch["Twitch"]
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
```
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
(`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts.
- Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS).
- Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the
`X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again.
Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a
`401`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public
profile) use plain `fetch`.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
## Project structure ## Project structure
```text ```text
@@ -91,7 +118,6 @@ npm run build
## Roadmap ## Roadmap
- Mood filter for "What to play next". - Mood filter for "What to play next".
- Enforce the Content Security Policy (it runs in report-only mode now).
## License ## License
+1 -2
View File
@@ -3,8 +3,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
# An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile). # An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile).
# Report-Only until production shows no violation in the browser console; then rename the header. add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+13 -2
View File
@@ -288,7 +288,7 @@
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?", "TITLE": "Was spiele ich als Nächstes?",
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst.", "SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.",
"BUDGET_LABEL": "Spieldauer", "BUDGET_LABEL": "Spieldauer",
"BUDGET": { "BUDGET": {
"SHORT": "Bis 5 h", "SHORT": "Bis 5 h",
@@ -304,7 +304,18 @@
"START_OVER": "Von vorn", "START_OVER": "Von vorn",
"EMPTY": "Dein Backlog ist leer. Stark!", "EMPTY": "Dein Backlog ist leer. Stark!",
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.", "EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden." "ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.",
"MOOD_LABEL": "Stimmung",
"MOOD": {
"ANY": "Jede Stimmung",
"STORY": "Story",
"COOP": "Koop",
"CHILL": "Entspannt",
"ACTION": "Action",
"SCARY": "Gruselig"
},
"MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.",
"EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl."
}, },
"MANUAL_GAME": { "MANUAL_GAME": {
"ADD_BUTTON": "Spiel hinzufügen", "ADD_BUTTON": "Spiel hinzufügen",
+13 -2
View File
@@ -288,7 +288,7 @@
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "What to play next", "TITLE": "What to play next",
"SUBTITLE": "Three games from your backlog, high priority first.", "SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.",
"BUDGET_LABEL": "Time to beat", "BUDGET_LABEL": "Time to beat",
"BUDGET": { "BUDGET": {
"SHORT": "Up to 5 h", "SHORT": "Up to 5 h",
@@ -304,7 +304,18 @@
"START_OVER": "Start over", "START_OVER": "Start over",
"EMPTY": "Your backlog is empty. Nice!", "EMPTY": "Your backlog is empty. Nice!",
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.", "EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
"ERROR_LOAD": "Could not load the picks." "ERROR_LOAD": "Could not load the picks.",
"MOOD_LABEL": "Mood",
"MOOD": {
"ANY": "Any mood",
"STORY": "Story",
"COOP": "Co-op",
"CHILL": "Chill",
"ACTION": "Action",
"SCARY": "Scary"
},
"MOOD_HINT": "Only games whose genre IGDB knows.",
"EMPTY_MOOD": "No backlog game fits. Try another mood or length. New games get their mood a moment after the first pick."
}, },
"MANUAL_GAME": { "MANUAL_GAME": {
"ADD_BUTTON": "Add game", "ADD_BUTTON": "Add game",
+6 -1
View File
@@ -4,7 +4,7 @@ import {describe, expect, it} from 'vitest';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
const nginx = readFileSync(path.join(root, 'nginx.conf'), 'utf8'); const nginx = readFileSync(path.join(root, 'nginx.conf'), 'utf8');
const policy = nginx.match(/Content-Security-Policy(?:-Report-Only)? "([^"]+)"/)[1]; const policy = nginx.match(/Content-Security-Policy "([^"]+)"/)[1];
const directive = (name) => policy.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name} `)); const directive = (name) => policy.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name} `));
const LINK_ONLY_HOSTS = ['store.steampowered.com']; const LINK_ONLY_HOSTS = ['store.steampowered.com'];
@@ -34,6 +34,11 @@ describe('Content Security Policy in nginx.conf', () => {
expect(directive('connect-src')).toBe("connect-src 'self' https://${AUTH_HOST} https://${API_HOST}"); expect(directive('connect-src')).toBe("connect-src 'self' https://${AUTH_HOST} https://${API_HOST}");
}); });
it('is enforced, not only reported', () => {
expect(nginx).toMatch(/add_header Content-Security-Policy "/);
expect(nginx).not.toContain('Content-Security-Policy-Report-Only');
});
it('allows no inline or foreign script', () => { it('allows no inline or foreign script', () => {
expect(directive('script-src')).toBe("script-src 'self'"); expect(directive('script-src')).toBe("script-src 'self'");
expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'"); expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'");
+44 -9
View File
@@ -23,13 +23,16 @@ const BUDGETS = [
{key: 'LONG', maxMinutes: 2400}, {key: 'LONG', maxMinutes: 2400},
{key: 'ANY', maxMinutes: null}, {key: 'ANY', maxMinutes: null},
]; ];
// The moods Core derives from IGDB's genres, themes and game modes; a mood leaves out games without IGDB data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY'];
const PICK_COUNT = 3; const PICK_COUNT = 3;
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100; const MAX_EXCLUDES = 100;
const pickerQuery = (maxMinutes, exclude) => { const pickerQuery = (maxMinutes, mood, exclude) => {
const params = new URLSearchParams({count: String(PICK_COUNT)}); const params = new URLSearchParams({count: String(PICK_COUNT)});
if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
if (mood !== 'ANY') params.set('mood', mood);
exclude.forEach((appid) => params.append('exclude', String(appid))); exclude.forEach((appid) => params.append('exclude', String(appid)));
return params.toString(); return params.toString();
}; };
@@ -39,14 +42,15 @@ export default function PlayNextPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const [budget, setBudget] = useState(BUDGETS[3]); const [budget, setBudget] = useState(BUDGETS[3]);
const [mood, setMood] = useState('ANY');
const [games, setGames] = useState(null); const [games, setGames] = useState(null);
const [seen, setSeen] = useState([]); const [seen, setSeen] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// A slow answer for an older budget must not replace the newer one. // A slow answer for an older budget or mood must not replace the newer one.
const latestRequest = useRef(0); const latestRequest = useRef(0);
const load = useCallback(async (maxMinutes, exclude) => { const load = useCallback(async (maxMinutes, moodKey, exclude) => {
if (!hasSession()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
@@ -56,7 +60,7 @@ export default function PlayNextPage() {
setError(null); setError(null);
try { try {
const response = await apiFetch( const response = await apiFetch(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`); `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
if (request !== latestRequest.current) return; if (request !== latestRequest.current) return;
if (response.status === 401) { if (response.status === 401) {
expireSession(); expireSession();
@@ -80,8 +84,8 @@ export default function PlayNextPage() {
}, [navigate, expireSession]); }, [navigate, expireSession]);
useEffect(() => { useEffect(() => {
load(budget.maxMinutes, []); load(budget.maxMinutes, mood, []);
}, [budget, load]); }, [budget, mood, load]);
const chooseBudget = (next) => { const chooseBudget = (next) => {
if (next.key === budget.key) return; if (next.key === budget.key) return;
@@ -90,6 +94,13 @@ export default function PlayNextPage() {
setBudget(next); setBudget(next);
}; };
const chooseMood = (next) => {
if (next === mood) return;
setGames(null);
setSeen([]);
setMood(next);
};
const allShown = games?.length === 0 && seen.length > 0; const allShown = games?.length === 0 && seen.length > 0;
return ( return (
@@ -118,6 +129,25 @@ export default function PlayNextPage() {
)} )}
</div> </div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.MOOD_LABEL')}>
{MOODS.map((key) => (
<Button
key={key}
size="sm"
variant={mood === key ? 'default' : 'outline'}
aria-pressed={mood === key}
onClick={() => chooseMood(key)}
>
{t(`PLAY_NEXT.MOOD.${key}`)}
</Button>
))}
</div>
{mood !== 'ANY' && (
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.MOOD_HINT')}</p>
)}
</div>
{error && <p className="text-sm text-destructive">{t(error)}</p>} {error && <p className="text-sm text-destructive">{t(error)}</p>}
{games === null && !error && ( {games === null && !error && (
@@ -133,7 +163,7 @@ export default function PlayNextPage() {
)} )}
{(games?.length > 0 || error) && ( {(games?.length > 0 || error) && (
<Button variant="outline" onClick={() => load(budget.maxMinutes, seen)} disabled={loading}> <Button variant="outline" onClick={() => load(budget.maxMinutes, mood, seen)} disabled={loading}>
{loading ? <Loader2 className="animate-spin"/> : <Shuffle/>} {loading ? <Loader2 className="animate-spin"/> : <Shuffle/>}
{t('PLAY_NEXT.REROLL')} {t('PLAY_NEXT.REROLL')}
</Button> </Button>
@@ -142,7 +172,7 @@ export default function PlayNextPage() {
{allShown && ( {allShown && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-muted-foreground">{t('PLAY_NEXT.ALL_SHOWN')}</p> <p className="text-muted-foreground">{t('PLAY_NEXT.ALL_SHOWN')}</p>
<Button variant="outline" onClick={() => load(budget.maxMinutes, [])} disabled={loading}> <Button variant="outline" onClick={() => load(budget.maxMinutes, mood, [])} disabled={loading}>
<Shuffle/>{t('PLAY_NEXT.START_OVER')} <Shuffle/>{t('PLAY_NEXT.START_OVER')}
</Button> </Button>
</div> </div>
@@ -150,13 +180,18 @@ export default function PlayNextPage() {
{games?.length === 0 && seen.length === 0 && ( {games?.length === 0 && seen.length === 0 && (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')} {t(emptyKey(budget, mood))}
</p> </p>
)} )}
</PageContainer> </PageContainer>
); );
} }
const emptyKey = (budget, mood) => {
if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD';
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
};
function PickCard({game, expireSession}) { function PickCard({game, expireSession}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
+41
View File
@@ -69,6 +69,47 @@ describe('PlayNextPage', () => {
expect(pickerUrls()[1].has('exclude')).toBe(false); expect(pickerUrls()[1].has('exclude')).toBe(false);
}); });
it('asks for games of the chosen mood and keeps the length', async () => {
mockBackend([[game(1)], [game(2)], [game(3)]]);
renderPage();
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
await screen.findByText('Game 2');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
expect(await screen.findByText('Game 3')).toBeTruthy();
expect(pickerUrls()[2].get('mood')).toBe('COOP');
expect(pickerUrls()[2].get('maxMinutes')).toBe('300');
expect(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}).getAttribute('aria-pressed')).toBe('true');
});
it('sends no mood for any mood and rerolls within the mood', async () => {
mockBackend([[game(1)], [game(2)], [game(3)]]);
renderPage();
await screen.findByText('Game 1');
expect(pickerUrls()[0].has('mood')).toBe(false);
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
await screen.findByText('Game 2');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
await screen.findByText('Game 3');
expect(pickerUrls()[2].get('mood')).toBe('STORY');
expect(pickerUrls()[2].getAll('exclude')).toEqual(['2']);
});
it('suggests another mood when none of the backlog fits', async () => {
mockBackend([[game(1)], []]);
renderPage();
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SCARY'}));
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy();
});
it('drops the old picks when a new budget fails to load', async () => { it('drops the old picks when a new budget fails to load', async () => {
mockBackend([[game(1)]]); mockBackend([[game(1)]]);
const pickerFetch = globalThis.fetch; const pickerFetch = globalThis.fetch;