This turns the project into a monorepo using pnpm workspaces, dramatically simplifying the build process. It also fixes a lot of bugs and just generally makes the codebase a lot cleaner.
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import { LitElement, html, css, isServer, type PropertyValues } from 'lit';
|
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
import { type Challenge } from '@impost/lib';
|
|
import { get_wasm_module } from '@impost/lib/solver';
|
|
import * as Comlink from 'comlink';
|
|
|
|
import SolverWorker from './solver-worker?worker&inline';
|
|
|
|
type SolverWorkerAPI = Comlink.Remote<typeof import("./solver-worker")>;
|
|
|
|
@customElement('impost-captcha')
|
|
export class ImpostCaptcha extends LitElement {
|
|
static override styles = css`
|
|
:host {
|
|
display: block;
|
|
width: var(--impost-widget-width, 250px);
|
|
}
|
|
.impost-widget {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--impost-widget-border-color, #505050);
|
|
font-family: sans-serif;
|
|
background-color: var(--impost-widget-background-color, #070408);
|
|
border-radius: var(--impost-widget-border-radius, 8px);
|
|
}
|
|
.impost-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.75rem;
|
|
}
|
|
.impost-main span {
|
|
height: min-content;
|
|
}
|
|
.impost-checkbox {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.5rem;
|
|
height: 1.5rem;
|
|
color: var(--impost-widget-checkbox-color, #6e6e6eff);
|
|
}
|
|
.impost-checkbox input {
|
|
height: 80%;
|
|
width: 80%;
|
|
margin: 0;
|
|
}
|
|
.impost-footer {
|
|
display: flex;
|
|
padding: 0.25rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
color: var(--impost-widget-footer-color, #a2a2a2);
|
|
justify-content: space-around;
|
|
align-items: end;
|
|
}
|
|
.impost-footer div {
|
|
height: min-content;
|
|
}
|
|
.impost-footer #provider-link {
|
|
margin-left: auto;
|
|
}
|
|
.impost-footer div a {
|
|
color: var(--impost-widget-footer-color, #a2a2a2);
|
|
}
|
|
.impost-footer div a:hover {
|
|
text-decoration: none;
|
|
}
|
|
`;
|
|
|
|
/// ================================================
|
|
/// Configuration
|
|
/// ================================================
|
|
|
|
|
|
@property({ type: String })
|
|
auto: "onload" | "onfocus" | "onsubmit" | "off" = "off";
|
|
|
|
@property({ type: String })
|
|
challengeUrl: string = 'http://localhost:3000/api/pow';
|
|
|
|
@property({ type: String })
|
|
challengejson: string = '';
|
|
|
|
@property({ type: Boolean })
|
|
showHashrate: boolean = false;
|
|
|
|
/// ================================================
|
|
/// Internals
|
|
/// ================================================
|
|
|
|
// needed to allow for multiple widgets on the same page if you wanted to
|
|
// do that for some reason
|
|
private uid: string = Math.floor(Math.random() * 100000).toString();
|
|
|
|
private _internals: ElementInternals | null = null;
|
|
static formAssociated = true;
|
|
|
|
@state()
|
|
private solution: string | null = null;
|
|
|
|
@state()
|
|
private challengeData: Challenge | null = null;
|
|
|
|
@state()
|
|
private status: 'unsolved' | 'solving' | 'solved' | 'error' = 'unsolved';
|
|
|
|
@state()
|
|
private disabled: boolean = true;
|
|
|
|
@state()
|
|
private hashRate: number = 0;
|
|
|
|
|
|
// stores the nonce and solution atomics
|
|
private sab: SharedArrayBuffer = new SharedArrayBuffer(8);
|
|
|
|
private solverWorkers: SolverWorkerAPI[] | null = null;
|
|
private nativeWorkers: Worker[] | null = null;
|
|
|
|
private solveStartTime: number | null = null;
|
|
private hashRateInterval: number | null = null;
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this._internals = this.attachInternals();
|
|
this.fetchChallenge();
|
|
|
|
console.log(this._internals.form);
|
|
|
|
this.initWorkers();
|
|
|
|
switch (this.auto) {
|
|
case 'onload':
|
|
this.solveChallenge();
|
|
break;
|
|
case 'onfocus':
|
|
this.addEventListener('focus', () => this.solveChallenge());
|
|
break;
|
|
case 'onsubmit':
|
|
if (this.parentElement?.nodeName === 'FORM') {
|
|
this.parentElement.addEventListener('submit', async (ev) => {
|
|
if (this.status === 'solved') {
|
|
return;
|
|
}
|
|
|
|
ev.preventDefault();
|
|
|
|
await this.solveChallenge();
|
|
|
|
const form = this.parentElement as HTMLFormElement;
|
|
|
|
if (form.requestSubmit) {
|
|
form.requestSubmit();
|
|
} else {
|
|
form.submit();
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
protected override firstUpdated(_changedProperties: PropertyValues): void {
|
|
super.firstUpdated(_changedProperties);
|
|
|
|
this.disabled = false;
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
if (this.hashRateInterval !== null) {
|
|
clearInterval(this.hashRateInterval);
|
|
this.hashRateInterval = null;
|
|
}
|
|
|
|
for (const worker of this.nativeWorkers || []) {
|
|
worker.terminate();
|
|
this.solverWorkers = null;
|
|
this.nativeWorkers = null;
|
|
}
|
|
}
|
|
|
|
getCurrentWorkingNonce() {
|
|
return Atomics.load(new Uint32Array(this.sab), 0);
|
|
}
|
|
|
|
async fetchChallenge() {
|
|
if (this.challengeData !== null) {
|
|
return;
|
|
}
|
|
|
|
if (this.challengejson !== '' && this.challengeData === null) {
|
|
this.challengeData = JSON.parse(this.challengejson);
|
|
this.challengejson = '';
|
|
return;
|
|
}
|
|
|
|
// challenge data must be provided by the user when using SSR
|
|
if (isServer) {
|
|
return;
|
|
}
|
|
|
|
await fetch(`${this.challengeUrl}/challenge`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
this.challengeData = data;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching challenge:', error);
|
|
this.status = 'error';
|
|
});
|
|
}
|
|
|
|
async initWorkers() {
|
|
this.solverWorkers = [];
|
|
this.nativeWorkers = [];
|
|
|
|
const num_workers = navigator.hardwareConcurrency;
|
|
for (let i = 0; i < num_workers; i++) {
|
|
const nativeWorker = new SolverWorker();
|
|
const comlinkWorker = Comlink.wrap<SolverWorkerAPI>(nativeWorker);
|
|
|
|
this.nativeWorkers.push(nativeWorker);
|
|
this.solverWorkers.push(comlinkWorker);
|
|
}
|
|
|
|
const atomics_view = new Int32Array(this.sab);
|
|
Atomics.store(atomics_view, 0, 0);
|
|
Atomics.store(atomics_view, 1, 0);
|
|
|
|
let wasm_module = await get_wasm_module();
|
|
let worker_promises: Promise<void>[] = [];
|
|
for (let i = 0; i < this.solverWorkers.length; i++) {
|
|
const solver = this.solverWorkers[i]!;
|
|
worker_promises.push(solver.init(wasm_module, this.sab)); // Direct call to exposed `init` method
|
|
}
|
|
|
|
const timeoutMs = 10 * 1000;
|
|
let timeout: number;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timeout = setTimeout(() => {
|
|
reject(new Error(`Function timeout after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
});
|
|
|
|
await Promise.race([
|
|
Promise.allSettled(worker_promises).then(results => {
|
|
const failedInits = results.filter(r => r.status === 'rejected');
|
|
if (failedInits.length > 0) {
|
|
console.error('Some workers failed to initialize:', failedInits);
|
|
// we might want to collect all errors, and if every
|
|
// worker fails, we can throw, but carry on if only some
|
|
// fail. For now, we'll just throw if any fail.
|
|
|
|
throw new Error("One or more workers failed to initialize.");
|
|
}
|
|
|
|
console.log('All workers initialized');
|
|
return;
|
|
}),
|
|
timeoutPromise,
|
|
]).then(() => {
|
|
clearTimeout(timeout);
|
|
}).catch(error => {
|
|
clearTimeout(timeout);
|
|
console.error("Worker initialization failed:", error);
|
|
this.status = 'error';
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async solveChallenge() {
|
|
if (!this.challengeData || this.solverWorkers === null) {
|
|
// in all normal cases, this should be impossible
|
|
this.status = 'error';
|
|
return;
|
|
}
|
|
|
|
if (this.solution !== null || this.status !== 'unsolved') {
|
|
// do not solve twice
|
|
return;
|
|
}
|
|
|
|
this.solveStartTime = performance.now();
|
|
this.hashRateInterval = setInterval(async () => {
|
|
const nonce = this.getCurrentWorkingNonce();
|
|
|
|
this.hashRate = (nonce / ((performance.now() - this.solveStartTime!) / 1000));
|
|
console.log(this.hashRate);
|
|
}, 250);
|
|
|
|
this.dispatchEvent(new CustomEvent('impost:solve', {
|
|
detail: { challenge: this.challengeData, }, // empty solution
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
|
|
this.status = 'solving';
|
|
this.solution = null;
|
|
|
|
const atomics_view = new Int32Array(this.sab);
|
|
// reset atomics
|
|
Atomics.store(atomics_view, 0, 0);
|
|
Atomics.store(atomics_view, 1, -1);
|
|
|
|
console.log('Sending challenge to workers...');
|
|
// TODO: the first response is not always the solution, due to cmpxchg
|
|
// blocking, some workers may block on the read, and as soon as they
|
|
// unblock, they return 0 since the challenge is already solved.
|
|
//
|
|
// We need to do a better job of tracking solvers, so if one worker
|
|
// errors out, we only error out if all workers have errored out.
|
|
let worker_promises: Promise<string>[] = [];
|
|
for (let solver of this.solverWorkers) {
|
|
worker_promises.push(solver.solve_challenge(this.challengeData as Challenge));
|
|
}
|
|
|
|
try {
|
|
await Promise.race(worker_promises);
|
|
|
|
// The true solution is stored in the SharedArrayBuffer.
|
|
this.solution = String(Atomics.load(atomics_view, 1));
|
|
this.status = 'solved';
|
|
} catch (error: any) {
|
|
console.error("Captcha solving failed:", error);
|
|
this.status = 'error';
|
|
} finally {
|
|
if (this.hashRateInterval !== null) {
|
|
clearInterval(this.hashRateInterval);
|
|
this.hashRateInterval = null;
|
|
}
|
|
}
|
|
|
|
if (this.status === 'solved') {
|
|
this._internals!.setFormValue(JSON.stringify({
|
|
challenge: this.challengeData.salt,
|
|
solution: this.solution,
|
|
}));
|
|
|
|
this.dispatchEvent(new CustomEvent('impost:solved', {
|
|
detail: {
|
|
challenge: this.challengeData,
|
|
solution: this.solution,
|
|
},
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
solvePreventDefault(event: Event) {
|
|
event.preventDefault();
|
|
|
|
this.solveChallenge();
|
|
}
|
|
|
|
override render() {
|
|
if (this.challengejson !== '' && this.challengeData === null) {
|
|
this.challengeData = JSON.parse(this.challengejson);
|
|
this.challengejson = '';
|
|
}
|
|
|
|
if (this.challengeData === null) {
|
|
return html`
|
|
<div class="loading-message">Loading captcha challenge...</div>
|
|
`;
|
|
}
|
|
|
|
return html`
|
|
<div class="impost-widget">
|
|
<div class="impost-main">
|
|
<div class="impost-checkbox">
|
|
${this.status !== 'solving' ? html`${this.status === 'error' ? html`<svg class="impost-error-icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M12 14q-.425 0-.712-.288T11 13V6q0-.425.288-.712T12 5t.713.288T13 6v7q0 .425-.288.713T12 14m0 5q-.425 0-.712-.288T11 18t.288-.712T12 17t.713.288T13 18t-.288.713T12 19"/></svg>` : html`
|
|
<input type="checkbox" id="impost-checkbox-${this.uid}" @click=${this.solvePreventDefault} @change=${this.solvePreventDefault} ?disabled=${this.disabled} ?checked=${this.status === 'solved'}>
|
|
`}` : html`
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
|
<!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
|
|
<g stroke="currentColor">
|
|
<circle cx="12" cy="12" r="9.5" fill="none" stroke-linecap="round" stroke-width="3">
|
|
<animate attributeName="stroke-dasharray" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0 150;42 150;42 150;42 150"/>
|
|
<animate attributeName="stroke-dashoffset" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0;-16;-59;-59"/>
|
|
</circle>
|
|
<animateTransform attributeName="transform" dur="2s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
|
|
</g>
|
|
</svg>
|
|
`}
|
|
</div>
|
|
<label for="impost-checkbox-${this.uid}">${this.status === 'error' ? 'Something went wrong' : this.status === 'solved' ? 'Verified' : html`${this.status === 'solving' ? 'Verifying...' : 'I am not a robot'}`}</label>
|
|
</div>
|
|
|
|
<div class="impost-footer">
|
|
${this.showHashrate && this.status === 'solving' && this.hashRate > 0 ? html`
|
|
<div>
|
|
<span>H/s:</span>
|
|
<span>${this.hashRate.toFixed(2)}</span>
|
|
</div>
|
|
` : ``}
|
|
<div id="provider-link">
|
|
Protected by <a href="https://github.com/impost/pow-captcha" target="_blank">Impost</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
type = "text"
|
|
name = "impost-solution"
|
|
id = "impost-solution-${this.uid}"
|
|
style="display: none;"
|
|
.value = ${this.solution}
|
|
/>
|
|
`;
|
|
}
|
|
}
|