Private
Public Access
1
0

chg: usr: replace Google ReCaptcha with Cap instance #13

This commit is contained in:
2026-06-01 22:24:34 +02:00
parent 199bb7e525
commit 7063704539
24 changed files with 389 additions and 376 deletions
+6 -4
View File
@@ -22,10 +22,12 @@ APP_PUBLIC_HOSTNAME=localhost
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:15432/POSTGRES_DB?serverVersion=18&charset=utf8
###< doctrine/doctrine-bundle ###
###> google/recaptcha ###
RECAPTCHA_SITE_KEY=changethis
RECAPTCHA_SECRET_KEY=changethis
###< google/recaptcha ###
###> trycap.dev/cap ###
# CAP_API_ENDPOINT: Full URL including site-key path, e.g. https://cap.example.com/my-site-key/
# After deploying the Cap service, create a site in the dashboard and paste the endpoint here.
CAP_API_ENDPOINT=http://localhost:3000/changethis/
CAP_SECRET_KEY=changethis
###< trycap.dev/cap ###
###> minio/minio ###
MINIO_ROOT_USER=changethis
+4 -3
View File
@@ -78,7 +78,8 @@ Edit `.env` and fill in every value. Key ones:
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
| `RECAPTCHA_SITE_KEY/SECRET_KEY` | Google reCAPTCHA v3 keys for your domain |
| `CAP_API_ENDPOINT` | Full URL of your Cap instance including the site-key path (e.g. `https://cap.example.com/my-site-key/`) |
| `CAP_SECRET_KEY` | Secret key from your Cap site dashboard (stored in `PROD_ENV_FILE` Gitea secret on prod) |
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
@@ -222,8 +223,8 @@ MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu
RECAPTCHA_SITE_KEY="<your reCAPTCHA v3 site key>"
RECAPTCHA_SECRET_KEY="<your reCAPTCHA v3 secret key>"
CAP_API_ENDPOINT="https://cap.example.com/your-site-key/"
CAP_SECRET_KEY="<secret key from your Cap site dashboard>"
MERCURE_URL=https://mineseeker.hu/.well-known/mercure
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
+28 -1
View File
@@ -75,7 +75,7 @@
border-radius: 10px;
padding: 44px 48px 40px;
width: 100%;
max-width: 420px;
max-width: 520px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
}
@@ -87,6 +87,33 @@
margin-bottom: 6px;
}
.auth-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
.auth-title {
margin-bottom: 0;
}
}
.auth-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(149, 207, 245, 0.35);
background: rgba(35, 111, 135, 0.2);
color: #95cff5;
font: 600 11px 'Rajdhani', sans-serif;
letter-spacing: 0.8px;
text-transform: uppercase;
white-space: nowrap;
}
.auth-sub {
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6);
+88
View File
@@ -0,0 +1,88 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import 'cap-widget';
const FORMS_SELECTOR = '.auth-form';
const SUBMIT_SELECTOR = 'button[type="submit"], input[type="submit"]';
const createInvisibleCap = apiEndpoint => {
const host = document.createElement('cap-widget');
host.style.display = 'none';
const cap = new window.Cap({ apiEndpoint }, host);
return { cap, host };
};
const lockSubmit = (form, locked) => {
const submit = form.querySelector(SUBMIT_SELECTOR);
if (!submit) return;
submit.disabled = locked;
};
const bindInvisibleCap = (form, apiEndpoint) => {
const { cap, host } = createInvisibleCap(apiEndpoint);
form.appendChild(host);
let solving = null;
const solveToken = async () => {
if (!solving) {
solving = cap.solve()
.finally(() => {
solving = null;
});
}
const result = await solving;
return result?.token ?? cap.token ?? '';
};
lockSubmit(form, true);
solveToken()
.catch(() => '')
.finally(() => lockSubmit(form, false));
form.addEventListener('submit', async e => {
if (cap.token) {
return;
}
e.preventDefault();
lockSubmit(form, true);
try {
const token = await solveToken();
if (!token) {
lockSubmit(form, false);
return;
}
form.requestSubmit();
} catch (_) {
lockSubmit(form, false);
}
});
};
const initInvisibleCap = () => {
const endpointSource =
document.querySelector('[data-cap-api-endpoint]') ||
document.getElementById('mine-wrapper');
const apiEndpoint = endpointSource?.dataset.capApiEndpoint;
if (!apiEndpoint || typeof window.Cap !== 'function') {
return;
}
document.querySelectorAll(FORMS_SELECTOR).forEach(form => bindInvisibleCap(form, apiEndpoint));
};
initInvisibleCap();
+2 -79
View File
@@ -7,83 +7,6 @@
* file that was distributed with this source code.
*/
import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
const ContactForm = () => null;
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm;
export default ContactForm;
@@ -7,17 +7,16 @@
* file that was distributed with this source code.
*/
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import 'cap-widget';
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const capRef = useRef(null);
const handleToken = useCallback(token => {
const wrapper = document.getElementById('mine-wrapper');
@@ -30,15 +29,9 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
onVerified?.();
}, [onVerified]);
const buttonClasses = useMemo(() => [
'captcha-button',
error && 'captcha-button--error',
loading && 'captcha-button--loading',
].filter(Boolean).join(' '), [error, loading]);
useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
if (storedToken && storedTime) {
const elapsed = (Date.now() - parseInt(storedTime)) / 1000;
@@ -52,40 +45,42 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
return;
}
}
}, [onVerified]);
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
})
.catch(() => {
setError(true);
});
});
}
}, [siteKey, onVerified, handleToken]);
useEffect(() => {
const widget = document.createElement('cap-widget');
widget.style.display = 'none';
capRef.current = widget;
document.body.appendChild(widget);
const cap = new window.Cap({ apiEndpoint }, widget);
let cancelled = false;
const handleClick = () => {
setLoading(true);
setError(false);
const run = async () => {
try {
const result = await cap.solve();
if (!cancelled && result?.token) {
handleToken(result.token);
}
} catch (_) {
if (!cancelled) {
setTimeout(() => {
if (!cancelled) {
run();
}
}, 1200);
}
}
};
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
};
run();
return () => {
cancelled = true;
widget.remove();
capRef.current = null;
};
}, [handleToken]);
if (verified) {
return <Fragment>{children}</Fragment>;
@@ -99,16 +94,8 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
</div>
<h1 className="captcha-title">Ready to Play?</h1>
<p className="captcha-description">
Click below to verify you&apos;re human and start playing.
Verifying your session...
</p>
<button
className={buttonClasses}
onClick={handleClick}
disabled={loading}
>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
</button>
</div>
</div>
);
@@ -117,7 +104,7 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
export default CaptchaOverlay;
CaptchaOverlay.propTypes = {
siteKey: string.isRequired,
onVerified: func,
children: node,
apiEndpoint: string.isRequired,
onVerified: func,
children: node,
};
@@ -19,7 +19,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
const apiEndpoint = document.getElementById('mine-wrapper')?.dataset.capApiEndpoint;
if (!gridReady) {
return (
@@ -29,9 +29,9 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
);
}
if (!captchaVerified && siteKey) {
if (!captchaVerified && apiEndpoint) {
return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
<CaptchaOverlay apiEndpoint={apiEndpoint} onVerified={() => setCaptchaVerified(true)} />
);
}
+25 -22
View File
@@ -14,6 +14,7 @@
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"cap-widget": "^0.1.47",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
@@ -30,7 +31,7 @@
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.8",
"vite": "^8.0.9",
"vite-plugin-symfony": "^8.2.4",
},
},
@@ -168,7 +169,7 @@
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "http://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -176,7 +177,7 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "http://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@oxc-project/types": ["@oxc-project/types@0.126.0", "http://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "http://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@@ -210,35 +211,35 @@
"@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
@@ -338,6 +339,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001788", "http://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"cap-widget": ["cap-widget@0.1.47", "", {}, "sha512-7dBjwiGvv7+q6O4tRPFtF94h0GfLDCU2qXyggwLI2fZbglNUlSqgQv0u9B+dhxYC45yHg3edSfTOSXac4WFRcQ=="],
"chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -700,7 +703,7 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "http://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss": ["postcss@8.5.10", "http://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -732,7 +735,7 @@
"reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"rolldown": ["rolldown@1.0.0-rc.16", "http://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"run-parallel": ["run-parallel@1.2.0", "http://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -816,7 +819,7 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite": ["vite@8.0.9", "http://registry.npmjs.org/vite/-/vite-8.0.9.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite-plugin-symfony": ["vite-plugin-symfony@8.2.4", "http://registry.npmjs.org/vite-plugin-symfony/-/vite-plugin-symfony-8.2.4.tgz", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3", "picocolors": "^1.1.1", "sirv": "^3.0.1" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-ph98EMPx8FhA6QIp43ZiK0zjODV9jumB7EHXfZEhRme2lo9oBa9sAXCNCCIvdVk/m9EWkNZpcRBjequjXZiSuA=="],
@@ -866,7 +869,7 @@
"prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+3 -5
View File
@@ -25,8 +25,8 @@ services:
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
MAILER_DSN: smtp://mail:25?verify_peer=0
RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY}
RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY}
CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
@@ -105,7 +105,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 5s
@@ -121,5 +121,3 @@ volumes:
caddy_config:
postfix_spool:
minio_data:
+1 -1
View File
@@ -4,4 +4,4 @@ twig:
strict_variables: '%kernel.debug%'
globals:
version: "%jotunheimr.version%"
recaptcha_site_key: "%env(RECAPTCHA_SITE_KEY)%"
cap_api_endpoint: "%env(CAP_API_ENDPOINT)%"
+1
View File
@@ -23,6 +23,7 @@
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"cap-widget": "^0.1.47",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
+106
View File
@@ -0,0 +1,106 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class ApiAuthController
*
* Provides a JSON login endpoint for native desktop clients.
* This endpoint is intentionally exempt from the CAPTCHA listener
* because desktop clients cannot display or solve the Cap widget.
*
* After a successful password login, if the user has TOTP enabled the response
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
* code to the standard /2fa_check endpoint (which is already exempt from
* the CAPTCHA listener via LoginCaptchaListener).
*
* @package App\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 26.
*/
#[AsController]
class ApiAuthController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly Security $security,
) {
}
/**
* POST /api/auth/login
*
* Request body (JSON): { "username": "...", "password": "..." }
*
* Responses:
* 200 { "success": true, "requiresTwoFactor": false }
* 200 { "success": true, "requiresTwoFactor": true }
* 400 { "success": false, "error": "..." }
* 401 { "success": false, "error": "..." }
*/
#[Route('/api/auth/login', name: 'MineSeekerBundle_api_auth_login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$data = $request->toArray();
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if ($username === '' || $password === '') {
return $this->json(
['success' => false, 'error' => 'Username and password are required.'],
Response::HTTP_BAD_REQUEST
);
}
/** @var User|null $user */
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $username]);
if ($user === null || !$this->passwordHasher->isPasswordValid($user, $password)) {
return $this->json(
['success' => false, 'error' => 'Invalid username or password.'],
Response::HTTP_UNAUTHORIZED
);
}
if (!$user->isVerified) {
return $this->json(
['success' => false, 'error' => 'Account not yet activated. Check your email.'],
Response::HTTP_UNAUTHORIZED
);
}
// Log the user in via the Symfony security system.
// If TOTP is enabled, scheb/2fa will place the session into
// IS_AUTHENTICATED_2FA_IN_PROGRESS state, and the client must
// complete 2FA by POSTing the code to /2fa_check.
$this->security->login($user, 'form_login');
return $this->json([
'success' => true,
'requiresTwoFactor' => $user->isTotpAuthenticationEnabled(),
]);
}
}
+4 -4
View File
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/**
* Class LoginCaptchaListener
*
* Validates the Google reCAPTCHA v3 token during form-login authentication.
* Validates the Cap CAPTCHA token during form-login authentication.
* Fires on CheckPassportEvent, which is dispatched after credentials are
* collected but before the user is authenticated.
*
@@ -53,12 +53,12 @@ readonly class LoginCaptchaListener
return;
}
$token = $request->request->getString('g-recaptcha-response');
$token = $request->request->getString('cap-token');
if ($this->recaptcha->verify($token, $request->getClientIp())) {
if ($this->recaptcha->verify($token)) {
return;
}
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.');
throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
}
}
+4 -5
View File
@@ -22,8 +22,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Class RecaptchaType
*
* Reads the Google reCAPTCHA v3 token from the raw POST field
* `g-recaptcha-response` (populated by JS before form submit) and injects
* Reads the Cap CAPTCHA token from the raw POST field
* `cap-token` (auto-injected by the cap-widget web component) and injects
* it as this field's value before validation runs.
*
* @package App\Form
@@ -41,9 +41,8 @@ class RecaptchaType extends AbstractType
{
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
$request = $this->requestStack->getCurrentRequest();
$token = $request?->request->getString('g-recaptcha-response') ?? '';
// For forms that set the token directly on the field (e.g. registration_form[recaptcha])
// rather than via a standalone g-recaptcha-response input, fall back to the submitted value.
$token = $request?->request->getString('cap-token') ?? '';
// For forms that set the token directly on the field, fall back to the submitted value.
if ($token === '') {
$token = (string) ($event->getData() ?? '');
}
+13 -25
View File
@@ -26,50 +26,38 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
readonly final class RecaptchaService
{
private const string SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
/**
* Minimum score to accept a request (0.0 = bot, 1.0 = human).
* 0.5 is Google's recommended default threshold.
*/
private const float SCORE_THRESHOLD = 0.5;
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'RECAPTCHA_SECRET_KEY')]
#[Autowire(env: 'CAP_API_ENDPOINT')]
private string $apiEndpoint,
#[Autowire(env: 'CAP_SECRET_KEY')]
private string $secretKey,
) {}
public function verify(string $token, string $remoteIp = ''): bool
public function verify(string $token): bool
{
if ($token === '') {
return false;
}
try {
$body = ['secret' => $this->secretKey, 'response' => $token];
if ($remoteIp !== '') {
$body['remoteip'] = $remoteIp;
}
$siteverifyUrl = rtrim($this->apiEndpoint, '/') . '/siteverify';
$data = $this->httpClient
->request('POST', self::SITEVERIFY_URL, ['body' => $body])
->request('POST', $siteverifyUrl, [
'body' => ['secret' => $this->secretKey, 'response' => $token],
])
->toArray();
$this->logger->info('reCAPTCHA verify response', [
'success' => $data['success'] ?? null,
'score' => $data['score'] ?? null,
'hostname' => $data['hostname'] ?? null,
'error-codes' => $data['error-codes'] ?? [],
'token_length' => strlen($token),
$this->logger->info('Cap verify response', [
'success' => $data['success'] ?? null,
'token_length' => strlen($token),
]);
return ($data['success'] ?? false) === true
&& ($data['score'] ?? 0.0) >= self::SCORE_THRESHOLD;
return ($data['success'] ?? false) === true;
} catch (\Throwable $e) {
$this->logger->error('reCAPTCHA verification failed: ' . $e->getMessage());
$this->logger->error('Cap verification failed: ' . $e->getMessage());
return false;
}
+1 -6
View File
@@ -11,7 +11,6 @@
namespace App\Validator;
use App\Service\RecaptchaService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -30,7 +29,6 @@ final class RecaptchaValidator extends ConstraintValidator
{
public function __construct(
private readonly RecaptchaService $recaptcha,
private readonly RequestStack $requestStack,
) {
}
@@ -40,10 +38,7 @@ final class RecaptchaValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, Recaptcha::class);
}
$request = $this->requestStack->getCurrentRequest();
$remoteIp = $request !== null ? ((string)$request->getClientIp()) : '';
if ($this->recaptcha->verify((string)$value, $remoteIp)) {
if ($this->recaptcha->verify((string)$value)) {
return;
}
+1 -2
View File
@@ -14,7 +14,7 @@
data-is-authenticated="{{ app.user ? '1' : '0' }}"
data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-recaptcha-site-key="{{ recaptcha_site_key }}">
data-cap-api-endpoint="{{ cap_api_endpoint }}">
</div>
</div>
{% endblock %}
@@ -39,6 +39,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
{% endblock %}
+7 -6
View File
@@ -37,6 +37,12 @@
</p>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
<div class="auth-title-row">
<h2 class="auth-title">Contact Form</h2>
<span class="auth-cap-badge"><i class="fas fa-shield-halved"></i> Protected by Cap</span>
</div>
<p class="auth-sub">Your message is protected by automated abuse checks.</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field">
@@ -127,10 +133,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+6 -17
View File
@@ -34,8 +34,12 @@
</div>
{% else %}
<div class="auth-card">
<h2 class="auth-title">Forgot Password</h2>
<div class="auth-title-row">
<h2 class="auth-title">Forgot Password</h2>
<span class="auth-cap-badge"><i class="fas fa-shield-halved"></i> Protected by Cap</span>
</div>
<p class="auth-sub">Enter your email and we'll send you a reset link</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -75,20 +79,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
(function () {
const form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'forgot_password'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+7 -18
View File
@@ -36,7 +36,10 @@
{% endfor %}
<div class="auth-card">
<h2 class="auth-title">Sign In</h2>
<div class="auth-title-row">
<h2 class="auth-title">Sign In</h2>
<span class="auth-cap-badge"><i class="fas fa-shield-halved"></i> Protected by Cap</span>
</div>
<p class="auth-sub">Welcome back, commander</p>
{% if error %}
@@ -46,6 +49,8 @@
</div>
{% endif %}
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_login') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
@@ -92,8 +97,6 @@
</p>
</div>
<input type="hidden" id="g-recaptcha-response" name="g-recaptcha-response"/>
<button type="submit" class="auth-submit">
<i class="fas fa-right-to-bracket"></i> Sign In
</button>
@@ -121,20 +124,6 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
document.querySelector('.auth-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'login'}).then(function (token) {
document.getElementById('g-recaptcha-response').value = token;
form.submit();
});
});
});
</script>
{{ vite_entry_script_tags('cap') }}
{{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
{% endblock %}
+6 -17
View File
@@ -42,8 +42,12 @@
</div>
{% else %}
<div class="auth-card">
<h2 class="auth-title">Create Account</h2>
<div class="auth-title-row">
<h2 class="auth-title">Create Account</h2>
<span class="auth-cap-badge"><i class="fas fa-shield-halved"></i> Protected by Cap</span>
</div>
<p class="auth-sub">Join the battle — no subscription required</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -153,20 +157,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
(function () {
const form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'register'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+6 -16
View File
@@ -16,8 +16,12 @@
{% block body %}
<div class="auth-page">
<div class="auth-card">
<h2 class="auth-title">Reset Password</h2>
<div class="auth-title-row">
<h2 class="auth-title">Reset Password</h2>
<span class="auth-cap-badge"><i class="fas fa-shield-halved"></i> Protected by Cap</span>
</div>
<p class="auth-sub">Choose a new password for your account</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -65,19 +69,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
(function () {
document.querySelector('.auth-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'reset_password'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+25 -88
View File
@@ -34,16 +34,17 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
#[TestDox('Recaptcha Service')]
class RecaptchaServiceTest extends TestCase
{
private const SECRET_KEY = 'test-secret-key';
private const API_ENDPOINT = 'http://localhost:3000/test-site-key/';
private const SECRET_KEY = 'test-secret-key';
#[Test]
#[TestDox('Verify returns false for empty token')]
public function verifyReturnsFalseForEmptyToken(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('');
@@ -55,15 +56,14 @@ class RecaptchaServiceTest extends TestCase
public function verifyReturnsFalseWhenApiReturnsFailure(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => false,
'error-codes' => ['invalid-input-secret'],
'success' => false,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('invalid-token');
@@ -71,47 +71,24 @@ class RecaptchaServiceTest extends TestCase
}
#[Test]
#[TestDox('Verify returns true when api returns success and high score')]
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void
#[TestDox('Verify returns true when api returns success')]
public function verifyReturnsTrueWhenApiReturnsSuccess(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.8,
'hostname' => 'test.com',
'success' => true,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('valid-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify returns false when score below threshold')]
public function verifyReturnsFalseWhenScoreBelowThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.3,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('low-score-token');
$this->assertFalse($result);
}
#[Test]
#[TestDox('Verify returns false when api throws exception')]
public function verifyReturnsFalseWhenApiThrowsException(): void
@@ -119,10 +96,10 @@ class RecaptchaServiceTest extends TestCase
$mockResponse = new MockResponse('', ['http_code' => 500]);
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('error');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('test-token');
@@ -130,59 +107,19 @@ class RecaptchaServiceTest extends TestCase
}
#[Test]
#[TestDox('Verify includes remote ip when provided')]
public function verifyIncludesRemoteIpWhenProvided(): void
#[TestDox('Siteverify URL is derived from api endpoint')]
public function siteverifyUrlIsDerivedFromApiEndpoint(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.9,
], JSON_THROW_ON_ERROR));
$mockResponse = new MockResponse(json_encode(['success' => true], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$httpClient = new MockHttpClient(function (string $method, string $url) use ($mockResponse): MockResponse {
$this->assertSame('http://localhost:3000/test-site-key/siteverify', $url);
return $mockResponse;
});
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('test-token', '192.168.1.1');
$this->assertTrue($result);
$service->verify('test-token');
}
#[Test]
#[TestDox('Verify with score at threshold')]
public function verifyWithScoreAtThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.5,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('threshold-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify with no score fails')]
public function verifyWithNoScoreFails(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('no-score-token');
$this->assertFalse($result);
}
}
}
+1
View File
@@ -28,6 +28,7 @@ export default defineConfig({
passkey: './assets/js/passkey.jsx',
profile: './assets/js/profile.jsx',
contact: './assets/js/contact.jsx',
cap: './assets/js/cap.js',
mineseekerStyle: './assets/css/style.mineseeker.scss',
homeStyle: './assets/css/style.layout.scss',
passkeyStyle: './assets/css/passkey.scss',