diff --git a/api/app.py b/api/app.py index 4bce094..79cf1db 100644 --- a/api/app.py +++ b/api/app.py @@ -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' diff --git a/api/data/changelog.json b/api/data/changelog.json index 94ef5c5..3ff44b9 100644 --- a/api/data/changelog.json +++ b/api/data/changelog.json @@ -1,6 +1,21 @@ { "site": "jaeswift.xyz", "entries": [ + { + "version": "1.37.0", + "date": "20/04/2026", + "category": "FEATURE", + "title": "SCAN THE VISITOR — Typewriter Operator Recon Overlay", + "changes": [ + "New homepage overlay greets every visitor with a dramatic typewriter-animated recon scan (~22ms/char): geolocation via GeoLite2, reverse-DNS-derived ISP, user-agent parsing (browser/OS/device) via user-agents pip package, masked IP (X.***.***.Y), screen/language/timezone/connection fingerprinting, plus GREEN/AMBER/RED threat-level status bar", + "Backend: new api/visitor_routes.py Flask blueprint with /api/visitor/scan endpoint — in-memory per-IP rate limiting (1 req / 10s), lazy GeoIP reader (country + city when available), 1s-timeout reverse DNS hostname lookup with ISP dictionary (BT/Virgin/Sky/AWS/Cloudflare/Hetzner etc.), country-flag emoji auto-generation, TOR exit-node heuristic", + "Frontend: js/scan-visitor.js + css/scan-visitor.css — 720px CRT-scanline overlay with blur backdrop, monospace green typewriter output, blink cursor, three action buttons (CLOSE / DISMISS 7D / ACKNOWLEDGE), ESC-to-close, collapses into a persistent bottom-right OPERATOR ID badge on close, click badge to re-run scan", + "localStorage persistence: scanDismissed (7-day auto-expiry so returning operators only see the badge), scanBadgeHidden (× on badge hides it permanently)", + "Fully mobile responsive: panel narrows to 96vw at <600px, reduces typography, adjusts badge position above bottom HUD bar", + "Bundled api/data/country_centroids.json (130+ ISO-3166 country lat/lon centroids) to power upcoming 3D globe traffic-arc feature", + "Exposed window.__jaeScan.run() / window.__jaeScan.reset() for debug re-triggering" + ] + }, { "version": "1.36.2", "date": "20/04/2026", @@ -78,9 +93,9 @@ "Chat history persistence across page reloads (last 50 messages in localStorage key jae-ai-history-v1, auto-restored with RESTORED FROM MEMORY divider)", "Memory Vault modal UI: military/terminal themed, grouped by category (identity/preference/project/skill/goal/relationship/other) with per-entry delete, importance/timestamp display", "Modal controls: EXPORT JSON (download backup), FORCE EXTRACT (manual trigger), CLEAR HISTORY (wipes conversation), CLEAR ALL MEMORIES (with confirm)", - "Memory button in chat header with live count badge + privacy info tooltip (\u2139) explaining local-only storage", + "Memory button in chat header with live count badge + privacy info tooltip (ℹ) explaining local-only storage", "Fuzzy dedup: new memories >80% similar to existing are merged (importance boosted) instead of duplicated", - "Privacy-first design: all memories stay in the user\u2019s browser; only current query + small relevant subset sent to Venice per message", + "Privacy-first design: all memories stay in the user’s browser; only current query + small relevant subset sent to Venice per message", "Graceful degradation: missing localStorage or extraction failures never break the chat loop" ] }, @@ -88,7 +103,7 @@ "version": "1.33.0", "date": "19/04/2026", "category": "FEATURE", - "title": "Homepage SERVER METRICS \u2014 Real Live Data", + "title": "Homepage SERVER METRICS — Real Live Data", "changes": [ "SERVER METRICS panel now shows real VPS metrics via /api/stats", "CPU LOAD bar: real load_avg / nproc percentage", @@ -117,7 +132,7 @@ "Purged file from entire git history via git-filter-repo (all 120 commits rewritten)", "Force-pushed cleaned history to Gitea (old commits garbage-collected)", "Created apikeys.example.json template for future contributors", - "Rotated Venice API key \u2014 old key revoked by user", + "Rotated Venice API key — old key revoked by user", "VPS file permissions hardened: chmod 600 api/data/apikeys.json", "Verified raw URL git.jaeswift.xyz/.../apikeys.json now returns HTTP 404", "Audit confirmed: zero occurrences of any API key in git history across all branches" @@ -127,21 +142,21 @@ "version": "1.31.0", "date": "19/04/2026", "category": "AI", - "title": "AI Model Switch \u2014 Gemma-4-Uncensored", + "title": "AI Model Switch — Gemma-4-Uncensored", "changes": [ "Switched JAE AI chat model from olafangensan-glm-4.7-flash-heretic to gemma-4-uncensored", "Switched SITREP daily briefing generator to same model for consistency", "Updated admin panel chat defaults: model + header tag now reflect GEMMA-4-UNCENSORED", - "API restarted and verified live \u2014 chat now returns tighter, more natural replies" + "API restarted and verified live — chat now returns tighter, more natural replies" ] }, { "version": "v1.30.0", "date": "18/04/2026", - "title": "ARMOURY: Wallet X-Ray \u2014 Solana Wallet Analyser", + "title": "ARMOURY: Wallet X-Ray — Solana Wallet Analyser", "category": "ARMOURY", "changes": [ - "New tool: Wallet X-Ray \u2014 deep scan any Solana wallet address or connect your own wallet", + "New tool: Wallet X-Ray — deep scan any Solana wallet address or connect your own wallet", "Overview panel: SOL balance with live USD value, total portfolio value, wallet age, transaction count, and activity rating", "Token Holdings: full SPL token table with logos, balances, live prices via Jupiter Price API, and USD values sorted by value", "NFT Detection: identifies NFTs (0-decimal single-supply tokens) with image grid and Solscan links", @@ -153,7 +168,7 @@ "URL parameter support: ?address=... for direct wallet scanning via shared links", "Wallet X-Ray card added to LAB page with cyan/turquoise theme", "Military radar sweep loading animation during wallet scan", - "Fully responsive design \u2014 works on mobile and desktop" + "Fully responsive design — works on mobile and desktop" ] }, { @@ -163,14 +178,14 @@ "category": "UNREDACTED", "changes": [ "Added 9 new collections across UFO/UAP, Covert Operations, and Government categories with 15 indexed documents", - "COMETA Report (France, 1999): 2 English translations of landmark French military UFO assessment \u2014 163 pages total", - "RAAF UFO Files (Australia): Declassified Royal Australian Air Force intelligence file \u2014 18 pages", - "Project Magnet (Canada, 1950-54): Official Canadian government UFO research programme documents \u2014 6 pages", - "NZDF UFO/UAP Files (New Zealand, 1984-2024): 3 documents including Cold War sighting reports and modern OIA responses \u2014 136 pages", - "Opera\u00e7\u00e3o Prato (Brazil, 1977): Secret Brazilian Air Force UFO investigation in the Amazon \u2014 58 pages", + "COMETA Report (France, 1999): 2 English translations of landmark French military UFO assessment — 163 pages total", + "RAAF UFO Files (Australia): Declassified Royal Australian Air Force intelligence file — 18 pages", + "Project Magnet (Canada, 1950-54): Official Canadian government UFO research programme documents — 6 pages", + "NZDF UFO/UAP Files (New Zealand, 1984-2024): 3 documents including Cold War sighting reports and modern OIA responses — 136 pages", + "Operação Prato (Brazil, 1977): Secret Brazilian Air Force UFO investigation in the Amazon — 58 pages", "COINTELPRO: 758-page FBI surveillance programme compilation plus analytical examination added to existing collection", - "Operation Paperclip (1945-59): Declassified CIA documents on Nazi scientist recruitment programme \u2014 11 pages", - "Watergate Scandal (1972-74): Ford Presidential Library documents on the constitutional crisis \u2014 35 pages", + "Operation Paperclip (1945-59): Declassified CIA documents on Nazi scientist recruitment programme — 11 pages", + "Watergate Scandal (1972-74): Ford Presidential Library documents on the constitutional crisis — 35 pages", "Iran-Contra Affair (1985-87): Complete 506-page Congressional investigation report", "New countries added to UFO/UAP category: France, Australia, Canada, New Zealand, Brazil" ] @@ -182,12 +197,12 @@ "category": "CRIME SCENE", "changes": [ "Added 6 new crime case collections across cold-cases, serial-killers, and landmark-cases with 11 indexed documents", - "D.B. Cooper Hijacking (1971): FBI investigation files \u2014 162 pages on America's only unsolved aircraft hijacking", - "JonBen\u00e9t Ramsey Murder (1996): Autopsy report, unsealed grand jury indictment, and analytical case study \u2014 19 pages", - "Black Dahlia / Elizabeth Short (1947): Complete FBI investigation file \u2014 204 pages on LA's most famous unsolved murder", - "Delphi Murders (2017): Probable cause affidavit and court documents from the Richard Allen prosecution \u2014 95 pages", - "Harold Shipman (1975-98): First three reports of the Shipman Inquiry (Dame Janet Smith) \u2014 1,162 pages on Britain's worst serial killer", - "Moors Murders \u2014 Brady & Hindley (1963-65): Mental Health Review Tribunal academic paper \u2014 22 pages", + "D.B. Cooper Hijacking (1971): FBI investigation files — 162 pages on America's only unsolved aircraft hijacking", + "JonBenét Ramsey Murder (1996): Autopsy report, unsealed grand jury indictment, and analytical case study — 19 pages", + "Black Dahlia / Elizabeth Short (1947): Complete FBI investigation file — 204 pages on LA's most famous unsolved murder", + "Delphi Murders (2017): Probable cause affidavit and court documents from the Richard Allen prosecution — 95 pages", + "Harold Shipman (1975-98): First three reports of the Shipman Inquiry (Dame Janet Smith) — 1,162 pages on Britain's worst serial killer", + "Moors Murders — Brady & Hindley (1963-65): Mental Health Review Tribunal academic paper — 22 pages", "New landmark-cases/US subcategory with Delphi Murders as first entry", "Total new document pages added: 1,664 across all crime scene collections" ] @@ -195,18 +210,18 @@ { "version": "v1.27.0", "date": "18/04/2026", - "title": "CRIME SCENE: UK Murder Cases \u2014 Mass Upload", + "title": "CRIME SCENE: UK Murder Cases — Mass Upload", "category": "CRIME SCENE", "changes": [ "Added 11 UK murder case collections across 4 categories with 57 indexed documents", "New 'Landmark Cases' category for cases that changed British law and policing", - "Daniel Morgan (1987): 3-volume Independent Panel Report \u2014 1,276 pages on Met Police corruption", + "Daniel Morgan (1987): 3-volume Independent Panel Report — 1,276 pages on Met Police corruption", "Claudia Lawrence (2009): ICO FOI audit of North Yorkshire Police practices", "Jill Dando (1999): Barry George appeal judgment and CCRC referral decision", "Suzy Lamplugh (1986): Suzy Lamplugh Trust safety resources and case documentation", - "Stephen Lawrence (1993): Complete Macpherson Report \u2014 389 pages, coined 'institutional racism'", + "Stephen Lawrence (1993): Complete Macpherson Report — 389 pages, coined 'institutional racism'", "James Bulger (1993): ECHR Grand Chamber judgments, anonymity injunction, minimum term review", - "Damilola Taylor (2000): Sentamu Oversight Panel investigation review \u2014 56 pages", + "Damilola Taylor (2000): Sentamu Oversight Panel investigation review — 56 pages", "Lee Rigby (2013): ISC intelligence report (200 pages), Government response, sentencing remarks", "Lord Lucan (1974): Presumption of Death Act legal analysis for Sandra Rivett murder", "Madeleine McCann (2007): PJ Police Report translation (57 pages), Jane Tanner statements (4 parts)", @@ -221,10 +236,10 @@ "category": "CRIME SCENE", "changes": [ "Populated the Police Reports subcollection for the Zodiac Killer with 5 documents (207 pages, 26.4 MB)", - "Added Lake Herman Road police reports \u2014 Benicia PD & Solano County Sheriff (60 pages, 5.6 MB)", - "Added Blue Rock Springs police reports \u2014 Vallejo PD (75 pages, 10.3 MB)", - "Added Lake Berryessa police reports \u2014 Napa County Sheriff's Office (35 pages, 5.1 MB)", - "Added Presidio Heights / Paul Stine police reports \u2014 SFPD (2 pages, 0.4 MB)", + "Added Lake Herman Road police reports — Benicia PD & Solano County Sheriff (60 pages, 5.6 MB)", + "Added Blue Rock Springs police reports — Vallejo PD (75 pages, 10.3 MB)", + "Added Lake Berryessa police reports — Napa County Sheriff's Office (35 pages, 5.1 MB)", + "Added Presidio Heights / Paul Stine police reports — SFPD (2 pages, 0.4 MB)", "Added California Department of Justice investigation report (35 pages, 5.0 MB)", "Zodiac Killer collection now contains 26 documents across 4 subcollections totalling approximately 78 MB", "All documents sourced from zodiackiller.com's authenticated police report archive" @@ -233,7 +248,7 @@ { "version": "1.25.0", "date": "16/04/2026", - "title": "Changelog Fix \u2014 Date Format & Missing Entries", + "title": "Changelog Fix — Date Format & Missing Entries", "category": "fix", "changes": [ "Fixed NaN/NaN/NaN date display bug in changelog renderer", @@ -250,18 +265,18 @@ "category": "fix", "changes": [ "Replaced encrypted/unreadable MKUltra PDF with two working documents", - "Added CIA Inspector General Report (1963) \u2014 48-page TOP SECRET internal review", - "Added Senate Hearing transcript (1977) \u2014 171-page Congressional testimony exposing 149 sub-projects" + "Added CIA Inspector General Report (1963) — 48-page TOP SECRET internal review", + "Added Senate Hearing transcript (1977) — 171-page Congressional testimony exposing 149 sub-projects" ] }, { "version": "1.23.0", "date": "16/04/2026", - "title": "PROPAGANDA \u2192 UNREDACTED Rename + Nav Animation + CRIME SCENE", + "title": "PROPAGANDA → UNREDACTED Rename + Nav Animation + CRIME SCENE", "category": "feature", "changes": [ "Renamed PROPAGANDA section to UNREDACTED across all pages, nav, API, and URLs", - "Built block-reveal animation on UNREDACTED nav item (\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2192 UNREDACTED \u2192 reverse \u2192 loop)", + "Built block-reveal animation on UNREDACTED nav item (██████████ → UNREDACTED → reverse → loop)", "Added global document search across all UNREDACTED categories, titles, and descriptions", "Built new CRIME SCENE section at /depot/crimescene with red CRT theme", "Four crime categories: Unsolved Murders, Serial Killers, Court Transcripts, Cold Cases", @@ -272,12 +287,12 @@ { "version": "1.22.0", "date": "15/04/2026", - "title": "CRIME SCENE: Zodiac Killer Expanded \u2014 Letters, Ciphers & Subcollections", + "title": "CRIME SCENE: Zodiac Killer Expanded — Letters, Ciphers & Subcollections", "category": "CRIME SCENE", "changes": [ "Fixed PDF path bug: document URLs now correctly include country code (US) via subcollection routing", "Restructured Zodiac Killer into 4 subcollections: FBI Investigation Files, Zodiac Letters & Cards, The Zodiac Ciphers, Police Reports & Crime Scene Documents", - "Added 11 original Zodiac letter PDFs (1969\u20131974) from zodiackiller.com: Chronicle/Examiner cipher letters, Debut letter, Stine bloody shirt letter, Bus Bomb letter, Belli letter, Dragon card, Phillips 66 map letter, Little List/Mikado letter, Exorcist letter, Citizen card", + "Added 11 original Zodiac letter PDFs (1969–1974) from zodiackiller.com: Chronicle/Examiner cipher letters, Debut letter, Stine bloody shirt letter, Bus Bomb letter, Belli letter, Dragon card, Phillips 66 map letter, Little List/Mikado letter, Exorcist letter, Citizen card", "Added 3 cipher PDFs: Z408 three-part cipher (solved 1969), Z340 cipher (solved 2020), Z32 map code cipher (unsolved)", "Added Z340 Solution academic paper by Oranchak, Blake & Van Eycke (2024, 38 pages) from arXiv", "Each of the 21 new documents has a unique historical description with contextual detail", @@ -293,7 +308,7 @@ "category": "CRIME SCENE", "changes": [ "Added complete FBI Zodiac Killer investigation files (6 parts, 1,116 pages, 34MB)", - "Files sourced from FBI Vault via Archive.org \u2014 declassified FOIA release", + "Files sourced from FBI Vault via Archive.org — declassified FOIA release", "Each document includes unique summary describing specific contents", "Covers: Arthur Leigh Allen suspect investigation, cipher analysis, forensic lab reports, fingerprint comparisons, decades of tips and suspect referrals", "PDFs served from /crimescene/docs/serial-killers/US/zodiac-killer/", @@ -307,9 +322,9 @@ "category": "feature", "changes": [ "Added PDF text search to document viewer (Ctrl+F, green/amber highlights, match counter, case toggle)", - "Text layer enabled \u2014 select and copy text from PDFs", + "Text layer enabled — select and copy text from PDFs", "Added unique descriptions to all 113 UK MOD UFO documents sourced from National Archives highlights guides", - "Added Project Condign (250MB, 460pp SECRET UK EYES ONLY) \u2014 the classified DI55 UAP study", + "Added Project Condign (250MB, 460pp SECRET UK EYES ONLY) — the classified DI55 UAP study", "Downloaded US documents: Project Blue Book, CIA UFO Collection, NSA UFO Documents, Pentagon UAP Report", "Downloaded Covert Ops: MKUltra, Stargate Program, Operation Northwoods", "Downloaded Government: JFK Warren Commission, Pentagon Papers, CIA Torture Report", @@ -323,7 +338,7 @@ "category": "fix", "changes": [ "Fixed dispatches post pages crashing (mood type error + fallback path)", - "SOL price ticker fixed \u2014 switched to Binance API (CORS-friendly)", + "SOL price ticker fixed — switched to Binance API (CORS-friendly)", "Tightened navbar spacing between SOL price and wallet connect", "Converted all post mood values from integers to proper strings" ] @@ -334,19 +349,19 @@ "title": "Admin Panel Overhaul", "category": "feature", "changes": [ - "Fixed broken Editor section \u2014 full post editing with live preview", - "Fixed broken Backups section \u2014 export/import site data as ZIP", - "Added SITREP admin section \u2014 generate reports, view archive", - "Added Data Sync section \u2014 trigger Contraband/RECON syncs, view stats", - "Added Changelog admin section \u2014 CRUD for maintenance log entries", - "Added Cron Jobs section \u2014 view/toggle all scheduled tasks", + "Fixed broken Editor section — full post editing with live preview", + "Fixed broken Backups section — export/import site data as ZIP", + "Added SITREP admin section — generate reports, view archive", + "Added Data Sync section — trigger Contraband/RECON syncs, view stats", + "Added Changelog admin section — CRUD for maintenance log entries", + "Added Cron Jobs section — view/toggle all scheduled tasks", "Reorganised sidebar into grouped sections" ] }, { "version": "1.18.0", "date": "06/04/2026", - "title": "SITREP \u2014 Daily AI Briefing System", + "title": "SITREP — Daily AI Briefing System", "category": "feature", "changes": [ "Built automated daily intelligence briefing at /transmissions/sitrep", @@ -360,7 +375,7 @@ { "version": "1.17.0", "date": "06/04/2026", - "title": "TOKEN FORGE \u2014 SPL Token Launcher", + "title": "TOKEN FORGE — SPL Token Launcher", "category": "feature", "changes": [ "Built token launcher at /tokenlauncher with full SPL token creation", @@ -404,7 +419,7 @@ "changes": [ "Global wallet connect button in navbar across all 28 pages", "Multi-wallet support: Phantom, Solflare, Backpack, Coinbase, Trust, MetaMask, Jupiter", - "Persistent connection via localStorage \u2014 survives page navigation", + "Persistent connection via localStorage — survives page navigation", "Connected dropdown with address copy, Solscan link, disconnect", "Global window.solWallet API for all Solana features", "Refactored soldomains.js to use shared wallet (removed 146 lines)" @@ -413,7 +428,7 @@ { "version": "1.13.0", "date": "05/04/2026", - "title": "RADAR \u2014 Live Tech News Feed", + "title": "RADAR — Live Tech News Feed", "category": "feature", "changes": [ "Built live tech news aggregator at /transmissions/radar", @@ -436,7 +451,7 @@ { "version": "1.11.0", "date": "04/04/2026", - "title": "RECON \u2014 Site Restructure & Accordion Navigation", + "title": "RECON — Site Restructure & Accordion Navigation", "category": "feature", "changes": [ "Moved RECON to /depot/recon for consistency with other depot pages", @@ -449,7 +464,7 @@ { "version": "1.10.0", "date": "04/04/2026", - "title": "RECON \u2014 Curated Lists Rebuild", + "title": "RECON — Curated Lists Rebuild", "category": "feature", "changes": [ "Flattened 4-level navigation to 2-level (sector > list > entries)", @@ -461,7 +476,7 @@ { "version": "1.9.0", "date": "04/04/2026", - "title": "RECON \u2014 Curated Lists Database", + "title": "RECON — Curated Lists Database", "category": "feature", "changes": [ "Parsed 660 curated lists into 28 themed sectors", @@ -478,7 +493,7 @@ "category": "feature", "changes": [ "Subcategories now display as 2-column card grid with expandable detail panels", - "Added weekly auto-sync \u2014 resource database updates every Sunday at 03:00", + "Added weekly auto-sync — resource database updates every Sunday at 03:00", "Click any subcategory card to expand/collapse its entries below", "Active card highlighting with amber glow", "Responsive grid: 2-col desktop, 1-col mobile" @@ -490,8 +505,8 @@ "title": "Sitewide Visual Overhaul", "category": "fix", "changes": [ - "Bumped 64 font sizes sitewide \u2014 no more microscopic text", - "Brightened all text colours: primary #c0c0c0\u2192#d8d8d8, secondary #707070\u2192#999999, muted #3a3a3a\u2192#666666", + "Bumped 64 font sizes sitewide — no more microscopic text", + "Brightened all text colours: primary #c0c0c0→#d8d8d8, secondary #707070→#999999, muted #3a3a3a→#666666", "CONTRABAND page: 4-column category grid with responsive breakpoints", "Purged all third-party attribution references from entire codebase" ] @@ -499,13 +514,13 @@ { "version": "1.6.0", "date": "03/04/2026", - "title": "CONTRABAND \u2014 Classified Resource Index", + "title": "CONTRABAND — Classified Resource Index", "category": "feature", "changes": [ "Launched CONTRABAND page at /depot/contraband with 15,800+ indexed assets", "24 categories with military codenames (CRT-001 through CRT-024)", "Full-text search across all entries via API", - "Starred/top-pick filter system with \u2b50 indicators", + "Starred/top-pick filter system with ⭐ indicators", "Collapsible subcategories with item counts", "Flask API endpoints: /api/contraband, /api/contraband/, /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" diff --git a/api/data/country_centroids.json b/api/data/country_centroids.json new file mode 100644 index 0000000..a4fa481 --- /dev/null +++ b/api/data/country_centroids.json @@ -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] +} diff --git a/api/visitor_routes.py b/api/visitor_routes.py new file mode 100644 index 0000000..f1656fe --- /dev/null +++ b/api/visitor_routes.py @@ -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) diff --git a/css/scan-visitor.css b/css/scan-visitor.css new file mode 100644 index 0000000..ac33b89 --- /dev/null +++ b/css/scan-visitor.css @@ -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; } +} diff --git a/index.html b/index.html index 2975a53..b6fdaf3 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ JAESWIFT // SYSTEMS ONLINE + @@ -583,6 +584,7 @@ + diff --git a/js/scan-visitor.js b/js/scan-visitor.js new file mode 100644 index 0000000..8d71408 --- /dev/null +++ b/js/scan-visitor.js @@ -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 = ` + + `; + 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 = ` + ${flag} +
+ OPERATOR + ${locShort} +
+ + `; + 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(); + }, + }; +})();