feat(visitor): SCAN THE VISITOR - typewriter recon overlay with geo/UA/ISP/threat-level + country centroids dataset

This commit is contained in:
jae 2026-04-20 01:30:20 +00:00
parent 7e9423969b
commit 33dfb3410b
7 changed files with 1333 additions and 61 deletions

View file

@ -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'

View file

@ -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 users 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 (19691974) 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"

View 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
View 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
View 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; }
}

View file

@ -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
View 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();
},
};
})();