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:
|
except Exception as _tb_err:
|
||||||
print(f'[WARN] telemetry_routes not loaded: {_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'
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
|
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
|
||||||
ADMIN_USER = 'jae'
|
ADMIN_USER = 'jae'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
{
|
{
|
||||||
"site": "jaeswift.xyz",
|
"site": "jaeswift.xyz",
|
||||||
"entries": [
|
"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",
|
"version": "1.36.2",
|
||||||
"date": "20/04/2026",
|
"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)",
|
"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",
|
"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)",
|
"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",
|
"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"
|
"Graceful degradation: missing localStorage or extraction failures never break the chat loop"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -88,7 +103,7 @@
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"date": "19/04/2026",
|
"date": "19/04/2026",
|
||||||
"category": "FEATURE",
|
"category": "FEATURE",
|
||||||
"title": "Homepage SERVER METRICS \u2014 Real Live Data",
|
"title": "Homepage SERVER METRICS — Real Live Data",
|
||||||
"changes": [
|
"changes": [
|
||||||
"SERVER METRICS panel now shows real VPS metrics via /api/stats",
|
"SERVER METRICS panel now shows real VPS metrics via /api/stats",
|
||||||
"CPU LOAD bar: real load_avg / nproc percentage",
|
"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)",
|
"Purged file from entire git history via git-filter-repo (all 120 commits rewritten)",
|
||||||
"Force-pushed cleaned history to Gitea (old commits garbage-collected)",
|
"Force-pushed cleaned history to Gitea (old commits garbage-collected)",
|
||||||
"Created apikeys.example.json template for future contributors",
|
"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",
|
"VPS file permissions hardened: chmod 600 api/data/apikeys.json",
|
||||||
"Verified raw URL git.jaeswift.xyz/.../apikeys.json now returns HTTP 404",
|
"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"
|
"Audit confirmed: zero occurrences of any API key in git history across all branches"
|
||||||
|
|
@ -127,21 +142,21 @@
|
||||||
"version": "1.31.0",
|
"version": "1.31.0",
|
||||||
"date": "19/04/2026",
|
"date": "19/04/2026",
|
||||||
"category": "AI",
|
"category": "AI",
|
||||||
"title": "AI Model Switch \u2014 Gemma-4-Uncensored",
|
"title": "AI Model Switch — Gemma-4-Uncensored",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Switched JAE AI chat model from olafangensan-glm-4.7-flash-heretic to gemma-4-uncensored",
|
"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",
|
"Switched SITREP daily briefing generator to same model for consistency",
|
||||||
"Updated admin panel chat defaults: model + header tag now reflect GEMMA-4-UNCENSORED",
|
"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",
|
"version": "v1.30.0",
|
||||||
"date": "18/04/2026",
|
"date": "18/04/2026",
|
||||||
"title": "ARMOURY: Wallet X-Ray \u2014 Solana Wallet Analyser",
|
"title": "ARMOURY: Wallet X-Ray — Solana Wallet Analyser",
|
||||||
"category": "ARMOURY",
|
"category": "ARMOURY",
|
||||||
"changes": [
|
"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",
|
"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",
|
"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",
|
"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",
|
"URL parameter support: ?address=... for direct wallet scanning via shared links",
|
||||||
"Wallet X-Ray card added to LAB page with cyan/turquoise theme",
|
"Wallet X-Ray card added to LAB page with cyan/turquoise theme",
|
||||||
"Military radar sweep loading animation during wallet scan",
|
"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",
|
"category": "UNREDACTED",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Added 9 new collections across UFO/UAP, Covert Operations, and Government categories with 15 indexed documents",
|
"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",
|
"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 \u2014 18 pages",
|
"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 \u2014 6 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 \u2014 136 pages",
|
"NZDF UFO/UAP Files (New Zealand, 1984-2024): 3 documents including Cold War sighting reports and modern OIA responses — 136 pages",
|
||||||
"Opera\u00e7\u00e3o Prato (Brazil, 1977): Secret Brazilian Air Force UFO investigation in the Amazon \u2014 58 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",
|
"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",
|
"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 \u2014 35 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",
|
"Iran-Contra Affair (1985-87): Complete 506-page Congressional investigation report",
|
||||||
"New countries added to UFO/UAP category: France, Australia, Canada, New Zealand, Brazil"
|
"New countries added to UFO/UAP category: France, Australia, Canada, New Zealand, Brazil"
|
||||||
]
|
]
|
||||||
|
|
@ -182,12 +197,12 @@
|
||||||
"category": "CRIME SCENE",
|
"category": "CRIME SCENE",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Added 6 new crime case collections across cold-cases, serial-killers, and landmark-cases with 11 indexed documents",
|
"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",
|
"D.B. Cooper Hijacking (1971): FBI investigation files — 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",
|
"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 \u2014 204 pages on LA's most famous unsolved murder",
|
"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 \u2014 95 pages",
|
"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) \u2014 1,162 pages on Britain's worst serial killer",
|
"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 \u2014 Brady & Hindley (1963-65): Mental Health Review Tribunal academic paper \u2014 22 pages",
|
"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",
|
"New landmark-cases/US subcategory with Delphi Murders as first entry",
|
||||||
"Total new document pages added: 1,664 across all crime scene collections"
|
"Total new document pages added: 1,664 across all crime scene collections"
|
||||||
]
|
]
|
||||||
|
|
@ -195,18 +210,18 @@
|
||||||
{
|
{
|
||||||
"version": "v1.27.0",
|
"version": "v1.27.0",
|
||||||
"date": "18/04/2026",
|
"date": "18/04/2026",
|
||||||
"title": "CRIME SCENE: UK Murder Cases \u2014 Mass Upload",
|
"title": "CRIME SCENE: UK Murder Cases — Mass Upload",
|
||||||
"category": "CRIME SCENE",
|
"category": "CRIME SCENE",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Added 11 UK murder case collections across 4 categories with 57 indexed documents",
|
"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",
|
"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",
|
"Claudia Lawrence (2009): ICO FOI audit of North Yorkshire Police practices",
|
||||||
"Jill Dando (1999): Barry George appeal judgment and CCRC referral decision",
|
"Jill Dando (1999): Barry George appeal judgment and CCRC referral decision",
|
||||||
"Suzy Lamplugh (1986): Suzy Lamplugh Trust safety resources and case documentation",
|
"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",
|
"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",
|
"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",
|
"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)",
|
"Madeleine McCann (2007): PJ Police Report translation (57 pages), Jane Tanner statements (4 parts)",
|
||||||
|
|
@ -221,10 +236,10 @@
|
||||||
"category": "CRIME SCENE",
|
"category": "CRIME SCENE",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Populated the Police Reports subcollection for the Zodiac Killer with 5 documents (207 pages, 26.4 MB)",
|
"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 Lake Herman Road police reports — 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 Blue Rock Springs police reports — Vallejo PD (75 pages, 10.3 MB)",
|
||||||
"Added Lake Berryessa police reports \u2014 Napa County Sheriff's Office (35 pages, 5.1 MB)",
|
"Added Lake Berryessa police reports — 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 Presidio Heights / Paul Stine police reports — SFPD (2 pages, 0.4 MB)",
|
||||||
"Added California Department of Justice investigation report (35 pages, 5.0 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",
|
"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"
|
"All documents sourced from zodiackiller.com's authenticated police report archive"
|
||||||
|
|
@ -233,7 +248,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.25.0",
|
"version": "1.25.0",
|
||||||
"date": "16/04/2026",
|
"date": "16/04/2026",
|
||||||
"title": "Changelog Fix \u2014 Date Format & Missing Entries",
|
"title": "Changelog Fix — Date Format & Missing Entries",
|
||||||
"category": "fix",
|
"category": "fix",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Fixed NaN/NaN/NaN date display bug in changelog renderer",
|
"Fixed NaN/NaN/NaN date display bug in changelog renderer",
|
||||||
|
|
@ -250,18 +265,18 @@
|
||||||
"category": "fix",
|
"category": "fix",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Replaced encrypted/unreadable MKUltra PDF with two working documents",
|
"Replaced encrypted/unreadable MKUltra PDF with two working documents",
|
||||||
"Added CIA Inspector General Report (1963) \u2014 48-page TOP SECRET internal review",
|
"Added CIA Inspector General Report (1963) — 48-page TOP SECRET internal review",
|
||||||
"Added Senate Hearing transcript (1977) \u2014 171-page Congressional testimony exposing 149 sub-projects"
|
"Added Senate Hearing transcript (1977) — 171-page Congressional testimony exposing 149 sub-projects"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"date": "16/04/2026",
|
"date": "16/04/2026",
|
||||||
"title": "PROPAGANDA \u2192 UNREDACTED Rename + Nav Animation + CRIME SCENE",
|
"title": "PROPAGANDA → UNREDACTED Rename + Nav Animation + CRIME SCENE",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Renamed PROPAGANDA section to UNREDACTED across all pages, nav, API, and URLs",
|
"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",
|
"Added global document search across all UNREDACTED categories, titles, and descriptions",
|
||||||
"Built new CRIME SCENE section at /depot/crimescene with red CRT theme",
|
"Built new CRIME SCENE section at /depot/crimescene with red CRT theme",
|
||||||
"Four crime categories: Unsolved Murders, Serial Killers, Court Transcripts, Cold Cases",
|
"Four crime categories: Unsolved Murders, Serial Killers, Court Transcripts, Cold Cases",
|
||||||
|
|
@ -272,12 +287,12 @@
|
||||||
{
|
{
|
||||||
"version": "1.22.0",
|
"version": "1.22.0",
|
||||||
"date": "15/04/2026",
|
"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",
|
"category": "CRIME SCENE",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Fixed PDF path bug: document URLs now correctly include country code (US) via subcollection routing",
|
"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",
|
"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 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",
|
"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",
|
"Each of the 21 new documents has a unique historical description with contextual detail",
|
||||||
|
|
@ -293,7 +308,7 @@
|
||||||
"category": "CRIME SCENE",
|
"category": "CRIME SCENE",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Added complete FBI Zodiac Killer investigation files (6 parts, 1,116 pages, 34MB)",
|
"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",
|
"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",
|
"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/",
|
"PDFs served from /crimescene/docs/serial-killers/US/zodiac-killer/",
|
||||||
|
|
@ -307,9 +322,9 @@
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Added PDF text search to document viewer (Ctrl+F, green/amber highlights, match counter, case toggle)",
|
"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 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 US documents: Project Blue Book, CIA UFO Collection, NSA UFO Documents, Pentagon UAP Report",
|
||||||
"Downloaded Covert Ops: MKUltra, Stargate Program, Operation Northwoods",
|
"Downloaded Covert Ops: MKUltra, Stargate Program, Operation Northwoods",
|
||||||
"Downloaded Government: JFK Warren Commission, Pentagon Papers, CIA Torture Report",
|
"Downloaded Government: JFK Warren Commission, Pentagon Papers, CIA Torture Report",
|
||||||
|
|
@ -323,7 +338,7 @@
|
||||||
"category": "fix",
|
"category": "fix",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Fixed dispatches post pages crashing (mood type error + fallback path)",
|
"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",
|
"Tightened navbar spacing between SOL price and wallet connect",
|
||||||
"Converted all post mood values from integers to proper strings"
|
"Converted all post mood values from integers to proper strings"
|
||||||
]
|
]
|
||||||
|
|
@ -334,19 +349,19 @@
|
||||||
"title": "Admin Panel Overhaul",
|
"title": "Admin Panel Overhaul",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Fixed broken Editor section \u2014 full post editing with live preview",
|
"Fixed broken Editor section — full post editing with live preview",
|
||||||
"Fixed broken Backups section \u2014 export/import site data as ZIP",
|
"Fixed broken Backups section — export/import site data as ZIP",
|
||||||
"Added SITREP admin section \u2014 generate reports, view archive",
|
"Added SITREP admin section — generate reports, view archive",
|
||||||
"Added Data Sync section \u2014 trigger Contraband/RECON syncs, view stats",
|
"Added Data Sync section — trigger Contraband/RECON syncs, view stats",
|
||||||
"Added Changelog admin section \u2014 CRUD for maintenance log entries",
|
"Added Changelog admin section — CRUD for maintenance log entries",
|
||||||
"Added Cron Jobs section \u2014 view/toggle all scheduled tasks",
|
"Added Cron Jobs section — view/toggle all scheduled tasks",
|
||||||
"Reorganised sidebar into grouped sections"
|
"Reorganised sidebar into grouped sections"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
"date": "06/04/2026",
|
"date": "06/04/2026",
|
||||||
"title": "SITREP \u2014 Daily AI Briefing System",
|
"title": "SITREP — Daily AI Briefing System",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Built automated daily intelligence briefing at /transmissions/sitrep",
|
"Built automated daily intelligence briefing at /transmissions/sitrep",
|
||||||
|
|
@ -360,7 +375,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.17.0",
|
"version": "1.17.0",
|
||||||
"date": "06/04/2026",
|
"date": "06/04/2026",
|
||||||
"title": "TOKEN FORGE \u2014 SPL Token Launcher",
|
"title": "TOKEN FORGE — SPL Token Launcher",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Built token launcher at /tokenlauncher with full SPL token creation",
|
"Built token launcher at /tokenlauncher with full SPL token creation",
|
||||||
|
|
@ -404,7 +419,7 @@
|
||||||
"changes": [
|
"changes": [
|
||||||
"Global wallet connect button in navbar across all 28 pages",
|
"Global wallet connect button in navbar across all 28 pages",
|
||||||
"Multi-wallet support: Phantom, Solflare, Backpack, Coinbase, Trust, MetaMask, Jupiter",
|
"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",
|
"Connected dropdown with address copy, Solscan link, disconnect",
|
||||||
"Global window.solWallet API for all Solana features",
|
"Global window.solWallet API for all Solana features",
|
||||||
"Refactored soldomains.js to use shared wallet (removed 146 lines)"
|
"Refactored soldomains.js to use shared wallet (removed 146 lines)"
|
||||||
|
|
@ -413,7 +428,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.13.0",
|
"version": "1.13.0",
|
||||||
"date": "05/04/2026",
|
"date": "05/04/2026",
|
||||||
"title": "RADAR \u2014 Live Tech News Feed",
|
"title": "RADAR — Live Tech News Feed",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Built live tech news aggregator at /transmissions/radar",
|
"Built live tech news aggregator at /transmissions/radar",
|
||||||
|
|
@ -436,7 +451,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.11.0",
|
"version": "1.11.0",
|
||||||
"date": "04/04/2026",
|
"date": "04/04/2026",
|
||||||
"title": "RECON \u2014 Site Restructure & Accordion Navigation",
|
"title": "RECON — Site Restructure & Accordion Navigation",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Moved RECON to /depot/recon for consistency with other depot pages",
|
"Moved RECON to /depot/recon for consistency with other depot pages",
|
||||||
|
|
@ -449,7 +464,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"date": "04/04/2026",
|
"date": "04/04/2026",
|
||||||
"title": "RECON \u2014 Curated Lists Rebuild",
|
"title": "RECON — Curated Lists Rebuild",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Flattened 4-level navigation to 2-level (sector > list > entries)",
|
"Flattened 4-level navigation to 2-level (sector > list > entries)",
|
||||||
|
|
@ -461,7 +476,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"date": "04/04/2026",
|
"date": "04/04/2026",
|
||||||
"title": "RECON \u2014 Curated Lists Database",
|
"title": "RECON — Curated Lists Database",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Parsed 660 curated lists into 28 themed sectors",
|
"Parsed 660 curated lists into 28 themed sectors",
|
||||||
|
|
@ -478,7 +493,7 @@
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Subcategories now display as 2-column card grid with expandable detail panels",
|
"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",
|
"Click any subcategory card to expand/collapse its entries below",
|
||||||
"Active card highlighting with amber glow",
|
"Active card highlighting with amber glow",
|
||||||
"Responsive grid: 2-col desktop, 1-col mobile"
|
"Responsive grid: 2-col desktop, 1-col mobile"
|
||||||
|
|
@ -490,8 +505,8 @@
|
||||||
"title": "Sitewide Visual Overhaul",
|
"title": "Sitewide Visual Overhaul",
|
||||||
"category": "fix",
|
"category": "fix",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Bumped 64 font sizes sitewide \u2014 no more microscopic text",
|
"Bumped 64 font sizes sitewide — no more microscopic text",
|
||||||
"Brightened all text colours: primary #c0c0c0\u2192#d8d8d8, secondary #707070\u2192#999999, muted #3a3a3a\u2192#666666",
|
"Brightened all text colours: primary #c0c0c0→#d8d8d8, secondary #707070→#999999, muted #3a3a3a→#666666",
|
||||||
"CONTRABAND page: 4-column category grid with responsive breakpoints",
|
"CONTRABAND page: 4-column category grid with responsive breakpoints",
|
||||||
"Purged all third-party attribution references from entire codebase"
|
"Purged all third-party attribution references from entire codebase"
|
||||||
]
|
]
|
||||||
|
|
@ -499,13 +514,13 @@
|
||||||
{
|
{
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"date": "03/04/2026",
|
"date": "03/04/2026",
|
||||||
"title": "CONTRABAND \u2014 Classified Resource Index",
|
"title": "CONTRABAND — Classified Resource Index",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Launched CONTRABAND page at /depot/contraband with 15,800+ indexed assets",
|
"Launched CONTRABAND page at /depot/contraband with 15,800+ indexed assets",
|
||||||
"24 categories with military codenames (CRT-001 through CRT-024)",
|
"24 categories with military codenames (CRT-001 through CRT-024)",
|
||||||
"Full-text search across all entries via API",
|
"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",
|
"Collapsible subcategories with item counts",
|
||||||
"Flask API endpoints: /api/contraband, /api/contraband/<slug>, /api/contraband/search"
|
"Flask API endpoints: /api/contraband, /api/contraband/<slug>, /api/contraband/search"
|
||||||
]
|
]
|
||||||
|
|
@ -527,8 +542,8 @@
|
||||||
"title": "Globe & Chat AI Admin Panels",
|
"title": "Globe & Chat AI Admin Panels",
|
||||||
"category": "feature",
|
"category": "feature",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Admin panel: Globe management section \u2014 server location, rotation speed, arc cities, colours",
|
"Admin panel: Globe management section — server location, rotation speed, arc cities, colours",
|
||||||
"Admin panel: Chat AI configuration \u2014 model selection, system prompt, greeting toggle",
|
"Admin panel: Chat AI configuration — model selection, system prompt, greeting toggle",
|
||||||
"New API endpoints: /api/globe, /api/chat-config with auth-protected GET/POST",
|
"New API endpoints: /api/globe, /api/chat-config with auth-protected GET/POST",
|
||||||
"Interactive colour picker and slider controls for globe parameters",
|
"Interactive colour picker and slider controls for globe parameters",
|
||||||
"Arc cities table with add/remove functionality"
|
"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>
|
<title>JAESWIFT // SYSTEMS ONLINE</title>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<link rel="stylesheet" href="/css/chat-memory.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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
</footer>
|
||||||
|
|
||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
|
<script src="/js/scan-visitor.js"></script>
|
||||||
<script src="/js/wallet-connect.js"></script>
|
<script src="/js/wallet-connect.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/chat-memory.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