chg: usr: replace Google ReCaptcha with Cap instance #13
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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'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)} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
|
||||
@@ -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)%"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() ?? '');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user