logs/index.html
2026-06-04 23:56:59 +00:00

1036 lines
No EOL
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>twonly Log Decryptor & Viewer</title>
<style>
:root {
--bg-primary: #141217;
--bg-secondary: #26222d;
--bg-card: rgba(45, 41, 54, 0.4);
--accent: #57cc99;
--accent-hover: #38b000;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--text-error: #ef4444;
--text-success: #10b981;
--border-color: rgba(255, 255, 255, 0.08);
--glass-glow: rgba(87, 204, 153, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
line-height: 1.6;
background-image:
radial-gradient(at 0% 0%, rgba(87, 204, 153, 0.05) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(56, 176, 0, 0.05) 0px, transparent 50%);
}
/* Header Styling */
header {
background-color: rgba(38, 34, 45, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.logo-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-badge {
background-color: var(--accent);
color: var(--bg-primary);
font-weight: 700;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.9rem;
letter-spacing: 0.05em;
text-transform: uppercase;
box-shadow: 0 0 15px rgba(87, 204, 153, 0.3);
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
/* Container Layout */
main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 1rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Glassmorphism Card styling */
.card {
background: var(--bg-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 1.25rem;
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.3);
transition: box-shadow 0.3s ease, border-color 0.3s ease;
}
.card:hover {
border-color: rgba(87, 204, 153, 0.2);
}
h2 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
p.description {
color: var(--text-secondary);
margin-bottom: 0.75rem;
font-size: 0.88rem;
}
/* Form Fields */
.input-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input[type="text"] {
background-color: rgba(20, 18, 23, 0.8);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
}
input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 10px rgba(87, 204, 153, 0.15);
}
/* Buttons */
.btn-container {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
background-color: var(--accent);
color: var(--bg-primary);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:hover {
background-color: var(--accent-hover);
}
.btn:active {
transform: scale(0.98);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.1);
box-shadow: none;
}
/* Drag & Drop Area */
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 1.25rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
background-color: rgba(20, 18, 23, 0.3);
margin-bottom: 0.75rem;
}
.drop-zone.hover {
border-color: var(--accent);
background-color: rgba(87, 204, 153, 0.05);
}
.drop-icon {
font-size: 1.75rem;
color: var(--accent);
margin-bottom: 0.4rem;
}
.drop-text {
font-size: 0.88rem;
color: var(--text-secondary);
}
.drop-text b {
color: var(--accent);
}
#file-input {
display: none;
}
/* Progress & Loader */
.status-container {
display: none;
flex-direction: column;
gap: 1rem;
}
.status-step {
display: flex;
align-items: center;
gap: 1rem;
color: var(--text-secondary);
font-size: 0.95rem;
}
.status-icon {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.8rem;
}
.status-step.pending .status-icon {
color: var(--text-secondary);
border-color: var(--border-color);
}
.status-step.active .status-icon {
color: var(--accent);
border-color: var(--accent);
border-top-color: transparent;
animation: spin 1s linear infinite;
}
.status-step.success .status-icon {
color: var(--bg-primary);
background-color: var(--text-success);
border-color: var(--text-success);
}
.status-step.success .status-icon::before {
content: '✓';
}
.status-step.error .status-icon {
color: var(--bg-primary);
background-color: var(--text-error);
border-color: var(--text-error);
}
.status-step.error .status-icon::before {
content: '✗';
}
.status-step.success {
color: var(--text-primary);
}
.status-step.error {
color: var(--text-error);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Log Viewer styling */
.viewer-card {
display: none;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.75rem;
}
.viewer-controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.search-input-wrapper {
position: relative;
}
.search-input-wrapper input {
padding-right: 2.5rem;
width: 250px;
}
.log-container {
background-color: rgba(20, 18, 23, 0.95);
border: 1px solid var(--border-color);
border-radius: 6px;
max-height: 70vh;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.82rem;
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
}
.log-line {
display: block;
margin-bottom: 0.25rem;
border-left: 2px solid transparent;
padding-left: 0.5rem;
}
.log-line.error {
color: #f87171;
border-left-color: var(--text-error);
background-color: rgba(239, 68, 68, 0.05);
}
.log-line.warn {
color: #fbbf24;
border-left-color: #fbbf24;
background-color: rgba(251, 191, 36, 0.05);
}
.log-line.info {
color: #60a5fa;
}
.log-line.success {
color: #34d399;
}
/* Utility classes */
.hidden {
display: none !important;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--text-error);
}
.alert-info {
background-color: rgba(87, 204, 153, 0.05);
border: 1px solid rgba(87, 204, 153, 0.15);
color: var(--text-primary);
}
/* Footer */
footer {
text-align: center;
padding: 1rem;
color: var(--text-secondary);
font-size: 0.8rem;
border-top: 1px solid var(--border-color);
}
/* Full Screen Log Loaded Layout override */
body.log-loaded {
height: 100vh;
overflow: hidden;
}
body.log-loaded main {
max-width: none;
width: 100%;
margin: 0;
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0;
min-height: 0;
}
body.log-loaded footer {
display: none;
}
body.log-loaded .viewer-card {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: none;
border-radius: 0;
box-shadow: none;
padding: 0;
min-height: 0;
}
body.log-loaded .viewer-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
body.log-loaded .log-container {
flex: 1;
max-height: none;
border: none;
border-radius: 0;
padding: 1.5rem;
background-color: rgba(20, 18, 23, 0.6);
overflow-y: auto;
}
</style>
</head>
<body>
<header>
<div class="logo-container">
<span class="logo-badge">twonly</span>
<span class="logo-text">Debug Log Viewer</span>
</div>
</header>
<main>
<!-- Setup / Input Form -->
<section class="card" id="setup-card">
<h2>Load Log File</h2>
<p class="description">Enter the twonly log link or drop the downloaded binary file to decompress, decrypt, and
inspect the logs locally in your browser.</p>
<div id="error-alert" class="alert alert-error hidden"></div>
<div class="input-group">
<label for="log-url">Log Link or Hash</label>
<input type="text" id="log-url" placeholder="https://logs.twonly.eu#downloadtoken/encryption_key" value="">
</div>
<div class="drop-zone" id="drop-zone">
<div class="drop-icon">📥</div>
<div class="drop-text">Drag and drop the downloaded <b>upload</b> file here, or <b>click to browse</b></div>
<input type="file" id="file-input">
</div>
<div class="btn-container">
<button class="btn" id="btn-decrypt-url">Load & Decrypt Log</button>
<button class="btn btn-secondary" id="btn-clear-fields">Clear</button>
</div>
</section>
<!-- Progress Status Steps -->
<section class="card status-container" id="status-card">
<h2>Decrypting Log...</h2>
<div class="status-step pending" id="step-fetch">
<div class="status-icon"></div>
<div>Fetching encrypted file from server...</div>
</div>
<div class="status-step pending" id="step-parse">
<div class="status-icon"></div>
<div>Parsing custom Protobuf wrapper...</div>
</div>
<div class="status-step pending" id="step-decrypt">
<div class="status-icon"></div>
<div>Decrypting ciphertext (AES-GCM-256)...</div>
</div>
<div class="status-step pending" id="step-decompress">
<div class="status-icon"></div>
<div>Decompressing data (GZip)...</div>
</div>
</section>
<!-- Decrypted Log Output -->
<section class="card viewer-card" id="viewer-card">
<div class="viewer-header">
<div>
<h2>Decrypted Log Output</h2>
<div id="log-stats" style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.25rem;">0 lines
loaded</div>
</div>
<div class="viewer-controls">
<div class="search-input-wrapper">
<input type="text" id="search-query" placeholder="Filter logs (e.g. error, sql)...">
</div>
<button class="btn btn-secondary" id="btn-copy-log" style="padding: 0.5rem 1rem;">Copy Log</button>
<button class="btn" id="btn-download-log" style="padding: 0.5rem 1rem;">Download Text</button>
<button class="btn btn-secondary" id="btn-close-log" style="padding: 0.5rem 1rem;">Load Another</button>
</div>
</div>
<div class="log-container" id="log-display"></div>
</section>
</main>
<footer>
<p>Everything is executed locally in your browser. Your encryption keys and logs are never uploaded.</p>
</footer>
<script>
// Constants
const API_DOWNLOAD_BASE = 'https://api.twonly.eu/api/download/';
// State Variables
let rawProtobufBytes = null;
let decryptedLogText = "";
// DOM Elements
const logUrlInput = document.getElementById('log-url');
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const btnDecryptUrl = document.getElementById('btn-decrypt-url');
const btnClearFields = document.getElementById('btn-clear-fields');
const errorAlert = document.getElementById('error-alert');
const setupCard = document.getElementById('setup-card');
const statusCard = document.getElementById('status-card');
const viewerCard = document.getElementById('viewer-card');
const logDisplay = document.getElementById('log-display');
const logStats = document.getElementById('log-stats');
const searchQuery = document.getElementById('search-query');
const btnCopyLog = document.getElementById('btn-copy-log');
const btnDownloadLog = document.getElementById('btn-download-log');
const btnCloseLog = document.getElementById('btn-close-log');
// Status Steps
const stepFetch = document.getElementById('step-fetch');
const stepParse = document.getElementById('step-parse');
const stepDecrypt = document.getElementById('step-decrypt');
const stepDecompress = document.getElementById('step-decompress');
// Init Page State
window.addEventListener('load', () => {
// Set input from URL hash if present
if (window.location.hash) {
logUrlInput.value = window.location.href;
handleUrlDecrypt();
} else {
showError("No decryption parameters (download token and encryption key) were provided in the URL hash. Expected format: #downloadtoken/encryption_key");
}
});
// Hash change handler
window.addEventListener('hashchange', () => {
if (window.location.hash) {
logUrlInput.value = window.location.href;
handleUrlDecrypt();
} else {
showError("No decryption parameters (download token and encryption key) were provided in the URL hash. Expected format: #downloadtoken/encryption_key");
}
});
// Event Listeners
btnDecryptUrl.addEventListener('click', handleUrlDecrypt);
btnClearFields.addEventListener('click', clearAll);
searchQuery.addEventListener('input', applyLogFilters);
btnCopyLog.addEventListener('click', copyLogToClipboard);
btnDownloadLog.addEventListener('click', downloadLogFile);
btnCloseLog.addEventListener('click', clearAll);
// Dropzone logic
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFileSelect);
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('hover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('hover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('hover');
if (e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0];
processFile(file);
}
});
// Hex String to Uint8Array converter
function hexToBytes(hex) {
if (hex.length % 2 !== 0) {
throw new Error("Invalid hex string length");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return bytes;
}
// Parsing token and key from URL/Hash
function parseHashParams(input) {
let hash = "";
if (input.includes('#')) {
hash = input.split('#')[1];
} else {
hash = input;
}
hash = hash.trim();
const parts = hash.split('/');
if (parts.length < 2) {
throw new Error("Please enter a valid format: downloadtoken/encryption_key");
}
const token = parts[0].trim();
const key = parts[1].trim();
if (token.length !== 64 || key.length !== 64) {
throw new Error("Tokens and keys must be 64-character hex strings (32 bytes)");
}
return { token, key };
}
// Update steps state visual helper
function setStepState(stepElement, state) {
stepElement.className = `status-step ${state}`;
}
function clearAll() {
logUrlInput.value = '';
window.location.hash = '';
hideError();
viewerCard.style.display = 'none';
statusCard.style.display = 'none';
setupCard.style.display = 'block';
document.body.classList.remove('log-loaded');
rawProtobufBytes = null;
decryptedLogText = '';
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('hidden');
statusCard.style.display = 'none';
viewerCard.style.display = 'none';
setupCard.style.display = 'block';
document.body.classList.remove('log-loaded');
// Scroll to error
errorAlert.scrollIntoView({ behavior: 'smooth' });
}
function hideError() {
errorAlert.classList.add('hidden');
}
// High level Decrypt trigger from URL
async function handleUrlDecrypt() {
hideError();
const val = logUrlInput.value.trim();
if (!val) {
showError("Please enter a log link or hash parameters.");
return;
}
try {
const { token, key } = parseHashParams(val);
const keyBytes = hexToBytes(key);
statusCard.style.display = 'flex';
viewerCard.style.display = 'none';
// Reset steps
setStepState(stepFetch, 'active');
setStepState(stepParse, 'pending');
setStepState(stepDecrypt, 'pending');
setStepState(stepDecompress, 'pending');
// Step 1: Fetch (with dev-api fallback)
let response;
try {
response = await fetch(`${API_DOWNLOAD_BASE}${token}`);
if (!response.ok) {
throw new Error(`Status ${response.status}`);
}
} catch (e) {
try {
response = await fetch(`https://dev-api.twonly.eu/api/download/${token}`);
if (!response.ok) {
throw new Error(`Status ${response.status}`);
}
} catch (devError) {
throw new Error("CORS/Network Error or Log Expired: Could not fetch from the API (tried production and dev-api). " +
"Please verify the URL/hash, or download the file directly and drag-and-drop it below.");
}
}
const buffer = await response.arrayBuffer();
rawProtobufBytes = new Uint8Array(buffer);
setStepState(stepFetch, 'success');
// Proceed to decrypt/decompress
await processAndDisplay(keyBytes);
} catch (e) {
setStepState(statusCard.querySelector('.status-step.active') || stepFetch, 'error');
showError(e.message);
}
}
// High level Decrypt trigger from file drop
function handleFileSelect(e) {
if (e.target.files.length > 0) {
processFile(e.target.files[0]);
}
}
async function processFile(file) {
hideError();
const val = logUrlInput.value.trim();
if (!val) {
showError("Please paste the link or hash in the input field above so we have the decryption key.");
return;
}
try {
const { key } = parseHashParams(val);
const keyBytes = hexToBytes(key);
statusCard.style.display = 'flex';
viewerCard.style.display = 'none';
// Reset steps (Fetch is skipped/automatic)
setStepState(stepFetch, 'success');
setStepState(stepParse, 'active');
setStepState(stepDecrypt, 'pending');
setStepState(stepDecompress, 'pending');
const reader = new FileReader();
reader.onload = async (e) => {
rawProtobufBytes = new Uint8Array(e.target.result);
await processAndDisplay(keyBytes);
};
reader.onerror = () => {
throw new Error("Failed to read the local file.");
};
reader.readAsArrayBuffer(file);
} catch (e) {
showError(e.message);
}
}
// Core decryption, decompression, and parsing pipeline
async function processAndDisplay(keyBytes) {
try {
// Step 2: Parse Protobuf
setStepState(stepParse, 'active');
const encryptedData = getEncryptedData(rawProtobufBytes);
setStepState(stepParse, 'success');
// Step 3: Decrypt AES-GCM
setStepState(stepDecrypt, 'active');
const compressedBytes = await decryptAESGCM(keyBytes, encryptedData);
setStepState(stepDecrypt, 'success');
// Step 4: Decompress GZip
setStepState(stepDecompress, 'active');
const logBytes = await decompressGzip(compressedBytes);
// Decode to UTF-8 string
const decoder = new TextDecoder('utf-8');
decryptedLogText = decoder.decode(logBytes);
setStepState(stepDecompress, 'success');
// Display results
renderLogText(decryptedLogText);
statusCard.style.display = 'none';
setupCard.style.display = 'none';
document.body.classList.add('log-loaded');
viewerCard.style.display = 'flex';
} catch (e) {
const activeStep = statusCard.querySelector('.status-step.active') || stepParse;
setStepState(activeStep, 'error');
showError("Decryption / Decompression failed. Ensure the encryption key is correct. Details: " + e.message);
}
}
// Extract encrypted data. If the bytes do not start with a valid UploadRequest
// field tag, or if parsing fails, we treat it as raw encrypted bytes.
function getEncryptedData(bytes) {
if (!bytes || bytes.length === 0) {
throw new Error("Empty file data.");
}
const firstByte = bytes[0];
if (firstByte !== 10 && firstByte !== 18 && firstByte !== 26) {
return bytes;
}
try {
return getEncryptedDataFromUploadRequest(bytes);
} catch (e) {
return bytes;
}
}
// Manual Protobuf field extractor for UploadRequest
// UploadRequest:
// Field 1: encryptedData (bytes, wire-type 2)
function getEncryptedDataFromUploadRequest(bytes) {
let offset = 0;
while (offset < bytes.length) {
const key = bytes[offset++];
const fieldNumber = key >> 3;
const wireType = key & 7;
if (wireType === 2) {
// Read varint length
let length = 0;
let shift = 0;
while (true) {
const b = bytes[offset++];
length |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7;
}
if (fieldNumber === 1) {
return bytes.subarray(offset, offset + length);
}
offset += length;
} else if (wireType === 0) {
while (bytes[offset++] & 0x80) { }
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
throw new Error("Unsupported wire type: " + wireType);
}
}
throw new Error("Could not find the encryptedData (Field 1) in the protobuf wrapper.");
}
// AES-GCM Decryption (first 12 bytes nonce, remainder ciphertext + 16 bytes MAC)
async function decryptAESGCM(keyBytes, concatenatedBytes) {
if (concatenatedBytes.length < 28) {
throw new Error("Ciphertext too short. Encrypted data might be corrupted.");
}
const nonce = concatenatedBytes.slice(0, 12);
const ciphertextAndTag = concatenatedBytes.slice(12);
const cryptoKey = await window.crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM" },
false,
["decrypt"]
);
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonce,
tagLength: 128
},
cryptoKey,
ciphertextAndTag
);
return new Uint8Array(decrypted);
}
// Native DecompressionStream (GZip)
async function decompressGzip(compressedBytes) {
try {
const ds = new DecompressionStream("gzip");
const writer = ds.writable.getWriter();
writer.write(compressedBytes);
writer.close();
const response = new Response(ds.readable);
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
} catch (e) {
throw new Error("GZip decompression failed. " + e.message);
}
}
// Render Log Lines to DOM
function renderLogText(text) {
logDisplay.innerHTML = '';
const lines = text.split('\n');
const fragment = document.createDocumentFragment();
let renderedCount = 0;
for (const line of lines) {
if (!line.trim()) continue;
const span = document.createElement('span');
span.className = 'log-line';
span.textContent = line;
// Highlight matching levels
const lowerLine = line.toLowerCase();
if (lowerLine.includes(' shout ') || lowerLine.includes(' error ') || lowerLine.includes(' exception ')) {
span.classList.add('error');
} else if (lowerLine.includes(' warning ') || lowerLine.includes(' warn ')) {
span.classList.add('warn');
} else if (lowerLine.includes(' fine ') || lowerLine.includes(' info ')) {
span.classList.add('info');
}
fragment.appendChild(span);
renderedCount++;
}
logDisplay.appendChild(fragment);
logStats.textContent = `${renderedCount} lines loaded`;
}
// Filter search logs
function applyLogFilters() {
const query = searchQuery.value.toLowerCase().trim();
const lines = logDisplay.getElementsByClassName('log-line');
let visibleCount = 0;
for (const line of lines) {
if (!query || line.textContent.toLowerCase().includes(query)) {
line.style.display = 'block';
visibleCount++;
} else {
line.style.display = 'none';
}
}
logStats.textContent = `${visibleCount} of ${lines.length} lines matching`;
}
// Copy to Clipboard helper
function copyLogToClipboard() {
navigator.clipboard.writeText(decryptedLogText)
.then(() => {
const oldText = btnCopyLog.textContent;
btnCopyLog.textContent = "Copied!";
btnCopyLog.style.borderColor = "var(--text-success)";
btnCopyLog.style.color = "var(--text-success)";
setTimeout(() => {
btnCopyLog.textContent = oldText;
btnCopyLog.style.borderColor = "";
btnCopyLog.style.color = "";
}, 2000);
})
.catch(err => {
alert('Could not copy text: ' + err);
});
}
// Text file downloader
function downloadLogFile() {
const blob = new Blob([decryptedLogText], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const now = new Date();
const timestamp = now.toISOString().replace(/T/, '_').replace(/\..+/, '').replace(/:/g, '-');
a.download = `twonly-decrypted-debug_${timestamp}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
</body>
</html>