Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 6edb53162e feat(security): add a report-only CSP and security headers
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 14s
nginx.conf is now an nginx template, so connect-src takes
AUTH_HOST and API_HOST from the container env. The image
sets both to empty by default, so nginx still starts
without them. Report-only until production shows no
violation. Also nosniff, Referrer-Policy and
Permissions-Policy.
2026-09-25 15:47:23 +02:00
lobo f8e6c2c970 feat(pwa): make the app installable
Manifest, gamepad icon (also the new favicon, instead of
the Vite logo), iOS meta tags and the tab title
ToBePlayed. No service worker, so a deploy never meets
an old cached bundle. The title bar color follows
light and dark mode. The README also lists adding a
game by name.
2026-09-25 15:32:55 +02:00
14 changed files with 173 additions and 6 deletions

No files matched your search

+3 -1
View File
@@ -9,6 +9,8 @@ RUN npm run build
# Serve the static files using a lightweight web server # Serve the static files using a lightweight web server
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf # nginx.conf is a template. An env var that is not set stays in it as ${...}, and nginx then fails to start.
ENV AUTH_HOST="" API_HOST=""
COPY nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+6 -3
View File
@@ -26,6 +26,8 @@ To Be Played is split into three repositories:
priority first, reroll without repeats, and "Start playing" in one click. priority first, reroll without repeats, and "Start playing" in one click.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. every achievement is unlocked.
@@ -34,6 +36,7 @@ To Be Played is split into three repositories:
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode).
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token - **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
once and sends the request again; parallel requests and other tabs share one refresh. once and sends the request again; parallel requests and other tabs share one refresh.
@@ -43,7 +46,8 @@ To Be Played is split into three repositories:
- Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons - Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons
- 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 and long-term caching for hashed assets - Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
(report-only for now) 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)
## Project structure ## Project structure
@@ -87,8 +91,7 @@ npm run build
## Roadmap ## Roadmap
- Mood filter for "What to play next". - Mood filter for "What to play next".
- A Content Security Policy in `nginx.conf`. - Enforce the Content Security Policy (it runs in report-only mode now).
- Installable PWA.
## License ## License
+8 -1
View File
@@ -3,8 +3,15 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-title" content="TBP" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Game Log</title> <title>ToBePlayed</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+15
View File
@@ -2,11 +2,26 @@ server {
listen 80; listen 80;
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).
# 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 X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Keeps the headers above in the locations that add their own Cache-Control (nginx 1.29.3 and later).
add_header_inherit merge;
# Vite puts a content hash in these file names, so they never change. # Vite puts a content hash in these file names, so they never change.
location /assets/ { location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
} }
# Not in every nginx mime.types; browsers expect this type for the PWA manifest.
location = /manifest.webmanifest {
types { application/manifest+json webmanifest; }
add_header Cache-Control "no-cache";
}
# index.html and /i18n/*.json keep their names across deploys. Without # index.html and /i18n/*.json keep their names across deploys. Without
# no-cache the browser shows old translation keys or loads an old bundle. # no-cache the browser shows old translation keys or loads an old bundle.
location / { location / {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+10 -1
View File
@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#7f22fe"/>
<g transform="translate(88 88) scale(14)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" x2="10" y1="11" y2="11"/>
<line x1="8" x2="8" y1="9" y2="13"/>
<line x1="15" x2="15.01" y1="12" y2="12"/>
<line x1="18" x2="18.01" y1="10" y2="10"/>
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"id": "/",
"name": "ToBePlayed",
"short_name": "TBP",
"description": "Your game backlog: Steam library, games from other platforms, and what to play next.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}
+4
View File
@@ -11,6 +11,8 @@ import {
} from '../utils/appearance.js'; } from '../utils/appearance.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
// The --background of style.css; the installed app paints its title bar in this color.
const THEME_COLORS = {dark: '#09090b', light: '#fafafa'};
const PreferencesContext = createContext(null); const PreferencesContext = createContext(null);
@@ -28,6 +30,8 @@ export function PreferencesProvider({children}) {
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
document.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode)); localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
}, [isDarkMode]); }, [isDarkMode]);
+35
View File
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react';
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
let preferences;
function Spy() {
preferences = usePreferences();
return null;
}
const themeColor = () => document.querySelector('meta[name="theme-color"]').content;
beforeEach(() => {
document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('PreferencesProvider theme color', () => {
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
localStorage.setItem('tbp_dark_mode', 'false');
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(themeColor()).toBe('#fafafa');
act(() => preferences.setDarkMode(true));
expect(themeColor()).toBe('#09090b');
});
});
+41
View File
@@ -0,0 +1,41 @@
import {readdirSync, readFileSync} from 'node:fs';
import path from 'node:path';
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 directive = (name) => policy.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name} `));
const LINK_ONLY_HOSTS = ['store.steampowered.com'];
const allows = (sources, host) => sources.split(' ').some((source) =>
source === `https://${host}` || (source.startsWith('https://*.') && host.endsWith(source.slice('https://*'.length))));
describe('Content Security Policy in nginx.conf', () => {
it('allows every image host the code builds URLs for', () => {
const code = readdirSync(path.join(root, 'src'), {recursive: true})
.filter((file) => /\.jsx?$/.test(file) && !/\.test\.jsx?$/.test(file) && !file.startsWith('test'))
.map((file) => readFileSync(path.join(root, 'src', file), 'utf8'))
.join('\n');
const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1]));
// Only links: a navigation needs no img-src.
LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host));
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures.
['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com']
.forEach((host) => hosts.add(host));
for (const host of hosts) {
expect(allows(directive('img-src'), host), host).toBe(true);
}
});
it('lets the app reach Auth and Core, whose hosts come from the container env', () => {
expect(directive('connect-src')).toBe("connect-src 'self' https://${AUTH_HOST} https://${API_HOST}");
});
it('allows no inline or foreign script', () => {
expect(directive('script-src')).toBe("script-src 'self'");
expect(directive('frame-ancestors')).toBe("frame-ancestors 'none'");
});
});
+35
View File
@@ -0,0 +1,35 @@
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {describe, expect, it} from 'vitest';
const root = path.resolve(import.meta.dirname, '..');
const read = (file) => readFileSync(path.join(root, file));
const manifest = JSON.parse(read('public/manifest.webmanifest'));
// Width and height sit at bytes 16-23 of every PNG.
const pngSize = (file) => {
const png = read(path.join('public', file));
return `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`;
};
describe('PWA manifest', () => {
it('links every icon to a PNG of the size it claims', () => {
for (const icon of manifest.icons) {
expect(pngSize(icon.src)).toBe(icon.sizes);
}
});
it('has the icons Chrome needs to offer the install', () => {
const sizes = manifest.icons.filter((icon) => icon.purpose === 'any').map((icon) => icon.sizes);
expect(sizes).toEqual(expect.arrayContaining(['192x192', '512x512']));
expect(manifest.display).toBe('standalone');
expect(manifest.icons.some((icon) => icon.purpose === 'maskable')).toBe(true);
});
it('is linked from index.html together with the iOS icon', () => {
const html = read('index.html').toString();
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />');
expect(pngSize('apple-touch-icon.png')).toBe('180x180');
});
});