feat(visitor): SCAN THE VISITOR - typewriter recon overlay with geo/UA/ISP/threat-level + country centroids dataset
This commit is contained in:
parent
7e9423969b
commit
33dfb3410b
7 changed files with 1333 additions and 61 deletions
|
|
@ -24,6 +24,13 @@ try:
|
|||
except Exception as _tb_err:
|
||||
print(f'[WARN] telemetry_routes not loaded: {_tb_err}')
|
||||
|
||||
# Register visitor blueprint (provides /api/visitor/* and /api/leaderboards)
|
||||
try:
|
||||
from visitor_routes import visitor_bp
|
||||
app.register_blueprint(visitor_bp)
|
||||
except Exception as _vb_err:
|
||||
print(f'[WARN] visitor_routes not loaded: {_vb_err}')
|
||||
|
||||
DATA_DIR = Path(__file__).parent / 'data'
|
||||
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
|
||||
ADMIN_USER = 'jae'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
{
|
||||
"site": "jaeswift.xyz",
|
||||
"entries": [
|
||||
{
|
||||
"version": "1.37.0",
|
||||
"date": "20/04/2026",
|
||||
"category": "FEATURE",
|
||||
"title": "SCAN THE VISITOR — Typewriter Operator Recon Overlay",
|
||||
"changes": [
|
||||
"New homepage overlay greets every visitor with a dramatic typewriter-animated recon scan (~22ms/char): geolocation via GeoLite2, reverse-DNS-derived ISP, user-agent parsing (browser/OS/device) via user-agents pip package, masked IP (X.***.***.Y), screen/language/timezone/connection fingerprinting, plus GREEN/AMBER/RED threat-level status bar",
|
||||
"Backend: new api/visitor_routes.py Flask blueprint with /api/visitor/scan endpoint — in-memory per-IP rate limiting (1 req / 10s), lazy GeoIP reader (country + city when available), 1s-timeout reverse DNS hostname lookup with ISP dictionary (BT/Virgin/Sky/AWS/Cloudflare/Hetzner etc.), country-flag emoji auto-generation, TOR exit-node heuristic",
|
||||
"Frontend: js/scan-visitor.js + css/scan-visitor.css — 720px CRT-scanline overlay with blur backdrop, monospace green typewriter output, blink cursor, three action buttons (CLOSE / DISMISS 7D / ACKNOWLEDGE), ESC-to-close, collapses into a persistent bottom-right OPERATOR ID badge on close, click badge to re-run scan",
|
||||
"localStorage persistence: scanDismissed (7-day auto-expiry so returning operators only see the badge), scanBadgeHidden (× on badge hides it permanently)",
|
||||
"Fully mobile responsive: panel narrows to 96vw at <600px, reduces typography, adjusts badge position above bottom HUD bar",
|
||||
"Bundled api/data/country_centroids.json (130+ ISO-3166 country lat/lon centroids) to power upcoming 3D globe traffic-arc feature",
|
||||
"Exposed window.__jaeScan.run() / window.__jaeScan.reset() for debug re-triggering"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.36.2",
|
||||
"date": "20/04/2026",
|
||||
|
|
@ -78,9 +93,9 @@
|
|||
"Chat history persistence across page reloads (last 50 messages in localStorage key jae-ai-history-v1, auto-restored with RESTORED FROM MEMORY divider)",
|
||||
"Memory Vault modal UI: military/terminal themed, grouped by category (identity/preference/project/skill/goal/relationship/other) with per-entry delete, importance/timestamp display",
|
||||
"Modal controls: EXPORT JSON (download backup), FORCE EXTRACT (manual trigger), CLEAR HISTORY (wipes conversation), CLEAR ALL MEMORIES (with confirm)",
|
||||
"Memory button in chat header with live count badge + privacy info tooltip (\u2139) explaining local-only storage",
|
||||
"Memory button in chat header with live count badge + privacy info tooltip (ℹ) explaining local-only storage",
|
||||
"Fuzzy dedup: new memories >80% similar to existing are merged (importance boosted) instead of duplicated",
|
||||
"Privacy-first design: all memories stay in the user\u2019s browser; only current query + small relevant subset sent to Venice per message",
|
||||
"Privacy-first design: all memories stay in the user’s browser; only current query + small relevant subset sent to Venice per message",
|
||||
"Graceful degradation: missing localStorage or extraction failures never break the chat loop"
|
||||
]
|
||||
},
|
||||
|
|
@ -88,7 +103,7 @@
|
|||
"version": "1.33.0",
|
||||
"date": "19/04/2026",
|
||||
"category": "FEATURE",
|
||||
"title": "Homepage SERVER METRICS \u2014 Real Live Data",
|
||||
"title": "Homepage SERVER METRICS — Real Live Data",
|
||||
"changes": [
|
||||
"SERVER METRICS panel now shows real VPS metrics via /api/stats",
|
||||
"CPU LOAD bar: real load_avg / nproc percentage",
|
||||
|
|
@ -117,7 +132,7 @@
|
|||
"Purged file from entire git history via git-filter-repo (all 120 commits rewritten)",
|
||||
"Force-pushed cleaned history to Gitea (old commits garbage-collected)",
|
||||
"Created apikeys.example.json template for future contributors",
|
||||
"Rotated Venice API key \u2014 old key revoked by user",
|
||||
"Rotated Venice API key — old key revoked by user",
|
||||
"VPS file permissions hardened: chmod 600 api/data/apikeys.json",
|
||||
"Verified raw URL git.jaeswift.xyz/.../apikeys.json now returns HTTP 404",
|
||||
"Audit confirmed: zero occurrences of any API key in git history across all branches"
|
||||
|
|
@ -127,21 +142,21 @@
|
|||
"version": "1.31.0",
|
||||
"date": "19/04/2026",
|
||||
"category": "AI",
|
||||
"title": "AI Model Switch \u2014 Gemma-4-Uncensored",
|
||||
"title": "AI Model Switch — Gemma-4-Uncensored",
|
||||
"changes": [
|
||||
"Switched JAE AI chat model from olafangensan-glm-4.7-flash-heretic to gemma-4-uncensored",
|
||||
"Switched SITREP daily briefing generator to same model for consistency",
|
||||
"Updated admin panel chat defaults: model + header tag now reflect GEMMA-4-UNCENSORED",
|
||||
"API restarted and verified live \u2014 chat now returns tighter, more natural replies"
|
||||
"API restarted and verified live — chat now returns tighter, more natural replies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v1.30.0",
|
||||
"date": "18/04/2026",
|
||||
"title": "ARMOURY: Wallet X-Ray \u2014 Solana Wallet Analyser",
|
||||
"title": "ARMOURY: Wallet X-Ray — Solana Wallet Analyser",
|
||||
"category": "ARMOURY",
|
||||
"changes": [
|
||||
"New tool: Wallet X-Ray \u2014 deep scan any Solana wallet address or connect your own wallet",
|
||||
"New tool: Wallet X-Ray — deep scan any Solana wallet address or connect your own wallet",
|
||||
"Overview panel: SOL balance with live USD value, total portfolio value, wallet age, transaction count, and activity rating",
|
||||
"Token Holdings: full SPL token table with logos, balances, live prices via Jupiter Price API, and USD values sorted by value",
|
||||
"NFT Detection: identifies NFTs (0-decimal single-supply tokens) with image grid and Solscan links",
|
||||
|
|
@ -153,7 +168,7 @@
|
|||
"URL parameter support: ?address=... for direct wallet scanning via shared links",
|
||||
"Wallet X-Ray card added to LAB page with cyan/turquoise theme",
|
||||
"Military radar sweep loading animation during wallet scan",
|
||||
"Fully responsive design \u2014 works on mobile and desktop"
|
||||
"Fully responsive design — works on mobile and desktop"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -163,14 +178,14 @@
|
|||
"category": "UNREDACTED",
|
||||
"changes": [
|
||||
"Added 9 new collections across UFO/UAP, Covert Operations, and Government categories with 15 indexed documents",
|
||||
"COMETA Report (France, 1999): 2 English translations of landmark French military UFO assessment \u2014 163 pages total",
|
||||
"RAAF UFO Files (Australia): Declassified Royal Australian Air Force intelligence file \u2014 18 pages",
|
||||
"Project Magnet (Canada, 1950-54): Official Canadian government UFO research programme documents \u2014 6 pages",
|
||||
"NZDF UFO/UAP Files (New Zealand, 1984-2024): 3 documents including Cold War sighting reports and modern OIA responses \u2014 136 pages",
|
||||
"Opera\u00e7\u00e3o Prato (Brazil, 1977): Secret Brazilian Air Force UFO investigation in the Amazon \u2014 58 pages",
|
||||
"COMETA Report (France, 1999): 2 English translations of landmark French military UFO assessment — 163 pages total",
|
||||
"RAAF UFO Files (Australia): Declassified Royal Australian Air Force intelligence file — 18 pages",
|
||||
"Project Magnet (Canada, 1950-54): Official Canadian government UFO research programme documents — 6 pages",
|
||||
"NZDF UFO/UAP Files (New Zealand, 1984-2024): 3 documents including Cold War sighting reports and modern OIA responses — 136 pages",
|
||||
"Operação Prato (Brazil, 1977): Secret Brazilian Air Force UFO investigation in the Amazon — 58 pages",
|
||||
"COINTELPRO: 758-page FBI surveillance programme compilation plus analytical examination added to existing collection",
|
||||
"Operation Paperclip (1945-59): Declassified CIA documents on Nazi scientist recruitment programme \u2014 11 pages",
|
||||
"Watergate Scandal (1972-74): Ford Presidential Library documents on the constitutional crisis \u2014 35 pages",
|
||||
"Operation Paperclip (1945-59): Declassified CIA documents on Nazi scientist recruitment programme — 11 pages",
|
||||
"Watergate Scandal (1972-74): Ford Presidential Library documents on the constitutional crisis — 35 pages",
|
||||
"Iran-Contra Affair (1985-87): Complete 506-page Congressional investigation report",
|
||||
"New countries added to UFO/UAP category: France, Australia, Canada, New Zealand, Brazil"
|
||||
]
|
||||
|
|
@ -182,12 +197,12 @@
|
|||
"category": "CRIME SCENE",
|
||||
"changes": [
|
||||
"Added 6 new crime case collections across cold-cases, serial-killers, and landmark-cases with 11 indexed documents",
|
||||
"D.B. Cooper Hijacking (1971): FBI investigation files \u2014 162 pages on America's only unsolved aircraft hijacking",
|
||||
"JonBen\u00e9t Ramsey Murder (1996): Autopsy report, unsealed grand jury indictment, and analytical case study \u2014 19 pages",
|
||||
"Black Dahlia / Elizabeth Short (1947): Complete FBI investigation file \u2014 204 pages on LA's most famous unsolved murder",
|
||||
"Delphi Murders (2017): Probable cause affidavit and court documents from the Richard Allen prosecution \u2014 95 pages",
|
||||
"Harold Shipman (1975-98): First three reports of the Shipman Inquiry (Dame Janet Smith) \u2014 1,162 pages on Britain's worst serial killer",
|
||||
"Moors Murders \u2014 Brady & Hindley (1963-65): Mental Health Review Tribunal academic paper \u2014 22 pages",
|
||||
"D.B. Cooper Hijacking (1971): FBI investigation files — 162 pages on America's only unsolved aircraft hijacking",
|
||||
"JonBenét Ramsey Murder (1996): Autopsy report, unsealed grand jury indictment, and analytical case study — 19 pages",
|
||||
"Black Dahlia / Elizabeth Short (1947): Complete FBI investigation file — 204 pages on LA's most famous unsolved murder",
|
||||
"Delphi Murders (2017): Probable cause affidavit and court documents from the Richard Allen prosecution — 95 pages",
|
||||
"Harold Shipman (1975-98): First three reports of the Shipman Inquiry (Dame Janet Smith) — 1,162 pages on Britain's worst serial killer",
|
||||
"Moors Murders — Brady & Hindley (1963-65): Mental Health Review Tribunal academic paper — 22 pages",
|
||||
"New landmark-cases/US subcategory with Delphi Murders as first entry",
|
||||
"Total new document pages added: 1,664 across all crime scene collections"
|
||||
]
|
||||
|
|
@ -195,18 +210,18 @@
|
|||
{
|
||||
"version": "v1.27.0",
|
||||
"date": "18/04/2026",
|
||||
"title": "CRIME SCENE: UK Murder Cases \u2014 Mass Upload",
|
||||
"title": "CRIME SCENE: UK Murder Cases — Mass Upload",
|
||||
"category": "CRIME SCENE",
|
||||
"changes": [
|
||||
"Added 11 UK murder case collections across 4 categories with 57 indexed documents",
|
||||
"New 'Landmark Cases' category for cases that changed British law and policing",
|
||||
"Daniel Morgan (1987): 3-volume Independent Panel Report \u2014 1,276 pages on Met Police corruption",
|
||||
"Daniel Morgan (1987): 3-volume Independent Panel Report — 1,276 pages on Met Police corruption",
|
||||
"Claudia Lawrence (2009): ICO FOI audit of North Yorkshire Police practices",
|
||||
"Jill Dando (1999): Barry George appeal judgment and CCRC referral decision",
|
||||
"Suzy Lamplugh (1986): Suzy Lamplugh Trust safety resources and case documentation",
|
||||
"Stephen Lawrence (1993): Complete Macpherson Report \u2014 389 pages, coined 'institutional racism'",
|
||||
"Stephen Lawrence (1993): Complete Macpherson Report — 389 pages, coined 'institutional racism'",
|
||||
"James Bulger (1993): ECHR Grand Chamber judgments, anonymity injunction, minimum term review",
|
||||
"Damilola Taylor (2000): Sentamu Oversight Panel investigation review \u2014 56 pages",
|
||||
"Damilola Taylor (2000): Sentamu Oversight Panel investigation review — 56 pages",
|
||||
"Lee Rigby (2013): ISC intelligence report (200 pages), Government response, sentencing remarks",
|
||||
"Lord Lucan (1974): Presumption of Death Act legal analysis for Sandra Rivett murder",
|
||||
"Madeleine McCann (2007): PJ Police Report translation (57 pages), Jane Tanner statements (4 parts)",
|
||||
|
|
@ -221,10 +236,10 @@
|
|||
"category": "CRIME SCENE",
|
||||
"changes": [
|
||||
"Populated the Police Reports subcollection for the Zodiac Killer with 5 documents (207 pages, 26.4 MB)",
|
||||
"Added Lake Herman Road police reports \u2014 Benicia PD & Solano County Sheriff (60 pages, 5.6 MB)",
|
||||
"Added Blue Rock Springs police reports \u2014 Vallejo PD (75 pages, 10.3 MB)",
|
||||
"Added Lake Berryessa police reports \u2014 Napa County Sheriff's Office (35 pages, 5.1 MB)",
|
||||
"Added Presidio Heights / Paul Stine police reports \u2014 SFPD (2 pages, 0.4 MB)",
|
||||
"Added Lake Herman Road police reports — Benicia PD & Solano County Sheriff (60 pages, 5.6 MB)",
|
||||
"Added Blue Rock Springs police reports — Vallejo PD (75 pages, 10.3 MB)",
|
||||
"Added Lake Berryessa police reports — Napa County Sheriff's Office (35 pages, 5.1 MB)",
|
||||
"Added Presidio Heights / Paul Stine police reports — SFPD (2 pages, 0.4 MB)",
|
||||
"Added California Department of Justice investigation report (35 pages, 5.0 MB)",
|
||||
"Zodiac Killer collection now contains 26 documents across 4 subcollections totalling approximately 78 MB",
|
||||
"All documents sourced from zodiackiller.com's authenticated police report archive"
|
||||
|
|
@ -233,7 +248,7 @@
|
|||
{
|
||||
"version": "1.25.0",
|
||||
"date": "16/04/2026",
|
||||
"title": "Changelog Fix \u2014 Date Format & Missing Entries",
|
||||
"title": "Changelog Fix — Date Format & Missing Entries",
|
||||
"category": "fix",
|
||||
"changes": [
|
||||
"Fixed NaN/NaN/NaN date display bug in changelog renderer",
|
||||
|
|
@ -250,18 +265,18 @@
|
|||
"category": "fix",
|
||||
"changes": [
|
||||
"Replaced encrypted/unreadable MKUltra PDF with two working documents",
|
||||
"Added CIA Inspector General Report (1963) \u2014 48-page TOP SECRET internal review",
|
||||
"Added Senate Hearing transcript (1977) \u2014 171-page Congressional testimony exposing 149 sub-projects"
|
||||
"Added CIA Inspector General Report (1963) — 48-page TOP SECRET internal review",
|
||||
"Added Senate Hearing transcript (1977) — 171-page Congressional testimony exposing 149 sub-projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.23.0",
|
||||
"date": "16/04/2026",
|
||||
"title": "PROPAGANDA \u2192 UNREDACTED Rename + Nav Animation + CRIME SCENE",
|
||||
"title": "PROPAGANDA → UNREDACTED Rename + Nav Animation + CRIME SCENE",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Renamed PROPAGANDA section to UNREDACTED across all pages, nav, API, and URLs",
|
||||
"Built block-reveal animation on UNREDACTED nav item (\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2192 UNREDACTED \u2192 reverse \u2192 loop)",
|
||||
"Built block-reveal animation on UNREDACTED nav item (██████████ → UNREDACTED → reverse → loop)",
|
||||
"Added global document search across all UNREDACTED categories, titles, and descriptions",
|
||||
"Built new CRIME SCENE section at /depot/crimescene with red CRT theme",
|
||||
"Four crime categories: Unsolved Murders, Serial Killers, Court Transcripts, Cold Cases",
|
||||
|
|
@ -272,12 +287,12 @@
|
|||
{
|
||||
"version": "1.22.0",
|
||||
"date": "15/04/2026",
|
||||
"title": "CRIME SCENE: Zodiac Killer Expanded \u2014 Letters, Ciphers & Subcollections",
|
||||
"title": "CRIME SCENE: Zodiac Killer Expanded — Letters, Ciphers & Subcollections",
|
||||
"category": "CRIME SCENE",
|
||||
"changes": [
|
||||
"Fixed PDF path bug: document URLs now correctly include country code (US) via subcollection routing",
|
||||
"Restructured Zodiac Killer into 4 subcollections: FBI Investigation Files, Zodiac Letters & Cards, The Zodiac Ciphers, Police Reports & Crime Scene Documents",
|
||||
"Added 11 original Zodiac letter PDFs (1969\u20131974) from zodiackiller.com: Chronicle/Examiner cipher letters, Debut letter, Stine bloody shirt letter, Bus Bomb letter, Belli letter, Dragon card, Phillips 66 map letter, Little List/Mikado letter, Exorcist letter, Citizen card",
|
||||
"Added 11 original Zodiac letter PDFs (1969–1974) from zodiackiller.com: Chronicle/Examiner cipher letters, Debut letter, Stine bloody shirt letter, Bus Bomb letter, Belli letter, Dragon card, Phillips 66 map letter, Little List/Mikado letter, Exorcist letter, Citizen card",
|
||||
"Added 3 cipher PDFs: Z408 three-part cipher (solved 1969), Z340 cipher (solved 2020), Z32 map code cipher (unsolved)",
|
||||
"Added Z340 Solution academic paper by Oranchak, Blake & Van Eycke (2024, 38 pages) from arXiv",
|
||||
"Each of the 21 new documents has a unique historical description with contextual detail",
|
||||
|
|
@ -293,7 +308,7 @@
|
|||
"category": "CRIME SCENE",
|
||||
"changes": [
|
||||
"Added complete FBI Zodiac Killer investigation files (6 parts, 1,116 pages, 34MB)",
|
||||
"Files sourced from FBI Vault via Archive.org \u2014 declassified FOIA release",
|
||||
"Files sourced from FBI Vault via Archive.org — declassified FOIA release",
|
||||
"Each document includes unique summary describing specific contents",
|
||||
"Covers: Arthur Leigh Allen suspect investigation, cipher analysis, forensic lab reports, fingerprint comparisons, decades of tips and suspect referrals",
|
||||
"PDFs served from /crimescene/docs/serial-killers/US/zodiac-killer/",
|
||||
|
|
@ -307,9 +322,9 @@
|
|||
"category": "feature",
|
||||
"changes": [
|
||||
"Added PDF text search to document viewer (Ctrl+F, green/amber highlights, match counter, case toggle)",
|
||||
"Text layer enabled \u2014 select and copy text from PDFs",
|
||||
"Text layer enabled — select and copy text from PDFs",
|
||||
"Added unique descriptions to all 113 UK MOD UFO documents sourced from National Archives highlights guides",
|
||||
"Added Project Condign (250MB, 460pp SECRET UK EYES ONLY) \u2014 the classified DI55 UAP study",
|
||||
"Added Project Condign (250MB, 460pp SECRET UK EYES ONLY) — the classified DI55 UAP study",
|
||||
"Downloaded US documents: Project Blue Book, CIA UFO Collection, NSA UFO Documents, Pentagon UAP Report",
|
||||
"Downloaded Covert Ops: MKUltra, Stargate Program, Operation Northwoods",
|
||||
"Downloaded Government: JFK Warren Commission, Pentagon Papers, CIA Torture Report",
|
||||
|
|
@ -323,7 +338,7 @@
|
|||
"category": "fix",
|
||||
"changes": [
|
||||
"Fixed dispatches post pages crashing (mood type error + fallback path)",
|
||||
"SOL price ticker fixed \u2014 switched to Binance API (CORS-friendly)",
|
||||
"SOL price ticker fixed — switched to Binance API (CORS-friendly)",
|
||||
"Tightened navbar spacing between SOL price and wallet connect",
|
||||
"Converted all post mood values from integers to proper strings"
|
||||
]
|
||||
|
|
@ -334,19 +349,19 @@
|
|||
"title": "Admin Panel Overhaul",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Fixed broken Editor section \u2014 full post editing with live preview",
|
||||
"Fixed broken Backups section \u2014 export/import site data as ZIP",
|
||||
"Added SITREP admin section \u2014 generate reports, view archive",
|
||||
"Added Data Sync section \u2014 trigger Contraband/RECON syncs, view stats",
|
||||
"Added Changelog admin section \u2014 CRUD for maintenance log entries",
|
||||
"Added Cron Jobs section \u2014 view/toggle all scheduled tasks",
|
||||
"Fixed broken Editor section — full post editing with live preview",
|
||||
"Fixed broken Backups section — export/import site data as ZIP",
|
||||
"Added SITREP admin section — generate reports, view archive",
|
||||
"Added Data Sync section — trigger Contraband/RECON syncs, view stats",
|
||||
"Added Changelog admin section — CRUD for maintenance log entries",
|
||||
"Added Cron Jobs section — view/toggle all scheduled tasks",
|
||||
"Reorganised sidebar into grouped sections"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.18.0",
|
||||
"date": "06/04/2026",
|
||||
"title": "SITREP \u2014 Daily AI Briefing System",
|
||||
"title": "SITREP — Daily AI Briefing System",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Built automated daily intelligence briefing at /transmissions/sitrep",
|
||||
|
|
@ -360,7 +375,7 @@
|
|||
{
|
||||
"version": "1.17.0",
|
||||
"date": "06/04/2026",
|
||||
"title": "TOKEN FORGE \u2014 SPL Token Launcher",
|
||||
"title": "TOKEN FORGE — SPL Token Launcher",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Built token launcher at /tokenlauncher with full SPL token creation",
|
||||
|
|
@ -404,7 +419,7 @@
|
|||
"changes": [
|
||||
"Global wallet connect button in navbar across all 28 pages",
|
||||
"Multi-wallet support: Phantom, Solflare, Backpack, Coinbase, Trust, MetaMask, Jupiter",
|
||||
"Persistent connection via localStorage \u2014 survives page navigation",
|
||||
"Persistent connection via localStorage — survives page navigation",
|
||||
"Connected dropdown with address copy, Solscan link, disconnect",
|
||||
"Global window.solWallet API for all Solana features",
|
||||
"Refactored soldomains.js to use shared wallet (removed 146 lines)"
|
||||
|
|
@ -413,7 +428,7 @@
|
|||
{
|
||||
"version": "1.13.0",
|
||||
"date": "05/04/2026",
|
||||
"title": "RADAR \u2014 Live Tech News Feed",
|
||||
"title": "RADAR — Live Tech News Feed",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Built live tech news aggregator at /transmissions/radar",
|
||||
|
|
@ -436,7 +451,7 @@
|
|||
{
|
||||
"version": "1.11.0",
|
||||
"date": "04/04/2026",
|
||||
"title": "RECON \u2014 Site Restructure & Accordion Navigation",
|
||||
"title": "RECON — Site Restructure & Accordion Navigation",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Moved RECON to /depot/recon for consistency with other depot pages",
|
||||
|
|
@ -449,7 +464,7 @@
|
|||
{
|
||||
"version": "1.10.0",
|
||||
"date": "04/04/2026",
|
||||
"title": "RECON \u2014 Curated Lists Rebuild",
|
||||
"title": "RECON — Curated Lists Rebuild",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Flattened 4-level navigation to 2-level (sector > list > entries)",
|
||||
|
|
@ -461,7 +476,7 @@
|
|||
{
|
||||
"version": "1.9.0",
|
||||
"date": "04/04/2026",
|
||||
"title": "RECON \u2014 Curated Lists Database",
|
||||
"title": "RECON — Curated Lists Database",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Parsed 660 curated lists into 28 themed sectors",
|
||||
|
|
@ -478,7 +493,7 @@
|
|||
"category": "feature",
|
||||
"changes": [
|
||||
"Subcategories now display as 2-column card grid with expandable detail panels",
|
||||
"Added weekly auto-sync \u2014 resource database updates every Sunday at 03:00",
|
||||
"Added weekly auto-sync — resource database updates every Sunday at 03:00",
|
||||
"Click any subcategory card to expand/collapse its entries below",
|
||||
"Active card highlighting with amber glow",
|
||||
"Responsive grid: 2-col desktop, 1-col mobile"
|
||||
|
|
@ -490,8 +505,8 @@
|
|||
"title": "Sitewide Visual Overhaul",
|
||||
"category": "fix",
|
||||
"changes": [
|
||||
"Bumped 64 font sizes sitewide \u2014 no more microscopic text",
|
||||
"Brightened all text colours: primary #c0c0c0\u2192#d8d8d8, secondary #707070\u2192#999999, muted #3a3a3a\u2192#666666",
|
||||
"Bumped 64 font sizes sitewide — no more microscopic text",
|
||||
"Brightened all text colours: primary #c0c0c0→#d8d8d8, secondary #707070→#999999, muted #3a3a3a→#666666",
|
||||
"CONTRABAND page: 4-column category grid with responsive breakpoints",
|
||||
"Purged all third-party attribution references from entire codebase"
|
||||
]
|
||||
|
|
@ -499,13 +514,13 @@
|
|||
{
|
||||
"version": "1.6.0",
|
||||
"date": "03/04/2026",
|
||||
"title": "CONTRABAND \u2014 Classified Resource Index",
|
||||
"title": "CONTRABAND — Classified Resource Index",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Launched CONTRABAND page at /depot/contraband with 15,800+ indexed assets",
|
||||
"24 categories with military codenames (CRT-001 through CRT-024)",
|
||||
"Full-text search across all entries via API",
|
||||
"Starred/top-pick filter system with \u2b50 indicators",
|
||||
"Starred/top-pick filter system with ⭐ indicators",
|
||||
"Collapsible subcategories with item counts",
|
||||
"Flask API endpoints: /api/contraband, /api/contraband/<slug>, /api/contraband/search"
|
||||
]
|
||||
|
|
@ -527,8 +542,8 @@
|
|||
"title": "Globe & Chat AI Admin Panels",
|
||||
"category": "feature",
|
||||
"changes": [
|
||||
"Admin panel: Globe management section \u2014 server location, rotation speed, arc cities, colours",
|
||||
"Admin panel: Chat AI configuration \u2014 model selection, system prompt, greeting toggle",
|
||||
"Admin panel: Globe management section — server location, rotation speed, arc cities, colours",
|
||||
"Admin panel: Chat AI configuration — model selection, system prompt, greeting toggle",
|
||||
"New API endpoints: /api/globe, /api/chat-config with auth-protected GET/POST",
|
||||
"Interactive colour picker and slider controls for globe parameters",
|
||||
"Arc cities table with add/remove functionality"
|
||||
|
|
|
|||
129
api/data/country_centroids.json
Normal file
129
api/data/country_centroids.json
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
{
|
||||
"US": [39.8283, -98.5795],
|
||||
"GB": [54.0, -2.0],
|
||||
"IE": [53.0, -8.0],
|
||||
"FR": [46.6, 2.2],
|
||||
"DE": [51.1, 10.4],
|
||||
"ES": [40.0, -3.7],
|
||||
"PT": [39.5, -8.0],
|
||||
"IT": [42.8, 12.8],
|
||||
"NL": [52.1, 5.3],
|
||||
"BE": [50.5, 4.5],
|
||||
"CH": [46.8, 8.2],
|
||||
"AT": [47.5, 14.6],
|
||||
"SE": [62.0, 15.0],
|
||||
"NO": [60.5, 8.5],
|
||||
"FI": [64.0, 26.0],
|
||||
"DK": [56.0, 9.5],
|
||||
"PL": [51.9, 19.1],
|
||||
"CZ": [49.8, 15.5],
|
||||
"SK": [48.7, 19.7],
|
||||
"HU": [47.2, 19.5],
|
||||
"RO": [45.9, 24.9],
|
||||
"BG": [42.7, 25.5],
|
||||
"GR": [39.1, 21.8],
|
||||
"TR": [38.9, 35.2],
|
||||
"RU": [61.5, 105.3],
|
||||
"UA": [48.4, 31.2],
|
||||
"BY": [53.7, 27.9],
|
||||
"LT": [55.2, 23.9],
|
||||
"LV": [56.9, 24.6],
|
||||
"EE": [58.6, 25.0],
|
||||
"IS": [64.9, -19.0],
|
||||
"CA": [56.1, -106.3],
|
||||
"MX": [23.6, -102.5],
|
||||
"BR": [-14.2, -51.9],
|
||||
"AR": [-38.4, -63.6],
|
||||
"CL": [-35.7, -71.5],
|
||||
"CO": [4.6, -74.3],
|
||||
"PE": [-9.2, -75.0],
|
||||
"VE": [6.4, -66.6],
|
||||
"EC": [-1.8, -78.2],
|
||||
"BO": [-16.3, -63.6],
|
||||
"UY": [-32.5, -55.8],
|
||||
"PY": [-23.4, -58.4],
|
||||
"CR": [9.7, -83.8],
|
||||
"PA": [8.5, -80.8],
|
||||
"GT": [15.8, -90.2],
|
||||
"DO": [18.7, -70.2],
|
||||
"CU": [21.5, -77.8],
|
||||
"JM": [18.1, -77.3],
|
||||
"HT": [18.9, -72.3],
|
||||
"CN": [35.9, 104.2],
|
||||
"JP": [36.2, 138.3],
|
||||
"KR": [35.9, 127.8],
|
||||
"KP": [40.3, 127.5],
|
||||
"TW": [23.7, 121.0],
|
||||
"HK": [22.3, 114.2],
|
||||
"IN": [20.6, 78.9],
|
||||
"PK": [30.4, 69.3],
|
||||
"BD": [23.7, 90.4],
|
||||
"LK": [7.9, 80.8],
|
||||
"NP": [28.4, 84.1],
|
||||
"MM": [21.9, 95.9],
|
||||
"TH": [15.9, 100.9],
|
||||
"VN": [14.1, 108.3],
|
||||
"LA": [19.9, 102.5],
|
||||
"KH": [12.6, 104.9],
|
||||
"MY": [4.2, 101.9],
|
||||
"SG": [1.35, 103.8],
|
||||
"ID": [-0.8, 113.9],
|
||||
"PH": [12.9, 121.8],
|
||||
"AU": [-25.3, 133.8],
|
||||
"NZ": [-40.9, 174.9],
|
||||
"PG": [-6.3, 143.9],
|
||||
"FJ": [-17.7, 178.1],
|
||||
"ZA": [-30.6, 22.9],
|
||||
"EG": [26.8, 30.8],
|
||||
"MA": [31.8, -7.1],
|
||||
"DZ": [28.0, 1.7],
|
||||
"TN": [33.9, 9.5],
|
||||
"LY": [26.3, 17.2],
|
||||
"NG": [9.1, 8.7],
|
||||
"KE": [-0.02, 37.9],
|
||||
"ET": [9.1, 40.5],
|
||||
"GH": [7.9, -1.0],
|
||||
"CI": [7.5, -5.5],
|
||||
"SN": [14.5, -14.5],
|
||||
"TZ": [-6.4, 34.9],
|
||||
"UG": [1.4, 32.3],
|
||||
"ZM": [-13.1, 27.8],
|
||||
"ZW": [-19.0, 29.2],
|
||||
"MZ": [-18.7, 35.5],
|
||||
"AO": [-11.2, 17.9],
|
||||
"SA": [23.9, 45.1],
|
||||
"AE": [23.4, 53.8],
|
||||
"IL": [31.0, 34.9],
|
||||
"IR": [32.4, 53.7],
|
||||
"IQ": [33.2, 43.7],
|
||||
"SY": [34.8, 38.1],
|
||||
"JO": [30.6, 36.2],
|
||||
"LB": [33.9, 35.9],
|
||||
"YE": [15.6, 48.5],
|
||||
"OM": [21.5, 55.9],
|
||||
"KW": [29.3, 47.5],
|
||||
"QA": [25.4, 51.2],
|
||||
"BH": [26.1, 50.6],
|
||||
"AF": [33.9, 67.7],
|
||||
"KZ": [48.0, 66.9],
|
||||
"UZ": [41.4, 64.6],
|
||||
"GE": [42.3, 43.4],
|
||||
"AM": [40.1, 45.0],
|
||||
"AZ": [40.1, 47.6],
|
||||
"AD": [42.5, 1.5],
|
||||
"MT": [35.9, 14.5],
|
||||
"LU": [49.8, 6.1],
|
||||
"MC": [43.7, 7.4],
|
||||
"LI": [47.2, 9.5],
|
||||
"SM": [43.9, 12.5],
|
||||
"VA": [41.9, 12.5],
|
||||
"CY": [35.1, 33.4],
|
||||
"HR": [45.1, 15.2],
|
||||
"SI": [46.2, 15.0],
|
||||
"RS": [44.0, 21.0],
|
||||
"BA": [43.9, 17.7],
|
||||
"AL": [41.2, 20.2],
|
||||
"MK": [41.6, 21.7],
|
||||
"XK": [42.6, 20.9],
|
||||
"ME": [42.7, 19.4]
|
||||
}
|
||||
552
api/visitor_routes.py
Normal file
552
api/visitor_routes.py
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
#!/usr/bin/env python3
|
||||
"""JAESWIFT Visitor Intelligence Endpoints
|
||||
Provides /api/visitor/* endpoints:
|
||||
/api/visitor/scan - scan current visitor (IP/geo/UA/device/threat)
|
||||
/api/visitor/recent-arcs - last N visitor lat/lon pairs for globe traffic arcs
|
||||
"""
|
||||
import os, re, time, socket, json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from collections import OrderedDict, Counter
|
||||
from functools import lru_cache
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
visitor_bp = Blueprint('visitor', __name__)
|
||||
|
||||
DATA_DIR = Path(__file__).parent / 'data'
|
||||
NGINX_LOG = '/var/log/nginx/access.log'
|
||||
GEOIP_PATHS = [
|
||||
'/usr/share/GeoIP/GeoLite2-Country.mmdb',
|
||||
'/usr/share/GeoIP/GeoLite2-City.mmdb',
|
||||
'/var/lib/GeoIP/GeoLite2-Country.mmdb',
|
||||
]
|
||||
CENTROIDS_FILE = DATA_DIR / 'country_centroids.json'
|
||||
|
||||
# ─── Lazy imports (optional deps) ──────────────────────
|
||||
_geoip_reader = None
|
||||
_geoip_has_city = False
|
||||
|
||||
def _get_geoip():
|
||||
global _geoip_reader, _geoip_has_city
|
||||
if _geoip_reader is not None:
|
||||
return _geoip_reader
|
||||
try:
|
||||
import geoip2.database
|
||||
for p in GEOIP_PATHS:
|
||||
if os.path.exists(p):
|
||||
_geoip_reader = geoip2.database.Reader(p)
|
||||
_geoip_has_city = 'City' in p
|
||||
return _geoip_reader
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ua(ua_string):
|
||||
"""Parse user-agent. Uses user-agents lib if available, else crude regex."""
|
||||
try:
|
||||
from user_agents import parse
|
||||
ua = parse(ua_string or '')
|
||||
browser_family = ua.browser.family or 'Unknown'
|
||||
browser_version = '.'.join(str(x) for x in ua.browser.version[:2] if x is not None) or ''
|
||||
os_family = ua.os.family or 'Unknown'
|
||||
os_version = '.'.join(str(x) for x in ua.os.version[:2] if x is not None) or ''
|
||||
device = 'Mobile' if ua.is_mobile else ('Tablet' if ua.is_tablet else ('Bot' if ua.is_bot else 'Desktop'))
|
||||
return {
|
||||
'browser': browser_family,
|
||||
'browser_version': browser_version,
|
||||
'os': os_family,
|
||||
'os_version': os_version,
|
||||
'device': device,
|
||||
'is_bot': bool(ua.is_bot),
|
||||
}
|
||||
except Exception:
|
||||
s = (ua_string or '').lower()
|
||||
browser = 'Unknown'; bver = ''
|
||||
if 'firefox' in s:
|
||||
browser = 'Firefox'
|
||||
m = re.search(r'firefox/([0-9.]+)', s); bver = m.group(1)[:6] if m else ''
|
||||
elif 'edg/' in s:
|
||||
browser = 'Edge'
|
||||
m = re.search(r'edg/([0-9.]+)', s); bver = m.group(1)[:6] if m else ''
|
||||
elif 'chrome' in s:
|
||||
browser = 'Chrome'
|
||||
m = re.search(r'chrome/([0-9.]+)', s); bver = m.group(1)[:6] if m else ''
|
||||
elif 'safari' in s:
|
||||
browser = 'Safari'
|
||||
is_bot = any(k in s for k in ('bot', 'crawl', 'spider', 'wget', 'curl'))
|
||||
os_family = 'Unknown'
|
||||
if 'windows' in s: os_family = 'Windows'
|
||||
elif 'mac os' in s or 'macintosh' in s: os_family = 'macOS'
|
||||
elif 'android' in s: os_family = 'Android'
|
||||
elif 'iphone' in s or 'ipad' in s or 'ios' in s: os_family = 'iOS'
|
||||
elif 'linux' in s: os_family = 'Linux'
|
||||
device = 'Mobile' if ('mobile' in s or 'android' in s or 'iphone' in s) else ('Bot' if is_bot else 'Desktop')
|
||||
return {
|
||||
'browser': browser, 'browser_version': bver,
|
||||
'os': os_family, 'os_version': '',
|
||||
'device': device, 'is_bot': is_bot,
|
||||
}
|
||||
|
||||
|
||||
def _mask_ip(ip):
|
||||
if not ip:
|
||||
return 'UNKNOWN'
|
||||
if ':' in ip: # IPv6 — mask middle groups
|
||||
parts = ip.split(':')
|
||||
if len(parts) >= 3:
|
||||
return f"{parts[0]}:****:****:{parts[-1]}"
|
||||
return ip
|
||||
parts = ip.split('.')
|
||||
if len(parts) == 4:
|
||||
return f"{parts[0]}.***.***.{ parts[3]}"
|
||||
return ip
|
||||
|
||||
|
||||
def _client_ip():
|
||||
xff = request.headers.get('X-Forwarded-For', '')
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
xr = request.headers.get('X-Real-IP', '')
|
||||
if xr:
|
||||
return xr.strip()
|
||||
return request.remote_addr or ''
|
||||
|
||||
|
||||
def _reverse_dns(ip, timeout=1.0):
|
||||
try:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
host, _, _ = socket.gethostbyaddr(ip)
|
||||
return host
|
||||
except Exception:
|
||||
return ''
|
||||
finally:
|
||||
socket.setdefaulttimeout(None)
|
||||
|
||||
|
||||
def _isp_guess(hostname):
|
||||
"""Guess ISP from reverse DNS hostname — crude but free."""
|
||||
if not hostname:
|
||||
return 'REDACTED'
|
||||
h = hostname.lower()
|
||||
known = {
|
||||
'bt.com': 'British Telecommunications',
|
||||
'btcentralplus': 'British Telecommunications',
|
||||
'virginm.net': 'Virgin Media',
|
||||
'sky.com': 'Sky Broadband',
|
||||
'talktalk': 'TalkTalk',
|
||||
'plus.net': 'Plusnet',
|
||||
'vodafone': 'Vodafone',
|
||||
'three.co.uk': 'Three UK',
|
||||
'ee.co.uk': 'EE',
|
||||
'comcast': 'Comcast',
|
||||
'verizon': 'Verizon',
|
||||
'amazonaws': 'Amazon AWS',
|
||||
'googleusercontent': 'Google Cloud',
|
||||
'googlebot': 'Google (Bot)',
|
||||
'azure': 'Microsoft Azure',
|
||||
'hetzner': 'Hetzner',
|
||||
'digitalocean': 'DigitalOcean',
|
||||
'ovh': 'OVH',
|
||||
'cloudflare': 'Cloudflare',
|
||||
'linode': 'Linode',
|
||||
'deutsche-telekom': 'Deutsche Telekom',
|
||||
'telekom': 'Deutsche Telekom',
|
||||
'orange.fr': 'Orange',
|
||||
'free.fr': 'Free',
|
||||
}
|
||||
for key, val in known.items():
|
||||
if key in h:
|
||||
return val
|
||||
# fallback: extract last 2 labels as domain
|
||||
parts = h.split('.')
|
||||
if len(parts) >= 2:
|
||||
return parts[-2].capitalize() + '.' + parts[-1]
|
||||
return 'REDACTED'
|
||||
|
||||
|
||||
def _country_flag(cc):
|
||||
if not cc or len(cc) != 2:
|
||||
return ''
|
||||
try:
|
||||
return chr(0x1F1E6 + ord(cc[0].upper()) - ord('A')) + chr(0x1F1E6 + ord(cc[1].upper()) - ord('A'))
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
# ─── Rate limiting (in-memory, simple) ─────────────────
|
||||
_rate_cache = OrderedDict()
|
||||
_RATE_LIMIT_SECS = 10
|
||||
|
||||
def _rate_limited(ip):
|
||||
now = time.time()
|
||||
# purge old
|
||||
expired = [k for k, t in _rate_cache.items() if now - t > _RATE_LIMIT_SECS]
|
||||
for k in expired:
|
||||
_rate_cache.pop(k, None)
|
||||
last = _rate_cache.get(ip)
|
||||
if last and now - last < _RATE_LIMIT_SECS:
|
||||
return True
|
||||
_rate_cache[ip] = now
|
||||
# cap size
|
||||
while len(_rate_cache) > 1000:
|
||||
_rate_cache.popitem(last=False)
|
||||
return False
|
||||
|
||||
|
||||
# ─── Country centroids (lat/lon) for arcs ──────────────
|
||||
_CENTROIDS = None
|
||||
|
||||
def _load_centroids():
|
||||
global _CENTROIDS
|
||||
if _CENTROIDS is not None:
|
||||
return _CENTROIDS
|
||||
if CENTROIDS_FILE.exists():
|
||||
try:
|
||||
with open(CENTROIDS_FILE) as f:
|
||||
_CENTROIDS = json.load(f)
|
||||
return _CENTROIDS
|
||||
except Exception:
|
||||
pass
|
||||
_CENTROIDS = {}
|
||||
return _CENTROIDS
|
||||
|
||||
|
||||
# ─── /api/visitor/scan ─────────────────────────────────
|
||||
@visitor_bp.route('/api/visitor/scan')
|
||||
def visitor_scan():
|
||||
ip = _client_ip()
|
||||
ua_string = request.headers.get('User-Agent', '')
|
||||
lang = request.headers.get('Accept-Language', 'en').split(',')[0].strip()
|
||||
|
||||
if _rate_limited(ip):
|
||||
return jsonify({'error': 'rate_limited', 'retry_after': _RATE_LIMIT_SECS}), 429
|
||||
|
||||
country = 'UNKNOWN'
|
||||
country_code = 'XX'
|
||||
city = ''
|
||||
latlon = None
|
||||
|
||||
reader = _get_geoip()
|
||||
if reader and ip:
|
||||
try:
|
||||
if _geoip_has_city:
|
||||
resp = reader.city(ip)
|
||||
country = resp.country.name or 'UNKNOWN'
|
||||
country_code = resp.country.iso_code or 'XX'
|
||||
city = resp.city.name or ''
|
||||
if resp.location.latitude is not None:
|
||||
latlon = [resp.location.latitude, resp.location.longitude]
|
||||
else:
|
||||
resp = reader.country(ip)
|
||||
country = resp.country.name or 'UNKNOWN'
|
||||
country_code = resp.country.iso_code or 'XX'
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ua_info = _parse_ua(ua_string)
|
||||
|
||||
# ISP via reverse DNS (1s timeout)
|
||||
hostname = _reverse_dns(ip, timeout=1.0) if ip else ''
|
||||
isp = _isp_guess(hostname)
|
||||
|
||||
# Threat heuristic
|
||||
threat_level = 'GREEN'
|
||||
threat_reason = 'TRUSTED OPERATOR'
|
||||
if ua_info.get('is_bot'):
|
||||
threat_level = 'AMBER'
|
||||
threat_reason = 'AUTOMATED AGENT DETECTED'
|
||||
# crude TOR heuristic via hostname
|
||||
is_tor = 'tor-exit' in hostname.lower() or 'torproject' in hostname.lower()
|
||||
if is_tor:
|
||||
threat_level = 'AMBER'
|
||||
threat_reason = 'TOR EXIT NODE'
|
||||
|
||||
return jsonify({
|
||||
'ip_masked': _mask_ip(ip),
|
||||
'country': country,
|
||||
'country_code': country_code,
|
||||
'country_flag': _country_flag(country_code),
|
||||
'city': city,
|
||||
'isp': isp,
|
||||
'hostname': hostname if hostname else '',
|
||||
'browser': ua_info['browser'],
|
||||
'browser_version': ua_info['browser_version'],
|
||||
'os': ua_info['os'],
|
||||
'os_version': ua_info['os_version'],
|
||||
'device': ua_info['device'],
|
||||
'language': lang,
|
||||
'threat_level': threat_level,
|
||||
'threat_reason': threat_reason,
|
||||
'is_tor': is_tor,
|
||||
'is_bot': ua_info['is_bot'],
|
||||
'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
})
|
||||
|
||||
|
||||
# ─── /api/visitor/recent-arcs ──────────────────────────
|
||||
_arcs_cache = {'ts': 0, 'data': []}
|
||||
_ARCS_CACHE_SECS = 300
|
||||
|
||||
def _parse_nginx_recent_ips(limit=20000):
|
||||
"""Read tail of nginx log, extract (ip, timestamp, path) from recent visits."""
|
||||
if not os.path.exists(NGINX_LOG):
|
||||
return []
|
||||
try:
|
||||
import subprocess
|
||||
r = subprocess.run(['tail', '-n', str(limit), NGINX_LOG],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
lines = r.stdout.strip().split('\n')
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Typical combined log: IP - - [dd/Mmm/yyyy:HH:MM:SS +0000] "METHOD /path HTTP/1.1" status size "ref" "ua"
|
||||
pat = re.compile(r'^(\S+) .* \[([^\]]+)\] "(\S+) (\S+) \S+" (\d+)')
|
||||
rows = []
|
||||
for line in lines:
|
||||
m = pat.match(line)
|
||||
if not m:
|
||||
continue
|
||||
ip, ts, method, path, status = m.groups()
|
||||
if method != 'GET':
|
||||
continue
|
||||
# skip asset/api requests for arc relevance
|
||||
if path.startswith(('/api/', '/css/', '/js/', '/assets/', '/fonts/', '/mascot/')):
|
||||
continue
|
||||
if any(path.endswith(ext) for ext in ('.ico', '.png', '.jpg', '.svg', '.webp', '.woff', '.woff2')):
|
||||
continue
|
||||
rows.append((ip, ts, path))
|
||||
return rows
|
||||
|
||||
|
||||
@visitor_bp.route('/api/visitor/recent-arcs')
|
||||
def visitor_recent_arcs():
|
||||
now = time.time()
|
||||
if _arcs_cache['data'] and now - _arcs_cache['ts'] < _ARCS_CACHE_SECS:
|
||||
return jsonify(_arcs_cache['data'])
|
||||
|
||||
rows = _parse_nginx_recent_ips(20000)
|
||||
reader = _get_geoip()
|
||||
centroids = _load_centroids()
|
||||
|
||||
seen_ips = OrderedDict() # preserve order, last-seen per IP
|
||||
for ip, ts, path in rows:
|
||||
seen_ips[ip] = (ts, path)
|
||||
|
||||
# Build arcs — most recent 50 unique
|
||||
arcs = []
|
||||
items = list(seen_ips.items())[-200:]
|
||||
items.reverse() # most recent first
|
||||
for ip, (ts, path) in items:
|
||||
if len(arcs) >= 50:
|
||||
break
|
||||
cc = 'XX'
|
||||
country_name = 'Unknown'
|
||||
lat = lon = None
|
||||
if reader:
|
||||
try:
|
||||
if _geoip_has_city:
|
||||
r = reader.city(ip)
|
||||
cc = r.country.iso_code or 'XX'
|
||||
country_name = r.country.name or 'Unknown'
|
||||
if r.location.latitude is not None:
|
||||
lat, lon = r.location.latitude, r.location.longitude
|
||||
else:
|
||||
r = reader.country(ip)
|
||||
cc = r.country.iso_code or 'XX'
|
||||
country_name = r.country.name or 'Unknown'
|
||||
except Exception:
|
||||
continue
|
||||
if lat is None and cc in centroids:
|
||||
lat, lon = centroids[cc][0], centroids[cc][1]
|
||||
if lat is None:
|
||||
continue
|
||||
# jitter slightly so multiple from same country don't overlap exactly
|
||||
import random
|
||||
lat += (random.random() - 0.5) * 1.5
|
||||
lon += (random.random() - 0.5) * 1.5
|
||||
arcs.append({
|
||||
'country_code': cc,
|
||||
'country_name': country_name,
|
||||
'lat': round(lat, 3),
|
||||
'lon': round(lon, 3),
|
||||
'timestamp': ts,
|
||||
'page_viewed': path,
|
||||
})
|
||||
|
||||
_arcs_cache['ts'] = now
|
||||
_arcs_cache['data'] = arcs
|
||||
return jsonify(arcs)
|
||||
|
||||
|
||||
# ─── /api/leaderboards ─────────────────────────────────
|
||||
_lb_cache = {'ts': 0, 'data': None}
|
||||
_LB_CACHE_SECS = 60
|
||||
|
||||
_UA_BROWSER_PAT = re.compile(r'(Firefox|Edg|Chrome|Safari|Opera|DuckDuckGo|SamsungBrowser|MSIE|Trident)/?([0-9.]*)', re.I)
|
||||
|
||||
def _lb_parse_log(limit_lines=50000):
|
||||
"""Parse nginx log for leaderboard data. Returns list of dicts."""
|
||||
if not os.path.exists(NGINX_LOG):
|
||||
return []
|
||||
try:
|
||||
import subprocess
|
||||
r = subprocess.run(['tail', '-n', str(limit_lines), NGINX_LOG],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
lines = r.stdout.strip().split('\n')
|
||||
except Exception:
|
||||
return []
|
||||
pat = re.compile(r'^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d+) \S+ "([^"]*)" "([^"]*)"')
|
||||
rows = []
|
||||
for line in lines:
|
||||
m = pat.match(line)
|
||||
if not m:
|
||||
continue
|
||||
ip, ts, method, path, status, referer, ua = m.groups()
|
||||
rows.append({
|
||||
'ip': ip, 'ts': ts, 'method': method, 'path': path,
|
||||
'status': int(status) if status.isdigit() else 0,
|
||||
'referer': referer, 'ua': ua,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_nginx_ts(ts_str):
|
||||
try:
|
||||
return datetime.strptime(ts_str.split()[0], '%d/%b/%Y:%H:%M:%S')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@visitor_bp.route('/api/leaderboards')
|
||||
def leaderboards():
|
||||
now = time.time()
|
||||
if _lb_cache['data'] and now - _lb_cache['ts'] < _LB_CACHE_SECS:
|
||||
return jsonify(_lb_cache['data'])
|
||||
|
||||
rows = _lb_parse_log(50000)
|
||||
reader = _get_geoip()
|
||||
now_dt = datetime.utcnow()
|
||||
|
||||
# split 24h / 7d
|
||||
rows_24h = []
|
||||
rows_7d = []
|
||||
for r in rows:
|
||||
dt = _parse_nginx_ts(r['ts'])
|
||||
if not dt:
|
||||
continue
|
||||
age_h = (now_dt - dt).total_seconds() / 3600
|
||||
if age_h <= 24:
|
||||
rows_24h.append({**r, 'dt': dt})
|
||||
if age_h <= 24 * 7:
|
||||
rows_7d.append({**r, 'dt': dt})
|
||||
|
||||
# Top Countries (24h)
|
||||
country_counter = Counter()
|
||||
if reader:
|
||||
for r in rows_24h:
|
||||
try:
|
||||
if _geoip_has_city:
|
||||
resp = reader.city(r['ip'])
|
||||
else:
|
||||
resp = reader.country(r['ip'])
|
||||
cc = resp.country.iso_code or 'XX'
|
||||
name = resp.country.name or 'Unknown'
|
||||
country_counter[(cc, name)] += 1
|
||||
except Exception:
|
||||
continue
|
||||
top_countries = [{'code': cc, 'name': name, 'count': c, 'flag': _country_flag(cc)}
|
||||
for (cc, name), c in country_counter.most_common(15)]
|
||||
|
||||
# Top Pages (24h) — exclude api/assets
|
||||
pages_counter = Counter()
|
||||
for r in rows_24h:
|
||||
p = r['path'].split('?', 1)[0]
|
||||
if p.startswith(('/api/', '/css/', '/js/', '/assets/', '/fonts/', '/mascot/')):
|
||||
continue
|
||||
if any(p.endswith(ext) for ext in ('.ico', '.png', '.jpg', '.jpeg', '.svg', '.webp', '.woff', '.woff2', '.gif', '.map')):
|
||||
continue
|
||||
if r['status'] >= 400:
|
||||
continue
|
||||
if r['method'] != 'GET':
|
||||
continue
|
||||
pages_counter[p] += 1
|
||||
top_pages = [{'path': p, 'count': c} for p, c in pages_counter.most_common(20)]
|
||||
|
||||
# Top Referrers (7d) — exclude self/empty
|
||||
ref_counter = Counter()
|
||||
for r in rows_7d:
|
||||
ref = r['referer']
|
||||
if not ref or ref == '-':
|
||||
continue
|
||||
if 'jaeswift.xyz' in ref:
|
||||
continue
|
||||
# extract hostname
|
||||
m = re.match(r'https?://([^/]+)', ref)
|
||||
if m:
|
||||
host = m.group(1)
|
||||
ref_counter[host] += 1
|
||||
top_referrers = [{'host': h, 'count': c} for h, c in ref_counter.most_common(10)]
|
||||
|
||||
# Peak Hours (24h)
|
||||
hour_counter = Counter()
|
||||
for r in rows_24h:
|
||||
hour_counter[r['dt'].hour] += 1
|
||||
peak_hours = [{'hour': h, 'count': hour_counter.get(h, 0)} for h in range(24)]
|
||||
|
||||
# Browser Breakdown (24h)
|
||||
browser_counter = Counter()
|
||||
for r in rows_24h:
|
||||
m = _UA_BROWSER_PAT.search(r['ua'] or '')
|
||||
if m:
|
||||
name = m.group(1)
|
||||
if name == 'Edg':
|
||||
name = 'Edge'
|
||||
browser_counter[name] += 1
|
||||
else:
|
||||
s = (r['ua'] or '').lower()
|
||||
if any(k in s for k in ('bot', 'crawl', 'spider', 'curl', 'wget', 'python')):
|
||||
browser_counter['Bot/CLI'] += 1
|
||||
else:
|
||||
browser_counter['Other'] += 1
|
||||
browsers = [{'name': n, 'count': c} for n, c in browser_counter.most_common(10)]
|
||||
|
||||
# Operator Leaderboard (7d) — top IPs
|
||||
ip_counter = Counter()
|
||||
ip_last_seen = {}
|
||||
for r in rows_7d:
|
||||
ip_counter[r['ip']] += 1
|
||||
ip_last_seen[r['ip']] = r['dt']
|
||||
top_ops = []
|
||||
for ip, c in ip_counter.most_common(10):
|
||||
last = ip_last_seen[ip]
|
||||
delta = (now_dt - last).total_seconds()
|
||||
if delta < 60:
|
||||
last_seen_str = f"{int(delta)}s ago"
|
||||
elif delta < 3600:
|
||||
last_seen_str = f"{int(delta/60)}m ago"
|
||||
elif delta < 86400:
|
||||
last_seen_str = f"{int(delta/3600)}h ago"
|
||||
else:
|
||||
last_seen_str = f"{int(delta/86400)}d ago"
|
||||
top_ops.append({
|
||||
'ip_masked': _mask_ip(ip),
|
||||
'count': c,
|
||||
'last_seen': last_seen_str,
|
||||
})
|
||||
|
||||
data = {
|
||||
'generated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'total_requests_24h': len(rows_24h),
|
||||
'total_requests_7d': len(rows_7d),
|
||||
'top_countries': top_countries,
|
||||
'top_pages': top_pages,
|
||||
'top_referrers': top_referrers,
|
||||
'peak_hours': peak_hours,
|
||||
'browsers': browsers,
|
||||
'top_operators': top_ops,
|
||||
}
|
||||
_lb_cache['ts'] = now
|
||||
_lb_cache['data'] = data
|
||||
return jsonify(data)
|
||||
261
css/scan-visitor.css
Normal file
261
css/scan-visitor.css
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/* ===================================================
|
||||
JAESWIFT.XYZ — Scan The Visitor
|
||||
Typewriter visitor-recon overlay + collapsible badge
|
||||
=================================================== */
|
||||
|
||||
.scan-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(5, 8, 5, 0.82);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
animation: scanFade 180ms ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scan-overlay.scan-closing {
|
||||
animation: scanFadeOut 260ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes scanFade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes scanFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.scan-panel {
|
||||
width: min(720px, 94vw);
|
||||
max-height: 86vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--status-green-dim);
|
||||
box-shadow:
|
||||
0 0 0 1px #00cc3318,
|
||||
0 0 24px #00cc3325,
|
||||
0 0 60px #00cc3310,
|
||||
inset 0 0 40px #00cc3308;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--status-green);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 2px,
|
||||
rgba(0, 204, 51, 0.03) 2px,
|
||||
rgba(0, 204, 51, 0.03) 3px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scan-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--status-green-dim);
|
||||
background: rgba(0, 204, 51, 0.05);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
.scan-panel-title {
|
||||
color: var(--status-green);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 8px #00cc3344;
|
||||
}
|
||||
|
||||
.scan-panel-status {
|
||||
color: var(--status-green);
|
||||
font-size: 10px;
|
||||
animation: scanBlink 1.2s infinite;
|
||||
}
|
||||
@keyframes scanBlink {
|
||||
0%, 60% { opacity: 1; }
|
||||
80%, 100% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.scan-close {
|
||||
background: transparent;
|
||||
border: 1px solid var(--status-green-dim);
|
||||
color: var(--status-green);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
transition: all 140ms ease;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.scan-close:hover {
|
||||
background: var(--status-green);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.scan-body {
|
||||
padding: 18px 20px 16px 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #b8e8c0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
display: block;
|
||||
min-height: 1.7em;
|
||||
}
|
||||
|
||||
.scan-line.scan-line-green { color: var(--status-green); }
|
||||
.scan-line.scan-line-amber { color: var(--warning); }
|
||||
.scan-line.scan-line-red { color: var(--danger); }
|
||||
.scan-line.scan-line-dim { color: var(--text-muted); }
|
||||
|
||||
.scan-val {
|
||||
color: #d8d8d8;
|
||||
font-weight: 500;
|
||||
}
|
||||
.scan-val-hl {
|
||||
color: var(--status-green);
|
||||
text-shadow: 0 0 6px #00cc3355;
|
||||
}
|
||||
|
||||
.scan-bar {
|
||||
display: inline-block;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.scan-bar-filled { color: var(--status-green); }
|
||||
.scan-bar-empty { color: #2a2a2a; }
|
||||
|
||||
.scan-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background: var(--status-green);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: scanCursor 0.9s infinite;
|
||||
}
|
||||
@keyframes scanCursor {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.scan-footer {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--status-green-dim);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scan-footer-action {
|
||||
background: transparent;
|
||||
border: 1px solid var(--status-green-dim);
|
||||
color: var(--status-green);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
transition: all 140ms ease;
|
||||
}
|
||||
.scan-footer-action:hover {
|
||||
background: var(--status-green);
|
||||
color: var(--bg-primary);
|
||||
box-shadow: 0 0 14px #00cc3360;
|
||||
}
|
||||
.scan-footer-action-dismiss {
|
||||
border-color: var(--mil-red-dim);
|
||||
color: #aa5050;
|
||||
}
|
||||
.scan-footer-action-dismiss:hover {
|
||||
background: var(--mil-red);
|
||||
color: #f0d8d8;
|
||||
border-color: var(--mil-red);
|
||||
}
|
||||
|
||||
/* ─── Collapsed operator badge (after scan) ──────── */
|
||||
.scan-badge {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 16px;
|
||||
z-index: 850;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--status-green-dim);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--status-green);
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 0 14px #00cc3320;
|
||||
animation: scanBadgeIn 360ms ease-out;
|
||||
max-width: 260px;
|
||||
}
|
||||
.scan-badge:hover {
|
||||
background: var(--bg-panel-hover);
|
||||
box-shadow: 0 0 22px #00cc3340;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
@keyframes scanBadgeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.scan-badge-flag { font-size: 15px; line-height: 1; }
|
||||
.scan-badge-label { color: var(--text-muted); font-size: 9px; letter-spacing: 1.4px; }
|
||||
.scan-badge-val { color: var(--status-green); font-weight: 600; }
|
||||
.scan-badge-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 0 0 6px;
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.scan-badge-close:hover { color: var(--mil-red); }
|
||||
|
||||
/* mobile */
|
||||
@media (max-width: 600px) {
|
||||
.scan-panel { width: 96vw; max-height: 92vh; }
|
||||
.scan-body { padding: 14px 12px; font-size: 11.5px; line-height: 1.6; }
|
||||
.scan-panel-header { padding: 8px 10px; font-size: 10px; }
|
||||
.scan-footer { padding: 8px 10px; font-size: 9px; flex-wrap: wrap; gap: 6px; }
|
||||
.scan-footer-action { font-size: 9px; padding: 5px 10px; }
|
||||
.scan-badge { bottom: 68px; right: 8px; font-size: 10px; padding: 6px 10px; }
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
<title>JAESWIFT // SYSTEMS ONLINE</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/chat-memory.css">
|
||||
<link rel="stylesheet" href="/css/scan-visitor.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
|
|
@ -583,6 +584,7 @@
|
|||
</footer>
|
||||
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="/js/scan-visitor.js"></script>
|
||||
<script src="/js/wallet-connect.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/chat-memory.js"></script>
|
||||
|
|
|
|||
306
js/scan-visitor.js
Normal file
306
js/scan-visitor.js
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/* ===================================================
|
||||
JAESWIFT.XYZ — Scan The Visitor
|
||||
Typewriter-animated visitor recon overlay.
|
||||
=================================================== */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const DISMISS_KEY = 'scanDismissed';
|
||||
const DISMISS_DAYS = 7;
|
||||
const BADGE_HIDE_KEY = 'scanBadgeHidden';
|
||||
const TYPE_SPEED = 22; // ms per char
|
||||
const LINE_PAUSE = 120;
|
||||
|
||||
// ─── Check if dismissed recently ────────────────
|
||||
function isDismissed() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISS_KEY);
|
||||
if (!raw) return false;
|
||||
const ts = parseInt(raw, 10);
|
||||
if (!ts) return false;
|
||||
const ageDays = (Date.now() - ts) / (1000 * 60 * 60 * 24);
|
||||
return ageDays < DISMISS_DAYS;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function setDismissed() {
|
||||
try { localStorage.setItem(DISMISS_KEY, Date.now().toString()); } catch (e) {}
|
||||
}
|
||||
|
||||
function isBadgeHidden() {
|
||||
try { return localStorage.getItem(BADGE_HIDE_KEY) === '1'; } catch (e) { return false; }
|
||||
}
|
||||
|
||||
// ─── Gather local client info ───────────────────
|
||||
function localInfo() {
|
||||
const scr = `${screen.width}x${screen.height}`;
|
||||
const dpr = (window.devicePixelRatio || 1);
|
||||
let conn = 'Unknown';
|
||||
try {
|
||||
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
if (c) {
|
||||
const t = c.effectiveType || c.type || '';
|
||||
conn = t ? t.toUpperCase() : 'Unknown';
|
||||
if (c.downlink) conn += `, ${c.downlink}Mbps`;
|
||||
if (c.rtt) conn += `, ${c.rtt}ms`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return {
|
||||
screen: `${scr} @ ${dpr}x`,
|
||||
connection: conn,
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
lang: navigator.language || 'en',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Build scan lines list ──────────────────────
|
||||
function buildLines(data, local) {
|
||||
const locStr = data.city ? `${data.city}, ${data.country}` : data.country;
|
||||
const browser = data.browser + (data.browser_version ? ' ' + data.browser_version : '');
|
||||
const osStr = data.os + (data.os_version ? ' ' + data.os_version : '');
|
||||
const flag = data.country_flag || '';
|
||||
const threatColor = data.threat_level === 'GREEN' ? 'green'
|
||||
: data.threat_level === 'AMBER' ? 'amber'
|
||||
: 'red';
|
||||
const bar = (() => {
|
||||
if (data.threat_level === 'GREEN') return '[█░░░░] GREEN — TRUSTED';
|
||||
if (data.threat_level === 'AMBER') return '[███░░] AMBER — MONITORED';
|
||||
if (data.threat_level === 'RED') return '[█████] RED — BLOCKED';
|
||||
return '[░░░░░] UNKNOWN';
|
||||
})();
|
||||
|
||||
return [
|
||||
{ cls: 'green', text: '>>> ESTABLISHING SECURE CONNECTION...' },
|
||||
{ cls: 'green', text: '>>> HANDSHAKE OK · TLS 1.3 · AES-256-GCM' },
|
||||
{ cls: 'green', text: '>>> SCANNING OPERATOR...' },
|
||||
{ cls: 'dim', text: '' },
|
||||
{ cls: 'green', text: `>>> IP ADDRESS....... ${data.ip_masked}` },
|
||||
{ cls: 'green', text: `>>> GEOLOCATION...... ${locStr} ${flag}` },
|
||||
{ cls: 'green', text: `>>> NETWORK.......... ${data.isp}` },
|
||||
data.hostname ? { cls: 'dim', text: `>>> HOSTNAME......... ${data.hostname}` } : null,
|
||||
{ cls: 'green', text: `>>> BROWSER.......... ${browser}` },
|
||||
{ cls: 'green', text: `>>> OS............... ${osStr}` },
|
||||
{ cls: 'green', text: `>>> DEVICE........... ${data.device}` },
|
||||
{ cls: 'green', text: `>>> SCREEN........... ${local.screen}` },
|
||||
{ cls: 'green', text: `>>> LANGUAGE......... ${local.lang}` },
|
||||
{ cls: 'green', text: `>>> TIMEZONE......... ${local.tz}` },
|
||||
{ cls: 'green', text: `>>> CONNECTION....... ${local.connection}` },
|
||||
{ cls: 'dim', text: '' },
|
||||
{ cls: threatColor, text: `>>> THREAT LEVEL..... ${bar}` },
|
||||
{ cls: threatColor, text: `>>> STATUS........... ${data.threat_reason}` },
|
||||
{ cls: 'dim', text: '' },
|
||||
{ cls: 'green', text: '>>> ACCESS GRANTED — WELCOME, OPERATOR' },
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Typewriter: types text into element ─────────
|
||||
function typeInto(el, text, speed) {
|
||||
return new Promise(function (resolve) {
|
||||
let i = 0;
|
||||
function tick() {
|
||||
if (i < text.length) {
|
||||
el.textContent += text.charAt(i);
|
||||
i++;
|
||||
setTimeout(tick, speed);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
if (!text.length) { resolve(); return; }
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Build overlay DOM ───────────────────────────
|
||||
function buildOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'scan-overlay';
|
||||
overlay.id = 'scanOverlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="scan-panel" role="dialog" aria-label="Visitor Scan">
|
||||
<div class="scan-panel-header">
|
||||
<span class="scan-panel-title">◉ VISITOR RECONNAISSANCE // SCAN IN PROGRESS</span>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span class="scan-panel-status" id="scanStatus">● ACTIVE</span>
|
||||
<button class="scan-close" id="scanClose" aria-label="Close">X CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scan-body" id="scanBody"></div>
|
||||
<div class="scan-footer">
|
||||
<span id="scanFooterInfo">JAESWIFT // COMMAND OPS · SCAN v1.0</span>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="scan-footer-action scan-footer-action-dismiss" id="scanDismissBtn">DISMISS 7D</button>
|
||||
<button class="scan-footer-action" id="scanAcceptBtn" disabled style="opacity:0.5;cursor:wait;">ACKNOWLEDGE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return overlay;
|
||||
}
|
||||
|
||||
// ─── Build badge (collapsed state) ───────────────
|
||||
function buildBadge(data) {
|
||||
const flag = data.country_flag || '🌐';
|
||||
const cc = data.country_code || 'XX';
|
||||
const locShort = data.city ? `${data.city}, ${cc}` : (data.country || cc);
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'scan-badge';
|
||||
badge.id = 'scanBadge';
|
||||
badge.title = 'Click to re-run scan';
|
||||
badge.innerHTML = `
|
||||
<span class="scan-badge-flag">${flag}</span>
|
||||
<div style="display:flex;flex-direction:column;line-height:1.2;">
|
||||
<span class="scan-badge-label">OPERATOR</span>
|
||||
<span class="scan-badge-val">${locShort}</span>
|
||||
</div>
|
||||
<button class="scan-badge-close" id="scanBadgeClose" aria-label="Hide badge" title="Hide">×</button>
|
||||
`;
|
||||
badge.addEventListener('click', function (e) {
|
||||
if (e.target && e.target.id === 'scanBadgeClose') return;
|
||||
// Re-run scan on click
|
||||
try { localStorage.removeItem(DISMISS_KEY); } catch (err) {}
|
||||
badge.remove();
|
||||
runScan();
|
||||
});
|
||||
badge.querySelector('#scanBadgeClose').addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem(BADGE_HIDE_KEY, '1'); } catch (err) {}
|
||||
badge.classList.add('scan-closing');
|
||||
setTimeout(() => badge.remove(), 220);
|
||||
});
|
||||
document.body.appendChild(badge);
|
||||
}
|
||||
|
||||
// ─── Render scan lines one-by-one ────────────────
|
||||
async function renderLines(body, lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const item = lines[i];
|
||||
const span = document.createElement('span');
|
||||
span.className = `scan-line scan-line-${item.cls}`;
|
||||
body.appendChild(span);
|
||||
if (item.text) {
|
||||
await typeInto(span, item.text, TYPE_SPEED);
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} else {
|
||||
span.textContent = '\u00A0';
|
||||
}
|
||||
await new Promise(r => setTimeout(r, LINE_PAUSE));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Run full scan sequence ──────────────────────
|
||||
async function runScan() {
|
||||
// fade in overlay first with 'CONNECTING...'
|
||||
const overlay = buildOverlay();
|
||||
document.body.appendChild(overlay);
|
||||
const body = overlay.querySelector('#scanBody');
|
||||
const statusEl = overlay.querySelector('#scanStatus');
|
||||
const closeBtn = overlay.querySelector('#scanClose');
|
||||
const dismissBtn = overlay.querySelector('#scanDismissBtn');
|
||||
const ackBtn = overlay.querySelector('#scanAcceptBtn');
|
||||
|
||||
let scanData = null;
|
||||
let apiFailed = false;
|
||||
|
||||
// Seed initial connecting line before fetch completes
|
||||
const initLine = document.createElement('span');
|
||||
initLine.className = 'scan-line scan-line-green';
|
||||
body.appendChild(initLine);
|
||||
typeInto(initLine, '>>> INITIALISING RECON PROTOCOL...', TYPE_SPEED);
|
||||
|
||||
function closeAll(dismiss) {
|
||||
overlay.classList.add('scan-closing');
|
||||
if (dismiss) setDismissed();
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
if (scanData && !isBadgeHidden()) {
|
||||
buildBadge(scanData);
|
||||
}
|
||||
}, 260);
|
||||
}
|
||||
|
||||
closeBtn.addEventListener('click', () => closeAll(false));
|
||||
dismissBtn.addEventListener('click', () => closeAll(true));
|
||||
ackBtn.addEventListener('click', () => closeAll(false));
|
||||
|
||||
// allow esc to close
|
||||
function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeAll(false);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/visitor/scan', { credentials: 'same-origin' });
|
||||
if (r.ok) {
|
||||
scanData = await r.json();
|
||||
} else if (r.status === 429) {
|
||||
apiFailed = true;
|
||||
} else {
|
||||
apiFailed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
apiFailed = true;
|
||||
}
|
||||
|
||||
// clear init line
|
||||
body.innerHTML = '';
|
||||
|
||||
if (apiFailed || !scanData) {
|
||||
const errSpan = document.createElement('span');
|
||||
errSpan.className = 'scan-line scan-line-red';
|
||||
body.appendChild(errSpan);
|
||||
await typeInto(errSpan, '>>> RECON SERVICE UNAVAILABLE — STANDBY', TYPE_SPEED);
|
||||
statusEl.textContent = '● OFFLINE';
|
||||
statusEl.style.color = 'var(--danger)';
|
||||
ackBtn.disabled = false; ackBtn.style.opacity = '1'; ackBtn.style.cursor = 'pointer';
|
||||
return;
|
||||
}
|
||||
|
||||
const local = localInfo();
|
||||
const lines = buildLines(scanData, local);
|
||||
await renderLines(body, lines);
|
||||
|
||||
statusEl.textContent = '● SCAN COMPLETE';
|
||||
ackBtn.disabled = false; ackBtn.style.opacity = '1'; ackBtn.style.cursor = 'pointer';
|
||||
ackBtn.focus();
|
||||
}
|
||||
|
||||
// ─── Boot ────────────────────────────────────────
|
||||
function boot() {
|
||||
if (isDismissed()) {
|
||||
// still show compact badge if not hidden — fetch silently
|
||||
if (!isBadgeHidden()) {
|
||||
fetch('/api/visitor/scan', { credentials: 'same-origin' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d && d.country_code) buildBadge(d); })
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Slight delay so page renders first
|
||||
setTimeout(runScan, 700);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
// Expose for debug / re-trigger
|
||||
window.__jaeScan = {
|
||||
run: runScan,
|
||||
reset: function () {
|
||||
try {
|
||||
localStorage.removeItem(DISMISS_KEY);
|
||||
localStorage.removeItem(BADGE_HIDE_KEY);
|
||||
} catch (e) {}
|
||||
const b = document.getElementById('scanBadge'); if (b) b.remove();
|
||||
const o = document.getElementById('scanOverlay'); if (o) o.remove();
|
||||
runScan();
|
||||
},
|
||||
};
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue