Add Quantum 8 Ball tool and click-to-copy for all outputs

New QTRNG tool: Quantum 8 Ball with all 20 classic Magic 8 Ball
responses, selected via rejection sampling on quantum camera noise.
Includes /8ball API endpoint, sentiment classification (positive/
neutral/negative), and a fun dark-orb UI with shake animation and
color-coded answers. Both output boxes (random bytes + tools) now
support click-to-copy with a toast notification.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-08 16:42:52 -05:00
parent 4d302559e4
commit 7bf292d64e
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
7 changed files with 649 additions and 15 deletions

View File

@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Camera TRNG - True random number generator using camera sensor noise. Generate cryptographically random bytes from thermal and shot noise.">
<title>Camera TRNG - True Random Number Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #ffffff;
@ -64,7 +67,7 @@
}
.skip-to-content:focus { top: 0; outline: 3px solid var(--focus-outline-color); outline-offset: 2px; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6; color: var(--text-color); background-color: var(--bg-color);
margin: 0 auto; padding: 20px; max-width: 800px;
}
@ -135,6 +138,15 @@
line-height: 1.6; min-height: 80px; max-height: 150px; overflow-y: auto; color: var(--accent-color);
}
.output.error { color: #dc3545; }
.output.copyable { cursor: pointer; position: relative; }
.output.copyable:hover { border-color: var(--accent-color); }
.copy-toast {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: var(--accent-color); color: #fff; padding: 0.5rem 1.25rem;
border-radius: 6px; font-size: 0.85rem; font-weight: 500;
opacity: 0; transition: opacity 0.25s; pointer-events: none; z-index: 9999;
}
.copy-toast.show { opacity: 1; }
.stats { display: flex; gap: 2rem; color: var(--date-color); font-size: 0.85rem; margin-top: 1rem; }
.stats span { color: var(--text-color); font-family: monospace; }
pre {
@ -167,8 +179,123 @@
.controls { flex-direction: column; }
.code-tabs { flex-wrap: wrap; }
}
.tools-section { margin-bottom: 1.75rem; }
.tools-section:last-of-type { margin-bottom: 0; }
.tools-section h3 { margin-top: 0; margin-bottom: 0.75rem; font-size: 1.05em; }
#tools-output { margin-top: 1rem; }
.eightball-answer {
font-size: 1.3rem; font-weight: 600; text-align: center; padding: 1.5rem;
border-radius: 50%; width: 180px; height: 180px; margin: 1rem auto;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(circle at 40% 40%, #333, #111);
color: #fff; font-family: serif; line-height: 1.3;
box-shadow: 0 4px 20px rgba(0,0,0,0.3); transition: transform 0.3s;
}
.eightball-answer:hover { transform: scale(1.05); }
.eightball-answer.positive { color: #4cff72; }
.eightball-answer.neutral { color: #ffd644; }
.eightball-answer.negative { color: #ff6b6b; }
@keyframes shake8ball {
0%, 100% { transform: rotate(0deg); }
10% { transform: rotate(-8deg); }
20% { transform: rotate(8deg); }
30% { transform: rotate(-6deg); }
40% { transform: rotate(6deg); }
50% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
70% { transform: rotate(-1deg); }
80% { transform: rotate(1deg); }
}
.eightball-shaking { animation: shake8ball 0.6s ease-in-out; }
/* Footer CTA with shimmer flourish */
.footer-cta {
text-align: center; padding: 1.5rem; margin: 1rem 0;
background: var(--bg-secondary); border-radius: 8px;
position: relative; overflow: hidden;
}
.footer-cta::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
border-radius: 8px; padding: 2px;
background: linear-gradient(90deg,
rgba(0,123,255,0.4), rgba(138,43,226,0.5), rgba(255,0,255,0.5),
rgba(255,20,147,0.5), rgba(0,255,255,0.5), rgba(0,200,255,0.5),
rgba(0,123,255,0.4));
background-size: 300% 100%;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
animation: shimmer 4.5s linear infinite; pointer-events: none;
}
@keyframes shimmer { 0% { background-position: 0% 0%; } 100% { background-position: 300% 0%; } }
.footer-cta > * { position: relative; z-index: 1; }
.footer-cta p { margin: 0; font-size: 1.1rem; }
.cta-link-btn {
background: none; border: none; color: var(--accent-color); font-size: inherit;
font-weight: 600; cursor: pointer; text-decoration: underline; padding: 0;
font-family: 'Montserrat', sans-serif;
}
.cta-link-btn:hover { color: var(--hover-color); }
.accessibility-notice {
font-size: 0.8rem; color: var(--date-color); line-height: 1.5;
margin: 1rem 0; padding: 0.75rem 0;
}
/* Contact Modal */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); display: flex; align-items: center;
justify-content: center; z-index: 9999; padding: 1rem;
}
.modal-content {
background: var(--bg-primary); border-radius: 12px; padding: 2rem;
max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto;
position: relative; box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.modal-close {
position: absolute; top: 1rem; right: 1rem; background: none; border: none;
font-size: 1.5rem; cursor: pointer; color: var(--text-color); line-height: 1;
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--accent-color); }
#modal-title { margin-top: 0; margin-bottom: 0.5rem; color: var(--accent-color); }
.modal-subtitle { color: var(--date-color); margin-bottom: 1.5rem; }
.modal-content .form-group { margin-bottom: 1rem; }
.modal-content .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
.modal-content .form-group input[type="text"],
.modal-content .form-group input[type="email"],
.modal-content .form-group input[type="tel"],
.modal-content .form-group textarea {
width: 100%; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: 4px;
background: var(--bg-primary); color: var(--text-color); font-size: 1rem;
font-family: 'Montserrat', sans-serif; box-sizing: border-box;
}
.modal-content .form-group input:focus,
.modal-content .form-group textarea:focus {
outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.modal-content .form-group textarea { resize: vertical; min-height: 80px; }
.modal-content .form-hint { font-size: 0.85rem; color: var(--date-color); margin-top: 0.25rem; }
.modal-content .checkbox-group label {
display: flex; align-items: flex-start; gap: 0.5rem; font-weight: normal; cursor: pointer;
}
.modal-content .checkbox-group input[type="checkbox"] { margin-top: 0.25rem; width: auto; }
.modal-content .form-message {
padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem;
}
.modal-content .form-message.success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
.modal-content .form-message.error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
.modal-content .form-message.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
.modal-content .cta-button {
display: block; width: 100%; padding: 0.75rem 1.5rem; background: var(--accent-color);
color: white; border: none; border-radius: 4px; font-size: 1rem; font-weight: 500;
cursor: pointer; transition: background 0.2s; font-family: 'Montserrat', sans-serif;
}
.modal-content .cta-button:hover { background: var(--hover-color); }
.modal-content .cta-button:disabled { opacity: 0.6; cursor: not-allowed; }
@media (max-width: 480px) { .modal-content { padding: 1.5rem; } }
</style>
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
@ -267,6 +394,74 @@
</div>
</div>
<div class="card">
<h2>QTRNG Tools</h2>
<p style="margin-bottom:1.25rem;color:var(--date-color);">Quantum dice, passwords, coin flips, and a Magic 8 Ball — all powered by quantum camera noise.</p>
<div class="tools-section">
<h3>Quantum Dice</h3>
<div class="controls">
<div class="field">
<label for="dice-sides">Sides (d)</label>
<input type="number" id="dice-sides" value="6" min="2" max="256">
</div>
<div class="field">
<label for="dice-count">Count</label>
<input type="number" id="dice-count" value="1" min="1" max="100">
</div>
<button id="btn-dice" class="btn-primary">Roll</button>
</div>
</div>
<div class="tools-section">
<h3>Quantum Password Generator</h3>
<div class="controls">
<div class="field">
<label for="pw-length">Length</label>
<input type="number" id="pw-length" value="16" min="1" max="128">
</div>
<div class="field">
<label for="pw-charset">Charset</label>
<select id="pw-charset">
<option value="alphanumeric">Alphanumeric</option>
<option value="full">Full (+ symbols)</option>
<option value="hex">Hex</option>
</select>
</div>
<div class="field" style="display:flex;align-items:flex-end;gap:0.5rem;">
<label class="toggle-label" for="pw-no-ambiguous" style="text-transform:none;white-space:nowrap;cursor:pointer;display:flex;align-items:center;gap:0.4rem;">
<input type="checkbox" id="pw-no-ambiguous" checked>
Exclude ambiguous <code style="font-size:0.8em;opacity:0.7;">0 O o I l 1 |</code>
</label>
</div>
<button id="btn-password" class="btn-primary">Generate</button>
</div>
</div>
<div class="tools-section">
<h3>Quantum Coin Flip</h3>
<div class="controls">
<button id="btn-coin" class="btn-primary">Flip</button>
</div>
</div>
<div class="tools-section">
<h3>Quantum 8 Ball</h3>
<div class="controls">
<div class="field" style="flex:3;">
<label for="eightball-question">Ask the Quantum Void</label>
<input type="text" id="eightball-question" placeholder="Will I ever understand quantum mechanics?" maxlength="200">
</div>
<button id="btn-eightball" class="btn-primary">Shake</button>
</div>
<div class="eightball-answer" id="eightball-orb" style="display:none;"></div>
</div>
<div class="output" id="tools-output">Use the buttons above.</div>
</div>
<div class="card">
<h2>API Usage</h2>
<p style="margin-bottom:1rem;color:var(--date-color);">Examples update automatically based on your settings above.</p>
@ -310,6 +505,58 @@
</div>
</main>
<footer>
<hr>
<p class="accessibility-notice"><strong>Accessibility:</strong> This website is designed and developed to meet WCAG 2.1 Level AAA standards, ensuring the highest level of accessibility for all users. Features include high contrast ratios, keyboard navigation, screen reader compatibility, and responsive design. The site supports both light and dark modes with automatic system preference detection.</p>
<div id="footer-cta" class="footer-cta">
<p>Interested in working together? <button type="button" id="open-contact-modal" class="cta-link-btn">Get in touch</button></p>
</div>
<!-- Contact Modal -->
<div id="contact-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="modal-title" style="display: none;">
<div class="modal-content">
<button type="button" class="modal-close" id="close-contact-modal" aria-label="Close contact form">&times;</button>
<h2 id="modal-title">Let's Work Together</h2>
<p class="modal-subtitle">Have a project in mind? Send me a message.</p>
<form id="contactForm">
<input type="hidden" name="form_id" value="colinknapp_contact">
<div class="form-group">
<label for="contact-name">Name</label>
<input type="text" id="contact-name" name="name" required maxlength="100" placeholder="Your name">
</div>
<div class="form-group">
<label for="contact-email">Email</label>
<input type="email" id="contact-email" name="email" maxlength="254" placeholder="your@email.com">
</div>
<div class="form-group">
<label for="contact-phone">Phone</label>
<input type="tel" id="contact-phone" name="phone" required inputmode="tel" placeholder="(555) 555-5555" maxlength="20">
<p class="form-hint">Required so I can reach you</p>
</div>
<div class="form-group">
<label for="contact-message">Message</label>
<textarea id="contact-message" name="message" rows="4" required maxlength="2000" placeholder="Tell me about your project..."></textarea>
</div>
<!-- Honeypot -->
<div class="form-group" style="display: none;">
<label for="website">Website</label>
<input type="text" id="website" name="website" autocomplete="off">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="contact-accept" name="accept_terms" required>
I agree to the <a href="https://motherboardrepair.ca/privacy.html" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
</label>
</div>
<div id="contact-form-message" class="form-message" style="display: none;"></div>
<button type="submit" class="cta-button" id="contact-submit-btn">Send Message</button>
</form>
</div>
</div>
</footer>
<div class="copy-toast" id="copy-toast">Copied!</div>
<script>
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
@ -375,6 +622,28 @@
formatSelect.addEventListener('change', updateCodeExamples);
updateCodeExamples();
// Click-to-copy for output boxes
const copyToast = document.getElementById('copy-toast');
let copyTimeout;
function copyToClipboard(text) {
if (!text || text.startsWith('Click generate') || text.startsWith('Use the buttons') || text.startsWith('Error:') || text === '...' || text.startsWith('Capturing') || text.startsWith('Rolling') || text.startsWith('Generating') || text.startsWith('Flipping')) return;
navigator.clipboard.writeText(text).then(() => {
clearTimeout(copyTimeout);
copyToast.classList.add('show');
copyTimeout = setTimeout(() => copyToast.classList.remove('show'), 1500);
});
}
// Make both output boxes clickable-to-copy
output.classList.add('copyable');
output.title = 'Click to copy';
output.addEventListener('click', () => copyToClipboard(output.textContent));
toolsOutput.classList.add('copyable');
toolsOutput.title = 'Click to copy';
toolsOutput.addEventListener('click', () => copyToClipboard(toolsOutput.textContent));
// Random generator
const btn = document.getElementById('generate');
const output = document.getElementById('output');
@ -412,6 +681,184 @@
} catch (e) { output.className = 'output error'; output.textContent = 'Error: ' + e.message; }
btn.disabled = false;
});
const toolsOutput = document.getElementById('tools-output');
const btnDice = document.getElementById('btn-dice');
const btnPassword = document.getElementById('btn-password');
const btnCoin = document.getElementById('btn-coin');
const btnEightball = document.getElementById('btn-eightball');
const eightballOrb = document.getElementById('eightball-orb');
function setToolsLoading(loading) {
toolsOutput.className = loading ? 'output loading' : 'output';
btnDice.disabled = btnPassword.disabled = btnCoin.disabled = btnEightball.disabled = loading;
}
btnDice.addEventListener('click', async () => {
setToolsLoading(true);
toolsOutput.style.fontFamily = ''; toolsOutput.style.fontSize = ''; toolsOutput.style.letterSpacing = ''; toolsOutput.textContent = 'Rolling...';
try {
const d = document.getElementById('dice-sides').value || 6;
const c = document.getElementById('dice-count').value || 1;
const r = await fetch(origin + '/dice?d=' + d + '&count=' + c);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
toolsOutput.textContent = 'Rolls: ' + j.rolls.join(', ') + (j.count > 1 ? ' Sum: ' + j.sum : '');
} catch (e) { toolsOutput.className = 'output error'; toolsOutput.textContent = 'Error: ' + e.message; }
setToolsLoading(false);
});
btnPassword.addEventListener('click', async () => {
setToolsLoading(true);
toolsOutput.textContent = 'Generating...';
try {
const len = document.getElementById('pw-length').value || 16;
const cs = document.getElementById('pw-charset').value || 'alphanumeric';
const noAmb = document.getElementById('pw-no-ambiguous').checked;
let url = origin + '/password?length=' + len + '&charset=' + cs;
if (noAmb) url += '&exclude_ambiguous=true';
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
toolsOutput.style.fontFamily = "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace";
toolsOutput.style.fontSize = '1.1rem';
toolsOutput.style.letterSpacing = '0.08em';
toolsOutput.textContent = j.password;
} catch (e) { toolsOutput.className = 'output error'; toolsOutput.textContent = 'Error: ' + e.message; }
setToolsLoading(false);
});
btnCoin.addEventListener('click', async () => {
setToolsLoading(true);
toolsOutput.style.fontFamily = ''; toolsOutput.style.fontSize = ''; toolsOutput.style.letterSpacing = ''; toolsOutput.textContent = 'Flipping...';
try {
const r = await fetch(origin + '/coin');
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
toolsOutput.textContent = 'Result: ' + j.result;
} catch (e) { toolsOutput.className = 'output error'; toolsOutput.textContent = 'Error: ' + e.message; }
setToolsLoading(false);
});
</script>
<!-- Contact Modal Logic -->
<script>
(function() {
'use strict';
var modal = document.getElementById('contact-modal');
var openBtn = document.getElementById('open-contact-modal');
var closeBtn = document.getElementById('close-contact-modal');
var form = document.getElementById('contactForm');
var submitBtn = document.getElementById('contact-submit-btn');
var formMessage = document.getElementById('contact-form-message');
var phoneInput = document.getElementById('contact-phone');
if (!modal || !form) return;
function openModal() {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
var fi = modal.querySelector('input, button');
if (fi) fi.focus();
}
function closeModal() {
modal.style.display = 'none';
document.body.style.overflow = '';
}
if (openBtn) openBtn.addEventListener('click', openModal);
if (closeBtn) closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.style.display === 'flex') closeModal();
});
if (phoneInput) {
phoneInput.addEventListener('input', function(e) {
var v = e.target.value.replace(/\D/g, '');
if (v.length > 10) v = v.slice(0, 10);
if (v.length >= 6) v = '(' + v.slice(0,3) + ') ' + v.slice(3,6) + '-' + v.slice(6);
else if (v.length >= 3) v = '(' + v.slice(0,3) + ') ' + v.slice(3);
else if (v.length > 0) v = '(' + v;
e.target.value = v;
});
}
function showMessage(type, message) {
formMessage.textContent = message;
formMessage.className = 'form-message ' + type;
formMessage.style.display = 'block';
if (type === 'success') {
setTimeout(function() { formMessage.style.display = 'none'; closeModal(); }, 3000);
}
}
function sanitizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/[\u0000-\u001F\u007F]/g, '').trim().replace(/\s+/g, ' ');
}
function sanitizeMultiline(input) {
if (typeof input !== 'string') return '';
return input.replace(/[\u0000-\u0009\u000B-\u001F\u007F]/g, '').replace(/[<>]/g, '');
}
form.addEventListener('submit', function(e) {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
var fd = new FormData(form);
if (fd.get('website')) {
submitBtn.disabled = false; submitBtn.textContent = 'Send Message';
showMessage('success', 'Thank you! Your message has been received.');
return;
}
var data = {
form_id: 'colinknapp_contact',
name: sanitizeText(fd.get('name')).slice(0, 100),
email: (fd.get('email') || '').trim().slice(0, 254),
phone: (fd.get('phone') || '').replace(/\D/g, '').slice(0, 10),
message: sanitizeMultiline(fd.get('message')).slice(0, 2000)
};
if (!data.name) { showMessage('error', 'Please enter your name.'); submitBtn.disabled = false; submitBtn.textContent = 'Send Message'; return; }
if (!data.phone || data.phone.length < 10) { showMessage('error', 'Please enter a valid phone number.'); submitBtn.disabled = false; submitBtn.textContent = 'Send Message'; return; }
if (!data.message) { showMessage('error', 'Please enter a message.'); submitBtn.disabled = false; submitBtn.textContent = 'Send Message'; return; }
showMessage('info', 'Sending your message...');
fetch('https://forms.motherboardrepair.ca/api/submit', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function(r) { if (!r.ok) throw new Error('Server error: ' + r.status); return r.json(); })
.then(function() {
showMessage('success', "Thank you! I'll get back to you soon.");
form.reset(); submitBtn.disabled = false; submitBtn.textContent = 'Send Message';
})
.catch(function(err) {
console.error('Form error:', err);
showMessage('error', 'Sorry, there was an issue. Please try again.');
submitBtn.disabled = false; submitBtn.textContent = 'Send Message';
});
});
})();
btnEightball.addEventListener('click', async () => {
setToolsLoading(true);
toolsOutput.style.fontFamily = ''; toolsOutput.style.fontSize = ''; toolsOutput.style.letterSpacing = '';
toolsOutput.textContent = '';
eightballOrb.style.display = 'flex';
eightballOrb.className = 'eightball-answer eightball-shaking';
eightballOrb.textContent = '...';
try {
const q = document.getElementById('eightball-question').value;
let url = origin + '/8ball';
if (q) url += '?question=' + encodeURIComponent(q);
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
eightballOrb.className = 'eightball-answer ' + j.sentiment;
eightballOrb.textContent = j.answer;
if (q) {
toolsOutput.textContent = 'Q: ' + q;
}
} catch (e) {
eightballOrb.style.display = 'none';
toolsOutput.className = 'output error';
toolsOutput.textContent = 'Error: ' + e.message;
}
setToolsLoading(false);
});
</script>
</body>
</html>

View File

@ -16,7 +16,8 @@ pub use entropy::{
};
// Re-export the OpenSSL provider init for cdylib
pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH};
pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH, filter_ambiguous};
pub use tools::{eightball_shake, eightball_bytes_needed, eightball_sentiment, EIGHTBALL_RESPONSES, EIGHTBALL_NUM_RESPONSES};
pub use provider::OSSL_provider_init;

View File

@ -12,7 +12,7 @@ use axum::{
};
use camera_trng::{extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, CameraConfig, CHUNK_SIZE};
mod qrng_handlers;
use qrng_handlers::{get_dice, get_password, get_coin};
use qrng_handlers::{get_dice, get_password, get_coin, get_eightball};
use bytes::Bytes;
use std::sync::{Arc, Mutex};
use serde_json::json;
@ -74,6 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/dice", get(get_dice))
.route("/password", get(get_password))
.route("/coin", get(get_coin))
.route("/8ball", get(get_eightball))
.route("/health", get(health))
.route("/.well-known/mcp.json", get(mcp_wellknown))
.route("/.well-known/skill.md", get(get_skill_md))

View File

@ -1,4 +1,4 @@
//! HTTP handlers for QTRNG-backed tools: dice, passwords.
//! HTTP handlers for QTRNG-backed tools: dice, passwords, coin, 8 ball.
use axum::{
extract::Query,
@ -14,6 +14,9 @@ use camera_trng::{
charset_full,
charset_hex,
charset_from_flags,
filter_ambiguous,
eightball_shake,
eightball_bytes_needed,
CameraConfig,
DEFAULT_SIDES,
DEFAULT_COUNT,
@ -45,6 +48,14 @@ pub struct PasswordQuery {
pub length: Option<usize>,
#[serde(default)]
pub charset: Option<String>,
#[serde(default)]
pub exclude_ambiguous: bool,
}
#[derive(Deserialize)]
pub struct EightBallQuery {
#[serde(default)]
pub question: Option<String>,
}
pub async fn get_dice(Query(params): Query<DiceQuery>) -> Response {
@ -52,15 +63,21 @@ pub async fn get_dice(Query(params): Query<DiceQuery>) -> Response {
let count = params.count;
if sides < MIN_SIDES || sides > MAX_SIDES {
return (StatusCode::BAD_REQUEST, format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES)).into_response();
return (StatusCode::BAD_REQUEST,
format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES))
.into_response();
}
if count == 0 || count > camera_trng::DICE_MAX_COUNT {
return (StatusCode::BAD_REQUEST, format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT)).into_response();
return (StatusCode::BAD_REQUEST,
format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT))
.into_response();
}
let need = dice_bytes_needed(sides, count);
let config = CameraConfig::from_env();
let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await;
let result = tokio::task::spawn_blocking(move || {
extract_entropy(need, &config)
}).await;
let data = match result {
Ok(Ok(d)) => d,
@ -87,12 +104,14 @@ pub async fn get_dice(Query(params): Query<DiceQuery>) -> Response {
}
pub async fn get_password(Query(params): Query<PasswordQuery>) -> Response {
let length = params.length.unwrap_or(PASSWORD_DEFAULT_LENGTH).min(PASSWORD_MAX_LENGTH);
let length = params.length
.unwrap_or(PASSWORD_DEFAULT_LENGTH)
.min(PASSWORD_MAX_LENGTH);
if length == 0 {
return (StatusCode::BAD_REQUEST, "length must be >= 1").into_response();
}
let charset: Vec<u8> = match params.charset.as_deref() {
let mut charset: Vec<u8> = match params.charset.as_deref() {
Some("alphanumeric") | None => charset_alphanumeric(),
Some("full") => charset_full(),
Some("hex") => charset_hex(),
@ -107,9 +126,20 @@ pub async fn get_password(Query(params): Query<PasswordQuery>) -> Response {
}
};
if params.exclude_ambiguous {
charset = filter_ambiguous(&charset);
if charset.is_empty() {
return (StatusCode::BAD_REQUEST,
"charset empty after removing ambiguous chars")
.into_response();
}
}
let need = length * 4 + 64;
let config = CameraConfig::from_env();
let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await;
let result = tokio::task::spawn_blocking(move || {
extract_entropy(need, &config)
}).await;
let data = match result {
Ok(Ok(d)) => d,
@ -119,12 +149,13 @@ pub async fn get_password(Query(params): Query<PasswordQuery>) -> Response {
let password = match generate_password(length, &charset, &data) {
Some(p) => p,
None => return (StatusCode::INTERNAL_SERVER_ERROR, "password generation failed").into_response(),
None => return (StatusCode::INTERNAL_SERVER_ERROR, "generation failed").into_response(),
};
let body = serde_json::json!({
"password": password,
"length": length,
"exclude_ambiguous": params.exclude_ambiguous,
});
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
@ -136,7 +167,9 @@ pub async fn get_password(Query(params): Query<PasswordQuery>) -> Response {
/// Quantum coin flip: one random bit from QTRNG.
pub async fn get_coin() -> Response {
let config = CameraConfig::from_env();
let result = tokio::task::spawn_blocking(move || extract_entropy(1, &config)).await;
let result = tokio::task::spawn_blocking(move || {
extract_entropy(1, &config)
}).await;
let data = match result {
Ok(Ok(d)) if !d.is_empty() => d[0],
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "entropy failed").into_response(),
@ -149,3 +182,38 @@ pub async fn get_coin() -> Response {
.body(axum::body::Body::from(body.to_string()))
.unwrap()
}
/// Quantum 8 Ball: ask a question, get a quantumly-random Magic 8 Ball answer.
pub async fn get_eightball(Query(params): Query<EightBallQuery>) -> Response {
let need = eightball_bytes_needed();
let config = CameraConfig::from_env();
let result = tokio::task::spawn_blocking(move || {
extract_entropy(need, &config)
}).await;
let data = match result {
Ok(Ok(d)) => d,
Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let (index, answer, sentiment) = match eightball_shake(&data) {
Some(r) => r,
None => return (StatusCode::INTERNAL_SERVER_ERROR, "8 ball shaking failed").into_response(),
};
let mut body = serde_json::json!({
"answer": answer,
"sentiment": sentiment,
"index": index,
});
if let Some(q) = params.question {
body["question"] = serde_json::Value::String(q);
}
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.header(header::CACHE_CONTROL, "no-store")
.body(axum::body::Body::from(body.to_string()))
.unwrap()
}

103
src/tools/eightball.rs Normal file
View File

@ -0,0 +1,103 @@
//! Quantum 8 Ball - a silly Magic 8 Ball powered by QTRNG.
//!
//! Uses quantum random bytes from camera noise to pick a response,
//! because the universe should decide your fate, not a PRNG.
/// The classic 20 Magic 8 Ball responses, split by sentiment.
pub const RESPONSES: &[&str] = &[
// Positive (10)
"It is certain.",
"It is decidedly so.",
"Without a doubt.",
"Yes, definitely.",
"You may rely on it.",
"As I see it, yes.",
"Most likely.",
"Outlook good.",
"Yes.",
"Signs point to yes.",
// Neutral (5)
"Reply hazy, try again.",
"Ask again later.",
"Better not tell you now.",
"Cannot predict now.",
"Concentrate and ask again.",
// Negative (5)
"Don't count on it.",
"My reply is no.",
"My sources say no.",
"Outlook not so good.",
"Very doubtful.",
];
/// Number of responses.
pub const NUM_RESPONSES: usize = 20;
/// Sentiment category for a response index.
pub fn sentiment(index: usize) -> &'static str {
match index {
0..=9 => "positive",
10..=14 => "neutral",
_ => "negative",
}
}
/// Pick a response using quantum random bytes. Uses rejection sampling
/// for uniform distribution across 20 responses.
/// Returns `(index, response, sentiment)` or `None` if not enough bytes.
pub fn shake(bytes: &[u8]) -> Option<(usize, &'static str, &'static str)> {
let n = NUM_RESPONSES as u32;
let threshold = (u32::MAX / n) * n;
let mut idx = 0;
loop {
if idx + 4 > bytes.len() {
return None;
}
let word = u32::from_be_bytes([bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3]]);
idx += 4;
if word < threshold {
let i = (word % n) as usize;
return Some((i, RESPONSES[i], sentiment(i)));
}
}
}
/// Estimate bytes needed (very generous for rejection sampling with n=20).
#[allow(dead_code)]
pub fn bytes_needed() -> usize {
// n=20: threshold acceptance is ~99.99%, so 8 bytes is more than enough.
// We request 16 to be safe.
16
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shake_returns_valid_response() {
let bytes = [0u8; 32];
let result = shake(&bytes);
assert!(result.is_some());
let (i, resp, sent) = result.unwrap();
assert!(i < NUM_RESPONSES);
assert_eq!(resp, RESPONSES[i]);
assert!(["positive", "neutral", "negative"].contains(&sent));
}
#[test]
fn shake_needs_at_least_4_bytes() {
let bytes = [0u8; 3];
assert!(shake(&bytes).is_none());
}
#[test]
fn sentiment_categories() {
assert_eq!(sentiment(0), "positive");
assert_eq!(sentiment(9), "positive");
assert_eq!(sentiment(10), "neutral");
assert_eq!(sentiment(14), "neutral");
assert_eq!(sentiment(15), "negative");
assert_eq!(sentiment(19), "negative");
}
}

View File

@ -1,10 +1,12 @@
//! QTRNG-backed tools: dice, passwords, etc.
//! QTRNG-backed tools: dice, passwords, 8 ball, etc.
mod dice;
mod eightball;
mod password;
pub use dice::{roll_dice, bytes_needed as dice_bytes_needed, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT, MAX_SIDES, MIN_SIDES};
pub use password::{
generate_password, charset_from_flags, charset_alphanumeric, charset_full, charset_hex,
DEFAULT_LENGTH, MAX_LENGTH,
DEFAULT_LENGTH, MAX_LENGTH, filter_ambiguous,
};
pub use eightball::{shake as eightball_shake, bytes_needed as eightball_bytes_needed, sentiment as eightball_sentiment, RESPONSES as EIGHTBALL_RESPONSES, NUM_RESPONSES as EIGHTBALL_NUM_RESPONSES};

View File

@ -94,3 +94,15 @@ pub fn bytes_needed(length: usize, charset_len: usize) -> usize {
let accept_prob = threshold as f64 / u32::MAX as f64;
(4.0_f64 / accept_prob * length as f64).ceil() as usize
}
/// Characters that look ambiguous in many fonts.
pub const AMBIGUOUS_CHARS: &[u8] = b"0OoIl1|";
/// Remove ambiguous characters from a charset.
pub fn filter_ambiguous(charset: &[u8]) -> Vec<u8> {
charset
.iter()
.copied()
.filter(|c| !AMBIGUOUS_CHARS.contains(c))
.collect()
}