Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c78d8dca | ||
|
|
fa1bd59509 | ||
|
|
7d9cf907fc |
No files matched your search
@@ -47,9 +47,36 @@ To Be Played is split into three repositories:
|
||||
- react-hook-form + zod for forms, i18next for translations (`public/i18n`)
|
||||
- Vitest + React Testing Library + jsdom
|
||||
- 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)
|
||||
|
||||
## 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
|
||||
|
||||
```text
|
||||
@@ -91,7 +118,6 @@ npm run build
|
||||
## Roadmap
|
||||
|
||||
- Mood filter for "What to play next".
|
||||
- Enforce the Content Security Policy (it runs in report-only mode now).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-2
@@ -3,8 +3,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# 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-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 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 X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
+13
-2
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"PLAY_NEXT": {
|
||||
"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": {
|
||||
"SHORT": "Bis 5 h",
|
||||
@@ -304,7 +304,18 @@
|
||||
"START_OVER": "Von vorn",
|
||||
"EMPTY": "Dein Backlog ist leer. Stark!",
|
||||
"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": {
|
||||
"ADD_BUTTON": "Spiel hinzufügen",
|
||||
|
||||
+13
-2
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"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": {
|
||||
"SHORT": "Up to 5 h",
|
||||
@@ -304,7 +304,18 @@
|
||||
"START_OVER": "Start over",
|
||||
"EMPTY": "Your backlog is empty. Nice!",
|
||||
"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": {
|
||||
"ADD_BUTTON": "Add game",
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@ import {describe, expect, it} from 'vitest';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
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 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}");
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(directive('script-src')).toBe("script-src 'self'");
|
||||
expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'");
|
||||
|
||||
@@ -23,13 +23,16 @@ const BUDGETS = [
|
||||
{key: 'LONG', maxMinutes: 2400},
|
||||
{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;
|
||||
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
||||
const MAX_EXCLUDES = 100;
|
||||
|
||||
const pickerQuery = (maxMinutes, exclude) => {
|
||||
const pickerQuery = (maxMinutes, mood, exclude) => {
|
||||
const params = new URLSearchParams({count: String(PICK_COUNT)});
|
||||
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
|
||||
if (mood !== 'ANY') params.set('mood', mood);
|
||||
exclude.forEach((appid) => params.append('exclude', String(appid)));
|
||||
return params.toString();
|
||||
};
|
||||
@@ -39,14 +42,15 @@ export default function PlayNextPage() {
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||
const [mood, setMood] = useState('ANY');
|
||||
const [games, setGames] = useState(null);
|
||||
const [seen, setSeen] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
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 load = useCallback(async (maxMinutes, exclude) => {
|
||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
@@ -56,7 +60,7 @@ export default function PlayNextPage() {
|
||||
setError(null);
|
||||
try {
|
||||
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 (response.status === 401) {
|
||||
expireSession();
|
||||
@@ -80,8 +84,8 @@ export default function PlayNextPage() {
|
||||
}, [navigate, expireSession]);
|
||||
|
||||
useEffect(() => {
|
||||
load(budget.maxMinutes, []);
|
||||
}, [budget, load]);
|
||||
load(budget.maxMinutes, mood, []);
|
||||
}, [budget, mood, load]);
|
||||
|
||||
const chooseBudget = (next) => {
|
||||
if (next.key === budget.key) return;
|
||||
@@ -90,6 +94,13 @@ export default function PlayNextPage() {
|
||||
setBudget(next);
|
||||
};
|
||||
|
||||
const chooseMood = (next) => {
|
||||
if (next === mood) return;
|
||||
setGames(null);
|
||||
setSeen([]);
|
||||
setMood(next);
|
||||
};
|
||||
|
||||
const allShown = games?.length === 0 && seen.length > 0;
|
||||
|
||||
return (
|
||||
@@ -118,6 +129,25 @@ export default function PlayNextPage() {
|
||||
)}
|
||||
</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>}
|
||||
|
||||
{games === null && !error && (
|
||||
@@ -133,7 +163,7 @@ export default function PlayNextPage() {
|
||||
)}
|
||||
|
||||
{(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/>}
|
||||
{t('PLAY_NEXT.REROLL')}
|
||||
</Button>
|
||||
@@ -142,7 +172,7 @@ export default function PlayNextPage() {
|
||||
{allShown && (
|
||||
<div className="space-y-3">
|
||||
<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')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,13 +180,18 @@ export default function PlayNextPage() {
|
||||
|
||||
{games?.length === 0 && seen.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')}
|
||||
{t(emptyKey(budget, mood))}
|
||||
</p>
|
||||
)}
|
||||
</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}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -69,6 +69,47 @@ describe('PlayNextPage', () => {
|
||||
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 () => {
|
||||
mockBackend([[game(1)]]);
|
||||
const pickerFetch = globalThis.fetch;
|
||||
|
||||
Reference in new issue
Block a user