Protege la visibilidad del aviso de entrada y su formulario nativo

main
dev 4 days ago
parent 11701d3699
commit c66accfd40

@ -12,7 +12,7 @@ Primera versión de una plataforma de juegos online en español. Svelte 5 + Svel
- Perfil e historial con victoria, derrota, empate y posición.
- Interfaz adaptada a móvil, temas claro/oscuro, avatar, color de fichas, sonidos y animaciones de cada juego.
- Salida de partidas y cierres sin resultado por inactividad; pendientes actualizadas en tiempo real.
- Servicio personal en desarrollo de Juan V. Navarro Leal: popup breve de entrada y aceptación recordada con una cookie firmada. Al rechazar el aviso, se vuelve a imaginacion.com.
- Servicio personal en desarrollo de Juan V. Navarro Leal: popup breve de entrada visible desde el HTML del servidor y aceptación recordada con una cookie firmada. Sus botones permiten aceptar o salir también antes de inicializar JavaScript. Al rechazar el aviso, se vuelve a imaginacion.com.
## Desarrollo local

@ -1,6 +1,6 @@
# Servidor de Juegoland
Versión 20261004-07. Web, API y WebSockets en mesa-game, 0.0.0.0:3000.
Versión 20261004-08. Web, API y WebSockets en mesa-game, 0.0.0.0:3000.
El proxy Nginx existente de la red apunta a http://192.168.18.155:3000.
Nginx y el antiguo servicio mesa-web están deshabilitados en la VM.
@ -8,7 +8,7 @@ Acceso local: http://192.168.18.155:3000.
Dominio público: https://juegoland.imaginacion.com.
78 pruebas y compilación Linux verificadas. Dos clientes reales han comprobado por el dominio público la aceptación, cookies, WebSocket, chat, movimiento, turno y resultado. Las identidades sintéticas se eliminaron después de la prueba.
El popup breve se muestra sobre el catálogo; no hay página de condiciones completas. «No acepto» y Escape vuelven a https://imaginacion.com/ sin ocultar el aviso mientras sigue abierta Juegoland. Open Graph y PNG de 1200×630 publicados para compartir.
El popup breve llega abierto en el HTML del servidor y pasa a modal nativo al inicializar JavaScript. Declara display, opacity y visibility en línea para evitar que una regla CSS de ocultación deje un modal invisible bloqueando la página. Se comprobó a 565×958 con reglas de ocultación !important y sin JavaScript en el navegador integrado. La comprobación directa en Edge sigue pendiente. El formulario puede aceptar y guardar la cookie sin JavaScript; conserva la invitación o ruta de entrada. «No acepto» es un enlace a https://imaginacion.com/ y Escape vuelve a la misma web. No hay página de condiciones completas. Open Graph y PNG de 1200×630 publicados para compartir.
El despliegue conserva los archivos inmutables del cliente anterior para que las pestañas abiertas puedan seguir cargando sus componentes.
Damas mantiene la ficha seleccionada durante capturas encadenadas y permite recorrido diagonal largo de las damas. Las partidas existentes siguen disponibles.

@ -16,7 +16,7 @@ import {
import { Platform } from './platform.mjs';
import { catalog } from './games/registry.mjs';
import { validateAvatar } from './avatar.mjs';
import { terms } from '../shared/terms.mjs';
import { terms, termsReturnPath } from '../shared/terms.mjs';
import { termsAcceptance, termsCookie, clearTermsCookie } from './terms.mjs';
const registerSchema = z.object({
@ -75,8 +75,13 @@ class RateLimit {
}
}
async function body(req, maxSize = 16384) {
if (!req.headers['content-type']?.startsWith('application/json'))
async function body(req, maxSize = 16384, allowForm = false) {
const form =
allowForm &&
req.headers['content-type']?.startsWith(
'application/x-www-form-urlencoded',
);
if (!form && !req.headers['content-type']?.startsWith('application/json'))
throw new Error('Se necesita contenido JSON.');
let size = 0;
const chunks = [];
@ -85,8 +90,10 @@ async function body(req, maxSize = 16384) {
if (size > maxSize) throw new Error('Solicitud demasiado grande.');
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
if (form) return Object.fromEntries(new URLSearchParams(text));
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
return JSON.parse(text);
} catch {
throw new Error('Solicitud JSON no válida.');
}
@ -367,10 +374,18 @@ export function createService(store, env = process.env, webHandler = null) {
}
if (path === '/api/terms/accept' && req.method === 'POST') {
limits.check(`terms:${ip}`, 20, 60000);
const nativeForm = req.headers['content-type']?.startsWith(
'application/x-www-form-urlencoded',
);
const input = await body(req, 4096, true);
z.object({
accepted: z.literal(true),
version: z.literal(terms.version),
}).parse(await body(req));
}).parse(
nativeForm
? { ...input, accepted: input.accepted === 'true' }
: input,
);
const cookie = termsCookie(pepper, secureCookies);
const accepted = termsAcceptance(cookie, pepper);
if (user)
@ -380,7 +395,14 @@ export function createService(store, env = process.env, webHandler = null) {
acceptedAt: accepted.acceptedAt,
};
});
send(200, { terms: accepted }, cookie);
if (nativeForm) {
res.writeHead(303, {
location: termsReturnPath(input.returnTo, req.headers.origin),
'set-cookie': cookie,
'cache-control': 'no-store',
});
res.end();
} else send(200, { terms: accepted }, cookie);
return;
}
if (path === '/api/terms/revoke' && req.method === 'POST') {

@ -4,21 +4,39 @@
label,
onclose,
children,
className='',
}: { label: string; onclose: () => void; children: Snippet;className?:string } = $props();
className = '',
serverOpen = false,
}: {
label: string;
onclose: () => void;
children: Snippet;
className?: string;
serverOpen?: boolean;
} = $props();
let dialog = $state<HTMLDialogElement>();
let mounted = $state(false);
onMount(() => {
// Upgrade the already visible server-rendered notice to a native modal.
if (dialog?.open) dialog.close();
dialog?.showModal();
const heading=dialog?.querySelector<HTMLElement>('h2');
heading?.setAttribute('tabindex','-1');
mounted = true;
const heading = dialog?.querySelector<HTMLElement>('h2');
heading?.setAttribute('tabindex', '-1');
heading?.focus();
});
</script>
{#if serverOpen && !mounted}
<div class="dialog-fallback-backdrop" aria-hidden="true"></div>
{/if}
<dialog
class="modal {className}"
bind:this={dialog}
aria-labelledby={label}
open={serverOpen}
style:display|important={serverOpen ? 'block' : undefined}
style:opacity|important={serverOpen ? '1' : undefined}
style:visibility|important={serverOpen ? 'visible' : undefined}
oncancel={(event) => {
event.preventDefault();
onclose();
@ -26,3 +44,13 @@
>
{@render children()}
</dialog>
<style>
.dialog-fallback-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
background: #263a2d55;
backdrop-filter: blur(4px);
}
</style>

@ -2,14 +2,14 @@
import { acceptTerms, consent, session } from '#lib/client.svelte.ts';
import { terms } from '../../../shared/terms.mjs';
import Dialog from './Dialog.svelte';
import { page } from '$app/state';
let { hasTermsCookie = false }: { hasTermsCookie?: boolean } = $props();
let checked = $state(false),
busy = $state(false),
error = $state('');
const visible = $derived(
consent.reviewOpen ||
(!consent.accepted &&
(!session.loading || !hasTermsCookie)),
(!consent.accepted && (!session.loading || !hasTermsCookie)),
);
async function accept() {
if (!checked || busy) return;
@ -31,32 +31,60 @@
</script>
{#if visible}
<Dialog label="terms-title" onclose={decline} className="terms-consent">
<Dialog
label="terms-title"
onclose={decline}
className="entry-notice"
serverOpen
>
<h2 id="terms-title">Antes de jugar</h2>
<p class="terms-author">{terms.introduction}</p>
<p>{terms.notice}</p>
<label class="terms-check">
<input type="checkbox" bind:checked disabled={busy} />
<span
>Entiendo que es un servicio en desarrollo y acepto usarlo bajo mi
responsabilidad.</span
>
</label>
<p class="terms-cookie">
Una cookie recordará tu aceptación durante un año.
</p>
<div class="terms-actions">
<button class="button secondary" disabled={busy} onclick={decline}
>No acepto</button
>
<button
class="button primary"
disabled={!checked || busy}
onclick={accept}
>
{busy ? 'Guardando…' : 'Aceptar y entrar'}
</button>
</div>
<form
method="POST"
action="/api/terms/accept"
onsubmit={(event) => {
event.preventDefault();
void accept();
}}
>
<input type="hidden" name="version" value={terms.version} />
<input
type="hidden"
name="returnTo"
value={page.url.pathname + page.url.search + page.url.hash}
/>
<label class="terms-check">
<input
type="checkbox"
name="accepted"
value="true"
required
bind:checked
disabled={busy}
/>
<span
>Entiendo que es un servicio en desarrollo y acepto usarlo bajo mi
responsabilidad.</span
>
</label>
<p class="terms-cookie">
Una cookie recordará tu aceptación durante un año.
</p>
<div class="terms-actions">
<a
class="button secondary"
href="https://imaginacion.com/"
aria-disabled={busy}
onclick={(event) => {
if (busy) event.preventDefault();
}}>No acepto</a
>
<button type="submit" class="button primary" disabled={busy}>
{busy ? 'Guardando…' : 'Aceptar y entrar'}
</button>
</div>
</form>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</Dialog>
{/if}

@ -1,19 +1,23 @@
.terms-consent {
.entry-notice {
position: fixed;
inset: 0;
margin: auto;
z-index: 1001;
width: min(600px, calc(100vw - 32px));
max-width: 600px;
padding: 32px;
}
.terms-consent h2 {
.entry-notice h2 {
margin: 0 0 20px;
font-size: 24px;
}
.terms-consent p {
.entry-notice p {
font-size: 16px;
line-height: 1.55;
margin: 0 0 16px;
color: var(--muted);
}
.terms-consent .terms-author {
.entry-notice .terms-author {
color: var(--text);
}
.terms-read {
@ -25,7 +29,7 @@
text-decoration: underline;
text-underline-offset: 3px;
}
.terms-consent .terms-check {
.entry-notice .terms-check {
display: flex;
flex-direction: row;
align-items: flex-start;
@ -44,7 +48,7 @@
padding: 0;
accent-color: var(--green);
}
.terms-consent .terms-cookie {
.entry-notice .terms-cookie {
font-size: 14px;
line-height: 1.5;
}
@ -57,6 +61,10 @@
.terms-actions .button {
font-size: 16px;
}
.terms-actions a[aria-disabled='true'] {
pointer-events: none;
opacity: 0.5;
}
.terms-page {
width: min(100%, 760px);
margin: 0 auto;
@ -91,7 +99,7 @@
max-width: 42ch;
}
@media (max-width: 600px) {
.terms-consent {
.entry-notice {
padding: 24px;
}
.terms-page {

@ -127,6 +127,52 @@ it('requires explicit current acceptance in HTTP and WebSockets, persists it and
)
).status,
).toBe(403);
const nativeAcceptance = (
fields: Record<string, string>,
requestOrigin = origin,
) =>
fetch(base + '/api/terms/accept', {
method: 'POST',
redirect: 'manual',
headers: {
origin: requestOrigin,
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields),
});
const formFields = {
accepted: 'true',
version: terms.version,
returnTo: '/?invite=abc123',
};
const nativeAccepted = await nativeAcceptance(formFields);
expect(nativeAccepted.status).toBe(303);
expect(nativeAccepted.headers.get('location')).toBe('/?invite=abc123');
expect(nativeAccepted.headers.get('cache-control')).toBe('no-store');
expect(nativeAccepted.headers.get('set-cookie')).toContain('; Secure');
const nativeCookie = nativeAccepted.headers
.get('set-cookie')!
.split(';')[0];
expect(
(await (await request('/session', nativeCookie)).json()).terms.accepted,
).toBe(true);
for (const fields of [
{ ...formFields, accepted: 'false' },
{ version: terms.version },
{ ...formFields, version: 'old' },
]) {
const rejected = await nativeAcceptance(fields);
expect(rejected.status).toBe(400);
expect(rejected.headers.get('set-cookie')).toBeNull();
}
expect(
(await nativeAcceptance(formFields, 'https://other.example')).status,
).toBe(403);
const safeReturn = await nativeAcceptance({
...formFields,
returnTo: 'https://other.example/',
});
expect(safeReturn.headers.get('location')).toBe('/');
const lanAcceptance = await request(
'/terms/accept',
'',

Loading…
Cancel
Save

Powered by TurnKey Linux.