feat(ui): migrate to Tailwind (compiled) + Flowbite JS; new navbar/layout; Docker CSS build
- Add multi-stage CSS build that compiles Tailwind into app/static/tw.css
- Add Tailwind config with dark tokens (bg/nav/card) and purge globs
- Add assets/input.css (@tailwind base/components/utilities + small utilities)
- Replace Tailwind CDN + REMOVE Flowbite CSS (keep Flowbite JS only)
- New base_tailwind.html (top navbar, responsive container, {%- block scripts -%})
- Port pages to Tailwind look/feel with wider content column:
- index: single-column form + recent results, fullscreen spinner overlay, copy-UUID
- result: sticky jump list, Tailwind tables/badges, Suspicious Scripts/Forms sections
- viewer: Monaco-based code viewer in Tailwind card, actions (copy/wrap/raw)
- ssl_tls macro: rewritten with Tailwind (details/summary for raw JSON)
- Dockerfile: add css-builder stage and copy built tw.css into /app/app/static
- Remove Flowbite stylesheet to avoid overrides; Flowbite JS loaded with defer
BREAKING CHANGE:
Legacy CSS classes/components (.card, .badge, etc.) are replaced by Tailwind utilities.
All templates now expect tw.css to be served from /static.
This commit is contained in:
@@ -1,99 +1,108 @@
|
||||
{# templates/_macros_ssl_tls.html #}
|
||||
{% macro ssl_tls_card(ssl_tls) %}
|
||||
<div class="card" id="ssl">
|
||||
<h2 class="card-title">SSL/TLS Intelligence</h2>
|
||||
<div class="space-y-4">
|
||||
|
||||
{# -------- 1) Error branch -------- #}
|
||||
{% if ssl_tls is none or 'error' in ssl_tls %}
|
||||
<div class="badge badge-danger">Error</div>
|
||||
<p class="muted">SSL/TLS enrichment failed or is unavailable.</p>
|
||||
{% if ssl_tls and ssl_tls.error %}<pre class="prewrap">{{ ssl_tls.error }}</pre>{% endif %}
|
||||
{% if ssl_tls is none or (ssl_tls.error is defined) or ('error' in ssl_tls) %}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-red-600/20 text-red-300 border-red-700">Error</span>
|
||||
<p class="text-sm text-gray-500">SSL/TLS enrichment failed or is unavailable.</p>
|
||||
{% if ssl_tls and ssl_tls.error %}
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ ssl_tls.error }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{# -------- 2) Skipped branch -------- #}
|
||||
{% elif ssl_tls.skipped %}
|
||||
<div class="badge badge-muted">Skipped</div>
|
||||
{% if ssl_tls.reason %}<span class="muted small">{{ ssl_tls.reason }}</span>{% endif %}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-gray-700/30 text-gray-300 border-gray-700">Skipped</span>
|
||||
{% if ssl_tls.reason %}<span class="text-gray-400 text-sm ml-2">{{ ssl_tls.reason }}</span>{% endif %}
|
||||
|
||||
<div class="section">
|
||||
<button class="badge badge-muted" data-toggle="tls-raw">Toggle raw</button>
|
||||
<pre id="tls-raw" hidden>{{ ssl_tls|tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">Raw TLS JSON</summary>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ ssl_tls|tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
|
||||
{# -------- 3) Normal branch (render probe + crt.sh) -------- #}
|
||||
{% else %}
|
||||
|
||||
{# ===================== LIVE PROBE ===================== #}
|
||||
{% set probe = ssl_tls.probe if ssl_tls else None %}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h3>Live TLS Probe</h3>
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-semibold">Live TLS Probe</h3>
|
||||
{% if probe %}
|
||||
<span class="muted">Host:</span> <code>{{ probe.hostname }}:{{ probe.port }}</code>
|
||||
<span class="text-gray-400 text-sm">Host:</span>
|
||||
<code class="text-sm">{{ probe.hostname }}:{{ probe.port }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not probe %}
|
||||
<p class="muted">No probe data.</p>
|
||||
<p class="text-sm text-gray-500">No probe data.</p>
|
||||
{% else %}
|
||||
<div class="tls-matrix">
|
||||
{% set versions = ['TLS1.0','TLS1.1','TLS1.2','TLS1.3'] %}
|
||||
{% for v in versions %}
|
||||
{% set r = probe.results_by_version.get(v) if probe.results_by_version else None %}
|
||||
<div class="tls-matrix-row">
|
||||
<div class="tls-cell version">{{ v }}</div>
|
||||
|
||||
{% if r and r.supported %}
|
||||
<div class="tls-cell status"><span class="badge badge-ok">Supported</span></div>
|
||||
<div class="tls-cell cipher">
|
||||
{% if r.selected_cipher %}
|
||||
<span class="chip">{{ r.selected_cipher }}</span>
|
||||
{% else %}
|
||||
<span class="muted">cipher: n/a</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tls-cell latency">
|
||||
{% if r.handshake_seconds is not none %}
|
||||
<span class="muted">{{ '%.0f' % (r.handshake_seconds*1000) }} ms</span>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tls-cell status"><span class="badge badge-muted">Not Supported</span></div>
|
||||
<div class="tls-cell cipher">
|
||||
{% if r and r.error %}
|
||||
<span class="muted small">({{ r.error }})</span>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tls-cell latency"><span class="muted">—</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Version</th>
|
||||
<th class="text-left py-2 pr-4">Status</th>
|
||||
<th class="text-left py-2 pr-4">Selected Cipher</th>
|
||||
<th class="text-left py-2 pr-4">Latency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set versions = ['TLS1.0','TLS1.1','TLS1.2','TLS1.3'] %}
|
||||
{% for v in versions %}
|
||||
{% set r = probe.results_by_version.get(v) if probe.results_by_version else None %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ v }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{% if r and r.supported %}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-green-600/20 text-green-300 border-green-700">Supported</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-gray-700/30 text-gray-300 border-gray-700">Not Supported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
{% if r and r.selected_cipher %}
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs">{{ r.selected_cipher }}</span>
|
||||
{% elif r and r.error %}
|
||||
<span class="text-gray-500 text-xs">({{ r.error }})</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">
|
||||
{% if r and r.handshake_seconds is not none %}
|
||||
<span class="text-gray-400">{{ '%.0f' % (r.handshake_seconds*1000) }} ms</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flag-row">
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
{% if probe.weak_protocols and probe.weak_protocols|length > 0 %}
|
||||
<span class="badge badge-warn">Weak Protocols</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-yellow-600/20 text-yellow-300 border-yellow-700">Weak Protocols</span>
|
||||
{% for wp in probe.weak_protocols %}
|
||||
<span class="chip chip-warn">{{ wp }}</span>
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs">{{ wp }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if probe.weak_ciphers and probe.weak_ciphers|length > 0 %}
|
||||
<span class="badge badge-warn">Weak Ciphers</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-yellow-600/20 text-yellow-300 border-yellow-700">Weak Ciphers</span>
|
||||
{% for wc in probe.weak_ciphers %}
|
||||
<span class="chip chip-warn">{{ wc }}</span>
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs">{{ wc }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if probe.errors and probe.errors|length > 0 %}
|
||||
<details class="details">
|
||||
<summary>Probe Notes</summary>
|
||||
<ul class="list">
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">Probe Notes</summary>
|
||||
<ul class="list-disc list-inside text-sm text-gray-400 space-y-1 mt-1">
|
||||
{% for e in probe.errors %}
|
||||
<li class="muted small">{{ e }}</li>
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
@@ -101,82 +110,86 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<hr class="divider"/>
|
||||
<hr class="border-gray-800 my-4"/>
|
||||
|
||||
{# ===================== CRT.SH ===================== #}
|
||||
{% set crtsh = ssl_tls.crtsh if ssl_tls else None %}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h3>Certificate Transparency (crt.sh)</h3>
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<h3 class="text-base font-semibold">Certificate Transparency (crt.sh)</h3>
|
||||
{% if crtsh %}
|
||||
<span class="muted">Parsed:</span>
|
||||
<code>{{ crtsh.hostname or 'n/a' }}</code>
|
||||
<span class="text-gray-400 text-sm">Parsed:</span>
|
||||
<code class="text-sm">{{ crtsh.hostname or 'n/a' }}</code>
|
||||
{% if crtsh.root_domain %}
|
||||
<span class="muted"> • Root:</span> <code>{{ crtsh.root_domain }}</code>
|
||||
{% if crtsh.is_root_domain %}<span class="badge badge-ok">Root</span>{% else %}<span class="badge badge-muted">Subdomain</span>{% endif %}
|
||||
<span class="text-gray-400 text-sm">• Root:</span>
|
||||
<code class="text-sm">{{ crtsh.root_domain }}</code>
|
||||
{% if crtsh.is_root_domain %}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-green-600/20 text-green-300 border-green-700">Root</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border bg-gray-700/30 text-gray-300 border-gray-700">Subdomain</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not crtsh %}
|
||||
<p class="muted">No CT data.</p>
|
||||
<p class="text-sm text-gray-500">No CT data.</p>
|
||||
{% else %}
|
||||
<div class="grid two">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="muted">Host Certificates</h4>
|
||||
<h4 class="text-gray-400 text-sm mb-2">Host Certificates</h4>
|
||||
{% set host_certs = crtsh.crtsh.host_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
|
||||
{% if host_certs and host_certs|length > 0 %}
|
||||
<ul class="list">
|
||||
<ul class="space-y-1 text-xs">
|
||||
{% for c in host_certs[:10] %}
|
||||
<li class="mono small">
|
||||
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span>
|
||||
<span class="muted"> • </span>
|
||||
<strong>{{ c.get('name_value','(name n/a)') }}</strong>
|
||||
<span class="muted"> • not_before:</span> {{ c.get('not_before','?') }}
|
||||
<li class="font-mono">
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-[10px]">{{ c.get('issuer_name','issuer n/a') }}</span>
|
||||
<span class="text-gray-500"> • </span>
|
||||
<strong class="break-all">{{ c.get('name_value','(name n/a)') }}</strong>
|
||||
<span class="text-gray-500"> • not_before:</span> {{ c.get('not_before','?') }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if host_certs|length > 10 %}
|
||||
<div class="muted small">(+ {{ host_certs|length - 10 }} more)</div>
|
||||
<div class="text-gray-500 text-xs mt-1">(+ {{ host_certs|length - 10 }} more)</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">No active host certs found.</p>
|
||||
<p class="text-sm text-gray-500">No active host certs found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="muted">Wildcard on Root</h4>
|
||||
<h4 class="text-gray-400 text-sm mb-2">Wildcard on Root</h4>
|
||||
{% set wc = crtsh.crtsh.wildcard_root_certs if 'crtsh' in crtsh and crtsh.crtsh else None %}
|
||||
{% if wc and wc|length > 0 %}
|
||||
<ul class="list">
|
||||
<ul class="space-y-1 text-xs">
|
||||
{% for c in wc[:10] %}
|
||||
<li class="mono small">
|
||||
<span class="chip">{{ c.get('issuer_name','issuer n/a') }}</span>
|
||||
<span class="muted"> • </span>
|
||||
<strong>{{ c.get('name_value','(name n/a)') }}</strong>
|
||||
<span class="muted"> • not_before:</span> {{ c.get('not_before','?') }}
|
||||
<li class="font-mono">
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-[10px]">{{ c.get('issuer_name','issuer n/a') }}</span>
|
||||
<span class="text-gray-500"> • </span>
|
||||
<strong class="break-all">{{ c.get('name_value','(name n/a)') }}</strong>
|
||||
<span class="text-gray-500"> • not_before:</span> {{ c.get('not_before','?') }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if wc|length > 10 %}
|
||||
<div class="muted small">(+ {{ wc|length - 10 }} more)</div>
|
||||
<div class="text-gray-500 text-xs mt-1">(+ {{ wc|length - 10 }} more)</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">No wildcard/root certs found.</p>
|
||||
<p class="text-sm text-gray-500">No wildcard/root certs found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# ===================== RAW JSON TOGGLE ===================== #}
|
||||
<div class="section">
|
||||
<button class="badge badge-muted" data-toggle="tls-raw">Toggle raw</button>
|
||||
<pre id="tls-raw" hidden>{{ ssl_tls|tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON toggle -->
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">Raw TLS JSON</summary>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ ssl_tls|tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,36 +1,76 @@
|
||||
<!doctype html>
|
||||
{# Base layout using Tailwind + Flowbite, non-destructive #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ app_name }} {{ app_version }}</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/sanitize.css" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{ app_name }} {{ app_version }}</h1>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{% block title %}{% endblock %} {{ app_name }} </title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<ul class="flash">
|
||||
{% for category, message in messages %}
|
||||
<li class="{{ category }}">{{ message }}</li>
|
||||
{% endfor %}
|
||||
<!-- # Tailwind CSS # -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='tw.css') }}">
|
||||
|
||||
{# Your existing CSS stays; we’ll keep only custom tweaks there. #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="bg-bg text-gray-200 min-h-screen flex flex-col">
|
||||
{# Top Navbar (Flowbite collapse) #}
|
||||
<nav class="bg-nav border-b border-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="{{ url_for('main.index') }}" class="text-xl font-bold text-white">
|
||||
SneakyScope
|
||||
</a>
|
||||
|
||||
{# Desktop nav #}
|
||||
<ul class="hidden md:flex items-center space-x-6 text-sm">
|
||||
<li>
|
||||
<a href="{{ url_for('main.index') }}">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main>
|
||||
{# Mobile toggle #}
|
||||
<button data-collapse-toggle="main-menu" type="button"
|
||||
class="md:hidden inline-flex items-center p-2 rounded hover:bg-gray-700"
|
||||
aria-controls="main-menu" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Mobile menu #}
|
||||
<div class="hidden md:hidden" id="main-menu">
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
<li>
|
||||
<a href="{{ url_for('main.index') }}">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{# Page content wrapper #}
|
||||
<main class="flex-1">
|
||||
<div class="max-w-7xl mx-auto p-4 md:p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<small>{{ app_name }} - A self-hosted URL analysis sandbox - {{ app_version }}</small>
|
||||
</footer>
|
||||
</body>
|
||||
{# Footer #}
|
||||
<footer class="bg-nav border-t border-gray-800 text-center p-4">
|
||||
<p class="text-sm text-gray-400">© {{ current_year }} SneakyScope {{ app_name }} {{ app_version }} - A selfhosted URL sandbox</p>
|
||||
</footer>
|
||||
|
||||
{# Flowbite JS (enables collapse) #}
|
||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{% block page_js %}
|
||||
{% endblock %}
|
||||
@@ -1,159 +1,201 @@
|
||||
{% extends 'base.html' %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Single-column stack, centered and comfortably narrow -->
|
||||
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
|
||||
|
||||
<!-- Analysis Form -->
|
||||
<form id="analyze-form" method="post" action="{{ url_for('main.analyze') }}" class="card">
|
||||
<h2>Analyze a URL</h2>
|
||||
<label for="url">Enter a URL to analyze</label>
|
||||
<input id="url" name="url" type="url" placeholder="https://example.com" required />
|
||||
<!-- Start a New Analysis -->
|
||||
<section class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Start a New Analysis</h2>
|
||||
|
||||
<!-- toggle for pulling ssl/cert data -->
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="fetch_ssl" value="1">
|
||||
Pull SSL/TLS data (crt.sh + version probe) - Warning, crt.sh can be <b>very slow</b> at times
|
||||
</label>
|
||||
<form id="analyze-form" action="{{ url_for('main.analyze') }}" method="post" class="space-y-3">
|
||||
<div>
|
||||
<label for="url" class="block text-sm text-gray-400 mb-1">Target URL or Domain</label>
|
||||
<input
|
||||
id="url"
|
||||
name="url"
|
||||
type="text"
|
||||
required
|
||||
class="w-full bg-[#0b0f14] border border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit">Analyze</button>
|
||||
</form>
|
||||
|
||||
<!-- Recent Results (optional; shown only if recent_results provided) -->
|
||||
{% if recent_results %}
|
||||
<div class="card" id="recent-results">
|
||||
<h2>Recent Results</h2>
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>URL</th>
|
||||
<th>UUID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent_results %}
|
||||
<tr>
|
||||
<td class="timestamp">
|
||||
{% if r.timestamp %}
|
||||
{{ r.timestamp }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="url">
|
||||
<a href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
|
||||
{{ r.final_url or r.submitted_url }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="uuid">
|
||||
<code id="uuid-{{ loop.index }}">{{ r.uuid }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
data-target="uuid-{{ loop.index }}">
|
||||
📋
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-60"
|
||||
>
|
||||
<span id="btn-spinner" class="hidden animate-spin inline-block h-4 w-4 rounded-full border-2 border-white/40 border-t-white"></span>
|
||||
Analyse
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- toggle for pulling ssl/cert data -->
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="fetch_ssl"
|
||||
value="1"
|
||||
class="rounded border-gray-600 bg-[#0b0f14]"
|
||||
>
|
||||
<span>
|
||||
Pull SSL/TLS data (crt.sh + version probe)
|
||||
<span class="text-gray-400">— crt.sh can be <b>very slow</b> at times</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Spinner Modal -->
|
||||
<div id="spinner-modal" style="
|
||||
display:none;
|
||||
opacity:0;
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:rgba(0,0,0,0.7);
|
||||
color:#fff;
|
||||
font-size:1.5rem;
|
||||
text-align:center;
|
||||
padding-top:20%;
|
||||
z-index:9999;
|
||||
transition: opacity 0.3s ease;
|
||||
">
|
||||
<div>
|
||||
<div class="loader" style="
|
||||
border: 8px solid #f3f3f3;
|
||||
border-top: 8px solid #1a2535;
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem auto;
|
||||
"></div>
|
||||
Analyzing website…
|
||||
<!-- Recent Results -->
|
||||
{% if recent_results %}
|
||||
<section class="bg-card border border-gray-800 rounded-xl p-4" id="recent-results">
|
||||
<h2 class="text-base font-semibold mb-3">Recent Results</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Timestamp</th>
|
||||
<th class="text-left py-2 pr-4">URL</th>
|
||||
<th class="text-left py-2 pr-4">UUID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recent_results %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">
|
||||
{% if r.timestamp %}{{ r.timestamp }}{% else %}N/A{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<a class="hover:text-blue-400" href="{{ url_for('main.view_result', run_uuid=r.uuid) }}">
|
||||
{{ r.final_url or r.submitted_url }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="uuid-{{ loop.index }}" class="text-gray-300">{{ r.uuid }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn inline-flex items-center justify-center rounded-md border border-gray-700 px-2 py-1 text-xs hover:bg-gray-800"
|
||||
data-target="uuid-{{ loop.index }}"
|
||||
title="Copy UUID"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-base font-semibold mb-2">Recent Results</h2>
|
||||
<p class="text-sm text-gray-500">No recent scans.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<!-- Fullscreen spinner overlay -->
|
||||
<div
|
||||
id="spinner-modal"
|
||||
class="fixed inset-0 hidden opacity-0 transition-opacity duration-300 bg-black/70 z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Analyzing website"
|
||||
>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 text-center">
|
||||
<div class="bg-card border border-gray-800 rounded-xl px-6 py-5 shadow">
|
||||
<div class="mx-auto mb-3 h-12 w-12 rounded-full border-4 border-white/30 border-t-white animate-spin"></div>
|
||||
<div class="text-base">Analyzing website…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_js %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('analyze-form');
|
||||
const modal = document.getElementById('spinner-modal');
|
||||
|
||||
function showModal() {
|
||||
modal.style.display = 'block';
|
||||
requestAnimationFrame(() => {
|
||||
modal.style.opacity = '1';
|
||||
});
|
||||
/**
|
||||
* Show the fullscreen spinner overlay.
|
||||
*/
|
||||
function showSpinner() {
|
||||
const modal = document.getElementById('spinner-modal');
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
// allow reflow so opacity transition runs
|
||||
requestAnimationFrame(() => modal.classList.remove('opacity-0'));
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modal.style.opacity = '0';
|
||||
/**
|
||||
* Hide the fullscreen spinner overlay.
|
||||
*/
|
||||
function hideSpinner() {
|
||||
const modal = document.getElementById('spinner-modal');
|
||||
if (!modal) return;
|
||||
modal.classList.add('opacity-0');
|
||||
modal.addEventListener('transitionend', () => {
|
||||
modal.style.display = 'none';
|
||||
modal.classList.add('hidden');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Hide spinner on initial load / back navigation
|
||||
window.addEventListener('pageshow', () => {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
/**
|
||||
* Initialize form submit handling:
|
||||
* - shows overlay spinner
|
||||
* - disables submit button
|
||||
* - shows small spinner inside button
|
||||
* - lets the browser continue with POST
|
||||
*/
|
||||
(function initAnalyzeForm() {
|
||||
const form = document.getElementById('analyze-form');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
showModal();
|
||||
// Prevent double submission
|
||||
form.querySelector('button').disabled = true;
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const btnSpinner = document.getElementById('btn-spinner');
|
||||
|
||||
// Allow browser to render the modal before submitting
|
||||
requestAnimationFrame(() => form.submit());
|
||||
e.preventDefault();
|
||||
});
|
||||
</script>
|
||||
// Hide spinner overlay if arriving from bfcache/back
|
||||
window.addEventListener('pageshow', () => {
|
||||
hideSpinner();
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
if (btnSpinner) btnSpinner.classList.add('hidden');
|
||||
});
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const buttons = document.querySelectorAll('.copy-btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
// prevent immediate submit so UI can paint spinner first
|
||||
e.preventDefault();
|
||||
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
if (btnSpinner) btnSpinner.classList.remove('hidden');
|
||||
|
||||
showSpinner();
|
||||
|
||||
// allow a frame so spinner paints, then submit
|
||||
requestAnimationFrame(() => form.submit());
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* Copy UUID buttons
|
||||
*/
|
||||
(function initCopyButtons() {
|
||||
document.querySelectorAll('.copy-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const targetId = btn.getAttribute('data-target');
|
||||
const uuidText = document.getElementById(targetId).innerText;
|
||||
const el = document.getElementById(targetId);
|
||||
if (!el) return;
|
||||
|
||||
navigator.clipboard.writeText(uuidText).then(() => {
|
||||
// Give quick feedback
|
||||
try {
|
||||
await navigator.clipboard.writeText(el.textContent.trim());
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = '✅';
|
||||
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
||||
}).catch(err => {
|
||||
setTimeout(() => { btn.textContent = prev; }, 1200);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy UUID:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,352 +1,394 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros_ssl_tls.html" import ssl_tls_card %}
|
||||
{% block title %}Scan Results{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
|
||||
|
||||
<!-- Top Jump List -->
|
||||
<!-- Top Jump List (sticky) -->
|
||||
<nav id="top-jump-list"
|
||||
class="sticky top-0 z-20 bg-bg/80 backdrop-blur border-b border-gray-800 py-2 px-3 rounded-b xl:rounded-b-none">
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<a href="{{ url_for('main.index') }}" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Analyse Another Page</a>
|
||||
<a href="#url-overview" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">URL Overview</a>
|
||||
<a href="#enrichment" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Enrichment</a>
|
||||
<a href="#ssl" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">TLS / Certs</a>
|
||||
<a href="#redirects" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Redirects</a>
|
||||
<a href="#forms" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Forms</a>
|
||||
<a href="#scripts" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Suspicious Scripts</a>
|
||||
<a href="#screenshot" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Screenshot</a>
|
||||
<a href="#source" class="px-2 py-1 rounded border border-gray-700 hover:bg-gray-800">Source</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sticky top nav -->
|
||||
<nav id="top-jump-list" class="top-jump-nav" aria-label="Jump to section">
|
||||
<a href="/">Analyse Another Page</a>
|
||||
<a href="#url-overview">URL Overview</a>
|
||||
<a href="#enrichment">Enrichment</a>
|
||||
<a href="#ssl">TLS / Certs</a>
|
||||
<a href="#redirects">Redirects</a>
|
||||
<a href="#forms">Forms</a>
|
||||
<a href="#scripts">Suspicious Scripts</a>
|
||||
<a href="#screenshot">Screenshot</a>
|
||||
<a href="#source">Source</a>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- URL Overview -->
|
||||
<div class="card" id="url-overview">
|
||||
<h2>URL Overview</h2>
|
||||
<p><strong>Submitted URL:</strong> {{ submitted_url }}</p>
|
||||
<p><strong>Final URL:</strong> <a href="{{ final_url }}" target="_blank">{{ final_url }}</a></p>
|
||||
<p><strong>Permalink:</strong>
|
||||
<a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}">
|
||||
{{ request.host_url }}results/{{ uuid }}
|
||||
<!-- URL Overview -->
|
||||
<section id="url-overview" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">URL Overview</h2>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="text-gray-400">Submitted URL:</span> <span class="break-all">{{ submitted_url }}</span></p>
|
||||
<p>
|
||||
<span class="text-gray-400">Final URL:</span>
|
||||
<a href="{{ final_url }}" target="_blank" rel="noopener" class="break-all hover:text-blue-400">{{ final_url }}</a>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-gray-400">Permalink:</span>
|
||||
<a href="{{ url_for('main.view_result', run_uuid=uuid, _external=True) }}" class="break-all hover:text-blue-400">
|
||||
{{ request.host_url }}results/{{ uuid }}
|
||||
</a>
|
||||
</p>
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
</p>
|
||||
<p><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Enrichment -->
|
||||
<div class="card" id="enrichment">
|
||||
<h2>Enrichment</h2>
|
||||
<!-- Enrichment -->
|
||||
<section id="enrichment" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Enrichment</h2>
|
||||
|
||||
<!-- WHOIS -->
|
||||
{% if enrichment.whois %}
|
||||
<h3>WHOIS</h3>
|
||||
<table class="enrichment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<h3 class="text-base font-semibold mt-2 mb-2">WHOIS</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Field</th>
|
||||
<th class="text-left py-2 pr-4">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k, v in enrichment.whois.items() %}
|
||||
<tr>
|
||||
<td>{{ k.replace('_', ' ').title() }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for k, v in enrichment.whois.items() %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ k.replace('_', ' ').title() }}</td>
|
||||
<td class="py-2 pr-4 break-all">{{ v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if enrichment.raw_whois %}
|
||||
<h3>Raw WHOIS</h3>
|
||||
<pre class="code">{{ enrichment.raw_whois }}</pre>
|
||||
<h3 class="text-base font-semibold mt-4 mb-2">Raw WHOIS</h3>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-sm">{{ enrichment.raw_whois }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<!-- GeoIP / IP-API -->
|
||||
{% if enrichment.geoip %}
|
||||
<h3>GeoIP</h3>
|
||||
{% for ip, info in enrichment.geoip.items() %}
|
||||
<details class="card" style="padding:0.5rem; margin-bottom:0.5rem;">
|
||||
<summary>{{ ip }}</summary>
|
||||
<table class="enrichment-table">
|
||||
<h3 class="text-base font-semibold mt-4 mb-2">GeoIP</h3>
|
||||
{% for ip, info in enrichment.geoip.items() %}
|
||||
<details class="border border-gray-800 rounded-lg mb-2">
|
||||
<summary class="px-3 py-2 cursor-pointer hover:bg-gray-900/50">{{ ip }}</summary>
|
||||
<div class="px-3 pb-3 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<tbody>
|
||||
{% for key, val in info.items() %}
|
||||
<tr>
|
||||
<td>{{ key.replace('_', ' ').title() }}</td>
|
||||
<td>{{ val }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for key, val in info.items() %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap text-gray-400">{{ key.replace('_', ' ').title() }}</td>
|
||||
<td class="py-2 pr-4 break-all">{{ val }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if not enrichment.whois and not enrichment.raw_whois and not enrichment.geoip and not enrichment.bec_words %}
|
||||
<p>No enrichment data available.</p>
|
||||
<p class="text-sm text-gray-500">No enrichment data available.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
<!-- TLS / SSL / CERTS -->
|
||||
{{ ssl_tls_card(enrichment.ssl_tls) }}
|
||||
<!-- TLS / SSL / CERTS -->
|
||||
<section id="ssl" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">TLS / Certs</h2>
|
||||
{{ ssl_tls_card(enrichment.ssl_tls) }}
|
||||
</section>
|
||||
|
||||
<!-- Redirects -->
|
||||
<div class="card" id="redirects">
|
||||
<h2>Redirects</h2>
|
||||
<!-- Redirects -->
|
||||
<section id="redirects" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Redirects</h2>
|
||||
{% if redirects %}
|
||||
<table class="enrichment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Status</th>
|
||||
<th class="text-left py-2 pr-4">URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in redirects %}
|
||||
<tr>
|
||||
<td>{{ r.status }}</td>
|
||||
<td><a href="{{ r.url }}" target="_blank">{{ r.url }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for r in redirects %}
|
||||
<tr class="border-b border-gray-900">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ r.status }}</td>
|
||||
<td class="py-2 pr-4 break-all">
|
||||
<a href="{{ r.url }}" target="_blank" rel="noopener" class="hover:text-blue-400">{{ r.url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No redirects detected.</p>
|
||||
<p class="text-sm text-gray-500">No redirects detected.</p>
|
||||
{% endif %}
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
<!-- Forms -->
|
||||
<div class="card" id="forms">
|
||||
<h2>Forms</h2>
|
||||
<!-- Forms -->
|
||||
<section id="forms" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Forms</h2>
|
||||
|
||||
{% if forms and forms|length > 0 %}
|
||||
<table class="enrichment-table forms-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Method</th>
|
||||
<th>Inputs</th>
|
||||
<th>Matches (Rules)</th>
|
||||
<th>Form Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in forms %}
|
||||
<tr>
|
||||
<!-- Action -->
|
||||
<td class="breakable">
|
||||
{% if f.action %}
|
||||
{{ f.action[:25] }}{% if f.action|length > 25 %}…{% endif %}
|
||||
{% else %}
|
||||
<span class="text-dim">(no action)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if forms and forms|length > 0 %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Action</th>
|
||||
<th class="text-left py-2 pr-4">Method</th>
|
||||
<th class="text-left py-2 pr-4">Inputs</th>
|
||||
<th class="text-left py-2 pr-4">Matches (Rules)</th>
|
||||
<th class="text-left py-2 pr-4">Form Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in forms %}
|
||||
<tr class="border-b border-gray-900 align-top">
|
||||
<!-- Action -->
|
||||
<td class="py-2 pr-4 break-all">
|
||||
{% if f.action %}
|
||||
{{ f.action[:80] }}{% if f.action|length > 80 %}…{% endif %}
|
||||
{% else %}
|
||||
<span class="text-gray-500">(no action)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Method -->
|
||||
<td>{{ (f.method or 'get')|upper }}</td>
|
||||
<!-- Method -->
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ (f.method or 'get')|upper }}</td>
|
||||
|
||||
<!-- Inputs -->
|
||||
<td>
|
||||
{% if f.inputs and f.inputs|length > 0 %}
|
||||
<div class="chips">
|
||||
<!-- Inputs -->
|
||||
<td class="py-2 pr-4">
|
||||
{% if f.inputs and f.inputs|length > 0 %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for inp in f.inputs %}
|
||||
<span class="chip" title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
|
||||
{{ inp.name or '(unnamed)' }}<small> : {{ (inp.type or 'text') }}</small>
|
||||
<span class="rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs"
|
||||
title="{{ (inp.name or '') ~ ' : ' ~ (inp.type or 'text') }}">
|
||||
{{ inp.name or '(unnamed)' }}<small class="text-gray-400"> : {{ (inp.type or 'text') }}</small>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-dim">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<span class="text-gray-500">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Matches (Rules) -->
|
||||
<td>
|
||||
{% if f.rules and f.rules|length > 0 %}
|
||||
<ul>
|
||||
<!-- Matches (Rules) -->
|
||||
<td class="py-2 pr-4">
|
||||
{% if f.rules and f.rules|length > 0 %}
|
||||
<ul class="space-y-1">
|
||||
{% for r in f.rules %}
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span>
|
||||
{% endif %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small> — {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
{% set sev = r.severity|lower %}
|
||||
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
|
||||
{% if sev == 'high' %} bg-red-600/20 text-red-300 border-red-700
|
||||
{% elif sev == 'medium' %} bg-yellow-600/20 text-yellow-300 border-yellow-700
|
||||
{% else %} bg-blue-600/20 text-blue-300 border-blue-700 {% endif %}">
|
||||
{{ r.severity|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<span class="ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs" title="Tag: {{ t }}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small class="text-gray-400"> — {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Form Snippet -->
|
||||
<td>
|
||||
{% if f.content_snippet %}
|
||||
<details>
|
||||
<summary>View snippet ({{ f.content_snippet|length }} chars)</summary>
|
||||
<pre class="code">{{ f.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-dim">No form issues detected.</p>
|
||||
{% endif %}
|
||||
<!-- Form Snippet -->
|
||||
<td class="py-2 pr-4">
|
||||
{% if f.content_snippet %}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">
|
||||
View snippet ({{ f.content_snippet|length }} chars)
|
||||
</summary>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ f.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No form issues detected.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
<!-- Suspicious Scripts -->
|
||||
<section id="scripts" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Suspicious Scripts</h2>
|
||||
|
||||
<!-- Suspicious Scripts -->
|
||||
<div class="card" id="scripts">
|
||||
<h2>Suspicious Scripts</h2>
|
||||
{% if suspicious_scripts %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-gray-400 border-b border-gray-800">
|
||||
<tr>
|
||||
<th class="text-left py-2 pr-4">Type</th>
|
||||
<th class="text-left py-2 pr-4">Source URL</th>
|
||||
<th class="text-left py-2 pr-4">Matches (Rules & Heuristics)</th>
|
||||
<th class="text-left py-2 pr-4">Content Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suspicious_scripts %}
|
||||
<tr class="border-b border-gray-900 align-top">
|
||||
<td class="py-2 pr-4 whitespace-nowrap">{{ s.type or 'unknown' }}</td>
|
||||
|
||||
{% if suspicious_scripts %}
|
||||
<table class="enrichment-table scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Source URL</th>
|
||||
<th>Matches (Rules & Heuristics)</th>
|
||||
<th>Content Snippet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suspicious_scripts %}
|
||||
<tr>
|
||||
<!-- Type -->
|
||||
<td>{{ s.type or 'unknown' }}</td>
|
||||
<td class="py-2 pr-4 break-all">
|
||||
{% if s.src %}
|
||||
<a href="{{ s.src }}" target="_blank" rel="noopener" class="hover:text-blue-400">
|
||||
{{ s.src[:100] }}{% if s.src|length > 100 %}…{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Source URL -->
|
||||
<td class="breakable">
|
||||
{% if s.src %}
|
||||
<a href="{{ s.src }}" target="_blank" rel="noopener">{{ s.src[:50] }}</a>
|
||||
{% else %} N/A {% endif %}
|
||||
</td>
|
||||
<!-- Matches (Rules & Heuristics) -->
|
||||
<td class="py-2 pr-4" data-role="matches-cell">
|
||||
{% set has_rules = s.rules and s.rules|length > 0 %}
|
||||
{% set has_heur = s.heuristics and s.heuristics|length > 0 %}
|
||||
|
||||
<!-- Matches (Rules & Heuristics) -->
|
||||
<td data-role="matches-cell">
|
||||
{% set has_rules = s.rules and s.rules|length > 0 %}
|
||||
{% set has_heur = s.heuristics and s.heuristics|length > 0 %}
|
||||
{% if has_rules %}
|
||||
<div class="mb-1"><strong>Rules</strong></div>
|
||||
<ul class="space-y-1">
|
||||
{% for r in s.rules %}
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
{% set sev = r.severity|lower %}
|
||||
<span class="ml-2 rounded-full px-2 py-0.5 text-xs border
|
||||
{% if sev == 'high' %} bg-red-600/20 text-red-300 border-red-700
|
||||
{% elif sev == 'medium' %} bg-yellow-600/20 text-yellow-300 border-yellow-700
|
||||
{% else %} bg-blue-600/20 text-blue-300 border-blue-700 {% endif %}">
|
||||
{{ r.severity|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<span class="ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs" title="Tag: {{ t }}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small class="text-gray-400"> — {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if has_rules %}
|
||||
<strong>Rules</strong>
|
||||
<ul>
|
||||
{% for r in s.rules %}
|
||||
<li title="{{ r.description or '' }}">
|
||||
{{ r.name }}
|
||||
{% if r.severity %}
|
||||
<span class="badge sev-{{ r.severity|lower }}">{{ r.severity|title }}</span>
|
||||
{% if has_heur %}
|
||||
<div class="mt-2 mb-1"><strong>Heuristics</strong></div>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
{% for h in s.heuristics %}
|
||||
<li>{{ h }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if not has_rules and not has_heur %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Content Snippet / Analyze external script -->
|
||||
<td class="py-2 pr-4" data-role="snippet-cell">
|
||||
{% if s.content_snippet %}
|
||||
<details>
|
||||
<summary class="cursor-pointer text-blue-300 hover:underline">
|
||||
View snippet ({{ s.content_snippet|length }} chars)
|
||||
</summary>
|
||||
<pre class="bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1">{{ s.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
{% if s.type == 'external' and s.src %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-analyze-snippet inline-flex items-center gap-2 rounded-lg px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs"
|
||||
data-url="{{ s.src }}"
|
||||
data-job="{{ uuid }}">
|
||||
Analyze external script
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-gray-500">N/A</span>
|
||||
{% endif %}
|
||||
{% if r.tags %}
|
||||
{% for t in r.tags %}
|
||||
<span class="chip" title="Tag: {{ t }}">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if r.description %}
|
||||
<small>— {{ r.description }}</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if has_heur %}
|
||||
<strong>Heuristics</strong>
|
||||
<ul>
|
||||
{% for h in s.heuristics %}
|
||||
<li>{{ h }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No suspicious scripts detected.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if not has_rules and not has_heur %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
<!-- Content Snippet (reused for Analyze button / dynamic snippet) -->
|
||||
<td data-role="snippet-cell">
|
||||
{% if s.content_snippet %}
|
||||
<details>
|
||||
<summary>View snippet ({{ s.content_snippet|length }} chars)</summary>
|
||||
<pre class="code">{{ s.content_snippet }}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
{% if s.type == 'external' and s.src %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary btn-analyze-snippet"
|
||||
data-url="{{ s.src }}"
|
||||
data-job="{{ uuid }}">Analyze external script</button>
|
||||
{% else %}
|
||||
<span class="text-dim">N/A</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Screenshot -->
|
||||
<section id="screenshot" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Screenshot</h2>
|
||||
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}"
|
||||
alt="Screenshot"
|
||||
class="w-full rounded-lg border border-gray-800">
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
<!-- Source -->
|
||||
<section id="source" class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<h2 class="text-lg font-semibold mb-3">Source</h2>
|
||||
<p>
|
||||
<a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}"
|
||||
target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700">
|
||||
View Source
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2"><a href="#top-jump-list" class="text-sm text-gray-400 hover:text-blue-400">Back to top</a></p>
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<p>No suspicious scripts detected.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div class="card" id="screenshot">
|
||||
<h2>Screenshot</h2>
|
||||
<img src="{{ url_for('main.artifacts', run_uuid=uuid, filename='screenshot.png') }}" alt="Screenshot">
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="card" id="source">
|
||||
<h2>Source</h2>
|
||||
<p><a href="{{ url_for('main.view_artifact', run_uuid=uuid, filename='source.html') }}" target="_blank">View Source</a></p>
|
||||
<p><a href="#top-jump-list">Back to top</a></p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block page_js %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
/**
|
||||
* From an absolute artifact path like:
|
||||
* /data/<uuid>/scripts/fetched/0.js
|
||||
* /data/<uuid>/1755803694244.js
|
||||
* C:\data\<uuid>\1755803694244.js
|
||||
* return { uuid, rel } where rel is the path segment(s) after the uuid.
|
||||
* Helpers to parse artifact path and build viewer URL
|
||||
*/
|
||||
function parseArtifactPath(artifactPath) {
|
||||
if (!artifactPath) return { uuid: null, rel: null };
|
||||
const norm = String(artifactPath).replace(/\\/g, '/'); // windows -> posix
|
||||
const re = /\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/(.+)$/;
|
||||
const m = norm.match(re);
|
||||
const m = norm.match(/\/([0-9a-fA-F-]{36})\/(.+)$/);
|
||||
if (!m) return { uuid: null, rel: null };
|
||||
return { uuid: m[1], rel: m[2] };
|
||||
}
|
||||
|
||||
/** Build /view/artifact/<uuid>/<path:filename> */
|
||||
function buildViewerUrlFromAbsPath(artifactPath) {
|
||||
const { uuid, rel } = parseArtifactPath(artifactPath);
|
||||
if (!uuid || !rel) return '#';
|
||||
@@ -354,7 +396,21 @@ function buildViewerUrlFromAbsPath(artifactPath) {
|
||||
return `/view/artifact/${encodeURIComponent(uuid)}/${encodedRel}`;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
/**
|
||||
* Map severities to Tailwind badge classes
|
||||
*/
|
||||
function severityClass(sev) {
|
||||
switch ((sev || '').toString().toLowerCase()) {
|
||||
case 'high': return 'ml-2 rounded-full px-2 py-0.5 text-xs border bg-red-600/20 text-red-300 border-red-700';
|
||||
case 'medium': return 'ml-2 rounded-full px-2 py-0.5 text-xs border bg-yellow-600/20 text-yellow-300 border-yellow-700';
|
||||
default: return 'ml-2 rounded-full px-2 py-0.5 text-xs border bg-blue-600/20 text-blue-300 border-blue-700';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Analyze external script" buttons
|
||||
*/
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-analyze-snippet');
|
||||
if (!btn) return;
|
||||
|
||||
@@ -365,125 +421,93 @@ document.addEventListener('click', function (e) {
|
||||
const url = btn.dataset.url;
|
||||
const job = btn.dataset.job;
|
||||
|
||||
// Replace button with a lightweight loading text
|
||||
// Replace button with lightweight loading text
|
||||
const loading = document.createElement('span');
|
||||
loading.className = 'text-dim';
|
||||
loading.className = 'text-gray-400';
|
||||
loading.textContent = 'Analyzing…';
|
||||
btn.replaceWith(loading);
|
||||
|
||||
fetch('/api/analyze_script', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, // include CSRF header if applicable
|
||||
body: JSON.stringify({ job_id: job, url: url})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
try {
|
||||
const r = await fetch('/api/analyze_script', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ job_id: job, url: url })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.ok) {
|
||||
loading.textContent = 'Error: ' + (data.error || 'Unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build the snippet details element ---
|
||||
const snippetText = data.snippet || ''; // backend should return a preview
|
||||
const snippetLen = data.snippet_len || snippetText.length;
|
||||
|
||||
// --- File path / viewer things
|
||||
const filepath = data.artifact_path || ''; // e.g., "/data/3ec90584-076e-457c-924b-861be7e11a34/1755803694244.js"
|
||||
const viewerUrl = buildViewerUrlFromAbsPath(filepath);
|
||||
|
||||
|
||||
// Build details with snippet
|
||||
const details = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'View snippet (' + data.snippet_len + ' chars' + (data.truncated ? ', truncated' : '') + ', ' + data.bytes + ' bytes)';
|
||||
summary.className = 'cursor-pointer text-blue-300 hover:underline';
|
||||
summary.textContent = 'View snippet (' + (data.snippet_len ?? (data.snippet || '').length) +
|
||||
' chars' + (data.truncated ? ', truncated' : '') +
|
||||
', ' + (data.bytes ?? '') + ' bytes)';
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'code';
|
||||
pre.textContent = snippetText; // textContent preserves literal code safely
|
||||
pre.className = 'bg-[#0b0f14] border border-gray-800 rounded-lg p-3 overflow-x-auto text-xs mt-1';
|
||||
pre.textContent = data.snippet || '';
|
||||
|
||||
// put things in the DOM
|
||||
const viewerUrl = buildViewerUrlFromAbsPath(data.artifact_path || '');
|
||||
const open = document.createElement('a');
|
||||
open.href = viewerUrl; open.target = '_blank'; open.rel = 'noopener';
|
||||
open.className = 'ml-2 text-sm text-gray-300 hover:text-blue-400';
|
||||
open.textContent = 'open in viewer';
|
||||
|
||||
summary.appendChild(document.createTextNode(' '));
|
||||
summary.appendChild(open);
|
||||
details.appendChild(summary);
|
||||
details.appendChild(pre);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = viewerUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.textContent = 'open in viewer';
|
||||
|
||||
summary.appendChild(document.createElement('br')); // line break under the summary text
|
||||
summary.appendChild(link);
|
||||
|
||||
loading.replaceWith(details);
|
||||
|
||||
// Replace "Analyzing…" with the new details block
|
||||
loading.replaceWith(details);
|
||||
|
||||
// --- Update the Matches cell with rule findings ---
|
||||
// Update Matches cell with rule findings
|
||||
if (matchesCell) {
|
||||
if (Array.isArray(data.findings) && data.findings.length) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = 'Rules';
|
||||
const strong = document.createElement('div');
|
||||
strong.className = 'mb-1';
|
||||
strong.innerHTML = '<strong>Rules</strong>';
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'space-y-1';
|
||||
|
||||
data.findings.forEach(function (f) {
|
||||
data.findings.forEach((f) => {
|
||||
const li = document.createElement('li');
|
||||
const name = f.name || 'Rule';
|
||||
const desc = f.description ? ' — ' + f.description : '';
|
||||
li.textContent = name + desc;
|
||||
|
||||
// Optional badges for severity/tags if present
|
||||
li.textContent = (f.name || 'Rule') + (f.description ? ' — ' + f.description : '');
|
||||
if (f.severity) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge sev-' + String(f.severity).toLowerCase();
|
||||
badge.className = severityClass(f.severity);
|
||||
badge.textContent = String(f.severity).charAt(0).toUpperCase() + String(f.severity).slice(1);
|
||||
li.appendChild(document.createTextNode(' '));
|
||||
li.appendChild(badge);
|
||||
}
|
||||
if (Array.isArray(f.tags)) {
|
||||
f.tags.forEach(function (t) {
|
||||
f.tags.forEach((t) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
chip.className = 'ml-1 rounded-full bg-gray-800 border border-gray-700 text-gray-300 px-2 py-0.5 text-xs';
|
||||
chip.title = 'Tag: ' + t;
|
||||
chip.textContent = t;
|
||||
li.appendChild(document.createTextNode(' '));
|
||||
li.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
frag.appendChild(strong);
|
||||
frag.appendChild(ul);
|
||||
|
||||
// Replace placeholder N/A or existing heuristics-only content
|
||||
matchesCell.innerHTML = '';
|
||||
matchesCell.appendChild(frag);
|
||||
} else {
|
||||
matchesCell.innerHTML = '<span class="text-dim">No rule matches.</span>';
|
||||
matchesCell.innerHTML = '<span class="text-gray-500">No rule matches.</span>';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
} catch (err) {
|
||||
loading.textContent = 'Request failed: ' + err;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.matches('[data-toggle]')) {
|
||||
var id = e.target.getAttribute('data-toggle');
|
||||
var el = document.getElementById(id);
|
||||
if (el) {
|
||||
var hidden = el.getAttribute('hidden') !== null;
|
||||
if (hidden) { el.removeAttribute('hidden'); } else { el.setAttribute('hidden', ''); }
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Source Viewer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width:1100px;margin:0 auto;padding:1rem 1.25rem;">
|
||||
<header style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:1.1rem;">Code Viewer</h2>
|
||||
<div class="text-dim" style="font-size:0.9rem;">
|
||||
<strong>File:</strong> <span id="fileName">{{ filename }}</span>
|
||||
<div class="max-w-xl md:max-w-3xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl mx-auto space-y-4">
|
||||
<section class="bg-card border border-gray-800 rounded-xl p-4">
|
||||
<header class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Source Viewer</h2>
|
||||
<div class="text-sm text-gray-400">
|
||||
<strong>File:</strong> <span id="fileName" class="break-all">{{ filename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;align-items:center;">
|
||||
<button id="copyBtn" class="btn btn-sm">Copy</button>
|
||||
<button id="wrapBtn" class="btn btn-sm">Toggle wrap</button>
|
||||
<a id="openRaw" class="btn btn-sm" href="{{ raw_url }}" target="_blank" rel="noopener">Open raw</a>
|
||||
<a id="downloadRaw" class="btn btn-sm" href="{{ raw_url }}" download>Download</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="copyBtn"
|
||||
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
|
||||
Copy
|
||||
</button>
|
||||
<button id="wrapBtn"
|
||||
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
|
||||
Toggle wrap
|
||||
</button>
|
||||
<a id="openRaw" href="{{ raw_url }}" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
|
||||
Open raw
|
||||
</a>
|
||||
<a id="downloadRaw" href="{{ raw_url }}" download
|
||||
class="inline-flex items-center rounded-lg px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-sm">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="viewerStatus" class="text-dim" style="margin:.5rem 0 .75rem;"></div>
|
||||
<div id="editor" style="height:72vh;border:1px solid #1f2a36;border-radius:8px;"></div>
|
||||
<div id="viewerStatus" class="text-sm text-gray-400 mt-2 mb-3"></div>
|
||||
|
||||
<div id="editor" class="h-[72vh] border border-gray-800 rounded-lg"></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- Monaco AMD loader (no integrity to avoid mismatch) -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.49.0/min/vs/loader.min.js"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
@@ -36,13 +55,18 @@
|
||||
if (!name) return 'plaintext';
|
||||
const m = name.toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||
const ext = m ? m[1] : '';
|
||||
const map = {js:'javascript',mjs:'javascript',cjs:'javascript',ts:'typescript',json:'json',
|
||||
html:'html',htm:'html',css:'css',py:'python',sh:'shell',bash:'shell',
|
||||
yml:'yaml',yaml:'yaml',md:'markdown',txt:'plaintext',log:'plaintext'};
|
||||
const map = {
|
||||
js:'javascript', mjs:'javascript', cjs:'javascript',
|
||||
ts:'typescript', json:'json',
|
||||
html:'html', htm:'html', css:'css',
|
||||
py:'python', sh:'shell', bash:'shell',
|
||||
yml:'yaml', yaml:'yaml',
|
||||
md:'markdown', txt:'plaintext', log:'plaintext'
|
||||
};
|
||||
return map[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
// Wait until the AMD loader has defined window.require
|
||||
// Wait until the AMD loader defines window.require
|
||||
function waitForRequire(msLeft = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t0 = performance.now();
|
||||
@@ -90,15 +114,23 @@
|
||||
|
||||
// Buttons
|
||||
document.getElementById('copyBtn')?.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(editor.getValue()); statusEl.textContent = 'Copied.'; }
|
||||
catch (e) { statusEl.textContent = 'Copy failed: ' + e; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(editor.getValue());
|
||||
statusEl.textContent = 'Copied.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Copy failed: ' + e;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('wrapBtn')?.addEventListener('click', () => {
|
||||
const opts = editor.getRawOptions();
|
||||
editor.updateOptions({ wordWrap: opts.wordWrap === 'on' ? 'off' : 'on' });
|
||||
});
|
||||
|
||||
statusEl.textContent = (resp.ok ? '' : `Warning: HTTP ${resp.status}`) + (text.length ? '' : ' (empty file)');
|
||||
// Status
|
||||
const warn = resp.ok ? '' : `Warning: HTTP ${resp.status}`;
|
||||
const empty = text.length ? '' : (warn ? ' · empty file' : 'Empty file');
|
||||
statusEl.textContent = warn + (warn && empty ? '' : '') + (empty || '');
|
||||
});
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Viewer error: ' + err.message;
|
||||
|
||||
Reference in New Issue
Block a user