- Implemented across bootstrap.php, chat frontend, and dashboard:

Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps.
Configurable text/voice reply threads and depth.
Registration/login-only honeypot and internal CAPTCHA.
Optional Google reCAPTCHA v2/v3 with server verification.
Archive/export support for replies and bitrate.
Dashboard controls for all settings.
Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and 
browser console. Native PHP lint was unavailable because PHP is not 
installed.
Google implementation follows Siteverify and reCAPTCHA v3.
This commit is contained in:
Ty Clifford
2026-06-09 10:59:58 -04:00
parent 88805057cc
commit eb57cde99f
14 changed files with 844 additions and 110 deletions
+275 -23
View File
@@ -13,6 +13,8 @@
},
loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '',
reply: null,
captcha: { internal: {}, widgets: {}, scriptPromise: null },
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
@@ -20,7 +22,7 @@
let root;
let wakeListenersBound = false;
const displayedFeatures = new Set([
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play',
'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play',
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa'
]);
@@ -151,9 +153,11 @@
state.config = await api('config.php').catch(() => ({
chat: {
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000,
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500,
replies_enabled: true, reply_max_depth: 4
},
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
security: { min_password_length: 4, captcha: {} },
voice: { enabled: true, max_duration_seconds: 60 },
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
}));
if (!wakeListenersBound) {
@@ -192,8 +196,141 @@
});
}
function authCaptchaEnabled(operation) {
const captcha = state.config.security?.captcha || {};
return operation === 'register'
? captcha.registration_enabled === true
: captcha.login_enabled === true;
}
function authCaptchaMarkup(operation) {
if (!authCaptchaEnabled(operation)) return '';
const captcha = state.config.security?.captcha || {};
return `${captcha.honeypot_enabled ? `<div class="cc-captcha-trap" aria-hidden="true">
<label>Website<input id="${operation}-hp-website" tabindex="-1" autocomplete="off"></label>
<label><input id="${operation}-hp-contact" type="checkbox" tabindex="-1"> Contact me</label>
</div>` : ''}
<div class="cc-captcha-block">
${captcha.internal_enabled ? `<div class="cc-internal-captcha">
<label class="cc-field-label">HUMAN CHECK</label>
<div class="cc-captcha-row"><span id="${operation}-captcha-question">LOADING...</span>
<input class="cc-input" id="${operation}-captcha-answer" inputmode="numeric" autocomplete="off" aria-label="CAPTCHA answer">
<button class="cc-captcha-refresh" id="${operation}-captcha-refresh" type="button">NEW</button></div>
</div>` : ''}
${captcha.google_enabled && captcha.google_version === 'v2'
? `<div class="cc-google-captcha" id="${operation}-google-captcha"></div>` : ''}
${captcha.google_enabled && captcha.google_version === 'v3'
? '<div class="cc-captcha-note">Protected by reCAPTCHA v3</div>' : ''}
</div>`;
}
async function loadInternalCaptcha(operation) {
if (!authCaptchaEnabled(operation) || !state.config.security?.captcha?.internal_enabled) return;
const result = await api('auth.php', null, new URLSearchParams({
action: 'captcha_challenge', operation
}).toString()).catch(() => ({ error: 'Could not load CAPTCHA' }));
const question = $(`#${operation}-captcha-question`);
if (result.error || !result.enabled) {
state.captcha.internal[operation] = null;
if (question) question.textContent = 'UNAVAILABLE';
return;
}
state.captcha.internal[operation] = result.token;
if (question) question.textContent = result.question;
const answer = $(`#${operation}-captcha-answer`);
if (answer) answer.value = '';
}
function loadGoogleCaptcha() {
const captcha = state.config.security?.captcha || {};
if (!captcha.google_enabled || !captcha.google_site_key) return Promise.resolve();
if (window.grecaptcha) return Promise.resolve();
if (state.captcha.scriptPromise) return state.captcha.scriptPromise;
state.captcha.scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
const render = captcha.google_version === 'v3'
? encodeURIComponent(captcha.google_site_key) : 'explicit';
script.src = `https://www.google.com/recaptcha/api.js?render=${render}`;
script.async = true;
script.defer = true;
script.onload = resolve;
script.onerror = () => reject(new Error('Google reCAPTCHA could not load'));
document.head.appendChild(script);
});
return state.captcha.scriptPromise;
}
async function renderGoogleCaptcha(operation) {
const captcha = state.config.security?.captcha || {};
if (!authCaptchaEnabled(operation) || !captcha.google_enabled
|| captcha.google_version !== 'v2' || !captcha.google_site_key) return;
await loadGoogleCaptcha();
const target = $(`#${operation}-google-captcha`);
if (!target || state.captcha.widgets[operation] != null) return;
state.captcha.widgets[operation] = window.grecaptcha.render(target, {
sitekey: captcha.google_site_key,
theme: document.body.classList.contains('cc-light') ? 'light' : 'dark',
});
}
async function setupAuthCaptcha() {
['register', 'login'].forEach(operation => {
$(`#${operation}-captcha-refresh`)?.addEventListener('click', () => loadInternalCaptcha(operation));
});
const activeOperation = $('[data-form].active')?.dataset.form;
if (activeOperation === 'register' || activeOperation === 'login') {
void loadInternalCaptcha(activeOperation);
}
const captcha = state.config.security?.captcha || {};
if (!captcha.google_enabled || !captcha.google_site_key) return;
try {
await loadGoogleCaptcha();
const active = $('[data-form].active')?.dataset.form;
if (active === 'register' || active === 'login') await renderGoogleCaptcha(active);
} catch (error) {
authError(error.message);
}
}
async function authForm(operation, values) {
const body = new FormData();
Object.entries(values).forEach(([key, value]) => body.append(key, value));
const captcha = state.config.security?.captcha || {};
if (!authCaptchaEnabled(operation)) return { method: 'POST', body };
if (captcha.honeypot_enabled) {
body.append('_hp_website', $(`#${operation}-hp-website`)?.value || '');
body.append('_hp_contact', $(`#${operation}-hp-contact`)?.checked ? '1' : '0');
}
if (captcha.internal_enabled) {
const token = state.captcha.internal[operation];
const answer = $(`#${operation}-captcha-answer`)?.value.trim() || '';
if (!token || !answer) throw new Error('Complete the human check');
body.append('captcha_internal_token', token);
body.append('captcha_internal_answer', answer);
}
if (captcha.google_enabled) {
if (!captcha.google_site_key || !window.grecaptcha) throw new Error('Google reCAPTCHA is unavailable');
let token = '';
if (captcha.google_version === 'v3') {
token = await new Promise((resolve, reject) => {
window.grecaptcha.ready(() => {
window.grecaptcha.execute(captcha.google_site_key, { action: operation }).then(resolve, reject);
});
});
} else {
const widget = state.captcha.widgets[operation];
if (widget == null) throw new Error('Google reCAPTCHA is still loading');
token = window.grecaptcha.getResponse(widget);
}
if (!token) throw new Error('Complete the Google reCAPTCHA check');
body.append('captcha_google_token', token);
}
return { method: 'POST', body };
}
function refreshAuthCaptcha(operation) {
void loadInternalCaptcha(operation);
const widget = state.captcha.widgets[operation];
if (widget != null && window.grecaptcha) window.grecaptcha.reset(widget);
}
function auth(tab) {
stopPoll();
state.captcha.internal = {};
state.captcha.widgets = {};
const maxUser = state.config.chat?.max_username_length || 12;
const minPass = state.config.security?.min_password_length || 4;
$('#cc-body').innerHTML = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
@@ -207,11 +344,13 @@
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
${authCaptchaMarkup('register')}
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
<p class="cc-form-hint">Welcome back.</p>
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
${authCaptchaMarkup('login')}
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
@@ -221,11 +360,16 @@
$('#register').addEventListener('click', register);
$('#login').addEventListener('click', login);
if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
void setupAuthCaptcha();
}
function switchAuth(name) {
$$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name));
$$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name));
authError('');
if (name === 'register' || name === 'login') {
if (!state.captcha.internal[name]) void loadInternalCaptcha(name);
renderGoogleCaptcha(name).catch(error => authError(error.message));
}
}
function authError(message) {
const item = $('#auth-error');
@@ -237,16 +381,29 @@
const password = $('#reg-pass').value;
if (!username || !password) return authError('Username and password are required');
if (password !== $('#reg-pass2').value) return authError('Passphrases do not match');
const result = await api('auth.php', form({ action: 'register', username, password }));
if (result.error) return authError(result.error);
let request;
try { request = await authForm('register', { action: 'register', username, password }); }
catch (error) { return authError(error.message); }
const result = await api('auth.php', request);
if (result.error) {
refreshAuthCaptcha('register');
return authError(result.error);
}
state.user = result.user;
await enter();
}
async function login() {
const result = await api('auth.php', form({
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
}));
if (result.error) return authError(result.error);
let request;
try {
request = await authForm('login', {
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
});
} catch (error) { return authError(error.message); }
const result = await api('auth.php', request);
if (result.error) {
refreshAuthCaptcha('login');
return authError(result.error);
}
if (result.requires_2fa) {
state.loginChallenge = result.challenge;
switchAuth('2fa');
@@ -306,20 +463,51 @@
else accountView();
}
function voiceBitrate() {
return Math.max(48, Math.min(320, Number(feature('voice_bitrate_kbps', 64)) || 64));
}
function replyPreviewText(message) {
if (!message) return '';
return message.message_type === 'voice' ? '[Voice clip]' : String(message.message || '').slice(0, 120);
}
function setReply(message) {
state.reply = {
id: Number(message.id),
username: message.username,
message: replyPreviewText(message),
depth: Number(message.reply_depth) || 0,
};
const bar = $('#reply-context');
if (bar) {
bar.hidden = false;
$('#reply-context-user').textContent = state.reply.username;
$('#reply-context-text').textContent = state.reply.message;
}
$('#message')?.focus();
}
function clearReply() {
state.reply = null;
const bar = $('#reply-context');
if (bar) bar.hidden = true;
}
function chat() {
const max = state.config.chat?.max_message_length || 500;
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
state.reply = null;
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></div>
<div class="cc-recording-indicator" id="recording" hidden></div>
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span>
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS</span>
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
${feature('auto_voice_play') ? `<label class="cc-voice-autoplay"><input id="auto-play" type="checkbox" ${state.voice.autoPlay ? 'checked' : ''}> AUTO PLAY</label>` : ''}
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
<div class="cc-reply-context" id="reply-context" hidden><span>REPLYING TO <b id="reply-context-user"></b></span>
<span id="reply-context-text"></span><button id="reply-cancel" type="button">CANCEL</button></div>
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
@@ -327,6 +515,7 @@
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
});
$('#send').addEventListener('click', sendText);
$('#reply-cancel').addEventListener('click', clearReply);
$('#record')?.addEventListener('click', toggleVoice);
$('#voice-send')?.addEventListener('click', sendVoice);
$('#voice-discard')?.addEventListener('click', discardVoice);
@@ -343,11 +532,20 @@
const input = $('#message');
const message = input.value.trim();
if (!message) return;
const max = state.config.chat?.max_message_length || 500;
input.value = '';
const result = await api('messages.php', form({ action: 'send', message }))
$('#count').textContent = max;
const result = await api('messages.php', form({
action: 'send', message, reply_to_id: state.reply?.id || ''
}))
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
if (result.error) { input.value = message; return toast(result.error); }
if (result.error) {
input.value = message;
$('#count').textContent = max - message.length;
return toast(result.error);
}
acknowledgeSentMessage(result.message);
clearReply();
requestPoll();
}
function startPoll() {
@@ -471,7 +669,14 @@
function normalizeMessage(message) {
const id = Number(message?.id);
if (!Number.isSafeInteger(id) || id < 1) return null;
return { ...message, id };
const replyToId = Number(message?.reply_to_id);
return {
...message,
id,
reply_to_id: Number.isSafeInteger(replyToId) && replyToId > 0 ? replyToId : null,
reply_depth: Math.max(0, Number(message?.reply_depth) || 0),
voice_bitrate_kbps: Number(message?.voice_bitrate_kbps) || null,
};
}
function acknowledgeSentMessage(message) {
const acknowledged = normalizeMessage(message);
@@ -489,7 +694,9 @@
return JSON.stringify([
message.user_id ?? null, message.username, message.color, message.message,
message.message_type, message.voice_url, message.voice_mime,
message.voice_duration, message.created_at
message.voice_duration, message.voice_bitrate_kbps, message.reply_to_id,
message.reply_depth, message.reply_username, message.reply_message,
message.reply_message_type, message.created_at
]);
}
function compareMessagesChronologically(a, b) {
@@ -562,16 +769,39 @@
}
function createMessageElement(message) {
const id = message.id;
const depth = Math.max(0, Number(message.reply_depth) || 0);
const maxDepth = Math.max(1, Number(state.config.chat?.reply_max_depth) || 4);
const item = document.createElement('div');
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new';
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '')
+ (depth ? ' cc-msg-thread' : '') + ' cc-msg-new';
item.style.setProperty('--cc-reply-indent', `${Math.min(depth, 6) * 12}px`);
item.dataset.id = String(id);
item.dataset.renderKey = messageRenderKey(message);
const content = message.message_type === 'voice' && message.voice_url
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps} KBPS` : ''}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
: linkify(esc(message.message));
const parentText = message.reply_message_type === 'voice'
? '[Voice clip]'
: (message.reply_message || 'Original message unavailable');
const replyReference = message.reply_to_id
? `<button class="cc-reply-ref" type="button" data-parent-id="${message.reply_to_id}">
<b>${esc(message.reply_username || `#${message.reply_to_id}`)}</b>
<span>${esc(String(parentText).slice(0, 120))}</span></button>`
: '';
const replyAction = state.config.chat?.replies_enabled !== false && depth < maxDepth
? '<button class="cc-msg-reply" type="button">REPLY</button>' : '';
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
<span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${content}</span>`;
<span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${replyReference}${content}</span>${replyAction}`;
$('.cc-msg-reply', item)?.addEventListener('click', () => setReply(message));
$('.cc-reply-ref', item)?.addEventListener('click', event => {
const parentId = Number(event.currentTarget.dataset.parentId);
const parent = $(`.cc-msg[data-id="${parentId}"]`);
if (!parent) return toast('Original message is outside the current view', 'info');
parent.scrollIntoView({ behavior: 'smooth', block: 'center' });
parent.classList.add('cc-msg-focus');
setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200);
});
bindAudioPlayers(item);
return item;
}
@@ -644,15 +874,25 @@
];
let recorder = null;
let mime = '';
const audioBitsPerSecond = voiceBitrate() * 1000;
for (const candidate of candidates) {
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
try {
recorder = new MediaRecorder(stream, { mimeType: candidate });
recorder = new MediaRecorder(stream, { mimeType: candidate, audioBitsPerSecond });
mime = candidate;
break;
} catch {}
} catch {
try {
recorder = new MediaRecorder(stream, { mimeType: candidate });
mime = candidate;
break;
} catch {}
}
}
if (!recorder) {
try { recorder = new MediaRecorder(stream, { audioBitsPerSecond }); }
catch { recorder = new MediaRecorder(stream); }
}
if (!recorder) recorder = new MediaRecorder(stream);
state.voice.stream = stream;
state.voice.chunks = [];
state.voice.started = Date.now();
@@ -677,7 +917,9 @@
function voiceTimer() {
const max = state.config.voice?.max_duration_seconds || 60;
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max);
if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`;
if ($('#voice-status')) {
$('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)} · ${voiceBitrate()} KBPS`;
}
if (elapsed >= max) stopVoice(false);
}
function stopVoice(discard) {
@@ -706,7 +948,7 @@
$('#voice-preview').src = state.voice.url;
$('#voice-preview').load();
$('#voice-review').hidden = false;
$('#voice-status').textContent = 'READY ' + duration(length);
$('#voice-status').textContent = `READY ${duration(length)} · ${voiceBitrate()} KBPS`;
}
function discardVoice() {
if (state.voice.recorder) stopVoice(true);
@@ -716,6 +958,9 @@
state.voice.mime = '';
state.voice.duration = 0;
if ($('#voice-review')) $('#voice-review').hidden = true;
if ($('#voice-status') && !state.voice.recorder) {
$('#voice-status').textContent = `VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS`;
}
}
async function sendVoice() {
if (!state.voice.blob || state.voice.sending) return;
@@ -723,6 +968,7 @@
const body = new FormData();
body.append('action', 'send_voice');
body.append('duration', state.voice.duration.toFixed(2));
body.append('reply_to_id', state.reply?.id || '');
const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
: state.voice.mime.includes('aac') ? 'voice.aac'
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
@@ -733,6 +979,7 @@
if (result.error) return toast(result.error);
acknowledgeSentMessage(result.message);
discardVoice();
clearReply();
requestPoll();
}
@@ -753,8 +1000,13 @@
function archiveRow(message) {
const content = message.message_type === 'voice' && message.voice_url
? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message);
const reply = message.reply_to_id
? `<div class="cc-archive-reply">Reply to ${esc(message.reply_username || `#${message.reply_to_id}`)}:
${esc(message.reply_message_type === 'voice' ? '[Voice clip]' : (message.reply_message || 'Original unavailable'))}</div>`
: '';
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
<span class="cc-type-tag">${esc(message.message_type)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps}k` : ''}</span>
<div>${reply}${content}</div></div>`;
}
async function accountView() {