1036 lines
No EOL
29 KiB
HTML
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> |