diff --git a/.config/eslint.config.js b/.config/eslint.config.js
new file mode 100644
index 000000000..3200a6807
--- /dev/null
+++ b/.config/eslint.config.js
@@ -0,0 +1,15 @@
+import eslintPluginAstro from 'eslint-plugin-astro';
+import tsParser from '@typescript-eslint/parser';
+
+export default [
+ ...eslintPluginAstro.configs.recommended,
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parser: tsParser,
+ },
+ },
+ {
+ ignores: ['dist/', 'node_modules/', '.astro/', '.netlify/', '.vercel/'],
+ },
+];
diff --git a/.env.sample b/.env.sample
new file mode 100644
index 000000000..2781bbaf0
--- /dev/null
+++ b/.env.sample
@@ -0,0 +1,30 @@
+# Environmental Variables can be stored here
+# Be sure to uncomment any line you populate
+# Everything is optional, but some features won't work without external API access
+
+# Kill switch for the public/hosted instance
+# VITE_DISABLE_EVERYTHING='false'
+
+# API Keys for external services (backend)
+GOOGLE_CLOUD_API_KEY=''
+TORRENT_IP_API_KEY=''
+SECURITY_TRAILS_API_KEY=''
+URL_SCAN_API_KEY=''
+TRANCO_USERNAME=''
+TRANCO_API_KEY=''
+CLOUDMERSIVE_API_KEY=''
+SHODAN_API_KEY=''
+WHO_API_KEY=''
+
+# Configuration settings
+# CHROME_PATH='/usr/bin/chromium' # The path the the Chromium executable
+# PORT='3000' # Port to serve the API, when running server.js
+# DISABLE_GUI='false' # Disable the GUI, and only serve the API
+# PUBLIC_API_TIMEOUT_LIMIT='25000'# Timeout for API requests, in milliseconds
+# API_CORS_ORIGIN='*' # Enable CORS, by setting your allowed hostname(s) here
+# API_ENABLE_RATE_LIMIT='true' # Enable rate limiting for the API
+# REACT_APP_API_ENDPOINT='/api' # The endpoint for the API (can be local or remote)
+# ENABLE_ANALYTICS='false' # Enable Plausible hit counter for the frontend
+# BOSS_SERVER='false' # Marketing homepage (only used by official instance)
+# TRUST_PROXY='1' # Set if running behind a reverse proxy (Traefik, nginx, etc).
+ # Use a number of hops (e.g. '1'), 'true', or a CIDR list.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..14dff0a36
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: [lissy93]
diff --git a/.github/README.md b/.github/README.md
new file mode 100644
index 000000000..3bf64617d
--- /dev/null
+++ b/.github/README.md
@@ -0,0 +1,1066 @@
+
Web-Check
+
+
+
+Comprehensive, on-demand open source intelligence for any website
+
+🌐 web-check.xyz
+
+
+
+
+---
+
+Kindly supported by:
+
+
+
+#### Contents
+
+- **[About](#about)**
+ - [Screenshot](#screenshot)
+ - [Live Demo](#live-demo)
+ - [Mirror](#mirror)
+ - [Features](#features)
+- **[Usage](#usage)**
+ - [Deployment](#deployment)
+ - [Option#1: Netlify](#deploying---option-1-netlify)
+ - [Option#2: Vercel](#deploying---option-2-vercel)
+ - [Option#3: Docker](#deploying---option-3-docker)
+ - [Option#4: Source](#deploying---option-4-from-source)
+ - [Configuration Options](#configuring)
+ - [Developer Setup](#developing)
+- **[Community](#community)**
+ - [Contributing](#contributing)
+ - [Bugs](#reporting-bugs)
+ - [Support](#supporting)
+- **[License](#license)**
+
+---
+
+## About
+
+Get an insight into the inner-workings of a given website: uncover potential attack vectors, analyse server architecture, view security configurations, and learn what technologies a site is using.
+
+Currently the dashboard will show: IP info, SSL chain, DNS records, cookies, headers, domain info, search crawl rules, page map, server location, redirect ledger, open ports, traceroute, DNS security extensions, site performance, trackers, associated hostnames, carbon footprint. Stay tuned, as I'll add more soon!
+
+The aim is to help you easily understand, optimize and secure your website.
+
+### Screenshot
+
+
+ Expand Screenshot
+
+[](https://web-check.as93.net/)
+
+
+
+[](https://github.com/Lissy93/web-check/tree/master/.github/screenshots)
+
+### Live Demo
+
+A hosted version can be accessed at: **[web-check.as93.net](https://web-check.as93.net)**
+
+### Mirror
+
+The source for this repo is mirrored to CodeBerg, available at: **[codeberg.org/alicia/web-check](https://codeberg.org/alicia/web-check)**
+
+### Status
+
+Build & Deploys: [](https://app.netlify.com/sites/web-check/deploys)
+[](https://vercel.com/as93/web-check/)
+[](https://github.com/Lissy93/web-check/actions/workflows/docker.yml)
+[](https://github.com/Lissy93/web-check/actions/workflows/deploy-aws.yml)
+
+Repo Management & Miscellaneous: [](https://github.com/Lissy93/web-check/actions/workflows/mirror.yml)
+[](https://github.com/Lissy93/web-check/actions/workflows/credits.yml)
+
+### Features
+
+
+Click to expand / collapse section
+
+**Note** _this list needs updating, many more jobs have been added since..._
+
+The following section outlines the core features, and briefly explains why this data might be useful for you to know, as well as linking to further resources for learning more.
+
+
+IP Info
+
+###### Description
+
+An IP address (Internet Protocol address) is a numerical label assigned to each device connected to a network / the internet. The IP associated with a given domain can be found by querying the Domain Name System (DNS) for the domain's A (address) record.
+
+###### Use Cases
+
+Finding the IP of a given server is the first step to conducting further investigations, as it allows us to probe the server for additional info. Including creating a detailed map of a target's network infrastructure, pinpointing the physical location of a server, identifying the hosting service, and even discovering other domains that are hosted on the same IP address.
+
+###### Useful Links
+
+- [Understanding IP Addresses](https://www.digitalocean.com/community/tutorials/understanding-ip-addresses-subnets-and-cidr-notation-for-networking)
+- [IP Addresses - Wiki](https://en.wikipedia.org/wiki/IP_address)
+- [RFC-791 Internet Protocol](https://tools.ietf.org/html/rfc791)
+- [whatismyipaddress.com](https://whatismyipaddress.com/)
+
+
+
+SSL Chain
+
+
+
+###### Description
+
+SSL certificates are digital certificates that authenticate the identity of a website or server, enable secure encrypted communication (HTTPS), and establish trust between clients and servers. A valid SSL certificate is required for a website to be able to use the HTTPS protocol, and encrypt user + site data in transit. SSL certificates are issued by Certificate Authorities (CAs), which are trusted third parties that verify the identity and legitimacy of the certificate holder.
+
+###### Use Cases
+
+SSL certificates not only provide the assurance that data transmission to and from the website is secure, but they also provide valuable OSINT data. Information from an SSL certificate can include the issuing authority, the domain name, its validity period, and sometimes even organization details. This can be useful for verifying the authenticity of a website, understanding its security setup, or even for discovering associated subdomains or other services.
+
+###### Useful Links
+
+- [TLS - Wiki](https://en.wikipedia.org/wiki/Transport_Layer_Security)
+- [What is SSL (via Cloudflare learning)](https://www.cloudflare.com/learning/ssl/what-is-ssl/)
+- [RFC-8446 - TLS](https://tools.ietf.org/html/rfc8446)
+- [SSL Checker](https://www.sslshopper.com/ssl-checker.html)
+
+
+
+DNS Records
+
+
+
+###### Description
+
+This task involves looking up the DNS records associated with a specific domain. DNS is a system that translates human-readable domain names into IP addresses that computers use to communicate. Various types of DNS records exist, including A (address), MX (mail exchange), NS (name server), CNAME (canonical name), and TXT (text), among others.
+
+###### Use Cases
+
+Extracting DNS records can provide a wealth of information in an OSINT investigation. For example, A and AAAA records can disclose IP addresses associated with a domain, potentially revealing the location of servers. MX records can give clues about a domain's email provider. TXT records are often used for various administrative purposes and can sometimes inadvertently leak internal information. Understanding a domain's DNS setup can also be useful in understanding how its online infrastructure is built and managed.
+
+###### Useful Links
+
+- [What are DNS records? (via Cloudflare learning)](https://www.cloudflare.com/learning/dns/dns-records/)
+- [DNS Record Types](https://en.wikipedia.org/wiki/List_of_DNS_record_types)
+- [RFC-1035 - DNS](https://tools.ietf.org/html/rfc1035)
+- [DNS Lookup (via MxToolbox)](https://mxtoolbox.com/DNSLookup.aspx)
+
+
+
+Cookies
+
+
+
+###### Description
+
+The Cookies task involves examining the HTTP cookies set by the target website. Cookies are small pieces of data stored on the user's computer by the web browser while browsing a website. They hold a modest amount of data specific to a particular client and website, such as site preferences, the state of the user's session, or tracking information.
+
+###### Use Cases
+
+Cookies can disclose information about how the website tracks and interacts with its users. For instance, session cookies can reveal how user sessions are managed, and tracking cookies can hint at what kind of tracking or analytics frameworks are being used. Additionally, examining cookie policies and practices can offer insights into the site's security settings and compliance with privacy regulations.
+
+###### Useful Links
+
+- [HTTP Cookie Docs (Mozilla)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)
+- [What are Cookies (via Cloudflare Learning)](https://www.cloudflare.com/learning/privacy/what-are-cookies/)
+- [Testing for Cookie Attributes (OWASP)](https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes)
+- [RFC-6265 - Coolies](https://tools.ietf.org/html/rfc6265)
+
+
+
+Crawl Rules
+
+
+
+###### Description
+
+Robots.txt is a file found (usually) at the root of a domain, and is used to implement the Robots Exclusion Protocol (REP) to indicate which pages should be ignored by which crawlers and bots. It's good practice to avoid search engine crawlers from over-loading your site, but should not be used to keep pages out of search results (use the noindex meta tag or header instead).
+
+###### Use Cases
+
+It's often useful to check the robots.txt file during an investigation, as it can sometimes disclose the directories and pages that the site owner doesn't want to be indexed, potentially because they contain sensitive information, or reveal the existence of otherwise hidden or unlinked directories. Additionally, understanding crawl rules may offer insights into a website's SEO strategies.
+
+###### Useful Links
+
+- [Google Search Docs - Robots.txt](https://developers.google.com/search/docs/advanced/robots/intro)
+- [Learn about robots.txt (via Moz.com)](https://moz.com/learn/seo/robotstxt)
+- [RFC-9309 - Robots Exclusion Protocol](https://datatracker.ietf.org/doc/rfc9309/)
+- [Robots.txt - wiki](https://en.wikipedia.org/wiki/Robots_exclusion_standard)
+
+
+
+Headers
+
+
+
+###### Description
+
+The Headers task involves extracting and interpreting the HTTP headers sent by the target website during the request-response cycle. HTTP headers are key-value pairs sent at the start of an HTTP response, or before the actual data. Headers contain important directives for how to handle the data being transferred, including cache policies, content types, encoding, server information, security policies, and more.
+
+###### Use Cases
+
+Analyzing HTTP headers can provide significant insights in an OSINT investigation. Headers can reveal specific server configurations, chosen technologies, caching directives, and various security settings. This information can help to determine a website's underlying technology stack, server-side security measures, potential vulnerabilities, and general operational practices.
+
+###### Useful Links
+
+- [HTTP Headers - Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers)
+- [RFC-7231 Section 7 - Headers](https://datatracker.ietf.org/doc/html/rfc7231#section-7)
+- [List of header response fields](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields)
+- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/)
+
+
+
+Quality Metrics
+
+
+
+###### Description
+
+Using Lighthouse, the Quality Metrics task measures the performance, accessibility, best practices, and SEO of the target website. This returns a simple checklist of 100 core metrics, along with a score for each category, to gauge the overall quality of a given site.
+
+###### Use Cases
+
+Useful for assessing a site's technical health, SEO issues, identify vulnerabilities, and ensure compliance with standards.
+
+###### Useful Links
+
+- [Lighthouse Docs](https://developer.chrome.com/docs/lighthouse/)
+- [Google Page Speed Tools](https://developers.google.com/speed)
+- [W3 Accessibility Tools](https://www.w3.org/WAI/test-evaluate/)
+- [Google Search Console](https://search.google.com/search-console)
+- [SEO Checker](https://www.seobility.net/en/seocheck/)
+- [PWA Builder](https://www.pwabuilder.com/)
+
+
+
+Server Location
+
+
+
+###### Description
+
+The Server Location task determines the physical location of the server hosting a given website based on its IP address. This is done by looking up the IP in a location database, which maps the IP to a lat + long of known data centers and ISPs. From the latitude and longitude, it's then possible to show additional contextual info, like a pin on the map, along with address, flag, time zone, currency, etc.
+
+###### Use Cases
+
+Knowing the server location is a good first step in better understanding a website. For site owners this aids in optimizing content delivery, ensuring compliance with data residency requirements, and identifying potential latency issues that may impact user experience in specific geographical regions. And for security researcher, assess the risk posed by specific regions or jurisdictions regarding cyber threats and regulations.
+
+###### Useful Links
+
+- [IP Locator](https://geobytes.com/iplocator/)
+- [Internet Geolocation - Wiki](https://en.wikipedia.org/wiki/Internet_geolocation)
+
+
+
+Associated Hosts
+
+
+
+###### Description
+
+This task involves identifying and listing all domains and subdomains (hostnames) that are associated with the website's primary domain. This process often involves DNS enumeration to discover any linked domains and hostnames, as well as looking at known DNS records.
+
+###### Use Cases
+
+During an investigation, understanding the full scope of a target's web presence is critical. Associated domains could lead to uncovering related projects, backup sites, development/test sites, or services linked to the main site. These can sometimes provide additional information or potential security vulnerabilities. A comprehensive list of associated domains and hostnames can also give an overview of the organization's structure and online footprint.
+
+###### Useful Links
+
+- [DNS Enumeration - Wiki](https://en.wikipedia.org/wiki/DNS_enumeration)
+- [OWASP - Enumerate Applications on Webserver](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/01-Information_Gathering/04-Enumerate_Applications_on_Webserver)
+- [DNS Enumeration - DNS Dumpster](https://dnsdumpster.com/)
+- [Subdomain Finder](https://subdomainfinder.c99.nl/)
+
+
+
+Redirect Chain
+
+
+
+###### Description
+
+This task traces the sequence of HTTP redirects that occur from the original URL to the final destination URL. An HTTP redirect is a response with a status code that advises the client to go to another URL. Redirects can occur for several reasons, such as URL normalization (directing to the www version of the site), enforcing HTTPS, URL shorteners, or forwarding users to a new site location.
+
+###### Use Cases
+
+Understanding the redirect chain can be useful for several reasons. From a security perspective, long or complicated redirect chains can be a sign of potential security risks, such as unencrypted redirects in the chain. Additionally, redirects can impact website performance and SEO, as each redirect introduces additional round-trip-time (RTT). For OSINT, understanding the redirect chain can help identify relationships between different domains or reveal the use of certain technologies or hosting providers.
+
+###### Useful Links
+
+- [HTTP Redirects - MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections)
+- [URL Redirection - Wiki](https://en.wikipedia.org/wiki/URL_redirection)
+- [301 Redirects explained](https://ahrefs.com/blog/301-redirects/)
+
+
+
+TXT Records
+
+
+
+###### Description
+
+TXT records are a type of DNS record that provides text information to sources outside your domain. They can be used for a variety of purposes, such as verifying domain ownership, ensuring email security, and even preventing unauthorized changes to your website.
+
+###### Use Cases
+
+The TXT records often reveal which external services and technologies are being used with a given domain. They may reveal details about the domain's email configuration, the use of specific services like Google Workspace or Microsoft 365, or security measures in place such as SPF and DKIM. Understanding these details can give an insight into the technologies used by the organization, their email security practices, and potential vulnerabilities.
+
+###### Useful Links
+
+- [TXT Records (via Cloudflare Learning)](https://www.cloudflare.com/learning/dns/dns-records/dns-txt-record/)
+- [TXT Records - Wiki](https://en.wikipedia.org/wiki/TXT_record)
+- [RFC-1464 - TXT Records](https://datatracker.ietf.org/doc/html/rfc1464)
+- [TXT Record Lookup (via MxToolbox)](https://mxtoolbox.com/TXTLookup.aspx)
+
+
+
+Server Status
+
+
+
+###### Description
+
+Checks if a server is online and responding to requests.
+
+###### Use Cases
+
+###### Useful Links
+
+
+
+Open Ports
+
+
+
+###### Description
+
+Open ports on a server are endpoints of communication which are available for establishing connections with clients. Each port corresponds to a specific service or protocol, such as HTTP (port 80), HTTPS (port 443), FTP (port 21), etc. The open ports on a server can be determined using techniques such as port scanning.
+
+###### Use Cases
+
+Knowing which ports are open on a server can provide information about the services running on that server, useful for understanding the potential vulnerabilities of the system, or for understanding the nature of the services the server is providing.
+
+###### Useful Links
+
+- [List of TCP & UDP Port Numbers](https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers)
+- [NMAP - Port Scanning Basics](https://nmap.org/book/man-port-scanning-basics.html)
+
+
+
+Traceroute
+
+
+
+###### Description
+
+Traceroute is a network diagnostic tool used to track in real-time the pathway taken by a packet of information from one system to another. It records each hop along the route, providing details about the IPs of routers and the delay at each point.
+
+###### Use Cases
+
+In OSINT investigations, traceroute can provide insights about the routing paths and geography of the network infrastructure supporting a website or service. This can help to identify network bottlenecks, potential censorship or manipulation of network traffic, and give an overall sense of the network's structure and efficiency. Additionally, the IP addresses collected during the traceroute may provide additional points of inquiry for further OSINT investigation.
+
+###### Useful Links
+
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+
+
+
+Carbon Footprint
+
+
+
+###### Description
+
+This task calculates the estimated carbon footprint of a website. It's based on the amount of data being transferred and processed, and the energy usage of the servers that host and deliver the website. The larger the website and the more complex its features, the higher its carbon footprint is likely to be.
+
+###### Use Cases
+
+From an OSINT perspective, understanding a website's carbon footprint doesn't directly provide insights into its internal workings or the organization behind it. However, it can still be valuable data in broader analyses, especially in contexts where environmental impact is a consideration. For example, it can be useful for activists, researchers, or ethical hackers who are interested in the sustainability of digital infrastructure, and who want to hold organizations accountable for their environmental impact.
+
+###### Useful Links
+
+- [WebsiteCarbon - Carbon Calculator](https://www.websitecarbon.com/)
+- [The Green Web Foundation](https://www.thegreenwebfoundation.org/)
+- [The Eco Friendly Web Alliance](https://ecofriendlyweb.org/)
+- [Reset.org](https://en.reset.org/)
+- [Your website is killing the planet - via Wired](https://www.wired.co.uk/article/internet-carbon-footprint)
+
+
+
+Server Info
+
+
+
+###### Description
+
+This task retrieves various pieces of information about the server hosting the target website. This can include the server type (e.g., Apache, Nginx), the hosting provider, the Autonomous System Number (ASN), and more. The information is usually obtained through a combination of IP address lookups and analysis of HTTP response headers.
+
+###### Use Cases
+
+In an OSINT context, server information can provide valuable clues about the organization behind a website. For instance, the choice of hosting provider could suggest the geographical region in which the organization operates, while the server type could hint at the technologies used by the organization. The ASN could also be used to find other domains hosted by the same organization.
+
+###### Useful Links
+
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+
+
+
+Whois Lookup
+
+
+
+###### Description
+
+This task retrieves Whois records for the target domain. Whois records are a rich source of information, including the name and contact information of the domain registrant, the domain's creation and expiration dates, the domain's nameservers, and more. The information is usually obtained through a query to a Whois database server.
+
+###### Use Cases
+
+In an OSINT context, Whois records can provide valuable clues about the entity behind a website. They can show when the domain was first registered and when it's set to expire, which could provide insights into the operational timeline of the entity. The contact information, though often redacted or anonymized, can sometimes lead to additional avenues of investigation. The nameservers could also be used to link together multiple domains owned by the same entity.
+
+###### Useful Links
+
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+
+
+
+Domain Info
+
+
+
+###### Description
+
+This task retrieves Whois records for the target domain. Whois records are a rich source of information, including the name and contact information of the domain registrant, the domain's creation and expiration dates, the domain's nameservers, and more. The information is usually obtained through a query to a Whois database server.
+
+###### Use Cases
+
+In an OSINT context, Whois records can provide valuable clues about the entity behind a website. They can show when the domain was first registered and when it's set to expire, which could provide insights into the operational timeline of the entity. The contact information, though often redacted or anonymized, can sometimes lead to additional avenues of investigation. The nameservers could also be used to link together multiple domains owned by the same entity.
+
+###### Useful Links
+
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+
+
+
+DNS Security Extensions
+
+
+
+###### Description
+
+Without DNSSEC, it's possible for MITM attackers to spoof records and lead users to phishing sites. This is because the DNS system includes no built-in methods to verify that the response to the request was not forged, or that any other part of the process wasn’t interrupted by an attacker. The DNS Security Extensions (DNSSEC) secures DNS lookups by signing your DNS records using public keys, so browsers can detect if the response has been tampered with. Another solution to this issue is DoH (DNS over HTTPS) and DoT (DNS over TLD).
+
+###### Use Cases
+
+DNSSEC information provides insight into an organization's level of cybersecurity maturity and potential vulnerabilities, particularly around DNS spoofing and cache poisoning. If no DNS secururity (DNSSEC, DoH, DoT, etc) is implemented, this may provide an entry point for an attacker.
+
+###### Useful Links
+
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+
+
+
+Site Features
+
+
+
+###### Description
+
+Checks which core features are present on a site. If a feature as marked as dead, that means it's not being actively used at load time
+
+###### Use Cases
+
+This is useful to understand what a site is capable of, and what technologies to look for
+
+###### Useful Links
+
+
+
+HTTP Strict Transport Security
+
+
+
+###### Description
+
+HTTP Strict Transport Security (HSTS) is a web security policy mechanism that helps protect websites against protocol downgrade attacks and cookie hijacking. A website can be included in the HSTS preload list by conforming to a set of requirements and then submitting itself to the list.
+
+###### Use Cases
+
+There are several reasons why it's important for a site to be HSTS enabled: 1. User bookmarks or manually types http://example.com and is subject to a man-in-the-middle attacker
+HSTS automatically redirects HTTP requests to HTTPS for the target domain 2. Web application that is intended to be purely HTTPS inadvertently contains HTTP links or serves content over HTTP
+HSTS automatically redirects HTTP requests to HTTPS for the target domain 3. A man-in-the-middle attacker attempts to intercept traffic from a victim user using an invalid certificate and hopes the user will accept the bad certificate
+HSTS does not allow a user to override the invalid certificate message
+
+###### Useful Links
+
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+- [undefined](function link() { [native code] })
+
+
+
+DNS Server
+
+
+
+###### Description
+
+This check determines the DNS server(s) that the requested URL / IP resolves to. Also fires off a rudimentary check to see if the DNS server supports DoH, and weather it's vulnerable to DNS cache poisoning.
+
+###### Use Cases
+
+###### Useful Links
+
+
+
+Tech Stack
+
+
+
+###### Description
+
+Checks what technologies a site is built with. This is done by fetching and parsing the site, then comparing it against a bit list of RegEx maintained by Wappalyzer to identify the unique fingerprints that different technologies leave.
+
+###### Use Cases
+
+Identifying a website's tech stack aids in evaluating its security by exposing potential vulnerabilities, informs competitive analyses and development decisions, and can guide tailored marketing strategies. Ethical application of this knowledge is crucial to avoid harmful activities like data theft or unauthorized intrusion.
+
+###### Useful Links
+
+- [Wappalyzer fingerprints](https://github.com/wappalyzer/wappalyzer/tree/master/src/technologies)
+- [BuiltWith - Check what tech a site is using](https://builtwith.com/)
+
+
+
+Listed Pages
+
+
+
+###### Description
+
+This job finds and parses a site's listed sitemap. This file lists public sub-pages on the site, which the author wishes to be crawled by search engines. Sitemaps help with SEO, but are also useful for seeing all a sites public content at a glance.
+
+###### Use Cases
+
+Understand the structure of a site's public-facing content, and for site-owners, check that you're site's sitemap is accessible, parsable and contains everything you wish it to.
+
+###### Useful Links
+
+- [Learn about Sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview)
+- [Sitemap XML spec](https://www.sitemaps.org/protocol.html)
+- [Sitemap tutorial](https://www.conductor.com/academy/xml-sitemap/)
+
+
+
+Security.txt
+
+
+
+###### Description
+
+The security.txt file tells researchers how they can responsibly disclose any security issues found on your site. The standard was proposed in RFC 9116, and specifies that this file should include a point of contact (email address), as well as optionally other info, like a link to the security disclosure policy, PGP key, proffered language, policy expiry and more. The file should be located at the root of your domain, either at /security.txt or /.well-known/security.txt.
+
+###### Use Cases
+
+This is important, as without a defined point of contact a security researcher may be unable to report a critical security issue, or may use insecure or possibly public channels to do so. From an OSINT perspective, you may also glean info about a site including their posture on security, their CSAF provider, and meta data from the PGP public key.
+
+###### Useful Links
+
+- [securitytxt.org](https://securitytxt.org/)
+- [RFC-9116 Proposal](https://datatracker.ietf.org/doc/html/rfc9116)
+- [RFC-9116 History](https://datatracker.ietf.org/doc/rfc9116/)
+- [Security.txt (Wikipedia)](https://en.wikipedia.org/wiki/Security.txt)
+- [Example security.txt (Cloudflare)](https://www.cloudflare.com/.well-known/security.txt)
+- [Tutorial for creating security.txt (Pieter Bakker)](https://pieterbakker.com/implementing-security-txt/)
+
+
+
+Linked Pages
+
+
+
+###### Description
+
+Displays all internal and external links found on a site, identified by the href attributes attached to anchor elements.
+
+###### Use Cases
+
+For site owners, this is useful for diagnosing SEO issues, improving the site structure, understanding how content is inter-connected. External links can show partnerships, dependencies, and potential reputation risks. From a security standpoint, the outbound links can help identify any potential malicious or compromised sites the website is unknowingly linking to. Analyzing internal links can aid in understanding the site's structure and potentially uncover hidden or vulnerable pages which are not intended to be public. And for an OSINT investigator, it can aid in building a comprehensive understanding of the target, uncovering related entities, resources, or even potential hidden parts of the site.
+
+###### Useful Links
+
+- [W3C Link Checker](https://validator.w3.org/checklink)
+
+
+
+Social Tags
+
+
+
+###### Description
+
+Websites can include certain meta tags, that tell search engines and social media platforms what info to display. This usually includes a title, description, thumbnail, keywords, author, social accounts, etc.
+
+###### Use Cases
+
+Adding this data to your site will boost SEO, and as an OSINT researcher it can be useful to understand how a given web app describes itself
+
+###### Useful Links
+
+- [SocialSharePreview.com](https://socialsharepreview.com/)
+- [The guide to social meta tags](https://css-tricks.com/essential-meta-tags-social-media/)
+- [Web.dev metadata tags](https://web.dev/learn/html/metadata/)
+- [Open Graph Protocol](https://ogp.me/)
+- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
+- [Facebook Open Graph](https://developers.facebook.com/docs/sharing/webmasters)
+
+
+
+Email Configuration
+
+
+
+###### Description
+
+DMARC (Domain-based Message Authentication, Reporting & Conformance): DMARC is an email authentication protocol that works with SPF and DKIM to prevent email spoofing and phishing. It allows domain owners to specify how to handle unauthenticated mail via a published policy in DNS, and provides a way for receiving mail servers to send feedback about emails' compliance to the sender. BIMI (Brand Indicators for Message Identification): BIMI is an emerging email standard that enables organizations to display a logo in their customers' email clients automatically. BIMI ties the logo to the domain's DMARC record, providing another level of visual assurance to recipients that the email is legitimate. DKIM (DomainKeys Identified Mail): DKIM is an email security standard designed to make sure that messages were not altered in transit between the sending and recipient servers. It uses digital signatures linked to the domain of the sender to verify the sender and ensure message integrity. SPF (Sender Policy Framework): SPF is an email authentication method designed to prevent email spoofing. It specifies which mail servers are authorized to send email on behalf of a domain by creating a DNS record. This helps protect against spam by providing a way for receiving mail servers to check that incoming mail from a domain comes from a host authorized by that domain's administrators.
+
+###### Use Cases
+
+This information is helpful for researchers as it helps assess a domain's email security posture, uncover potential vulnerabilities, and verify the legitimacy of emails for phishing detection. These details can also provide insight into the hosting environment, potential service providers, and the configuration patterns of a target organization, assisting in investigative efforts.
+
+###### Useful Links
+
+- [Intro to DMARC, DKIM, and SPF (via Cloudflare)](https://www.cloudflare.com/learning/email-security/dmarc-dkim-spf/)
+- [EasyDMARC Domain Scanner](https://easydmarc.com/tools/domain-scanner)
+- [MX Toolbox](https://mxtoolbox.com/)
+- [RFC-7208 - SPF](https://datatracker.ietf.org/doc/html/rfc7208)
+- [RFC-6376 - DKIM](https://datatracker.ietf.org/doc/html/rfc6376)
+- [RFC-7489 - DMARC](https://datatracker.ietf.org/doc/html/rfc7489)
+- [BIMI Group](https://bimigroup.org/)
+
+
+
+Firewall Detection
+
+
+
+###### Description
+
+A WAF or web application firewall helps protect web applications by filtering and monitoring HTTP traffic between a web application and the Internet. It typically protects web applications from attacks such as cross-site forgery, cross-site-scripting (XSS), file inclusion, and SQL injection, among others.
+
+###### Use Cases
+
+It's useful to understand if a site is using a WAF, and which firewall software / service it is using, as this provides an insight into the sites protection against several attack vectors, but also may reveal vulnerabilities in the firewall itself.
+
+###### Useful Links
+
+- [What is a WAF (via Cloudflare Learning)](https://www.cloudflare.com/learning/ddos/glossary/web-application-firewall-waf/)
+- [OWASP - Web Application Firewalls](https://owasp.org/www-community/Web_Application_Firewall)
+- [Web Application Firewall Best Practices](https://owasp.org/www-pdf-archive/Best_Practices_Guide_WAF_v104.en.pdf)
+- [WAF - Wiki](https://en.wikipedia.org/wiki/Web_application_firewall)
+
+
+
+HTTP Security Features
+
+
+
+###### Description
+
+Correctly configured security HTTP headers adds a layer of protection against common attacks to your site. The main headers to be aware of are: HTTP Strict Transport Security (HSTS): Enforces the use of HTTPS, mitigating man-in-the-middle attacks and protocol downgrade attempts. Content Security Policy (CSP): Constrains web page resources to prevent cross-site scripting and data injection attacks. X-Content-Type-Options: Prevents browsers from MIME-sniffing a response away from the declared content type, curbing MIME-type confusion attacks. X-Frame-Options: Protects users from clickjacking attacks by controlling whether a browser should render the page in a ` `, `
+
+Archive History
+
+
+
+###### Description
+
+Fetches full history of archives from the Wayback machine
+
+###### Use Cases
+
+This is useful for understanding the history of a site, and how it has changed over time. It can also be useful for finding old versions of a site, or for finding content that has been removed.
+
+###### Useful Links
+
+- [Wayback Machine](https://archive.org/web/)
+
+
+
+Global Ranking
+
+
+
+###### Description
+
+This check shows the global rank of the requested site. This is only accurate for websites which are in the top 1 million list. We're using data from the Tranco project (see below), which collates the top sites on the web from Umbrella, Majestic, Quantcast, the Chrome User Experience Report and Cloudflare Radar.
+
+###### Use Cases
+
+Knowing a websites overall global rank can be useful for understanding the scale of the site, and for comparing it to other sites. It can also be useful for understanding the relative popularity of a site, and for identifying potential trends.
+
+###### Useful Links
+
+- [Tranco List](https://tranco-list.eu/)
+- [Tranco Research Paper](https://tranco-list.eu/assets/tranco-ndss19.pdf)
+
+
+
+Block Detection
+
+
+
+###### Description
+
+Checks access to the URL using 10+ of the most popular privacy, malware and parental control blocking DNS servers.
+
+###### Use Cases
+
+###### Useful Links
+
+
+
+Malware & Phishing Detection
+
+
+
+###### Description
+
+Checks if a site appears in several common malware and phishing lists, to determine it's threat level.
+
+###### Use Cases
+
+Knowing if a site is listed as a threat by any of these services can be useful for understanding the reputation of a site, and for identifying potential trends.
+
+###### Useful Links
+
+- [URLHaus](https://urlhaus-api.abuse.ch/)
+- [PhishTank](https://www.phishtank.com/)
+
+
+
+TLS Cipher Suites
+
+
+
+###### Description
+
+These are combinations of cryptographic algorithms used by the server to establish a secure connection. It includes the key exchange algorithm, bulk encryption algorithm, MAC algorithm, and PRF (pseudorandom function).
+
+###### Use Cases
+
+This is important info to test for from a security perspective. Because a cipher suite is only as secure as the algorithms that it contains. If the version of encryption or authentication algorithm in a cipher suite have known vulnerabilities the cipher suite and TLS connection may then vulnerable to a downgrade or other attack
+
+###### Useful Links
+
+- [sslscan2 CLI](https://github.com/rbsec/sslscan)
+- [ssl-enum-ciphers (NPMAP script)](https://nmap.org/nsedoc/scripts/ssl-enum-ciphers.html)
+
+
+
+TLS Security Config
+
+
+
+###### Description
+
+This uses guidelines from Mozilla's TLS Observatory to check the security of the TLS configuration. It checks for bad configurations, which may leave the site vulnerable to attack, as well as giving advice on how to fix. It will also give suggestions around outdated and modern TLS configs
+
+###### Use Cases
+
+Understanding issues with a site's TLS configuration will help you address potential vulnerabilities, and ensure the site is using the latest and most secure TLS configuration.
+
+###### Useful Links
+
+
+
+TLS Handshake Simulation
+
+
+
+###### Description
+
+This simulates how different clients (browsers, operating systems) would perform a TLS handshake with the server. It helps identify compatibility issues and insecure configurations.
+
+###### Use Cases
+
+###### Useful Links
+
+- [TLS Handshakes (via Cloudflare Learning)](https://www.cloudflare.com/learning/ssl/what-happens-in-a-tls-handshake/)
+- [SSL Test (via SSL Labs)](https://www.ssllabs.com/ssltest/)
+
+
+
+Screenshot
+
+
+
+###### Description
+
+This check takes a screenshot of webpage that the requested URL / IP resolves to, and displays it.
+
+###### Use Cases
+
+This may be useful to see what a given website looks like, free of the constraints of your browser, IP, or location.
+
+
+
+
+
+Read more here: **[web-check.xyz/about](https://web-check.xyz/about)**
+
+---
+
+## Usage
+
+### Deployment
+
+### Deploying - Option #1: Netlify
+
+Click the button below, to deploy to Netlify 👇
+
+[](https://app.netlify.com/start/deploy?repository=https://github.com/lissy93/web-check)
+
+### Deploying - Option #2: Vercel
+
+Click the button below, to deploy to Vercel 👇
+
+[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Flissy93%2Fweb-check&project-name=web-check&repository-name=web-check-fork&demo-title=Web-Check%20Demo&demo-description=Check%20out%20web-check.xyz%20to%20see%20a%20live%20demo%20of%20this%20application%20running.&demo-url=https%3A%2F%2Fweb-check.xyz&demo-image=https%3A%2F%2Fraw.githubusercontent.com%2FLissy93%2Fweb-check%2Fmaster%2F.github%2Fscreenshots%2Fweb-check-screenshot10.png)
+
+### Deploying - Option #3: Docker
+
+Run `docker run -p 3000:3000 lissy93/web-check`, then open [`localhost:3000`](http://localhost:3000)
+
+
+Docker Options
+
+You can get the Docker image from:
+
+- DockerHub: [`lissy93/web-check`](https://hub.docker.com/r/lissy93/web-check)
+- GHCR: [`ghcr.io/lissy93/web-check`](https://github.com/Lissy93/web-check/pkgs/container/web-check)
+- Or build the image yourself by cloning the repo and running `docker build -t web-check .`
+
+
+
+### Deploying - Option #4: From Source
+
+Install the prerequisites listed in the [Developing](#developing) section, then run:
+
+```bash
+git clone https://github.com/Lissy93/web-check.git # Download the code from GitHub
+cd web-check # Navigate into the project dir
+yarn install # Install the NPM dependencies
+yarn build # Build the app for production
+yarn serve # Start the app (API and GUI)
+```
+
+---
+
+### Configuring
+
+By default, no configuration is needed.
+
+But there are some optional environmental variables that you can set to give you access to some additional checks, or to increase rate-limits for some checks that use external APIs.
+
+**API Keys & Credentials**:
+
+| Key | Value |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `GOOGLE_CLOUD_API_KEY` | A Google API key ([get here](https://cloud.google.com/api-gateway/docs/authenticate-api-keys)). This can be used to return quality metrics for a site |
+| `REACT_APP_SHODAN_API_KEY` | A Shodan API key ([get here](https://account.shodan.io/)). This will show associated host names for a given domain |
+| `REACT_APP_WHO_API_KEY` | A WhoAPI key ([get here](https://whoapi.com/)). This will show more comprehensive WhoIs records than the default job |
+
+
+ Full / Upcoming Vals
+
+- `GOOGLE_CLOUD_API_KEY` - A Google API key ([get here](https://cloud.google.com/api-gateway/docs/authenticate-api-keys)). This can be used to return quality metrics for a site
+- `REACT_APP_SHODAN_API_KEY` - A Shodan API key ([get here](https://account.shodan.io/)). This will show associated host names for a given domain
+- `REACT_APP_WHO_API_KEY` - A WhoAPI key ([get here](https://whoapi.com/)). This will show more comprehensive WhoIs records than the default job
+- `SECURITY_TRAILS_API_KEY` - A Security Trails API key ([get here](https://securitytrails.com/corp/api)). This will show org info associated with the IP
+- `CLOUDMERSIVE_API_KEY` - API key for Cloudmersive ([get here](https://account.cloudmersive.com/)). This will show known threats associated with the IP
+- `TRANCO_USERNAME` - A Tranco email ([get here](https://tranco-list.eu/)). This will show the rank of a site, based on traffic
+- `TRANCO_API_KEY` - A Tranco API key ([get here](https://tranco-list.eu/)). This will show the rank of a site, based on traffic
+- `URL_SCAN_API_KEY` - A URLScan API key ([get here](https://urlscan.io/)). This will fetch miscalanious info about a site
+- `BUILT_WITH_API_KEY` - A BuiltWith API key ([get here](https://api.builtwith.com/)). This will show the main features of a site
+- `TORRENT_IP_API_KEY` - A torrent API key ([get here](https://iknowwhatyoudownload.com/en/api/)). This will show torrents downloaded by an IP
+
+
+
+**Configuration Settings**:
+
+| Key | Value |
+| -------------------------- | -------------------------------------------------------------------------- |
+| `PORT` | Port to serve the API, when running server.js (e.g. `3000`) |
+| `API_ENABLE_RATE_LIMIT` | Enable rate-limiting for the /api endpoints (e.g. `true`) |
+| `PUBLIC_API_TIMEOUT_LIMIT` | The timeout limit for API requests, in milliseconds (e.g. `25000`) |
+| `API_CORS_ORIGIN` | Enable CORS, by setting your allowed hostname(s) here (e.g. `example.com`) |
+| `CHROME_PATH` | The path the Chromium executable (e.g. `/usr/bin/chromium`) |
+| `DISABLE_GUI` | Disable the GUI, and only serve the API (e.g. `false`) |
+| `REACT_APP_API_ENDPOINT` | The endpoint for the API, either local or remote (e.g. `/api`) |
+
+All values are optional.
+
+You can add these as environmental variables. Either put them directly into an `.env` file in the projects root, or via the Netlify / Vercel UI, or by passing to the Docker container with the --env flag, or using your own environmental variable management system
+
+Note that keys that are prefixed with `REACT_APP_` are used client-side, and as such they must be scoped correctly with minimum privileges, since may be made visible when intercepting browser <-> server network requests
+
+---
+
+### Developing
+
+1. Clone the repo, `git clone git@github.com:Lissy93/web-check.git`
+2. Cd into it, `cd web-check`
+3. Install dependencies: `yarn`
+4. Start the dev server, with `yarn dev`
+
+You'll need [Node.js](https://nodejs.org/en) (V 18.16.1 or later) installed, plus [yarn](https://yarnpkg.com/getting-started/install) as well as [git](https://git-scm.com/).
+Some checks also require `chromium`, `traceroute` and `dns` to be installed within your environment. These jobs will just be skipped if those packages aren't present.
+
+---
+
+## Community
+
+### Contributing
+
+Contributions of any kind are very welcome, and would be much appreciated.
+For Code of Conduct, see [Contributor Convent](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
+
+To get started, fork the repo, make your changes, add, commit and push the code, then come back here to open a pull request. If you're new to GitHub or open source, [this guide](https://www.freecodecamp.org/news/how-to-make-your-first-pull-request-on-github-3#let-s-make-our-first-pull-request-) or the [git docs](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) may help you get started, but feel free to reach out if you need any support.
+
+[](https://github.com/Lissy93/web-check/compare)
+
+### Reporting Bugs
+
+If you've found something that doesn't work as it should, or would like to suggest a new feature, then go ahead and raise a ticket on GitHub.
+For bugs, please outline the steps needed to reproduce, and include relevant info like system info and resulting logs.
+
+[](https://github.com/Lissy93/web-check/issues/new/choose)
+
+### Supporting
+
+The app will remain 100% free and open source.
+But due to the amount of traffic that the hosted instance gets, the lambda function usage is costing about $25/month.
+Any help with covering the costs via GitHub Sponsorship would be much appreciated.
+It's thanks to the support of the community that this project is able to be freely available for everyone :)
+
+[](https://github.com/sponsors/Lissy93)
+
+### Contributors
+
+Credit to the following users for contributing to Web-Check
+
+[](https://github.com/lissy93/web-check/graphs/contributors)
+
+### Sponsors
+
+Huge thanks to these wonderful people, who sponsor me on GitHub, their support helps cover the costs required to keep Web-Check and my other projects free for everyone. Consider joining them, by [sponsoring me on GitHub](https://github.com/sponsors/Lissy93) if you're able.
+
+[](https://github.com/sponsors/lissy93)
+
+---
+
+## License
+
+> _**[Lissy93/Web-Check](https://github.com/Lissy93/web-check)** is licensed under [MIT](https://github.com/Lissy93/web-check/blob/HEAD/LICENSE) © [Alicia Sykes](https://aliciasykes.com) 2023 - 2026._
+> For information, see TLDR Legal > MIT
+
+
+Expand License
+
+```
+The MIT License (MIT)
+Copyright (c) Alicia Sykes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sub-license, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included install
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANT ABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+[](https://app.fossa.com/projects/git%2Bgithub.com%2FLissy93%2Fweb-check?ref=badge_large&issueType=license)
+
+
+
+
+
+ © Alicia Sykes 2026
+ Licensed under MIT
+
+ Thanks for visiting :)
+
+
+
+
diff --git a/.github/screenshots/README.md b/.github/screenshots/README.md
new file mode 100644
index 000000000..e3750c89e
--- /dev/null
+++ b/.github/screenshots/README.md
@@ -0,0 +1 @@
+
diff --git a/.github/screenshots/tiles/archives.png b/.github/screenshots/tiles/archives.png
new file mode 100644
index 000000000..4b240023e
Binary files /dev/null and b/.github/screenshots/tiles/archives.png differ
diff --git a/.github/screenshots/tiles/block-lists.png b/.github/screenshots/tiles/block-lists.png
new file mode 100644
index 000000000..d0cc6f1d6
Binary files /dev/null and b/.github/screenshots/tiles/block-lists.png differ
diff --git a/.github/screenshots/tiles/carbon.png b/.github/screenshots/tiles/carbon.png
new file mode 100644
index 000000000..3da869b83
Binary files /dev/null and b/.github/screenshots/tiles/carbon.png differ
diff --git a/.github/screenshots/tiles/cookies.png b/.github/screenshots/tiles/cookies.png
new file mode 100644
index 000000000..9060afba3
Binary files /dev/null and b/.github/screenshots/tiles/cookies.png differ
diff --git a/.github/screenshots/tiles/dns-server.png b/.github/screenshots/tiles/dns-server.png
new file mode 100644
index 000000000..80fb84cd3
Binary files /dev/null and b/.github/screenshots/tiles/dns-server.png differ
diff --git a/.github/screenshots/tiles/dns.png b/.github/screenshots/tiles/dns.png
new file mode 100644
index 000000000..b3a5aeb39
Binary files /dev/null and b/.github/screenshots/tiles/dns.png differ
diff --git a/.github/screenshots/tiles/dnssec.png b/.github/screenshots/tiles/dnssec.png
new file mode 100644
index 000000000..0594983d9
Binary files /dev/null and b/.github/screenshots/tiles/dnssec.png differ
diff --git a/.github/screenshots/tiles/domain.png b/.github/screenshots/tiles/domain.png
new file mode 100644
index 000000000..3a0fa0b95
Binary files /dev/null and b/.github/screenshots/tiles/domain.png differ
diff --git a/.github/screenshots/tiles/email-config.png b/.github/screenshots/tiles/email-config.png
new file mode 100644
index 000000000..8067500d6
Binary files /dev/null and b/.github/screenshots/tiles/email-config.png differ
diff --git a/.github/screenshots/tiles/features.png b/.github/screenshots/tiles/features.png
new file mode 100644
index 000000000..89f30f0ac
Binary files /dev/null and b/.github/screenshots/tiles/features.png differ
diff --git a/.github/screenshots/tiles/firewall.png b/.github/screenshots/tiles/firewall.png
new file mode 100644
index 000000000..c979d817b
Binary files /dev/null and b/.github/screenshots/tiles/firewall.png differ
diff --git a/.github/screenshots/tiles/headers.png b/.github/screenshots/tiles/headers.png
new file mode 100644
index 000000000..17cb50807
Binary files /dev/null and b/.github/screenshots/tiles/headers.png differ
diff --git a/.github/screenshots/tiles/hosts.png b/.github/screenshots/tiles/hosts.png
new file mode 100644
index 000000000..b3cc205aa
Binary files /dev/null and b/.github/screenshots/tiles/hosts.png differ
diff --git a/.github/screenshots/tiles/hsts.png b/.github/screenshots/tiles/hsts.png
new file mode 100644
index 000000000..158657750
Binary files /dev/null and b/.github/screenshots/tiles/hsts.png differ
diff --git a/.github/screenshots/tiles/http-security.png b/.github/screenshots/tiles/http-security.png
new file mode 100644
index 000000000..be4a25667
Binary files /dev/null and b/.github/screenshots/tiles/http-security.png differ
diff --git a/.github/screenshots/tiles/linked-pages.png b/.github/screenshots/tiles/linked-pages.png
new file mode 100644
index 000000000..f41fc3a85
Binary files /dev/null and b/.github/screenshots/tiles/linked-pages.png differ
diff --git a/.github/screenshots/tiles/location.png b/.github/screenshots/tiles/location.png
new file mode 100644
index 000000000..91c061fb9
Binary files /dev/null and b/.github/screenshots/tiles/location.png differ
diff --git a/.github/screenshots/tiles/ports.png b/.github/screenshots/tiles/ports.png
new file mode 100644
index 000000000..e93c5753f
Binary files /dev/null and b/.github/screenshots/tiles/ports.png differ
diff --git a/.github/screenshots/tiles/quality.png b/.github/screenshots/tiles/quality.png
new file mode 100644
index 000000000..14a765983
Binary files /dev/null and b/.github/screenshots/tiles/quality.png differ
diff --git a/.github/screenshots/tiles/ranking.png b/.github/screenshots/tiles/ranking.png
new file mode 100644
index 000000000..4134c804a
Binary files /dev/null and b/.github/screenshots/tiles/ranking.png differ
diff --git a/.github/screenshots/tiles/redirects.png b/.github/screenshots/tiles/redirects.png
new file mode 100644
index 000000000..1e691dbf1
Binary files /dev/null and b/.github/screenshots/tiles/redirects.png differ
diff --git a/.github/screenshots/tiles/robots.png b/.github/screenshots/tiles/robots.png
new file mode 100644
index 000000000..a93655e67
Binary files /dev/null and b/.github/screenshots/tiles/robots.png differ
diff --git a/.github/screenshots/tiles/screenshot.png b/.github/screenshots/tiles/screenshot.png
new file mode 100644
index 000000000..107c1910e
Binary files /dev/null and b/.github/screenshots/tiles/screenshot.png differ
diff --git a/.github/screenshots/tiles/security-txt.png b/.github/screenshots/tiles/security-txt.png
new file mode 100644
index 000000000..878b6d946
Binary files /dev/null and b/.github/screenshots/tiles/security-txt.png differ
diff --git a/.github/screenshots/tiles/server.png b/.github/screenshots/tiles/server.png
new file mode 100644
index 000000000..dffc2e629
Binary files /dev/null and b/.github/screenshots/tiles/server.png differ
diff --git a/.github/screenshots/tiles/sitemap.png b/.github/screenshots/tiles/sitemap.png
new file mode 100644
index 000000000..31507674c
Binary files /dev/null and b/.github/screenshots/tiles/sitemap.png differ
diff --git a/.github/screenshots/tiles/social-tags.png b/.github/screenshots/tiles/social-tags.png
new file mode 100644
index 000000000..235dc6fce
Binary files /dev/null and b/.github/screenshots/tiles/social-tags.png differ
diff --git a/.github/screenshots/tiles/ssl.png b/.github/screenshots/tiles/ssl.png
new file mode 100644
index 000000000..9cf7dd1b7
Binary files /dev/null and b/.github/screenshots/tiles/ssl.png differ
diff --git a/.github/screenshots/tiles/status.png b/.github/screenshots/tiles/status.png
new file mode 100644
index 000000000..add34bd67
Binary files /dev/null and b/.github/screenshots/tiles/status.png differ
diff --git a/.github/screenshots/tiles/tech-stack.png b/.github/screenshots/tiles/tech-stack.png
new file mode 100644
index 000000000..bf8cd9298
Binary files /dev/null and b/.github/screenshots/tiles/tech-stack.png differ
diff --git a/.github/screenshots/tiles/threats.png b/.github/screenshots/tiles/threats.png
new file mode 100644
index 000000000..9412dde19
Binary files /dev/null and b/.github/screenshots/tiles/threats.png differ
diff --git a/.github/screenshots/tiles/tls-cipher-suites.png b/.github/screenshots/tiles/tls-cipher-suites.png
new file mode 100644
index 000000000..208af83fd
Binary files /dev/null and b/.github/screenshots/tiles/tls-cipher-suites.png differ
diff --git a/.github/screenshots/tiles/tls-handshake-simulation.png b/.github/screenshots/tiles/tls-handshake-simulation.png
new file mode 100644
index 000000000..7f4c12f21
Binary files /dev/null and b/.github/screenshots/tiles/tls-handshake-simulation.png differ
diff --git a/.github/screenshots/tiles/tls-security-config.png b/.github/screenshots/tiles/tls-security-config.png
new file mode 100644
index 000000000..f2d765c98
Binary files /dev/null and b/.github/screenshots/tiles/tls-security-config.png differ
diff --git a/.github/screenshots/tiles/trace-route.png b/.github/screenshots/tiles/trace-route.png
new file mode 100644
index 000000000..290a74726
Binary files /dev/null and b/.github/screenshots/tiles/trace-route.png differ
diff --git a/.github/screenshots/tiles/txt-records.png b/.github/screenshots/tiles/txt-records.png
new file mode 100644
index 000000000..72b2a5b79
Binary files /dev/null and b/.github/screenshots/tiles/txt-records.png differ
diff --git a/.github/screenshots/web-check-screenshot1.png b/.github/screenshots/web-check-screenshot1.png
new file mode 100644
index 000000000..90ab5d6fb
Binary files /dev/null and b/.github/screenshots/web-check-screenshot1.png differ
diff --git a/.github/screenshots/web-check-screenshot10.png b/.github/screenshots/web-check-screenshot10.png
new file mode 100644
index 000000000..a6de1f3d7
Binary files /dev/null and b/.github/screenshots/web-check-screenshot10.png differ
diff --git a/.github/screenshots/web-check-screenshot2.png b/.github/screenshots/web-check-screenshot2.png
new file mode 100644
index 000000000..f8c47b762
Binary files /dev/null and b/.github/screenshots/web-check-screenshot2.png differ
diff --git a/.github/screenshots/web-check-screenshot3.png b/.github/screenshots/web-check-screenshot3.png
new file mode 100644
index 000000000..ea6c895a9
Binary files /dev/null and b/.github/screenshots/web-check-screenshot3.png differ
diff --git a/.github/screenshots/web-check-screenshot4.png b/.github/screenshots/web-check-screenshot4.png
new file mode 100644
index 000000000..8bb3313a1
Binary files /dev/null and b/.github/screenshots/web-check-screenshot4.png differ
diff --git a/.github/screenshots/web-check-screenshot5.png b/.github/screenshots/web-check-screenshot5.png
new file mode 100644
index 000000000..092977b59
Binary files /dev/null and b/.github/screenshots/web-check-screenshot5.png differ
diff --git a/.github/screenshots/web-check-screenshot6.png b/.github/screenshots/web-check-screenshot6.png
new file mode 100644
index 000000000..e853c9578
Binary files /dev/null and b/.github/screenshots/web-check-screenshot6.png differ
diff --git a/.github/screenshots/web-check-screenshot7.png b/.github/screenshots/web-check-screenshot7.png
new file mode 100644
index 000000000..9f0e8e4dd
Binary files /dev/null and b/.github/screenshots/web-check-screenshot7.png differ
diff --git a/.github/screenshots/web-check-screenshot8.png b/.github/screenshots/web-check-screenshot8.png
new file mode 100644
index 000000000..f2fa63d4f
Binary files /dev/null and b/.github/screenshots/web-check-screenshot8.png differ
diff --git a/.github/screenshots/web-check-screenshot9.png b/.github/screenshots/web-check-screenshot9.png
new file mode 100644
index 000000000..cfbe8e745
Binary files /dev/null and b/.github/screenshots/web-check-screenshot9.png differ
diff --git a/.github/web-check-logo.png b/.github/web-check-logo.png
new file mode 100644
index 000000000..5b1384764
Binary files /dev/null and b/.github/web-check-logo.png differ
diff --git a/.github/workflows/bump-and-tag.yml b/.github/workflows/bump-and-tag.yml
new file mode 100644
index 000000000..bb2c7287d
--- /dev/null
+++ b/.github/workflows/bump-and-tag.yml
@@ -0,0 +1,325 @@
+# Creates a new git tag when a PR is merged, or on manual dispatch
+#
+# PR trigger flow:
+# - Triggered whenever a PR is merged, if that PR made code changes
+# - If version wasn't bumped in PR, increment patch version and update package.json
+# - Otherwise (if the PR did bump version) we use the new version from package.json
+# - Creates and pushes a git tag for the new version
+# - That git tag then triggers Docker publishing and AWS deployment in other CI
+# - Add tags to issues from newly released features/fixes (if applicable)
+# - Finally, shows summary of actions taken and new tag published
+#
+# Manual dispatch flow:
+# - If a version is provided, sets package.json to that version
+# - If no version is provided, increments patch version automatically
+# - Creates and pushes a git tag for the new version
+name: 🔖 Auto Version & Tag
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (e.g. 2.1.0). Leave blank to auto-increment patch.'
+ required: false
+ type: string
+ pull_request_target:
+ types: [closed]
+ branches: [master]
+
+concurrency:
+ group: auto-version-and-tag
+ cancel-in-progress: false
+
+permissions:
+ contents: write
+ pull-requests: read
+ issues: write
+
+env:
+ IS_MANUAL: ${{ github.event_name == 'workflow_dispatch' }}
+
+jobs:
+ version-and-tag:
+ if: >-
+ github.event_name == 'workflow_dispatch'
+ || github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
+ - name: Validate manual version input 🔢
+ if: env.IS_MANUAL == 'true' && inputs.version != ''
+ env:
+ INPUT_VERSION: ${{ inputs.version }}
+ run: |
+ if ! echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ echo "::error::Invalid version '${INPUT_VERSION}'. Must be semver (e.g. 2.1.0)."
+ exit 1
+ fi
+
+ - name: Check PR for code changes and version bump 📂
+ id: check_pr
+ if: env.IS_MANUAL != 'true'
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const pull_number = context.payload.pull_request.number;
+
+ const files = await github.paginate(
+ github.rest.pulls.listFiles, { owner, repo, pull_number }
+ );
+ const codePatterns = [
+ /^src\//, /^api\//, /^public\//, /^Dockerfile$/, /^[^/]+\.(js|mjs)$/,
+ ];
+ const codeChanged = files.some(f =>
+ codePatterns.some(p => p.test(f.filename))
+ );
+ const pkgChanged = files.some(f => f.filename === 'package.json');
+
+ if (!codeChanged && !pkgChanged) {
+ core.info('No code or package.json changes, skipping');
+ core.setOutput('needs_bump', 'false');
+ core.setOutput('needs_tag', 'false');
+ return;
+ }
+
+ let versionBumped = false;
+ if (pkgChanged) {
+ const mergeSha = context.payload.pull_request.merge_commit_sha;
+ const { data: mergeCommit } = await github.rest.git.getCommit({
+ owner, repo, commit_sha: mergeSha,
+ });
+ const parentSha = mergeCommit.parents[0].sha;
+ const getVersion = async (ref) => {
+ const { data } = await github.rest.repos.getContent({
+ owner, repo, path: 'package.json', ref,
+ });
+ return JSON.parse(Buffer.from(data.content, 'base64').toString()).version;
+ };
+ const [prevVersion, mergeVersion] = await Promise.all([
+ getVersion(parentSha), getVersion(mergeSha),
+ ]);
+ versionBumped = prevVersion !== mergeVersion;
+ core.info(`Version: ${prevVersion} → ${mergeVersion}`);
+ }
+
+ const needsBump = codeChanged && !versionBumped;
+ const needsTag = codeChanged || versionBumped;
+ core.info(`Needs bump: ${needsBump}, Needs tag: ${needsTag}`);
+ core.setOutput('needs_bump', needsBump.toString());
+ core.setOutput('needs_tag', needsTag.toString());
+
+ - name: Checkout repository 🛎️
+ if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true'
+ uses: actions/checkout@v6
+ with:
+ token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
+
+ - name: Configure git identity 👤
+ if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true'
+ run: |
+ git config user.name "Liss-Bot"
+ git config user.email "liss-bot@d0h.co"
+
+ - name: Extract referenced issues 🔍
+ id: issues
+ if: env.IS_MANUAL != 'true' && steps.check_pr.outputs.needs_tag == 'true'
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const body = context.payload.pull_request.body || '';
+ const prNumber = String(context.payload.pull_request.number);
+ const matches = body.match(/#(\d+)(?![a-fA-F0-9])/g);
+ if (!matches) {
+ core.info('No issue references found in PR body');
+ core.setOutput('numbers', '');
+ return;
+ }
+ const unique = [...new Set(matches.map(m => m.replace('#', '')))]
+ .filter(n => n !== prNumber && parseInt(n, 10) > 0);
+ if (unique.length === 0) {
+ core.info('No issue references after filtering');
+ core.setOutput('numbers', '');
+ return;
+ }
+ core.info(`Found issue references: ${unique.join(', ')}`);
+ core.setOutput('numbers', unique.join(','));
+
+ - name: Bump version ⬆️
+ if: >-
+ env.IS_MANUAL == 'true'
+ || steps.check_pr.outputs.needs_bump == 'true'
+ env:
+ INPUT_VERSION: ${{ inputs.version }}
+ run: |
+ if [ "$IS_MANUAL" = "true" ] && [ -n "$INPUT_VERSION" ]; then
+ npm version "$INPUT_VERSION" --no-git-tag-version --allow-same-version
+ else
+ npm version patch --no-git-tag-version
+ fi
+ git add package.json
+ git commit -m "🔖 Bump version to $(node -p "require('./package.json').version")"
+ git push
+
+ - name: Create and push tag 🏷️
+ id: tag
+ if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true'
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number || '' }}
+ PR_TITLE: ${{ github.event.pull_request.title || '' }}
+ PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }}
+ MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha || github.sha }}
+ ISSUES: ${{ steps.issues.outputs.numbers }}
+ run: |
+ VERSION=$(node -p "require('./package.json').version")
+ git fetch --tags --force
+ if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
+ echo "Tag $VERSION already exists, skipping"
+ exit 0
+ fi
+
+ {
+ printf 'Web-Check v%s 🚀\n\n' "$VERSION"
+ if [ -n "$PR_NUMBER" ]; then
+ printf 'PR: #%s - %s\n' "$PR_NUMBER" "$PR_TITLE"
+ else
+ printf 'Manual release by @%s\n' "$PR_AUTHOR"
+ fi
+ if [ -n "$ISSUES" ]; then
+ printf 'Resolves: %s\n' "$(echo "$ISSUES" | sed 's/,/, #/g; s/^/#/')"
+ fi
+ printf 'Author: @%s\n' "$PR_AUTHOR"
+ printf 'Commit: %s\n' "$MERGE_SHA"
+ } > tag-message.txt
+
+ git tag -a "$VERSION" -F tag-message.txt
+ git push origin "$VERSION"
+
+ - name: Label referenced issues 🛩️
+ id: label
+ if: >-
+ env.IS_MANUAL != 'true'
+ && steps.check_pr.outputs.needs_tag == 'true'
+ && steps.issues.outputs.numbers != ''
+ continue-on-error: true
+ uses: actions/github-script@v9
+ env:
+ ISSUES: ${{ steps.issues.outputs.numbers }}
+ with:
+ github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { owner, repo } = context.repo;
+ const { version } = JSON.parse(require('fs').readFileSync('package.json', 'utf8'));
+ const labelName = `🛩️ Released ${version}`;
+ const issues = process.env.ISSUES.split(',').filter(Boolean);
+
+ try {
+ await github.rest.issues.createLabel({
+ owner, repo,
+ name: labelName,
+ color: 'EDEDED',
+ description: `Included in release v${version}`,
+ });
+ core.info(`Created label: ${labelName}`);
+ } catch (e) {
+ if (e.status === 422) {
+ core.info(`Label already exists: ${labelName}`);
+ } else {
+ core.warning(`Failed to create label: ${e.message}`);
+ }
+ }
+
+ const prNumber = context.payload.pull_request.number;
+ const prAuthor = context.payload.pull_request.user?.login;
+ const creditAuthor = prAuthor && prAuthor.toLowerCase() !== 'lissy93';
+ const marker = `released-${version}`;
+ for (const num of issues) {
+ const issue_number = parseInt(num, 10);
+ try {
+ const [{ data: issue }, comments] = await Promise.all([
+ github.rest.issues.get({ owner, repo, issue_number }),
+ github.rest.issues.listComments({
+ owner, repo, issue_number, per_page: 100,
+ }),
+ ]);
+ const alreadyCommented = comments.data.some(
+ c => c.body?.includes(marker)
+ );
+ if (!alreadyCommented) {
+ const author = issue.user?.login;
+ const greeting = author ? `Hey @${author},` : 'Hey,';
+ const sixMonthsAgo = new Date();
+ sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
+ const isOld = new Date(issue.created_at) < sixMonthsAgo;
+ const byLine = creditAuthor ? ` by @${prAuthor}` : '';
+ const parts = [
+ greeting,
+ `this has now been implemented${byLine} in #${prNumber},`,
+ `and will be released shortly in ${version} 😇`,
+ ];
+ if (isOld) parts.push(`\n\nWe're sorry this one took so long 😔`);
+ parts.push(``);
+ const body = parts.join(' ');
+ await github.rest.issues.createComment({
+ owner, repo, issue_number, body,
+ });
+ }
+ await github.rest.issues.addLabels({
+ owner, repo, issue_number, labels: [labelName, '✅ Fixed'],
+ });
+ core.info(alreadyCommented
+ ? `Already commented on #${num}, labels applied`
+ : `Commented and labeled #${num}`);
+ } catch (e) {
+ core.warning(`Failed to process #${num}: ${e.message}`);
+ }
+ }
+
+ - name: Job summary 📋
+ if: always()
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number || '' }}
+ PR_TITLE: ${{ github.event.pull_request.title || '' }}
+ REPO_URL: ${{ github.server_url }}/${{ github.repository }}
+ NEEDS_BUMP: ${{ steps.check_pr.outputs.needs_bump }}
+ NEEDS_TAG: ${{ steps.check_pr.outputs.needs_tag }}
+ ISSUES: ${{ steps.issues.outputs.numbers }}
+ TAG_OUTCOME: ${{ steps.tag.outcome }}
+ LABEL_OUTCOME: ${{ steps.label.outcome }}
+ run: |
+ VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")
+
+ {
+ echo "## 🔖 Auto Version & Tag"
+ echo ""
+ echo "| Step | Result |"
+ echo "|------|--------|"
+
+ if [ "$IS_MANUAL" = "true" ]; then
+ echo "| Trigger | Manual dispatch |"
+ elif [ -n "$PR_NUMBER" ]; then
+ echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}) — ${PR_TITLE} |"
+ fi
+
+ if [ "$IS_MANUAL" = "true" ] || [ "$NEEDS_BUMP" = "true" ]; then
+ echo "| Version bump | ✅ \`${VERSION}\` |"
+ else
+ echo "| Version bump | ⏭️ Skipped |"
+ fi
+
+ if [ "$TAG_OUTCOME" = "success" ]; then
+ echo "| Tag | ✅ [\`${VERSION}\`](${REPO_URL}/releases/tag/${VERSION}) |"
+ elif [ "$IS_MANUAL" = "true" ] || [ "$NEEDS_TAG" = "true" ]; then
+ echo "| Tag | ❌ Failed |"
+ else
+ echo "| Tag | ⏭️ Skipped |"
+ fi
+
+ if [ -n "$ISSUES" ]; then
+ ISSUE_LINKS=$(echo "$ISSUES" | tr ',' '\n' | sed "s|.*|[#&](${REPO_URL}/issues/&)|" | paste -sd ' ' -)
+ if [ "$LABEL_OUTCOME" = "success" ]; then
+ echo "| Issues labeled | ✅ ${ISSUE_LINKS} |"
+ else
+ echo "| Issues labeled | ⚠️ ${ISSUE_LINKS} |"
+ fi
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..a2257b4f6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,169 @@
+name: 🚦 PR Quality Check
+
+on:
+ pull_request:
+ branches: ['master']
+ paths-ignore:
+ - '**.md'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ changes:
+ name: 🔎 Detect Changes
+ runs-on: ubuntu-latest
+ outputs:
+ lockfile: ${{ steps.check.outputs.lockfile }}
+ steps:
+ - name: 🔎 Check for lockfile changes
+ id: check
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ CHANGED=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \
+ --paginate -q '.[].filename' | grep -c '^yarn\.lock$' || true)
+ echo "lockfile=$( [ "$CHANGED" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
+
+ lint:
+ name: 📝 Lint Code
+ runs-on: ubuntu-latest
+ steps:
+ - name: 🛎️ Checkout Code
+ uses: actions/checkout@v6
+
+ - name: 🔧 Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: 📦 Install Dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: 🔍 Run ESLint
+ run: yarn lint
+
+ typecheck:
+ name: 🧷 Type Check
+ runs-on: ubuntu-latest
+ steps:
+ - name: 🛎️ Checkout Code
+ uses: actions/checkout@v6
+
+ - name: 🔧 Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: 📦 Install Dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: 🧷 Run Astro Check
+ run: yarn typecheck
+
+ format:
+ name: 🎨 Format Check
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: 🛎️ Checkout Code
+ uses: actions/checkout@v6
+
+ - name: 🔧 Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: 📦 Install Dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: 🎨 Run Prettier
+ run: yarn format:check
+
+ build:
+ name: 🏗️ Build Application
+ runs-on: ubuntu-latest
+ steps:
+ - name: 🛎️ Checkout Code
+ uses: actions/checkout@v6
+
+ - name: 🔧 Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: 📦 Install Dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: 🏗️ Build Project
+ run: yarn build
+
+ - name: ✅ Verify Build Output
+ run: |
+ if [ ! -d "dist/client" ]; then
+ echo "❌ Build failed: dist/client directory not created"
+ exit 1
+ fi
+ if [ ! -f "dist/server/entry.mjs" ]; then
+ echo "❌ Build failed: SSR entry not found"
+ exit 1
+ fi
+ echo "✅ Build successful"
+
+ docker-smoke:
+ name: 🐳 Docker Smoke Test
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: 🛎️ Checkout Code
+ uses: actions/checkout@v6
+
+ - name: 🐳 Build Docker Image
+ run: docker build -t web-check:test .
+ timeout-minutes: 10
+
+ - name: 🧪 Verify Container Starts
+ run: |
+ docker run -d --name wc-test -p 3000:3000 web-check:test
+ sleep 5
+ STATUS=$(docker inspect -f '{{.State.Running}}' wc-test)
+ if [ "$STATUS" != "true" ]; then
+ echo "❌ Container failed to start"
+ docker logs wc-test
+ exit 1
+ fi
+ HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/check || true)
+ if [ "$HTTP_CODE" != "200" ]; then
+ echo "❌ Health check failed (HTTP $HTTP_CODE)"
+ docker logs wc-test
+ exit 1
+ fi
+ echo "✅ Container running and responding"
+ docker stop wc-test && docker rm wc-test
+
+ # security:
+ # name: 🔒 Security Audit
+ # runs-on: ubuntu-latest
+ # needs: changes
+ # if: needs.changes.outputs.lockfile == 'true'
+ # continue-on-error: true
+ # steps:
+ # - name: 🛎️ Checkout Code
+ # uses: actions/checkout@v6
+
+ # - name: 🔧 Setup Node.js
+ # uses: actions/setup-node@v6
+ # with:
+ # node-version: '22'
+ # cache: 'yarn'
+
+ # - name: 📦 Install Dependencies
+ # run: yarn install --frozen-lockfile
+
+ # - name: 🔒 Run Security Audit
+ # run: yarn audit --level high
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 000000000..48997e779
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,86 @@
+name: 🐳 Build + Publish Docker Image
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - master
+ tags:
+ - '*'
+ paths:
+ - src/**
+ - api/**
+ - public/**
+ - Dockerfile
+
+permissions:
+ contents: read
+ packages: write
+
+env:
+ IMAGE_NAME: web-check
+ DOCKER_USER: lissy93
+ GHCR_REGISTRY: ghcr.io
+ DOCKERHUB_REGISTRY: docker.io
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout 🛎️
+ uses: actions/checkout@v6
+
+ - name: Extract tag name 🏷️
+ shell: bash
+ run: echo "GIT_TAG=$(echo ${GITHUB_REF#refs/tags/} | sed 's/\//_/g')" >> $GITHUB_ENV
+
+ - name: Compute tags 🔖
+ id: compute-tags
+ run: |
+ if [[ "${{ github.ref }}" == "refs/heads/master" ]]; then
+ echo "GHCR_TAG=${GHCR_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}:latest" >> $GITHUB_ENV
+ echo "DOCKERHUB_TAG=${DOCKERHUB_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}:latest" >> $GITHUB_ENV
+ else
+ echo "GHCR_TAG=${GHCR_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}:${GIT_TAG}" >> $GITHUB_ENV
+ echo "DOCKERHUB_TAG=${DOCKERHUB_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}:${GIT_TAG}" >> $GITHUB_ENV
+ fi
+
+ - name: Set up QEMU 🐧
+ uses: docker/setup-qemu-action@v4
+
+ - name: Set up Docker Buildx 🐳
+ uses: docker/setup-buildx-action@v4
+
+ - name: Extract Docker metadata 🏷️
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ env.GHCR_REGISTRY }}/${{ env.DOCKER_USER }}/${{ env.IMAGE_NAME }}
+ ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKER_USER }}/${{ env.IMAGE_NAME }}
+
+ - name: Login to GitHub Container Registry 🔑
+ uses: docker/login-action@v4
+ with:
+ registry: ${{ env.GHCR_REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to DockerHub 🔑
+ uses: docker/login-action@v4
+ with:
+ registry: ${{ env.DOCKERHUB_REGISTRY }}
+ username: ${{ env.DOCKER_USER }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+
+ - name: Build and push Docker images 🛠️
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ platforms: linux/amd64,linux/arm64/v8
+ labels: ${{ steps.meta.outputs.labels }}
+ tags: |
+ ${{ env.GHCR_TAG }}
+ ${{ env.DOCKERHUB_TAG }}
diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml
new file mode 100644
index 000000000..a6106e302
--- /dev/null
+++ b/.github/workflows/mirror.yml
@@ -0,0 +1,19 @@
+# Syncs the full source of the Web-Check repo over to our Codeberg mirror
+# For all you non-Microsoft babes!
+# This is then accessible over at https://codeberg.org/alicia/web-check
+name: 🪞 Mirror to Codeberg
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '30 3 * * 0' # At 03:30 on Sunday
+jobs:
+ codeberg:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: lissy93/repo-mirror-action@v1.5.0
+ with:
+ ssh_key: ${{ secrets.CODEBERG_SSH }}
+ host: git@codeberg.org
+ user: alicia
+ repo: web-check
+ force_push: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..184bc582e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,148 @@
+# Publishes GH release for new versions, with compiled app attached
+# - Triggered when a git tag matching X.Y.0 (major or minor) is pushed
+# - Or if manually dispatched with any valid and existing tag specified
+# - Check out the source at the requested tag
+# - Run a clean production build (yarn build → dist/)
+# - Bundle dist/ + server.js + package.json + yarn.lock into tar.gz and zip
+# - Create a DRAFT GH release with auto-gen changelog and bundled artifacts attached
+
+name: 🚀 Release
+
+on:
+ push:
+ tags:
+ - '*.*.0'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Existing git tag to release (e.g. 2.1.0)'
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-${{ inputs.tag || github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: 🚀 Build & Publish Release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Resolve tag 🏷️
+ id: tag
+ env:
+ INPUT_TAG: ${{ inputs.tag }}
+ EVENT_NAME: ${{ github.event_name }}
+ REF: ${{ github.ref }}
+ run: |
+ if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
+ TAG="$INPUT_TAG"
+ else
+ TAG="${REF#refs/tags/}"
+ fi
+ if ! echo "$TAG" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
+ echo "::error::Invalid tag '$TAG'. Expected semver (e.g. 2.1.0)."
+ exit 1
+ fi
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+ echo "Releasing tag: $TAG"
+
+ - name: Checkout source at tag 🛎️
+ uses: actions/checkout@v6
+ with:
+ ref: refs/tags/${{ steps.tag.outputs.tag }}
+ fetch-depth: 0
+
+ - name: Setup Node.js 🔧
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: Install dependencies 📦
+ run: yarn install --frozen-lockfile
+
+ - name: Build for production 🏗️
+ run: yarn build
+
+ - name: Verify build output ✅
+ run: |
+ if [ ! -d "dist/client" ]; then
+ echo "::error::Build failed: dist/client directory not created"
+ exit 1
+ fi
+ if [ ! -f "dist/server/entry.mjs" ]; then
+ echo "::error::Build failed: SSR entry not found"
+ exit 1
+ fi
+ echo "✅ Build successful"
+
+ - name: Package release artifacts 🗜️
+ id: package
+ env:
+ TAG: ${{ steps.tag.outputs.tag }}
+ run: |
+ STAGING="web-check-${TAG}"
+ rm -rf "$STAGING"
+ mkdir -p "$STAGING"
+ cp -r dist api public "$STAGING/"
+ cp server.js package.json yarn.lock "$STAGING/"
+ [ -f LICENSE ] && cp LICENSE "$STAGING/" || true
+ [ -f README.md ] && cp README.md "$STAGING/" || true
+
+ TARBALL="${STAGING}.tar.gz"
+ ZIPFILE="${STAGING}.zip"
+ tar -czf "$TARBALL" "$STAGING"
+ zip -qr "$ZIPFILE" "$STAGING"
+
+ ls -lh "$TARBALL" "$ZIPFILE"
+ echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
+ echo "zipfile=$ZIPFILE" >> "$GITHUB_OUTPUT"
+
+ - name: Create draft release 🚀
+ id: release
+ uses: softprops/action-gh-release@v3
+ with:
+ tag_name: ${{ steps.tag.outputs.tag }}
+ name: Web-Check v${{ steps.tag.outputs.tag }}
+ draft: true
+ prerelease: false
+ generate_release_notes: true
+ fail_on_unmatched_files: true
+ files: |
+ ${{ steps.package.outputs.tarball }}
+ ${{ steps.package.outputs.zipfile }}
+ token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
+
+ - name: Job summary 📋
+ if: always()
+ env:
+ TAG: ${{ steps.tag.outputs.tag }}
+ REPO_URL: ${{ github.server_url }}/${{ github.repository }}
+ RELEASE_URL: ${{ steps.release.outputs.url }}
+ RELEASE_OUTCOME: ${{ steps.release.outcome }}
+ run: |
+ {
+ echo "## 🚀 Release"
+ echo ""
+ echo "| Step | Result |"
+ echo "|------|--------|"
+ if [ -n "$TAG" ]; then
+ echo "| Tag | [\`${TAG}\`](${REPO_URL}/releases/tag/${TAG}) |"
+ else
+ echo "| Tag | ❌ Could not resolve |"
+ fi
+ if [ "$RELEASE_OUTCOME" = "success" ]; then
+ if [ -n "$RELEASE_URL" ]; then
+ echo "| Draft release | ✅ [View draft](${RELEASE_URL}) |"
+ else
+ echo "| Draft release | ✅ [View releases](${REPO_URL}/releases) |"
+ fi
+ echo "| Artifacts | \`web-check-${TAG}.tar.gz\`, \`web-check-${TAG}.zip\` |"
+ else
+ echo "| Draft release | ❌ Failed (outcome: ${RELEASE_OUTCOME:-unknown}) |"
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index 4d29575de..6e01eacd5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,23 +1,63 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+# ------------------------
+# ENVIRONMENT SETTINGS
+# ------------------------
+.env
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
+# ------------------------
+# PRODUCTION
+# ------------------------
+/build/
-# testing
-/coverage
+# ------------------------
+# BUILT FILES
+# ------------------------
+dist/
+.vercel/
+.netlify/
+.webpack/
+.serverless/
+.astro/
-# production
-/build
-
-# misc
-.DS_Store
-.env.local
-.env.development.local
-.env.test.local
-.env.production.local
+# ------------------------
+# DEPENDENCIES
+# ------------------------
+node_modules/
+.yarn/cache/
+.yarn/unplugged/
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnpm/
+.pnp.*
+# ------------------------
+# LOGS
+# ------------------------
+logs/
+*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# ------------------------
+# TESTING
+# ------------------------
+coverage/
+.nyc_output/
+
+# ------------------------
+# OS SPECIFIC
+# ------------------------
+.DS_Store
+Thumbs.db
+
+# ------------------------
+# EDITORS
+# ------------------------
+.idea/
+.vscode/
+*.swp
+*.swo
+
+.vercel
diff --git a/.yarnrc b/.yarnrc
new file mode 100644
index 000000000..93303a220
--- /dev/null
+++ b/.yarnrc
@@ -0,0 +1,4 @@
+# engines.node is pinned to 20.x so @astrojs/vercel@7.x emits a supported
+# Vercel runtime for the SSR _render function. Local devs on a different
+# Node version can still run yarn install via this flag.
+--install.ignore-engines true
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..4e9c525c3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,71 @@
+# Specify the Node.js version to use
+ARG NODE_VERSION=22
+
+# Specify the Debian version to use, the default is "bullseye"
+ARG DEBIAN_VERSION=bullseye
+
+# Use Node.js Docker image as the base image, with specific Node and Debian versions
+FROM node:${NODE_VERSION}-${DEBIAN_VERSION} AS build
+
+# Set the container's default shell to Bash and enable some options
+SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
+
+# Install Chromium browser and Download and verify Google Chrome's signing key
+RUN apt-get update -qq --fix-missing && \
+ apt-get -qqy install --allow-unauthenticated gnupg wget && \
+ wget --quiet --output-document=- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /etc/apt/trusted.gpg.d/google-archive.gpg && \
+ echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list && \
+ apt-get update -qq && \
+ apt-get -qqy --no-install-recommends install chromium traceroute python make g++ && \
+ rm -rf /var/lib/apt/lists/*
+
+# Run the Chromium browser's version command and redirect its output to the /etc/chromium-version file
+RUN /usr/bin/chromium --no-sandbox --version > /etc/chromium-version
+
+# Set the working directory to /app
+WORKDIR /app
+
+# Copy package.json and yarn.lock to the working directory
+COPY package.json yarn.lock ./
+
+# Run yarn install to install dependencies and clear yarn cache
+RUN apt-get update && \
+ yarn install --frozen-lockfile --network-timeout 100000 && \
+ rm -rf /app/node_modules/.cache
+
+# Copy all files to working directory
+COPY . .
+
+# Run yarn build to build the application
+RUN yarn build --production
+
+# Final stage
+FROM node:${NODE_VERSION}-${DEBIAN_VERSION} AS final
+
+WORKDIR /app
+
+COPY package.json yarn.lock ./
+COPY --from=build /app .
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends chromium traceroute && \
+ chmod 755 /usr/bin/chromium && \
+ rm -rf /var/lib/apt/lists/* /app/node_modules/.cache
+
+# Exposed container port, the default is 3000, which can be modified through the environment variable PORT
+EXPOSE ${PORT:-3000}
+
+# Point Chromium-using libs at the system binary, skip puppeteer's bundled download
+ENV CHROME_PATH='/usr/bin/chromium' \
+ PUPPETEER_EXECUTABLE_PATH='/usr/bin/chromium' \
+ PUPPETEER_SKIP_DOWNLOAD='true'
+
+LABEL org.opencontainers.image.title="Web-Check" \
+ org.opencontainers.image.description="All-in-one OSINT tool for analysing any website" \
+ org.opencontainers.image.url="https://web-check.xyz" \
+ org.opencontainers.image.source="https://github.com/lissy93/web-check" \
+ org.opencontainers.image.licenses="MIT" \
+ org.opencontainers.image.vendor="Alicia Sykes"
+
+# Define the command executed when the container starts and start the server.js of the Node.js application
+CMD ["yarn", "start"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..bee7ddc03
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Alicia Sykes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
deleted file mode 100644
index b87cb0044..000000000
--- a/README.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Getting Started with Create React App
-
-This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
-
-## Available Scripts
-
-In the project directory, you can run:
-
-### `npm start`
-
-Runs the app in the development mode.\
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
-
-The page will reload if you make edits.\
-You will also see any lint errors in the console.
-
-### `npm test`
-
-Launches the test runner in the interactive watch mode.\
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
-
-### `npm run build`
-
-Builds the app for production to the `build` folder.\
-It correctly bundles React in production mode and optimizes the build for the best performance.
-
-The build is minified and the filenames include the hashes.\
-Your app is ready to be deployed!
-
-See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
-
-### `npm run eject`
-
-**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
-
-If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
-
-Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
-
-You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
-
-## Learn More
-
-You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
-
-To learn React, check out the [React documentation](https://reactjs.org/).
diff --git a/api/_common/aws-webpack.config.js b/api/_common/aws-webpack.config.js
new file mode 100644
index 000000000..7f6fe93ab
--- /dev/null
+++ b/api/_common/aws-webpack.config.js
@@ -0,0 +1,50 @@
+const path = require('path');
+const nodeExternals = require('webpack-node-externals');
+
+module.exports = {
+ target: 'node',
+ mode: 'production',
+ entry: {
+ carbon: './api/carbon.js',
+ cookies: './api/cookies.js',
+ 'dns-server': './api/dns-server.js',
+ dns: './api/dns.js',
+ dnssec: './api/dnssec.js',
+ 'get-ip': './api/get-ip.js',
+ headers: './api/headers.js',
+ hsts: './api/hsts.js',
+ 'linked-pages': './api/linked-pages.js',
+ 'mail-config': './api/mail-config.js',
+ ports: './api/ports.js',
+ quality: './api/quality.js',
+ redirects: './api/redirects.js',
+ 'robots-txt': './api/robots-txt.js',
+ screenshot: './api/screenshot.js',
+ 'security-txt': './api/security-txt.js',
+ sitemap: './api/sitemap.js',
+ 'social-tags': './api/social-tags.js',
+ ssl: './api/ssl.js',
+ status: './api/status.js',
+ 'tech-stack': './api/tech-stack.js',
+ 'trace-route': './api/trace-route.js',
+ 'txt-records': './api/txt-records.js',
+ whois: './api/whois.js',
+ },
+ externals: [nodeExternals()],
+ output: {
+ filename: '[name].js',
+ path: path.resolve(__dirname, '.webpack'),
+ libraryTarget: 'commonjs2',
+ },
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ use: {
+ loader: 'babel-loader',
+ },
+ exclude: /node_modules/,
+ },
+ ],
+ },
+};
diff --git a/api/_common/http.js b/api/_common/http.js
new file mode 100644
index 000000000..795792c65
--- /dev/null
+++ b/api/_common/http.js
@@ -0,0 +1,115 @@
+// Thin fetch wrapper matching the axios shape used across the api: opts.params,
+// opts.headers, opts.auth, opts.timeout, opts.validateStatus; returns
+// { data, status, statusText, headers }; throws errors with response/code
+
+const DEFAULT_TIMEOUT = 60000;
+
+const buildAuth = (auth) => {
+ if (!auth?.username) return null;
+ return 'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
+};
+
+const appendParams = (url, params) => {
+ if (!params) return url;
+ const u = new URL(url);
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
+ return u.href;
+};
+
+const headersToObject = (headers) => {
+ const out = {};
+ for (const [k, v] of headers.entries()) {
+ const key = k.toLowerCase();
+ if (key === 'set-cookie') {
+ out[key] = headers.getSetCookie ? headers.getSetCookie() : v.split(/, (?=[^;]+=)/);
+ } else {
+ out[key] = v;
+ }
+ }
+ return out;
+};
+
+// Auto-parse JSON when the response advertises it, fall back to raw text
+const parseBody = async (response) => {
+ const ct = (response.headers.get('content-type') || '').toLowerCase();
+ const text = await response.text();
+ if (!text) return ct.includes('json') ? null : '';
+ if (ct.includes('json')) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+ }
+ return text;
+};
+
+const isOk = (status, validate) => (validate ? validate(status) : status >= 200 && status < 300);
+
+const wrapNetworkError = (error) => {
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') {
+ const e = new Error(error.message || 'Request timed out');
+ e.code = 'ECONNABORTED';
+ return e;
+ }
+ const code = error.cause?.code;
+ if (code) {
+ const e = new Error(error.message);
+ e.code = code;
+ return e;
+ }
+ return error;
+};
+
+export const UA =
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/120.0.0.0 Safari/537.36 (compatible; web-check/1.0; +https://web-check.xyz)';
+
+const send = async (method, url, body, opts = {}) => {
+ const finalUrl = appendParams(url, opts.params);
+ const headers = { 'user-agent': UA, ...opts.headers };
+ const authHeader = buildAuth(opts.auth);
+ if (authHeader) headers.authorization = authHeader;
+
+ const init = {
+ method,
+ headers,
+ signal: AbortSignal.timeout(opts.timeout || DEFAULT_TIMEOUT),
+ };
+
+ if (body !== undefined && body !== null) {
+ if (typeof body === 'object') {
+ init.body = JSON.stringify(body);
+ const hasCt = Object.keys(headers).some((k) => k.toLowerCase() === 'content-type');
+ if (!hasCt) init.headers['content-type'] = 'application/json';
+ } else {
+ init.body = body;
+ }
+ }
+
+ let response;
+ try {
+ response = await fetch(finalUrl, init);
+ } catch (error) {
+ throw wrapNetworkError(error);
+ }
+
+ const data = await parseBody(response);
+ const result = {
+ data,
+ status: response.status,
+ statusText: response.statusText,
+ headers: headersToObject(response.headers),
+ };
+
+ if (!isOk(response.status, opts.validateStatus)) {
+ const err = new Error(`Request failed with status code ${response.status}`);
+ err.response = result;
+ throw err;
+ }
+
+ return result;
+};
+
+export const httpGet = (url, opts) => send('GET', url, null, opts);
+export const httpPost = (url, body, opts) => send('POST', url, body, opts);
diff --git a/api/_common/logger.js b/api/_common/logger.js
new file mode 100644
index 000000000..0420a61bf
--- /dev/null
+++ b/api/_common/logger.js
@@ -0,0 +1,26 @@
+const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
+const THRESHOLD = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info;
+
+const fmt = (level, scope, msg, extra) => {
+ const ts = new Date().toISOString();
+ const tag = scope ? `[${scope}] ` : '';
+ const body = typeof msg === 'string' ? msg : JSON.stringify(msg);
+ const tail =
+ extra === undefined ? '' : ` ${typeof extra === 'string' ? extra : JSON.stringify(extra)}`;
+ return `${ts} ${level.toUpperCase().padEnd(5)} ${tag}${body}${tail}`;
+};
+
+const write = (level, stream, scope, msg, extra) => {
+ if (LEVELS[level] < THRESHOLD) return;
+ stream.write(fmt(level, scope, msg, extra) + '\n');
+};
+
+// Logger scoped to a route name, honours LOG_LEVEL env
+export const createLogger = (scope) => ({
+ debug: (msg, extra) => write('debug', process.stdout, scope, msg, extra),
+ info: (msg, extra) => write('info', process.stdout, scope, msg, extra),
+ warn: (msg, extra) => write('warn', process.stderr, scope, msg, extra),
+ error: (msg, extra) => write('error', process.stderr, scope, msg, extra),
+});
+
+export default createLogger;
diff --git a/api/_common/middleware.js b/api/_common/middleware.js
new file mode 100644
index 000000000..e96cb30d5
--- /dev/null
+++ b/api/_common/middleware.js
@@ -0,0 +1,123 @@
+import { bracketIPv6 } from './parse-target.js';
+
+const normalizeUrl = (url) => {
+ const withScheme = url.startsWith('http') ? url : `https://${url}`;
+ return bracketIPv6(withScheme);
+};
+
+const TIMEOUT = parseInt(process.env.PUBLIC_API_TIMEOUT_LIMIT || '40000', 10);
+
+// If present, set CORS allowed origins for responses
+const ALLOWED_ORIGINS = process.env.API_CORS_ORIGIN || '*';
+
+// Disable everything :( Setting this env var will turn off the instance, and show message
+const DISABLE_EVERYTHING = !!process.env.VITE_DISABLE_EVERYTHING;
+
+// Set the platform currently being used
+let PLATFORM = 'NETLIFY';
+if (process.env.PLATFORM) {
+ PLATFORM = process.env.PLATFORM.toUpperCase();
+} else if (process.env.VERCEL) {
+ PLATFORM = 'VERCEL';
+} else if (process.env.WC_SERVER) {
+ PLATFORM = 'NODE';
+}
+
+// Define the headers to be returned with each response
+const headers = {
+ 'Access-Control-Allow-Origin': ALLOWED_ORIGINS,
+ 'Access-Control-Allow-Credentials': true,
+ 'Content-Type': 'application/json;charset=UTF-8',
+};
+
+const timeoutErrorMsg =
+ 'You can re-trigger this request, by clicking "Retry"\n' +
+ "If you're running your own instance of Web Check, then you can " +
+ 'resolve this issue, by increasing the timeout limit in the ' +
+ '`PUBLIC_API_TIMEOUT_LIMIT` environmental variable to a higher value (in milliseconds), ' +
+ "or if you're hosting on Vercel increase the maxDuration in vercel.json.\n\n" +
+ `The public instance currently has a lower timeout of ${TIMEOUT}ms ` +
+ 'in order to keep running costs affordable, so that Web Check can ' +
+ 'remain freely available for everyone.';
+
+const disabledMsg =
+ 'WebCheck is temporarily disabled.\n\n' +
+ 'Due to the increased cost of running Web Check, the public instance has been ' +
+ 'paused while we look for affordable ways to keep it free for everyone.\n' +
+ 'Since the code is free and open source, you can run your own instance by ' +
+ 'following the instructions in the GitHub repo.';
+
+// A middleware function used by all API routes on all platforms
+const commonMiddleware = (handler) => {
+ // Create a timeout promise, to throw an error if a request takes too long
+ const createTimeoutPromise = (timeoutMs) => {
+ return new Promise((_, reject) => {
+ setTimeout(() => {
+ reject(new Error(`Request timed-out after ${timeoutMs} ms`));
+ }, timeoutMs);
+ });
+ };
+
+ // Vercel
+ const vercelHandler = async (request, response) => {
+ if (DISABLE_EVERYTHING) {
+ return response.status(200).json({ skipped: disabledMsg });
+ }
+
+ const queryParams = request.query || {};
+ const rawUrl = queryParams.url;
+
+ if (!rawUrl) {
+ return response.status(500).json({ error: 'No URL specified' });
+ }
+
+ const url = normalizeUrl(rawUrl);
+
+ try {
+ const result = await Promise.race([handler(url, request), createTimeoutPromise(TIMEOUT)]);
+ response.status(200).json(typeof result === 'object' ? result : JSON.parse(result));
+ } catch (error) {
+ const isTimeout = error.message.includes('timed-out') || response.statusCode === 504;
+ const message = isTimeout ? `${error.message}\n\n${timeoutErrorMsg}` : error.message;
+ response.status(isTimeout ? 408 : 500).json({ error: message });
+ }
+ };
+
+ // Netlify
+ const netlifyHandler = async (event, context) => {
+ const queryParams = event.queryStringParameters || event.query || {};
+ const rawUrl = queryParams.url;
+
+ if (DISABLE_EVERYTHING) {
+ return { statusCode: 200, body: JSON.stringify({ skipped: disabledMsg }), headers };
+ }
+ if (!rawUrl) {
+ return { statusCode: 500, body: JSON.stringify({ error: 'No URL specified' }), headers };
+ }
+
+ const url = normalizeUrl(rawUrl);
+ try {
+ const result = await Promise.race([
+ handler(url, event, context),
+ createTimeoutPromise(TIMEOUT),
+ ]);
+ return {
+ statusCode: 200,
+ body: typeof result === 'object' ? JSON.stringify(result) : result,
+ headers,
+ };
+ } catch (error) {
+ return { statusCode: 500, body: JSON.stringify({ error: error.message }), headers };
+ }
+ };
+
+ // The format of the handlers varies between platforms
+ const nativeMode = ['VERCEL', 'NODE'].includes(PLATFORM);
+ return nativeMode ? vercelHandler : netlifyHandler;
+};
+
+if (PLATFORM === 'NETLIFY') {
+ module.exports = commonMiddleware;
+}
+
+export default commonMiddleware;
diff --git a/api/_common/parse-target.js b/api/_common/parse-target.js
new file mode 100644
index 000000000..42183ab42
--- /dev/null
+++ b/api/_common/parse-target.js
@@ -0,0 +1,30 @@
+// Wrap bare IPv6 in brackets for URL parsing (2+ colons = IPv6)
+export const bracketIPv6 = (str) => {
+ const bare = str.replace(/^https?:\/\//i, '');
+ const host = bare.split('/')[0];
+ if (!host.startsWith('[') && (host.match(/:/g) || []).length >= 2)
+ return str.replace(host, `[${host}]`);
+ return str;
+};
+
+// Normalise a user-supplied target, stripping :port for DNS lookups
+export const parseTarget = (input) => {
+ if (!input) throw new Error('No target provided');
+ let normalised = /^https?:\/\//i.test(input) ? input : `https://${input}`;
+ normalised = bracketIPv6(normalised);
+ let u;
+ try {
+ u = new URL(normalised);
+ } catch {
+ throw new Error(`Invalid URL: ${input}`);
+ }
+ return {
+ hostname: u.hostname.replace(/^\[|]$/g, ''),
+ port: u.port || null,
+ protocol: u.protocol,
+ pathname: u.pathname || '/',
+ href: u.href,
+ };
+};
+
+export default parseTarget;
diff --git a/api/_common/upstream.js b/api/_common/upstream.js
new file mode 100644
index 000000000..60245fbee
--- /dev/null
+++ b/api/_common/upstream.js
@@ -0,0 +1,18 @@
+// Map axios/network errors to our shared envelope shape
+export const upstreamError = (error, context = 'Lookup') => {
+ const status = error.response?.status;
+ if (status === 404 || status === 410) return { skipped: `No ${context} data for this host` };
+ if (status === 401 || status === 403) return { error: `${context} blocked (HTTP ${status})` };
+ if (status === 429) return { error: `${context} rate-limited by upstream` };
+ if (status && status >= 500) return { error: `${context} upstream is unavailable` };
+ if (error.code === 'ECONNABORTED') return { error: `${context} timed out` };
+ if (error.code === 'ENOTFOUND') return { skipped: 'Host could not be resolved' };
+ if (error.code === 'ECONNREFUSED') return { error: 'Connection refused by upstream' };
+ return { error: `${context} failed: ${error.message}` };
+};
+
+// Read a required env var, or return a skipped envelope if missing
+export const requireEnv = (envVar, service) => {
+ const v = process.env[envVar];
+ return v ? { value: v } : { skipped: `${service} requires ${envVar} to be set` };
+};
diff --git a/api/archives.js b/api/archives.js
new file mode 100644
index 000000000..830760e06
--- /dev/null
+++ b/api/archives.js
@@ -0,0 +1,84 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+
+const convertTimestampToDate = (timestamp) => {
+ const [year, month, day, hour, minute, second] = [
+ timestamp.slice(0, 4),
+ timestamp.slice(4, 6) - 1,
+ timestamp.slice(6, 8),
+ timestamp.slice(8, 10),
+ timestamp.slice(10, 12),
+ timestamp.slice(12, 14),
+ ].map((num) => parseInt(num, 10));
+
+ return new Date(year, month, day, hour, minute, second);
+};
+
+const countPageChanges = (results) => {
+ let prevDigest = null;
+ return results.reduce((acc, curr) => {
+ if (curr[2] !== prevDigest) {
+ prevDigest = curr[2];
+ return acc + 1;
+ }
+ return acc;
+ }, -1);
+};
+
+const getAveragePageSize = (scans) => {
+ const totalSize = scans.map((scan) => parseInt(scan[3], 10)).reduce((sum, size) => sum + size, 0);
+ return Math.round(totalSize / scans.length);
+};
+
+const getScanFrequency = (firstScan, lastScan, totalScans, changeCount) => {
+ const formatToTwoDecimal = (num) => parseFloat(num.toFixed(2));
+
+ const dayFactor = (lastScan - firstScan) / (1000 * 60 * 60 * 24);
+ const daysBetweenScans = formatToTwoDecimal(dayFactor / totalScans);
+ const daysBetweenChanges = formatToTwoDecimal(dayFactor / changeCount);
+ const scansPerDay = formatToTwoDecimal((totalScans - 1) / dayFactor);
+ const changesPerDay = formatToTwoDecimal(changeCount / dayFactor);
+ return {
+ daysBetweenScans,
+ daysBetweenChanges,
+ scansPerDay,
+ changesPerDay,
+ };
+};
+
+const wayBackHandler = async (url) => {
+ // collapse=timestamp:8 returns one row per archived day, slashing payloads
+ // (Wikipedia: 25MB/373k rows -> 428KB/6k rows) without losing first/last/change counts
+ const cdxUrl =
+ `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(url)}` +
+ `&output=json&fl=timestamp,statuscode,digest,length&collapse=timestamp:8`;
+
+ try {
+ const { data } = await httpGet(cdxUrl);
+
+ if (!data || !Array.isArray(data) || data.length <= 1) {
+ return { skipped: 'Site has never before been archived via the Wayback Machine' };
+ }
+
+ data.shift();
+
+ const firstScan = convertTimestampToDate(data[0][0]);
+ const lastScan = convertTimestampToDate(data[data.length - 1][0]);
+ const daysArchived = data.length;
+ const changeCount = countPageChanges(data);
+ return {
+ firstScan,
+ lastScan,
+ daysArchived,
+ changeCount,
+ averagePageSize: getAveragePageSize(data),
+ scanFrequency: getScanFrequency(firstScan, lastScan, daysArchived, changeCount),
+ scanUrl: url,
+ };
+ } catch (err) {
+ return { error: `Error fetching Wayback data: ${err.message}` };
+ }
+};
+
+export const handler = middleware(wayBackHandler);
+export default handler;
diff --git a/api/block-lists.js b/api/block-lists.js
new file mode 100644
index 000000000..f34c0250b
--- /dev/null
+++ b/api/block-lists.js
@@ -0,0 +1,62 @@
+import dns from 'dns';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const DNS_SERVERS = [
+ { name: 'AdGuard', ip: '176.103.130.130' },
+ { name: 'AdGuard Family', ip: '176.103.130.132' },
+ { name: 'CleanBrowsing Adult', ip: '185.228.168.10' },
+ { name: 'CleanBrowsing Family', ip: '185.228.168.168' },
+ { name: 'CleanBrowsing Security', ip: '185.228.168.9' },
+ { name: 'CloudFlare', ip: '1.1.1.1' },
+ { name: 'CloudFlare Family', ip: '1.1.1.3' },
+ { name: 'Comodo Secure', ip: '8.26.56.26' },
+ { name: 'Google DNS', ip: '8.8.8.8' },
+ { name: 'OpenDNS', ip: '208.67.222.222' },
+ { name: 'OpenDNS Family', ip: '208.67.222.123' },
+ { name: 'Quad9', ip: '9.9.9.9' },
+];
+
+// Sink IPs used by blocking DNS servers
+const SINK_IPS = new Set(['0.0.0.0', '127.0.0.1', '::1', '::', '0:0:0:0:0:0:0:0']);
+
+// Resolve a domain via a specific DNS server
+const queryServer = (domain, serverIp) =>
+ new Promise((resolve) => {
+ const resolver = new dns.Resolver({ timeout: 3000, tries: 1 });
+ resolver.setServers([serverIp]);
+ resolver.resolve4(domain, (err, addrs) => {
+ if (err) return resolve({ err: err.code });
+ resolve({ addrs });
+ });
+ });
+
+// A domain is blocked if the resolver returns a sink IP or refuses
+// to resolve a domain that a neutral resolver can resolve
+const isBlocked = (result, refResolved) => {
+ if (!refResolved) return false;
+ if (result.err === 'NXDOMAIN' || result.err === 'SERVFAIL') return true;
+ if (!result.addrs) return false;
+ return result.addrs.every((ip) => SINK_IPS.has(ip));
+};
+
+const blockListHandler = async (url) => {
+ const { hostname: domain } = parseTarget(url);
+ const ref = await queryServer(domain, '8.8.8.8');
+ const refResolved = !!ref.addrs?.length;
+
+ const results = await Promise.all(
+ DNS_SERVERS.map(async ({ name, ip }) => {
+ const result = await queryServer(domain, ip);
+ return {
+ server: name,
+ serverIp: ip,
+ isBlocked: isBlocked(result, refResolved),
+ };
+ }),
+ );
+ return { blocklists: results };
+};
+
+export const handler = middleware(blockListHandler);
+export default handler;
diff --git a/api/carbon.js b/api/carbon.js
new file mode 100644
index 000000000..97531c4de
--- /dev/null
+++ b/api/carbon.js
@@ -0,0 +1,91 @@
+import middleware from './_common/middleware.js';
+import { UA } from './_common/http.js';
+import { createLogger } from './_common/logger.js';
+
+const log = createLogger('carbon');
+
+const TIMEOUT = 8000;
+const MAX_BYTES = 10 * 1024 * 1024;
+
+// Sustainable Web Design model v4 constants, matches websitecarbon.com formula
+const KWH_PER_GB = 0.3;
+const FIRST_VISIT = 0.25;
+const RETURN_VISIT = 0.75;
+const RETURN_DATA_PCT = 0.02;
+const GRID_INTENSITY = 494;
+const RENEWABLE_INTENSITY = 50;
+const LITRES_PER_GRAM = 0.5562;
+
+// Median CO2 for an HTML-only fetch at HTTP Archive's ~30 KB median
+const REFERENCE_MEDIAN_GRAMS = 0.001;
+
+// Percentile rank via log2 distance from the median, clamped to [1, 95]
+const estimateCleanerThan = (grams) => {
+ if (!grams || grams <= 0) return 0;
+ const pct = 50 - 15 * Math.log2(grams / REFERENCE_MEDIAN_GRAMS);
+ return Math.max(1, Math.min(95, Math.round(pct)));
+};
+
+// Stream the response, cap at MAX_BYTES so huge pages can't blow memory or time
+const fetchByteCount = async (url) => {
+ const r = await fetch(url, {
+ signal: AbortSignal.timeout(TIMEOUT),
+ redirect: 'follow',
+ headers: { 'user-agent': UA, accept: 'text/html,*/*;q=0.1' },
+ });
+ if (!r.ok) throw new Error(`status ${r.status}`);
+ if (!r.body) return 0;
+ const reader = r.body.getReader();
+ let total = 0;
+ while (total < MAX_BYTES) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ total += value.length;
+ }
+ reader.cancel().catch(() => {});
+ return total;
+};
+
+// SWD-based stats matching websitecarbon /data response shape
+const computeCarbon = (bytes) => {
+ const adjustedBytes = bytes * (FIRST_VISIT + RETURN_VISIT * RETURN_DATA_PCT);
+ const energy = (adjustedBytes / 1073741824) * KWH_PER_GB;
+ const gridGrams = energy * GRID_INTENSITY;
+ const renewableGrams = energy * RENEWABLE_INTENSITY;
+ return {
+ adjustedBytes,
+ energy,
+ co2: {
+ grid: { grams: gridGrams, litres: gridGrams * LITRES_PER_GRAM },
+ renewable: {
+ grams: renewableGrams,
+ litres: renewableGrams * LITRES_PER_GRAM,
+ },
+ },
+ };
+};
+
+// Fetch site, count bytes, compute SWD carbon stats locally, no third-party API
+const carbonHandler = async (url) => {
+ let bytes;
+ try {
+ bytes = await fetchByteCount(url);
+ } catch (error) {
+ log.warn(`fetch failed for ${url}`, error.message);
+ return { error: `Failed to fetch site: ${error.message}` };
+ }
+ if (!bytes) return { skipped: 'Site returned no content, cannot calculate carbon' };
+ log.debug(`measured ${bytes} bytes for ${url}`);
+ const statistics = computeCarbon(bytes);
+ return {
+ url,
+ bytes,
+ green: false,
+ statistics,
+ cleanerThan: estimateCleanerThan(statistics.co2.grid.grams),
+ scanUrl: url,
+ };
+};
+
+export const handler = middleware(carbonHandler);
+export default handler;
diff --git a/api/cookies.js b/api/cookies.js
new file mode 100644
index 000000000..bc6093211
--- /dev/null
+++ b/api/cookies.js
@@ -0,0 +1,51 @@
+import puppeteer from 'puppeteer';
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+
+const getPuppeteerCookies = async (url) => {
+ const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
+ });
+
+ try {
+ const page = await browser.newPage();
+ const navigationPromise = page.goto(url, { waitUntil: 'networkidle2' });
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Puppeteer took too long!')), 3000),
+ );
+ await Promise.race([navigationPromise, timeoutPromise]);
+ return await browser.cookies();
+ } finally {
+ await browser.close();
+ }
+};
+
+const cookieHandler = async (url) => {
+ let headerCookies = null;
+ let clientCookies = null;
+
+ try {
+ const response = await httpGet(url);
+ headerCookies = response.headers['set-cookie'];
+ } catch (error) {
+ if (error.response) {
+ return { error: `Request failed with status ${error.response.status}: ${error.message}` };
+ }
+ return { error: `No response received: ${error.message}` };
+ }
+
+ try {
+ clientCookies = await getPuppeteerCookies(url);
+ } catch (_) {
+ clientCookies = null;
+ }
+
+ return {
+ headerCookies: headerCookies || [],
+ clientCookies: clientCookies || [],
+ };
+};
+
+export const handler = middleware(cookieHandler);
+export default handler;
diff --git a/api/dns-server.js b/api/dns-server.js
new file mode 100644
index 000000000..d5a27d845
--- /dev/null
+++ b/api/dns-server.js
@@ -0,0 +1,33 @@
+import { promises as dnsPromises } from 'dns';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+import { upstreamError } from './_common/upstream.js';
+
+// Resolve a nameserver hostname to its IP addresses
+const resolveNs = async (ns) => {
+ try {
+ return (await dnsPromises.resolve4(ns))[0];
+ } catch {
+ return null;
+ }
+};
+
+const dnsHandler = async (url) => {
+ const { hostname: domain } = parseTarget(url);
+ let nameservers;
+ try {
+ nameservers = await dnsPromises.resolveNs(domain);
+ } catch (error) {
+ return upstreamError(error, `DNS server lookup for ${domain}`);
+ }
+ const results = await Promise.all(
+ nameservers.map(async (ns) => {
+ const ip = await resolveNs(ns);
+ return { address: ip, hostname: ns };
+ }),
+ );
+ return { domain, dns: results };
+};
+
+export const handler = middleware(dnsHandler);
+export default handler;
diff --git a/api/dns.js b/api/dns.js
new file mode 100644
index 000000000..307853eea
--- /dev/null
+++ b/api/dns.js
@@ -0,0 +1,23 @@
+import dns from 'dns/promises';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const dnsHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ const safe = (fn) => fn.catch(() => []);
+ const [a, aaaa, mx, txt, ns, cname, soa, srv, ptr] = await Promise.all([
+ safe(dns.resolve4(hostname)),
+ safe(dns.resolve6(hostname)),
+ safe(dns.resolveMx(hostname)),
+ safe(dns.resolveTxt(hostname)),
+ safe(dns.resolveNs(hostname)),
+ safe(dns.resolveCname(hostname)),
+ dns.resolveSoa(hostname).catch(() => null),
+ safe(dns.resolveSrv(hostname)),
+ safe(dns.resolvePtr(hostname)),
+ ]);
+ return { A: a, AAAA: aaaa, MX: mx, TXT: txt, NS: ns, CNAME: cname, SOA: soa, SRV: srv, PTR: ptr };
+};
+
+export const handler = middleware(dnsHandler);
+export default handler;
diff --git a/api/dnssec.js b/api/dnssec.js
new file mode 100644
index 000000000..066479901
--- /dev/null
+++ b/api/dnssec.js
@@ -0,0 +1,37 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+
+// Query Google's public DNS JSON API for a given record type
+const queryDns = async (domain, type) => {
+ const res = await httpGet('https://dns.google/resolve', {
+ params: { name: domain, type },
+ headers: { Accept: 'application/dns-json' },
+ });
+ return res.data;
+};
+
+const dnsSecHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ const [dnskey, ds, aRecord] = await Promise.all([
+ queryDns(hostname, 'DNSKEY'),
+ queryDns(hostname, 'DS'),
+ queryDns(hostname, 'A'),
+ ]);
+ return {
+ DNSKEY: dnskey.Answer
+ ? { isFound: true, answer: dnskey.Answer, response: dnskey.Answer }
+ : { isFound: false, answer: null, response: dnskey },
+ DS: ds.Answer
+ ? { isFound: true, answer: ds.Answer, response: ds.Answer }
+ : { isFound: false, answer: null, response: ds },
+ RRSIG: {
+ isFound: !!aRecord.AD,
+ answer: null,
+ response: aRecord,
+ },
+ };
+};
+
+export const handler = middleware(dnsSecHandler);
+export default handler;
diff --git a/api/firewall.js b/api/firewall.js
new file mode 100644
index 000000000..497c595b4
--- /dev/null
+++ b/api/firewall.js
@@ -0,0 +1,112 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+import { upstreamError } from './_common/upstream.js';
+
+const hasWaf = (waf) => ({ hasWaf: true, waf });
+
+const firewallHandler = async (url) => {
+ const { href } = parseTarget(url);
+ try {
+ const response = await httpGet(href, {
+ validateStatus: () => true,
+ });
+ const headers = response.headers;
+
+ if (headers['server'] && headers['server'].includes('cloudflare')) {
+ return hasWaf('Cloudflare');
+ }
+
+ if (headers['x-powered-by'] && headers['x-powered-by'].includes('AWS Lambda')) {
+ return hasWaf('AWS WAF');
+ }
+
+ if (headers['server'] && headers['server'].includes('AkamaiGHost')) {
+ return hasWaf('Akamai');
+ }
+
+ if (headers['server'] && headers['server'].includes('Sucuri')) {
+ return hasWaf('Sucuri');
+ }
+
+ if (headers['server'] && headers['server'].includes('BarracudaWAF')) {
+ return hasWaf('Barracuda WAF');
+ }
+
+ if (
+ headers['server'] &&
+ (headers['server'].includes('F5 BIG-IP') || headers['server'].includes('BIG-IP'))
+ ) {
+ return hasWaf('F5 BIG-IP');
+ }
+
+ if (headers['x-sucuri-id'] || headers['x-sucuri-cache']) {
+ return hasWaf('Sucuri CloudProxy WAF');
+ }
+
+ if (headers['server'] && headers['server'].includes('FortiWeb')) {
+ return hasWaf('Fortinet FortiWeb WAF');
+ }
+
+ if (headers['server'] && headers['server'].includes('Imperva')) {
+ return hasWaf('Imperva SecureSphere WAF');
+ }
+
+ if (headers['x-protected-by'] && headers['x-protected-by'].includes('Sqreen')) {
+ return hasWaf('Sqreen');
+ }
+
+ if (headers['x-waf-event-info']) {
+ return hasWaf('Reblaze WAF');
+ }
+
+ if (headers['set-cookie'] && headers['set-cookie'].includes('_citrix_ns_id')) {
+ return hasWaf('Citrix NetScaler');
+ }
+
+ if (headers['x-denied-reason'] || headers['x-wzws-requested-method']) {
+ return hasWaf('WangZhanBao WAF');
+ }
+
+ if (headers['x-webcoment']) {
+ return hasWaf('Webcoment Firewall');
+ }
+
+ if (headers['server'] && headers['server'].includes('Yundun')) {
+ return hasWaf('Yundun WAF');
+ }
+
+ if (headers['x-yd-waf-info'] || headers['x-yd-info']) {
+ return hasWaf('Yundun WAF');
+ }
+
+ if (headers['server'] && headers['server'].includes('Safe3WAF')) {
+ return hasWaf('Safe3 Web Application Firewall');
+ }
+
+ if (headers['server'] && headers['server'].includes('NAXSI')) {
+ return hasWaf('NAXSI WAF');
+ }
+
+ if (headers['x-datapower-transactionid']) {
+ return hasWaf('IBM WebSphere DataPower');
+ }
+
+ if (headers['server'] && headers['server'].includes('QRATOR')) {
+ return hasWaf('QRATOR WAF');
+ }
+
+ if (headers['server'] && headers['server'].includes('ddos-guard')) {
+ return hasWaf('DDoS-Guard WAF');
+ }
+
+ return {
+ hasWaf: false,
+ };
+ } catch (error) {
+ return upstreamError(error, 'Firewall check');
+ }
+};
+
+export const handler = middleware(firewallHandler);
+export default handler;
diff --git a/api/get-ip.js b/api/get-ip.js
new file mode 100644
index 000000000..87c13ce73
--- /dev/null
+++ b/api/get-ip.js
@@ -0,0 +1,19 @@
+import dns from 'dns';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const lookupAsync = (address) =>
+ new Promise((resolve, reject) => {
+ dns.lookup(address, (err, ip, family) => {
+ if (err) reject(err);
+ else resolve({ ip, family });
+ });
+ });
+
+const ipHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ return await lookupAsync(hostname);
+};
+
+export const handler = middleware(ipHandler);
+export default handler;
diff --git a/api/headers.js b/api/headers.js
new file mode 100644
index 000000000..c84be1970
--- /dev/null
+++ b/api/headers.js
@@ -0,0 +1,17 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { upstreamError } from './_common/upstream.js';
+
+const headersHandler = async (url) => {
+ try {
+ const response = await httpGet(url, {
+ validateStatus: (status) => status >= 200 && status < 600,
+ });
+ return response.headers;
+ } catch (error) {
+ return upstreamError(error, 'Headers fetch');
+ }
+};
+
+export const handler = middleware(headersHandler);
+export default handler;
diff --git a/api/hsts.js b/api/hsts.js
new file mode 100644
index 000000000..a0c307ba8
--- /dev/null
+++ b/api/hsts.js
@@ -0,0 +1,36 @@
+import https from 'https';
+import middleware from './_common/middleware.js';
+
+const MIN_MAX_AGE = 10886400;
+
+const verdict = (message, compatible = false, hstsHeader = null) => ({
+ message,
+ compatible,
+ hstsHeader,
+});
+
+const evaluate = (header) => {
+ if (!header) return verdict('Site does not serve any HSTS headers.');
+ const lower = header.toLowerCase();
+ const maxAge = parseInt(lower.match(/max-age=(\d+)/)?.[1] || '0', 10);
+ if (maxAge < MIN_MAX_AGE)
+ return verdict(`HSTS max-age is ${maxAge}, below the ${MIN_MAX_AGE} minimum.`, false, header);
+ if (!lower.includes('includesubdomains'))
+ return verdict('HSTS header does not include all subdomains.', false, header);
+ if (!lower.includes('preload'))
+ return verdict('HSTS header does not contain the preload directive.', false, header);
+ return verdict('Site is compatible with the HSTS preload list!', true, header);
+};
+
+const hstsHandler = async (url) =>
+ new Promise((resolve) => {
+ const req = https.request(url, (res) => {
+ resolve(evaluate(res.headers['strict-transport-security']));
+ res.resume();
+ });
+ req.on('error', (e) => resolve({ error: `HSTS check failed: ${e.message}` }));
+ req.end();
+ });
+
+export const handler = middleware(hstsHandler);
+export default handler;
diff --git a/api/http-security.js b/api/http-security.js
new file mode 100644
index 000000000..d86686055
--- /dev/null
+++ b/api/http-security.js
@@ -0,0 +1,30 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+
+// Security headers to check, mapped to response field names
+const HEADERS = {
+ 'content-security-policy': 'contentSecurityPolicy',
+ 'strict-transport-security': 'strictTransportPolicy',
+ 'x-content-type-options': 'xContentTypeOptions',
+ 'x-frame-options': 'xFrameOptions',
+ 'x-xss-protection': 'xXSSProtection',
+ 'referrer-policy': 'referrerPolicy',
+ 'permissions-policy': 'permissionsPolicy',
+ 'cross-origin-opener-policy': 'crossOriginOpenerPolicy',
+ 'cross-origin-resource-policy': 'crossOriginResourcePolicy',
+ 'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',
+};
+
+const httpsSecHandler = async (url) => {
+ try {
+ const { headers } = await httpGet(url, {
+ validateStatus: () => true,
+ });
+ return Object.fromEntries(Object.entries(HEADERS).map(([h, key]) => [key, !!headers[h]]));
+ } catch (error) {
+ return { error: `Unable to fetch headers: ${error.message}` };
+ }
+};
+
+export const handler = middleware(httpsSecHandler);
+export default handler;
diff --git a/api/linked-pages.js b/api/linked-pages.js
new file mode 100644
index 000000000..dafa883c0
--- /dev/null
+++ b/api/linked-pages.js
@@ -0,0 +1,53 @@
+import * as cheerio from 'cheerio';
+import urlLib from 'url';
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { upstreamError } from './_common/upstream.js';
+
+const linkedPagesHandler = async (url) => {
+ let response;
+ try {
+ response = await httpGet(url);
+ } catch (error) {
+ return upstreamError(error, 'Linked pages fetch');
+ }
+ const html = response.data;
+ const $ = cheerio.load(html);
+ const internalLinksMap = new Map();
+ const externalLinksMap = new Map();
+
+ // Get all links on the page
+ $('a[href]').each((i, link) => {
+ const href = $(link).attr('href');
+ const absoluteUrl = urlLib.resolve(url, href);
+
+ // Check if absolute / relative, append to appropriate map or increment occurrence count
+ if (absoluteUrl.startsWith(url)) {
+ const count = internalLinksMap.get(absoluteUrl) || 0;
+ internalLinksMap.set(absoluteUrl, count + 1);
+ } else if (href.startsWith('http://') || href.startsWith('https://')) {
+ const count = externalLinksMap.get(absoluteUrl) || 0;
+ externalLinksMap.set(absoluteUrl, count + 1);
+ }
+ });
+
+ // Sort by most occurrences, remove supplicates, and convert to array
+ const internalLinks = [...internalLinksMap.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map((entry) => entry[0]);
+ const externalLinks = [...externalLinksMap.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map((entry) => entry[0]);
+
+ if (!internalLinks.length && !externalLinks.length) {
+ return {
+ skipped:
+ 'No internal or external links found in the page HTML. ' +
+ 'This often happens with single-page apps that render content client-side.',
+ };
+ }
+ return { internal: internalLinks, external: externalLinks };
+};
+
+export const handler = middleware(linkedPagesHandler);
+export default handler;
diff --git a/api/location.js b/api/location.js
new file mode 100644
index 000000000..de4f58561
--- /dev/null
+++ b/api/location.js
@@ -0,0 +1,184 @@
+import { promises as dns } from 'dns';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+import { createLogger } from './_common/logger.js';
+
+const log = createLogger('location');
+const TIMEOUT = 4000;
+
+// Server-side fetch, no-cors so providers don't reject Sec-Fetch-Mode: cors
+const getJson = async (url, signal) => {
+ const r = await fetch(url, { mode: 'no-cors', signal });
+ if (!r.ok) throw new Error(`status ${r.status}`);
+ return r.json();
+};
+
+// Geo providers, each parser normalises to a shared field shape
+const providers = [
+ {
+ name: 'ipwho.is',
+ url: (ip) => `https://ipwho.is/${ip}`,
+ parse: (d) =>
+ d?.success === false
+ ? null
+ : {
+ ip: d.ip,
+ city: d.city,
+ region: d.region,
+ country_name: d.country,
+ country_code: d.country_code,
+ region_code: d.region_code,
+ postal: d.postal,
+ latitude: d.latitude,
+ longitude: d.longitude,
+ org: d.connection?.isp || d.connection?.org,
+ timezone: d.timezone?.id,
+ },
+ },
+ {
+ name: 'ip-api.com',
+ url: (ip) => `http://ip-api.com/json/${ip}`,
+ parse: (d) =>
+ d?.status === 'success'
+ ? {
+ ip: d.query,
+ city: d.city,
+ region: d.regionName,
+ country_name: d.country,
+ country_code: d.countryCode,
+ region_code: d.region,
+ postal: d.zip,
+ latitude: d.lat,
+ longitude: d.lon,
+ org: d.isp || d.org,
+ timezone: d.timezone,
+ }
+ : null,
+ },
+ {
+ name: 'geojs.io',
+ url: (ip) => `https://get.geojs.io/v1/ip/geo/${ip}.json`,
+ parse: (d) =>
+ d?.country_code
+ ? {
+ ip: d.ip,
+ city: d.city,
+ region: d.region,
+ country_name: d.country,
+ country_code: d.country_code,
+ latitude: d.latitude !== 'nil' ? parseFloat(d.latitude) : undefined,
+ longitude: d.longitude !== 'nil' ? parseFloat(d.longitude) : undefined,
+ org: d.organization_name,
+ timezone: d.timezone,
+ }
+ : null,
+ },
+ {
+ name: 'reallyfreegeoip.org',
+ url: (ip) => `https://reallyfreegeoip.org/json/${ip}`,
+ parse: (d) =>
+ d?.country_code
+ ? {
+ ip: d.ip,
+ city: d.city,
+ region: d.region_name,
+ country_name: d.country_name,
+ country_code: d.country_code,
+ region_code: d.region_code,
+ postal: d.zip_code,
+ latitude: d.latitude,
+ longitude: d.longitude,
+ timezone: d.time_zone,
+ }
+ : null,
+ },
+];
+
+// Query a single provider, throw unless it yields a usable result
+const tryProvider = async (p, ip, signal) => {
+ const parsed = p.parse(await getJson(p.url(ip), signal));
+ if (!parsed?.country_code) throw new Error('no usable data');
+ log.debug(`${p.name} resolved ${ip} to ${parsed.country_code}`);
+ return parsed;
+};
+
+// Race all providers, first successful result wins, abort the rest
+const lookupGeo = async (ip) => {
+ const ac = new AbortController();
+ const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(TIMEOUT)]);
+ const tasks = providers.map((p) =>
+ tryProvider(p, ip, signal).catch((e) => {
+ if (e.name !== 'AbortError') log.warn(`${p.name} failed for ${ip}`, e.message);
+ throw e;
+ }),
+ );
+ try {
+ const result = await Promise.any(tasks);
+ ac.abort();
+ return result;
+ } catch {
+ return null;
+ }
+};
+
+// Fetch country-level metadata to fill fields not provided by every geo source
+const enrichCountry = async (code) => {
+ if (!code) return {};
+ try {
+ const data = await getJson(
+ `https://restcountries.com/v3.1/alpha/${code}` +
+ '?fields=tld,languages,currencies,area,population',
+ );
+ const c = Array.isArray(data) ? data[0] : data;
+ if (!c) {
+ log.debug(`restcountries returned no entry for ${code}`);
+ return {};
+ }
+ const languages = c.languages ? Object.values(c.languages).join(', ') : undefined;
+ const currCode = c.currencies ? Object.keys(c.currencies)[0] : undefined;
+ const curr = currCode ? c.currencies[currCode] : null;
+ return {
+ country_tld: c.tld?.[0],
+ languages,
+ currency: currCode,
+ currency_name: curr?.name,
+ country_area: c.area,
+ country_population: c.population,
+ };
+ } catch (error) {
+ log.debug(`restcountries enrichment failed for ${code}`, error.message);
+ return {};
+ }
+};
+
+// Strip empty values so they don't shadow enrichment defaults during merge
+const compact = (o) =>
+ Object.fromEntries(
+ Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ''),
+ );
+
+// Resolve hostname to IP so providers requiring a numeric address still work
+const resolveHost = async (hostname) => {
+ try {
+ return (await dns.lookup(hostname)).address;
+ } catch (error) {
+ log.warn(`DNS lookup failed for ${hostname}, falling through with raw host`, error.message);
+ return hostname;
+ }
+};
+
+// Resolve geographic info for a host via a chain of providers with country enrichment
+const locationHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ const ip = await resolveHost(hostname);
+ const geo = await lookupGeo(ip);
+ if (!geo) {
+ log.error(`all geo providers failed for ${ip}`);
+ return { error: 'IP location lookup unavailable across all providers, please try again later' };
+ }
+ const enrichment = await enrichCountry(geo.country_code);
+ return { ...enrichment, ...compact(geo) };
+};
+
+export const handler = middleware(locationHandler);
+export default handler;
diff --git a/api/mail-config.js b/api/mail-config.js
new file mode 100644
index 000000000..71ac95ee7
--- /dev/null
+++ b/api/mail-config.js
@@ -0,0 +1,99 @@
+import dns from 'dns/promises';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+// Safely query TXT, returning [] on ENODATA/ENOTFOUND
+const safeTxt = (name) => dns.resolveTxt(name).catch(() => []);
+
+// Try common DKIM selectors to detect if DKIM is configured
+const DKIM_SELECTORS = [
+ 'default',
+ 'google',
+ 'selector1',
+ 'selector2',
+ 'k1',
+ 'k2',
+ 'k3',
+ 's1',
+ 's2',
+ 'dkim',
+ 'mail',
+];
+const findDkim = async (domain) => {
+ const checks = DKIM_SELECTORS.map((s) =>
+ safeTxt(`${s}._domainkey.${domain}`).then((r) => {
+ if (!r.length) return null;
+ const txt = r[0].join('');
+ // Skip revoked keys (empty p= value)
+ if (/p=\s*(;|$)/.test(txt)) return null;
+ return { selector: s, record: r[0] };
+ }),
+ );
+ const results = await Promise.all(checks);
+ return results.filter(Boolean);
+};
+
+// Detect mail provider from MX exchange hostnames
+const MX_PROVIDERS = [
+ [/google(mail)?\.com$/i, 'Google Workspace'],
+ [/outlook\.com$|microsoft\.com$/i, 'Microsoft 365'],
+ [/protonmail\.ch$|protonme\.ch$/i, 'ProtonMail'],
+ [/zoho\.(com|eu|in)$/i, 'Zoho Mail'],
+ [/yahoodns\.net$/i, 'Yahoo Mail'],
+ [/mimecast\.com$/i, 'Mimecast'],
+ [/pphosted\.com$/i, 'Proofpoint'],
+ [/messagelabs\.com$/i, 'Broadcom Email Security'],
+ [/iphmx\.com$/i, 'Cisco Email Security'],
+ [/mailgun\.org$/i, 'Mailgun'],
+ [/sendgrid\.net$/i, 'SendGrid'],
+ [/fireeyecloud\.com$/i, 'Trellix Email Security'],
+ [/barracudanetworks\.com$/i, 'Barracuda'],
+];
+
+const detectProviders = (mxRecords) => {
+ const seen = new Set();
+ return mxRecords.reduce((out, { exchange }) => {
+ const match = MX_PROVIDERS.find(([re]) => re.test(exchange));
+ if (match && !seen.has(match[1])) {
+ seen.add(match[1]);
+ out.push({ provider: match[1], value: exchange });
+ }
+ return out;
+ }, []);
+};
+
+const mailConfigHandler = async (url) => {
+ const { hostname: domain } = parseTarget(url);
+ try {
+ const [mxRecords, rootTxt, dmarcTxt, bimiTxt, dkimResults] = await Promise.all([
+ dns.resolveMx(domain),
+ safeTxt(domain),
+ safeTxt(`_dmarc.${domain}`),
+ safeTxt(`default._bimi.${domain}`),
+ findDkim(domain),
+ ]);
+
+ // Collect email-relevant TXT records
+ const emailTxt = rootTxt.filter((r) => {
+ const s = r.join('').toLowerCase();
+ return s.startsWith('v=spf1');
+ });
+ dmarcTxt.forEach((r) => emailTxt.push(r));
+ bimiTxt.forEach((r) => emailTxt.push(r));
+ dkimResults.forEach(({ record }) => emailTxt.push(record));
+
+ return {
+ mxRecords,
+ txtRecords: emailTxt,
+ mailServices: detectProviders(mxRecords),
+ };
+ } catch (error) {
+ if (error.code === 'ENOTFOUND' || error.code === 'ENODATA') {
+ return { skipped: 'No mail server in use on this domain' };
+ }
+ return { error: `Mail config lookup failed: ${error.message}` };
+ }
+};
+
+export const handler = middleware(mailConfigHandler);
+export default handler;
diff --git a/api/ports.js b/api/ports.js
new file mode 100644
index 000000000..6345ccb4c
--- /dev/null
+++ b/api/ports.js
@@ -0,0 +1,99 @@
+import net from 'net';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+// A list of commonly used ports.
+const DEFAULT_PORTS_TO_CHECK = [
+ 20, 21, 22, 23, 25, 53, 80, 67, 68, 69, 110, 119, 123, 143, 156, 161, 162, 179, 194, 389, 443,
+ 587, 993, 995, 3000, 3306, 3389, 5060, 5900, 8000, 8080, 8888,
+];
+/*
+ * Checks if the env PORTS_TO_CHECK is set, if so the string is split via "," to get an array of ports to check.
+ * If the env is not set, return the default commonly used ports.
+ */
+const PORTS = process.env.PORTS_TO_CHECK
+ ? process.env.PORTS_TO_CHECK.split(',')
+ : DEFAULT_PORTS_TO_CHECK;
+
+async function checkPort(port, domain) {
+ return new Promise((resolve, reject) => {
+ const socket = new net.Socket();
+
+ socket.setTimeout(1500);
+
+ socket.once('connect', () => {
+ socket.destroy();
+ resolve(port);
+ });
+
+ socket.once('timeout', () => {
+ socket.destroy();
+ reject(new Error(`Timeout at port: ${port}`));
+ });
+
+ socket.once('error', (e) => {
+ socket.destroy();
+ reject(e);
+ });
+
+ socket.connect(port, domain);
+ });
+}
+
+const portsHandler = async (url, event, context) => {
+ const { hostname: domain } = parseTarget(url);
+
+ const delay = (ms) => new Promise((res) => setTimeout(res, ms));
+ const timeout = delay(9000);
+
+ const openPorts = [];
+ const failedPorts = [];
+
+ const promises = PORTS.map((port) =>
+ checkPort(port, domain)
+ .then(() => {
+ openPorts.push(port);
+ return { status: 'fulfilled', port };
+ })
+ .catch(() => {
+ failedPorts.push(port);
+ return { status: 'rejected', port };
+ }),
+ );
+
+ let timeoutReached = false;
+
+ for (const promise of promises) {
+ const result = await Promise.race([
+ promise,
+ timeout.then(() => ({ status: 'timeout', timeout: true })),
+ ]);
+ if (result.status === 'timeout') {
+ timeoutReached = true;
+ if (result.timeout) {
+ // Add the ports not checked yet to the failedPorts array
+ const checkedPorts = [...openPorts, ...failedPorts];
+ const portsNotChecked = PORTS.filter((port) => !checkedPorts.includes(port));
+ failedPorts.push(...portsNotChecked);
+ }
+ break;
+ }
+ }
+
+ if (timeoutReached) {
+ return errorResponse('The function timed out before completing.');
+ }
+
+ // Sort openPorts and failedPorts before returning
+ openPorts.sort((a, b) => a - b);
+ failedPorts.sort((a, b) => a - b);
+
+ return { openPorts, failedPorts };
+};
+
+const errorResponse = (message, statusCode = 444) => {
+ return { error: message };
+};
+
+export const handler = middleware(portsHandler);
+export default handler;
diff --git a/api/quality.js b/api/quality.js
new file mode 100644
index 000000000..ee84db8c8
--- /dev/null
+++ b/api/quality.js
@@ -0,0 +1,28 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { requireEnv, upstreamError } from './_common/upstream.js';
+
+const qualityHandler = async (url) => {
+ const auth = requireEnv('GOOGLE_CLOUD_API_KEY', 'Quality check');
+ if (auth.skipped) return auth;
+ const endpoint =
+ `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?` +
+ `url=${encodeURIComponent(url)}&category=PERFORMANCE&category=ACCESSIBILITY` +
+ `&category=BEST_PRACTICES&category=SEO&strategy=mobile` +
+ `&key=${auth.value}`;
+
+ let data;
+ try {
+ data = (await httpGet(endpoint)).data;
+ } catch (error) {
+ return upstreamError(error, 'Quality check');
+ }
+ const result = data.lighthouseResult || data;
+ if (!result?.categories || !Object.keys(result.categories).length) {
+ return { skipped: 'No quality report available for this URL' };
+ }
+ return result;
+};
+
+export const handler = middleware(qualityHandler);
+export default handler;
diff --git a/api/rank.js b/api/rank.js
new file mode 100644
index 000000000..3625fa5ff
--- /dev/null
+++ b/api/rank.js
@@ -0,0 +1,31 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+import { upstreamError } from './_common/upstream.js';
+
+const rankHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ const { TRANCO_USERNAME, TRANCO_API_KEY } = process.env;
+ const auth = TRANCO_API_KEY
+ ? { auth: { username: TRANCO_USERNAME, password: TRANCO_API_KEY } }
+ : {};
+ const fallback = hostname.startsWith('www.') ? hostname.slice(4) : `www.${hostname}`;
+ // Tranco indexes only one variant per site, so try as-is, then toggle www
+ const lookup = (domain) => httpGet(`https://tranco-list.eu/api/ranks/domain/${domain}`, auth);
+ try {
+ const first = await lookup(hostname);
+ if (first.data?.ranks?.length) return first.data;
+ try {
+ const second = await lookup(fallback);
+ if (second.data?.ranks?.length) return second.data;
+ } catch {
+ // Ignore fallback failures (e.g. rate limit) and accept the empty first result
+ }
+ return { skipped: `${hostname} isn't ranked in the top 1 million sites yet` };
+ } catch (error) {
+ return upstreamError(error, 'Tranco rank lookup');
+ }
+};
+
+export const handler = middleware(rankHandler);
+export default handler;
diff --git a/api/redirects.js b/api/redirects.js
new file mode 100644
index 000000000..4e3f0e6a2
--- /dev/null
+++ b/api/redirects.js
@@ -0,0 +1,43 @@
+import middleware from './_common/middleware.js';
+import { UA } from './_common/http.js';
+import { upstreamError } from './_common/upstream.js';
+
+const MAX_REDIRECTS = 12;
+const TIMEOUT_MS = 10000;
+
+// Walks the redirect chain manually, recording each Location header as got did
+const redirectsHandler = async (url) => {
+ const redirects = [url];
+ let current = url;
+ try {
+ for (let i = 0; i < MAX_REDIRECTS; i++) {
+ const response = await fetch(current, {
+ redirect: 'manual',
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ headers: { 'user-agent': UA },
+ });
+ if (response.status < 300 || response.status >= 400) {
+ if (response.status >= 400) {
+ const err = new Error(`HTTP ${response.status}`);
+ err.response = { status: response.status };
+ throw err;
+ }
+ break;
+ }
+ const location = response.headers.get('location');
+ if (!location) break;
+ redirects.push(location);
+ current = new URL(location, current).href;
+ }
+ return { redirects };
+ } catch (error) {
+ if (error.cause?.code) error.code = error.cause.code;
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') {
+ error.code = 'ECONNABORTED';
+ }
+ return upstreamError(error, 'Redirect lookup');
+ }
+};
+
+export const handler = middleware(redirectsHandler);
+export default handler;
diff --git a/api/robots-txt.js b/api/robots-txt.js
new file mode 100644
index 000000000..aa7822002
--- /dev/null
+++ b/api/robots-txt.js
@@ -0,0 +1,34 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+import { upstreamError } from './_common/upstream.js';
+
+// Extract User-agent / Allow / Disallow rules from a robots.txt body
+const parseRobotsTxt = (content) => {
+ const rules = [];
+ for (let line of content.split('\n')) {
+ line = line.trim();
+ const ruleMatch = line.match(/^(Allow|Disallow|User-agent):\s*(\S*)$/i);
+ if (ruleMatch) rules.push({ lbl: ruleMatch[1], val: ruleMatch[2] });
+ }
+ return { robots: rules };
+};
+
+const robotsHandler = async (url) => {
+ const { protocol, hostname } = parseTarget(url);
+ const host = hostname.includes(':') ? `[${hostname}]` : hostname;
+ try {
+ const res = await httpGet(`${protocol}//${host}/robots.txt`);
+ const parsed = parseRobotsTxt(res.data || '');
+ return parsed.robots.length ? parsed : { skipped: 'No robots.txt rules found for this host' };
+ } catch (error) {
+ const status = error.response?.status;
+ if (status >= 400 && status < 500) {
+ return { skipped: 'No robots.txt file present on this host' };
+ }
+ return upstreamError(error, 'robots.txt fetch');
+ }
+};
+
+export const handler = middleware(robotsHandler);
+export default handler;
diff --git a/api/screenshot.js b/api/screenshot.js
new file mode 100644
index 000000000..725841b35
--- /dev/null
+++ b/api/screenshot.js
@@ -0,0 +1,96 @@
+import puppeteer from 'puppeteer-core';
+import chromium from '@sparticuz/chromium';
+import { randomUUID } from 'crypto';
+import { execFile } from 'child_process';
+import { promises as fs } from 'fs';
+import path from 'path';
+import middleware from './_common/middleware.js';
+import { createLogger } from './_common/logger.js';
+
+const log = createLogger('screenshot');
+
+// Screenshot via the system Chromium binary
+const directChromiumScreenshot = async (url) => {
+ const tmpDir = '/tmp';
+ const screenshotPath = path.join(tmpDir, `screenshot-${randomUUID()}.png`);
+ log.debug(`direct method, saving to ${screenshotPath}`);
+
+ return new Promise((resolve, reject) => {
+ const chromePath = process.env.CHROME_PATH || '/usr/bin/chromium';
+ const args = [
+ '--headless',
+ '--disable-gpu',
+ '--no-sandbox',
+ `--screenshot=${screenshotPath}`,
+ url,
+ ];
+ execFile(chromePath, args, async (error) => {
+ if (error) return reject(error);
+ try {
+ const buf = await fs.readFile(screenshotPath);
+ await fs
+ .unlink(screenshotPath)
+ .catch((err) => log.warn(`temp cleanup failed: ${err.message}`));
+ resolve(buf.toString('base64'));
+ } catch (readError) {
+ reject(readError);
+ }
+ });
+ });
+};
+
+// Fallback to puppeteer when the direct Chromium binary call fails
+const puppeteerScreenshot = async (targetUrl) => {
+ let browser = null;
+ try {
+ browser = await puppeteer.launch({
+ args: [...chromium.args, '--no-sandbox'],
+ defaultViewport: { width: 800, height: 600 },
+ executablePath: process.env.CHROME_PATH || (await chromium.executablePath()),
+ headless: true,
+ acceptInsecureCerts: true,
+ ignoreDefaultArgs: ['--disable-extensions'],
+ });
+ const page = await browser.newPage();
+ await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
+ page.setDefaultNavigationTimeout(8000);
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => {
+ if (!document.querySelector('body')) {
+ throw new Error('No body element found on the page');
+ }
+ });
+ const buffer = await page.screenshot();
+ return buffer.toString('base64');
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ }
+};
+
+const screenshotHandler = async (targetUrl) => {
+ if (!targetUrl) throw new Error('URL is missing from queryStringParameters');
+ try {
+ new URL(targetUrl);
+ } catch {
+ throw new Error('URL provided is invalid');
+ }
+
+ log.debug(`request received: ${targetUrl}`);
+ try {
+ return { image: await directChromiumScreenshot(targetUrl) };
+ } catch (directError) {
+ log.warn(`direct chromium failed, falling back to puppeteer: ${directError.message}`);
+ }
+ try {
+ return { image: await puppeteerScreenshot(targetUrl) };
+ } catch (error) {
+ if (/ENOENT|Browser was not found|Could not find Chromium/i.test(error.message)) {
+ return { skipped: error.message };
+ }
+ log.error(`puppeteer screenshot failed: ${error.message}`);
+ throw error;
+ }
+};
+
+export const handler = middleware(screenshotHandler);
+export default handler;
diff --git a/api/security-txt.js b/api/security-txt.js
new file mode 100644
index 000000000..f773d6a3a
--- /dev/null
+++ b/api/security-txt.js
@@ -0,0 +1,80 @@
+import { URL } from 'url';
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+
+// RFC 9116 recommends .well-known first, legacy /security.txt as fallback
+const SECURITY_TXT_PATHS = ['/.well-known/security.txt', '/security.txt'];
+
+const parseResult = (result) => {
+ let output = {};
+ let counts = {};
+ const lines = result.split('\n');
+ const regex = /^([^:]+):\s*(.+)$/;
+
+ for (const line of lines) {
+ if (!line.startsWith('#') && !line.startsWith('-----') && line.trim() !== '') {
+ const match = line.match(regex);
+ if (match && match.length > 2) {
+ let key = match[1].trim();
+ const value = match[2].trim();
+ if (output.hasOwnProperty(key)) {
+ counts[key] = counts[key] ? counts[key] + 1 : 1;
+ key += counts[key];
+ }
+ output[key] = value;
+ }
+ }
+ }
+
+ return output;
+};
+
+const isPgpSigned = (result) => {
+ if (result.includes('-----BEGIN PGP SIGNED MESSAGE-----')) {
+ return true;
+ }
+ return false;
+};
+
+const securityTxtHandler = async (urlParam) => {
+ let url;
+ try {
+ url = new URL(urlParam.includes('://') ? urlParam : 'https://' + urlParam);
+ } catch (error) {
+ throw new Error('Invalid URL format');
+ }
+ url.pathname = '';
+
+ for (let path of SECURITY_TXT_PATHS) {
+ try {
+ const result = await fetchSecurityTxt(url, path);
+ if (result && result.toLowerCase().includes(' {
+ const url = new URL(path, baseURL);
+ const res = await httpGet(url.toString(), {
+ headers: { 'User-Agent': 'curl/8.0.0' },
+ validateStatus: () => true,
+ });
+ return res.status === 200 ? res.data : null;
+};
+
+export const handler = middleware(securityTxtHandler);
+export default handler;
diff --git a/api/shodan.js b/api/shodan.js
new file mode 100644
index 000000000..51a2eea7d
--- /dev/null
+++ b/api/shodan.js
@@ -0,0 +1,20 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+import { requireEnv, upstreamError } from './_common/upstream.js';
+
+// Server-side Shodan lookup so the API key never touches the client
+const shodanHandler = async (url) => {
+ const auth = requireEnv('SHODAN_API_KEY', 'Shodan');
+ if (auth.skipped) return auth;
+ const { hostname } = parseTarget(url);
+ try {
+ const res = await httpGet(`https://api.shodan.io/shodan/host/${hostname}?key=${auth.value}`);
+ return res.data;
+ } catch (error) {
+ return upstreamError(error, 'Shodan lookup');
+ }
+};
+
+export const handler = middleware(shodanHandler);
+export default handler;
diff --git a/api/sitemap.js b/api/sitemap.js
new file mode 100644
index 000000000..330690b1f
--- /dev/null
+++ b/api/sitemap.js
@@ -0,0 +1,125 @@
+import xml2js from 'xml2js';
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { upstreamError } from './_common/upstream.js';
+import { createLogger } from './_common/logger.js';
+
+const log = createLogger('sitemap');
+const TIMEOUT = 6000;
+const MAX_DEPTH = 3;
+const MAX_CHILD_SITEMAPS = 25;
+const MAX_URLS = 5000;
+
+const HEADERS = { accept: 'application/xml, text/xml, application/rss+xml, */*;q=0.1' };
+
+// Reduce a target URL to its origin so child paths resolve cleanly
+const toOrigin = (url) => {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return url.replace(/\/+$/, '');
+ }
+};
+
+// Fetch and parse XML in lenient mode so minor well-formedness errors do not abort
+const fetchSitemap = async (sitemapUrl) => {
+ const res = await httpGet(sitemapUrl, { timeout: TIMEOUT, headers: HEADERS });
+ return new xml2js.Parser({ strict: false, normalizeTags: true }).parseStringPromise(res.data);
+};
+
+// Pull the first Sitemap: directive out of robots.txt, if any
+const findSitemapInRobots = async (origin) => {
+ try {
+ const robots = await httpGet(`${origin}/robots.txt`, { timeout: TIMEOUT, headers: HEADERS });
+ for (const line of String(robots.data).split('\n')) {
+ if (line.toLowerCase().startsWith('sitemap:')) {
+ return line.split(/\s+/)[1]?.trim() || null;
+ }
+ }
+ } catch (error) {
+ log.debug(`robots.txt fetch failed for ${origin}`, error.message);
+ }
+ return null;
+};
+
+// Recursively flatten a sitemap-index into its child url sets
+const expandSitemap = async (parsed, depth) => {
+ if (!parsed?.sitemapindex?.sitemap || depth >= MAX_DEPTH) return parsed;
+ const children = parsed.sitemapindex.sitemap
+ .map((s) => s?.loc?.[0])
+ .filter(Boolean)
+ .slice(0, MAX_CHILD_SITEMAPS);
+ const fetched = await Promise.all(
+ children.map((loc) =>
+ fetchSitemap(loc).catch((error) => {
+ log.debug(`child sitemap failed for ${loc}`, error.message);
+ return null;
+ }),
+ ),
+ );
+ const expanded = await Promise.all(
+ fetched.map((child) => (child ? expandSitemap(child, depth + 1) : null)),
+ );
+ const urls = expanded.flatMap((child) => child?.urlset?.url || []);
+ return {
+ sitemapindex: parsed.sitemapindex,
+ urlset: urls.length ? { url: urls } : undefined,
+ sources: children,
+ };
+};
+
+// Whether the parsed XML matched one of the canonical sitemap shapes
+const isValidSitemap = (p) => !!(p?.urlset?.url?.length || p?.sitemapindex?.sitemap?.length);
+
+// Keep only the four fields the frontend renders, dropping locale alternates and image/video extras
+const slimUrl = (u) => {
+ const out = { loc: u.loc };
+ if (u.lastmod) out.lastmod = u.lastmod;
+ if (u.changefreq) out.changefreq = u.changefreq;
+ if (u.priority) out.priority = u.priority;
+ return out;
+};
+
+// Drop bulky XML extensions and cap total entries so the JSON payload stays sane
+const slimResult = (r) => {
+ if (!r) return r;
+ const out = {};
+ if (r.urlset?.url) out.urlset = { url: r.urlset.url.slice(0, MAX_URLS).map(slimUrl) };
+ if (r.sitemapindex?.sitemap) {
+ out.sitemapindex = {
+ sitemap: r.sitemapindex.sitemap.map((s) => ({ loc: s.loc })),
+ };
+ }
+ if (r.sources) out.sources = r.sources;
+ return out;
+};
+
+// Try a candidate URL, return parsed result only when it looks like a real sitemap
+const tryFetch = async (target, label) => {
+ try {
+ const parsed = await fetchSitemap(target);
+ if (isValidSitemap(parsed)) return parsed;
+ log.debug(`${label} parsed but lacked urlset/sitemapindex (${target})`);
+ } catch (error) {
+ log.debug(`${label} fetch failed (${target})`, error.message);
+ }
+ return null;
+};
+
+const sitemapHandler = async (url) => {
+ const origin = toOrigin(url);
+ let parsed = await tryFetch(`${origin}/sitemap.xml`, 'sitemap.xml');
+ if (!parsed) {
+ const fromRobots = await findSitemapInRobots(origin);
+ if (fromRobots) parsed = await tryFetch(fromRobots, 'robots-listed sitemap');
+ }
+ if (!parsed) return { skipped: 'No sitemap found for this site' };
+ try {
+ return slimResult(await expandSitemap(parsed, 0));
+ } catch (error) {
+ return upstreamError(error, 'Sitemap fetch');
+ }
+};
+
+export const handler = middleware(sitemapHandler);
+export default handler;
diff --git a/api/social-tags.js b/api/social-tags.js
new file mode 100644
index 000000000..7902f60cf
--- /dev/null
+++ b/api/social-tags.js
@@ -0,0 +1,76 @@
+import * as cheerio from 'cheerio';
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { upstreamError } from './_common/upstream.js';
+
+const socialTagsHandler = async (url) => {
+ let response;
+ try {
+ response = await httpGet(url);
+ } catch (error) {
+ return upstreamError(error, 'Social tags fetch');
+ }
+ try {
+ const $ = cheerio.load(response.data);
+
+ const metadata = {
+ // Basic meta tags
+ title: $('head title').text(),
+ description: $('meta[name="description"]').attr('content'),
+ keywords: $('meta[name="keywords"]').attr('content'),
+ canonicalUrl: $('link[rel="canonical"]').attr('href'),
+
+ // OpenGraph Protocol
+ ogTitle: $('meta[property="og:title"]').attr('content'),
+ ogType: $('meta[property="og:type"]').attr('content'),
+ ogImage: $('meta[property="og:image"]').attr('content'),
+ ogUrl: $('meta[property="og:url"]').attr('content'),
+ ogDescription: $('meta[property="og:description"]').attr('content'),
+ ogSiteName: $('meta[property="og:site_name"]').attr('content'),
+
+ // Twitter Cards
+ twitterCard: $('meta[name="twitter:card"]').attr('content'),
+ twitterSite: $('meta[name="twitter:site"]').attr('content'),
+ twitterCreator: $('meta[name="twitter:creator"]').attr('content'),
+ twitterTitle: $('meta[name="twitter:title"]').attr('content'),
+ twitterDescription: $('meta[name="twitter:description"]').attr('content'),
+ twitterImage: $('meta[name="twitter:image"]').attr('content'),
+
+ // Misc
+ themeColor: $('meta[name="theme-color"]').attr('content'),
+ robots: $('meta[name="robots"]').attr('content'),
+ googlebot: $('meta[name="googlebot"]').attr('content'),
+ generator: $('meta[name="generator"]').attr('content'),
+ viewport: $('meta[name="viewport"]').attr('content'),
+ author: $('meta[name="author"]').attr('content'),
+ publisher: $('link[rel="publisher"]').attr('href'),
+ favicon: $('link[rel="icon"]').attr('href'),
+ };
+
+ const SOCIAL_FIELDS = [
+ 'title',
+ 'description',
+ 'keywords',
+ 'canonicalUrl',
+ 'ogTitle',
+ 'ogImage',
+ 'ogDescription',
+ 'ogSiteName',
+ 'twitterTitle',
+ 'twitterDescription',
+ 'twitterImage',
+ 'author',
+ 'publisher',
+ 'themeColor',
+ ];
+ if (!SOCIAL_FIELDS.some((f) => metadata[f])) {
+ return { skipped: 'No social tags found on this page' };
+ }
+ return metadata;
+ } catch (error) {
+ return { error: `Failed parsing social tags: ${error.message}` };
+ }
+};
+
+export const handler = middleware(socialTagsHandler);
+export default handler;
diff --git a/api/ssl.js b/api/ssl.js
new file mode 100644
index 000000000..fe166d5c1
--- /dev/null
+++ b/api/ssl.js
@@ -0,0 +1,36 @@
+import tls from 'tls';
+import middleware from './_common/middleware.js';
+
+const sslHandler = async (urlString) => {
+ const parsedUrl = new URL(urlString);
+ const options = {
+ host: parsedUrl.hostname,
+ port: parsedUrl.port || 443,
+ servername: parsedUrl.hostname,
+ rejectUnauthorized: false,
+ };
+
+ return new Promise((resolve, reject) => {
+ const socket = tls.connect(options, () => {
+ const cert = socket.getPeerCertificate();
+ if (!cert || Object.keys(cert).length === 0) {
+ reject(new Error('No certificate presented by the server'));
+ socket.end();
+ return;
+ }
+ const { raw, issuerCertificate, ...certData } = cert;
+ resolve({
+ ...certData,
+ isValid: socket.authorized,
+ authError: socket.authorizationError || null,
+ });
+ socket.end();
+ });
+ socket.on('error', (e) => {
+ reject(new Error(`SSL connection failed: ${e.message}`));
+ });
+ });
+};
+
+export const handler = middleware(sslHandler);
+export default handler;
diff --git a/api/status.js b/api/status.js
new file mode 100644
index 000000000..42442246e
--- /dev/null
+++ b/api/status.js
@@ -0,0 +1,59 @@
+import https from 'https';
+import { performance, PerformanceObserver } from 'perf_hooks';
+import middleware from './_common/middleware.js';
+import { UA } from './_common/http.js';
+
+const statusHandler = async (url) => {
+ if (!url) {
+ throw new Error('You must provide a URL query parameter!');
+ }
+
+ let dnsLookupTime;
+ let responseCode;
+ let startTime;
+
+ const obs = new PerformanceObserver((items) => {
+ dnsLookupTime = items.getEntries()[0].duration;
+ performance.clearMarks();
+ });
+
+ obs.observe({ entryTypes: ['measure'] });
+
+ performance.mark('A');
+
+ try {
+ startTime = performance.now();
+ const response = await new Promise((resolve, reject) => {
+ const req = https.get(url, { headers: { 'user-agent': UA } }, (res) => {
+ let data = '';
+ responseCode = res.statusCode;
+ res.on('data', (chunk) => {
+ data += chunk;
+ });
+ res.on('end', () => {
+ resolve(res);
+ });
+ });
+
+ req.on('error', reject);
+ req.end();
+ });
+
+ if (responseCode < 200 || responseCode >= 400) {
+ throw new Error(`Received non-success response code: ${responseCode}`);
+ }
+
+ performance.mark('B');
+ performance.measure('A to B', 'A', 'B');
+ let responseTime = performance.now() - startTime;
+ obs.disconnect();
+
+ return { isUp: true, dnsLookupTime, responseTime, responseCode };
+ } catch (error) {
+ obs.disconnect();
+ throw error;
+ }
+};
+
+export const handler = middleware(statusHandler);
+export default handler;
diff --git a/api/subdomains.js b/api/subdomains.js
new file mode 100644
index 000000000..71bcb06c3
--- /dev/null
+++ b/api/subdomains.js
@@ -0,0 +1,107 @@
+import psl from 'psl';
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const MAX_SUBDOMAINS = 500;
+const SOURCE_TIMEOUT = 6000;
+const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
+
+const baseDomain = (host) => psl.parse(host)?.domain || host;
+const isIpAddress = (host) => /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':');
+
+const certSpotter = async (domain) => {
+ const token = process.env.CERTSPOTTER_TOKEN;
+ const res = await httpGet('https://api.certspotter.com/v1/issuances', {
+ params: { domain, include_subdomains: 'true', expand: 'dns_names' },
+ headers: { Accept: 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
+ timeout: SOURCE_TIMEOUT,
+ });
+ if (!Array.isArray(res.data)) throw new Error('certSpotter returned an unexpected response');
+ return res.data.flatMap((row) => (Array.isArray(row?.dns_names) ? row.dns_names : []));
+};
+
+const crtSh = async (domain) => {
+ const res = await httpGet('https://crt.sh/', {
+ params: { q: `%.${domain}`, output: 'json' },
+ headers: { Accept: 'application/json' },
+ timeout: SOURCE_TIMEOUT,
+ });
+ if (!Array.isArray(res.data)) throw new Error('crt.sh returned an unexpected response');
+ return res.data.flatMap((row) => String(row?.name_value ?? '').split('\n'));
+};
+
+const hackerTarget = async (domain) => {
+ const res = await httpGet('https://api.hackertarget.com/hostsearch/', {
+ params: { q: domain },
+ timeout: SOURCE_TIMEOUT,
+ });
+ const body = typeof res.data === 'string' ? res.data : '';
+ if (!body || /error|api count|quota/i.test(body)) throw new Error('hackerTarget unavailable');
+ return body.split('\n').map((line) => line.split(',')[0]);
+};
+
+const SOURCES = [
+ { name: 'certSpotter', lookup: certSpotter },
+ { name: 'crt.sh', lookup: crtSh },
+ { name: 'hackerTarget', lookup: hackerTarget },
+];
+
+const isTransient = (error) => {
+ const status = error.response?.status;
+ if (status && status < 500) return false;
+ return true;
+};
+
+const subdomainsHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ if (isIpAddress(hostname)) {
+ return { skipped: 'Subdomain enumeration only applies to domain names' };
+ }
+ const domain = baseDomain(hostname);
+ if (!domain || !domain.includes('.')) {
+ return { skipped: 'Could not resolve a registrable domain' };
+ }
+
+ const suffix = `.${domain}`;
+ const sieve = (names) => [
+ ...new Set(
+ names
+ .filter((n) => typeof n === 'string')
+ .map((n) => n.trim().toLowerCase().replace(/^\*\./, ''))
+ .filter((n) => n && n !== domain && n.endsWith(suffix) && HOSTNAME_RE.test(n)),
+ ),
+ ].sort();
+
+ let succeeded = 0;
+ const errors = [];
+ for (const source of SOURCES) {
+ if (source.requires && !process.env[source.requires]) continue;
+ try {
+ const subdomains = sieve(await source.lookup(domain));
+ succeeded += 1;
+ if (subdomains.length) {
+ return {
+ domain,
+ count: subdomains.length,
+ truncated: subdomains.length > MAX_SUBDOMAINS,
+ subdomains: subdomains.slice(0, MAX_SUBDOMAINS),
+ source: source.name,
+ };
+ }
+ } catch (error) {
+ errors.push(error);
+ }
+ }
+
+ if (succeeded) {
+ return { skipped: `No subdomains found for ${domain} in Certificate Transparency logs` };
+ }
+ return {
+ error: 'Subdomain lookup is temporarily unavailable for this domain',
+ retryable: errors.some(isTransient),
+ };
+};
+
+export const handler = middleware(subdomainsHandler);
+export default handler;
diff --git a/api/tech-stack.js b/api/tech-stack.js
new file mode 100644
index 000000000..2d62eff3b
--- /dev/null
+++ b/api/tech-stack.js
@@ -0,0 +1,47 @@
+import chromium from '@sparticuz/chromium';
+import middleware from './_common/middleware.js';
+
+// Wappalyzer reads CHROMIUM_BIN at module load, so we must resolve
+// the path before importing it (hence the dynamic import in the handler)
+const ensureChromiumBin = async () => {
+ if (process.env.CHROMIUM_BIN) return;
+ const envPath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
+ if (envPath) {
+ process.env.CHROMIUM_BIN = envPath;
+ return;
+ }
+ // On serverless, puppeteer has no cached binary, use @sparticuz/chromium
+ if (process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.NETLIFY) {
+ process.env.CHROMIUM_BIN = await chromium.executablePath();
+ // Avoid conflict with @sparticuz/chromium's extraction path at /tmp/chromium
+ if (!process.env.CHROMIUM_DATA_DIR) {
+ process.env.CHROMIUM_DATA_DIR = '/tmp/chromium-data';
+ }
+ }
+};
+
+const techStackHandler = async (url) => {
+ await ensureChromiumBin();
+ const { default: Wappalyzer } = await import('wappalyzer');
+ const wappalyzer = new Wappalyzer({});
+
+ try {
+ await wappalyzer.init();
+ const site = await wappalyzer.open(url, {}, { local: {}, session: {} });
+ const results = await site.analyze();
+ if (!results.technologies || results.technologies.length === 0) {
+ throw new Error('Unable to find any technologies for site');
+ }
+ return results;
+ } catch (error) {
+ if (/ENOENT|Browser was not found|Could not find Chromium/i.test(error.message)) {
+ return { skipped: error.message };
+ }
+ throw new Error(error.message);
+ } finally {
+ await wappalyzer.destroy();
+ }
+};
+
+export const handler = middleware(techStackHandler);
+export default handler;
diff --git a/api/threats.js b/api/threats.js
new file mode 100644
index 000000000..1f8d72655
--- /dev/null
+++ b/api/threats.js
@@ -0,0 +1,96 @@
+import xml2js from 'xml2js';
+import middleware from './_common/middleware.js';
+import { httpPost } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+import { requireEnv, upstreamError } from './_common/upstream.js';
+
+const safeBrowsing = async (url) => {
+ const auth = requireEnv('GOOGLE_CLOUD_API_KEY', 'Google Safe Browsing');
+ if (auth.skipped) return auth;
+ try {
+ const res = await httpPost(
+ `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${auth.value}`,
+ {
+ threatInfo: {
+ threatTypes: [
+ 'MALWARE',
+ 'SOCIAL_ENGINEERING',
+ 'UNWANTED_SOFTWARE',
+ 'POTENTIALLY_HARMFUL_APPLICATION',
+ 'API_ABUSE',
+ ],
+ platformTypes: ['ANY_PLATFORM'],
+ threatEntryTypes: ['URL'],
+ threatEntries: [{ url }],
+ },
+ },
+ );
+ return res.data?.matches ? { unsafe: true, details: res.data.matches } : { unsafe: false };
+ } catch (error) {
+ return upstreamError(error, 'Google Safe Browsing');
+ }
+};
+
+const urlHaus = async (url) => {
+ const { hostname } = parseTarget(url);
+ try {
+ const res = await httpPost('https://urlhaus-api.abuse.ch/v1/host/', `host=${hostname}`, {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ });
+ return res.data;
+ } catch (error) {
+ return upstreamError(error, 'URLhaus');
+ }
+};
+
+const phishTank = async (url) => {
+ try {
+ const encoded = Buffer.from(url).toString('base64');
+ const res = await httpPost(`https://checkurl.phishtank.com/checkurl/?url=${encoded}`, null, {
+ headers: { 'User-Agent': 'phishtank/web-check' },
+ timeout: 3000,
+ });
+ const parsed = await xml2js.parseStringPromise(res.data, { explicitArray: false });
+ return parsed.response.results;
+ } catch (error) {
+ return upstreamError(error, 'PhishTank');
+ }
+};
+
+const cloudmersive = async (url) => {
+ const auth = requireEnv('CLOUDMERSIVE_API_KEY', 'Cloudmersive');
+ if (auth.skipped) return auth;
+ try {
+ const res = await httpPost(
+ 'https://api.cloudmersive.com/virus/scan/website',
+ `Url=${encodeURIComponent(url)}`,
+ {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Apikey: auth.value,
+ },
+ },
+ );
+ return res.data;
+ } catch (error) {
+ return upstreamError(error, 'Cloudmersive');
+ }
+};
+
+// Aggregate four threat-feed lookups; skip the card if every source failed
+const threatsHandler = async (url) => {
+ const sources = await Promise.all([
+ safeBrowsing(url),
+ urlHaus(url),
+ phishTank(url),
+ cloudmersive(url),
+ ]);
+ const [safe, haus, phish, cloud] = sources;
+ if (sources.every((s) => s?.error || s?.skipped)) {
+ return { skipped: 'No threat sources returned data for this host' };
+ }
+ return { safeBrowsing: safe, urlHaus: haus, phishTank: phish, cloudmersive: cloud };
+};
+
+export const handler = middleware(threatsHandler);
+export default handler;
diff --git a/api/tls-connection.js b/api/tls-connection.js
new file mode 100644
index 000000000..329b09c62
--- /dev/null
+++ b/api/tls-connection.js
@@ -0,0 +1,84 @@
+import tls from 'tls';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const HANDSHAKE_TIMEOUT = 4000;
+
+const isIp = (h) => /^[\d.]+$/.test(h) || h.includes(':');
+
+// Open one TLS handshake to the host and capture what was negotiated
+const handshake = ({ hostname, port }) =>
+ new Promise((resolve, reject) => {
+ let ocspStapled = false;
+ const socket = tls.connect({
+ host: hostname,
+ port: port ? Number(port) : 443,
+ ...(isIp(hostname) ? {} : { servername: hostname }),
+ ALPNProtocols: ['h2', 'http/1.1'],
+ rejectUnauthorized: false,
+ requestOCSP: true,
+ });
+ socket.on('OCSPResponse', (res) => {
+ ocspStapled = res?.length > 0;
+ });
+
+ let settled = false;
+ const finish = (fn) => (arg) => {
+ if (settled) return;
+ settled = true;
+ fn(arg);
+ };
+ socket.setTimeout(HANDSHAKE_TIMEOUT);
+ socket.once(
+ 'secureConnect',
+ finish(() => {
+ const cipher = socket.getCipher();
+ const protocol = socket.getProtocol();
+ const ephemeral =
+ typeof socket.getEphemeralKeyInfo === 'function' ? socket.getEphemeralKeyInfo() : null;
+ const result = {
+ protocol,
+ cipher: cipher && {
+ name: cipher.name,
+ standardName: cipher.standardName,
+ version: cipher.version,
+ },
+ alpnProtocol: socket.alpnProtocol || null,
+ sessionResumption: !!socket.getSession(),
+ forwardSecrecy: protocol === 'TLSv1.3' || (!!cipher && /^(ECDHE|DHE)/.test(cipher.name)),
+ authorized: socket.authorized,
+ authError: socket.authorizationError ? String(socket.authorizationError) : null,
+ ephemeralKey: ephemeral && {
+ type: ephemeral.type || null,
+ name: ephemeral.name || null,
+ size: ephemeral.size || null,
+ },
+ ocspStapled,
+ };
+ socket.end();
+ resolve(result);
+ }),
+ );
+ socket.once(
+ 'timeout',
+ finish(() => {
+ socket.destroy();
+ reject(new Error('TLS handshake timed-out'));
+ }),
+ );
+ socket.once(
+ 'error',
+ finish((err) => {
+ socket.destroy();
+ reject(err);
+ }),
+ );
+ });
+
+const tlsConnectionHandler = async (url) => {
+ const { hostname, port } = parseTarget(url);
+ return handshake({ hostname, port });
+};
+
+export const handler = middleware(tlsConnectionHandler);
+export default handler;
diff --git a/api/tls-labs.js b/api/tls-labs.js
new file mode 100644
index 000000000..19bd983aa
--- /dev/null
+++ b/api/tls-labs.js
@@ -0,0 +1,31 @@
+import middleware from './_common/middleware.js';
+import { httpGet } from './_common/http.js';
+import { parseTarget } from './_common/parse-target.js';
+import { upstreamError } from './_common/upstream.js';
+
+const SSL_LABS = 'https://api.ssllabs.com/api/v3/analyze';
+
+// Return cached report if ready, pending status while a scan is running, else skip
+const tlsLabsHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ try {
+ const res = await httpGet(SSL_LABS, {
+ params: { host: hostname, fromCache: 'on', maxAge: 168, all: 'done' },
+ headers: { 'User-Agent': 'web-check (https://web-check.xyz)' },
+ });
+ const data = res.data;
+ if (data?.status === 'READY' && data.endpoints?.length) return data;
+ if (data?.status === 'DNS' || data?.status === 'IN_PROGRESS') {
+ return { pending: true };
+ }
+ if (data?.status === 'ERROR') {
+ return { error: `SSL Labs: ${data.statusMessage || 'Assessment failed'}` };
+ }
+ return { skipped: 'No SSL Labs report available for this host' };
+ } catch (error) {
+ return upstreamError(error, 'SSL Labs lookup');
+ }
+};
+
+export const handler = middleware(tlsLabsHandler);
+export default handler;
diff --git a/api/trace-route.js b/api/trace-route.js
new file mode 100644
index 000000000..c181bdb91
--- /dev/null
+++ b/api/trace-route.js
@@ -0,0 +1,58 @@
+import { execFile } from 'child_process';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const LOCAL_TIMEOUT = 8000;
+
+// Parse traceroute -n output into [{ip, time}] entries, skipping unanswered hops
+const parseHops = (stdout) => {
+ const hops = [];
+ for (const line of stdout.split('\n')) {
+ const m = line.match(/^\s*\d+\s+([\d.]+|\S*::\S*)\s+([\d.]+)\s*ms/);
+ if (m) hops.push({ ip: m[1], time: Number(m[2]) });
+ }
+ return hops;
+};
+
+// Run the system traceroute binary via execFile (no shell, no injection)
+const runTraceroute = (host) =>
+ new Promise((resolve, reject) => {
+ execFile(
+ 'traceroute',
+ ['-q', '1', '-n', '-w', '2', host],
+ { timeout: LOCAL_TIMEOUT },
+ (err, stdout) => (err ? reject(err) : resolve(parseHops(stdout))),
+ );
+ });
+
+const isMissingBinary = (err) =>
+ err?.code === 'ENOENT' || /command not found|not installed/i.test(err?.message || '');
+
+const traceRouteHandler = async (url) => {
+ const start = Date.now();
+ const { hostname } = parseTarget(url);
+ let hops;
+ try {
+ hops = await runTraceroute(hostname);
+ } catch (err) {
+ if (isMissingBinary(err)) {
+ return {
+ skipped:
+ 'Traceroute is not installed in this environment. ' +
+ 'Install via your package manager, or run web-check via Docker.',
+ };
+ }
+ return { error: `Traceroute failed: ${err.message}` };
+ }
+ if (!hops.length) {
+ return { skipped: 'Traceroute returned no answered hops for this host' };
+ }
+ return {
+ message: 'Traceroute completed!',
+ result: hops.map(({ ip, time }) => ({ [ip]: [time] })),
+ timeTaken: Date.now() - start,
+ };
+};
+
+export const handler = middleware(traceRouteHandler);
+export default handler;
diff --git a/api/txt-records.js b/api/txt-records.js
new file mode 100644
index 000000000..e52fb3618
--- /dev/null
+++ b/api/txt-records.js
@@ -0,0 +1,30 @@
+import dns from 'dns/promises';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+
+const NO_RECORDS = new Set(['ENODATA', 'ENOTFOUND', 'NXDOMAIN']);
+
+const txtRecordHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ let txtRecords;
+ try {
+ txtRecords = await dns.resolveTxt(hostname);
+ } catch (error) {
+ if (NO_RECORDS.has(error.code)) return { skipped: 'No TXT records for this host' };
+ throw error;
+ }
+ // Join chunks (DNS splits long records at 255 bytes), then key=value
+ const result = {};
+ for (const chunks of txtRecords) {
+ const full = chunks.join('');
+ const eq = full.indexOf('=');
+ let key = eq > 0 ? full.slice(0, eq) : full;
+ const val = eq > 0 ? full.slice(eq + 1) : '';
+ while (key in result) key += '_';
+ result[key] = val;
+ }
+ return result;
+};
+
+export const handler = middleware(txtRecordHandler);
+export default handler;
diff --git a/api/whois.js b/api/whois.js
new file mode 100644
index 000000000..9a3e0ec13
--- /dev/null
+++ b/api/whois.js
@@ -0,0 +1,135 @@
+import net from 'net';
+import psl from 'psl';
+import { whoisDomain } from 'whoiser';
+import middleware from './_common/middleware.js';
+import { parseTarget } from './_common/parse-target.js';
+import { createLogger } from './_common/logger.js';
+
+const log = createLogger('whois');
+const TIMEOUT = 8000;
+
+// Walk every WHOIS/RDAP source, return the first non-empty value across the given keys
+const pick = (results, ...keys) => {
+ for (const src of Object.values(results)) {
+ for (const key of keys) {
+ const v = src?.[key];
+ if (v === undefined || v === null) continue;
+ if (typeof v === 'string' && !v.trim()) continue;
+ if (Array.isArray(v) && !v.length) continue;
+ return v;
+ }
+ }
+ return undefined;
+};
+
+// Lower-case + dedupe nameservers, drop empty entries
+const cleanNs = (ns) => {
+ if (!Array.isArray(ns)) return undefined;
+ const out = [...new Set(ns.map((n) => String(n).trim().toLowerCase()).filter(Boolean))];
+ return out.length ? out : undefined;
+};
+
+// Convert a date string to ISO 8601 when parseable, otherwise return the raw value
+const toIso = (raw) => {
+ if (!raw || typeof raw !== 'string') return raw;
+ const trimmed = raw.trim().replace(/\s*#.*$/, '');
+ const t = Date.parse(trimmed);
+ if (!Number.isNaN(t)) return new Date(t).toISOString();
+ const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})$/);
+ if (m) return new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00Z`).toISOString();
+ return raw;
+};
+
+// Reduce a hostname to its registrable domain so registry WHOIS lookups succeed
+const baseDomain = (host) => psl.parse(host)?.domain || host;
+
+// Pull the formatted-name value out of an RDAP entity vCard
+const vcardFn = (vcard) => {
+ if (!Array.isArray(vcard?.[1])) return undefined;
+ const fn = vcard[1].find((f) => Array.isArray(f) && f[0] === 'fn');
+ return fn?.[3] || undefined;
+};
+
+// Map an RDAP response into the same field names whoiser exposes, so pick() works uniformly
+const rdapToWhoiserShape = (data) => {
+ if (!data || data.errorCode || data.objectClassName === 'error') return null;
+ const events = data.events || [];
+ const evt = (a) => events.find((e) => e.eventAction === a)?.eventDate;
+ const registrar = (data.entities || []).find((e) => (e.roles || []).includes('registrar'));
+ const ianaId = registrar?.publicIds?.find((p) => /iana/i.test(p.type))?.identifier;
+ const registrarUrlEntry = registrar?.links?.find((l) => l.rel === 'about' || l.rel === 'related');
+ return {
+ 'Domain Name': data.ldhName,
+ 'Created Date': evt('registration'),
+ 'Updated Date': evt('last changed') || evt('last update of RDAP database'),
+ 'Expiry Date': evt('expiration'),
+ 'Registry Domain ID': data.handle,
+ Registrar: vcardFn(registrar?.vcardArray),
+ 'Registrar IANA ID': ianaId,
+ 'Registrar URL': registrarUrlEntry?.href,
+ 'Domain Status': data.status,
+ 'Name Server': (data.nameservers || []).map((n) => n.ldhName).filter(Boolean),
+ DNSSEC: data.secureDNS?.delegationSigned ? 'signed' : 'unsigned',
+ };
+};
+
+// Last-resort lookup via rdap.org (a meta-resolver that bootstraps the right RDAP server)
+const fetchRdapFallback = async (domain) => {
+ try {
+ const res = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
+ signal: AbortSignal.timeout(TIMEOUT),
+ });
+ if (!res.ok) return null;
+ return rdapToWhoiserShape(await res.json());
+ } catch {
+ return null;
+ }
+};
+
+// Whether a parsed result set has anything worth returning to the frontend
+const hasUsefulData = (r) =>
+ r &&
+ (pick(r, 'Created Date', 'Creation Date') ||
+ pick(r, 'Updated Date') ||
+ pick(r, 'Expiry Date', 'Registry Expiry Date') ||
+ pick(r, 'Registrar') ||
+ cleanNs(pick(r, 'Name Server')));
+
+// Resolve domain registration data via whoiser, with rdap.org as a fallback for TLD gaps
+const whoisHandler = async (url) => {
+ const { hostname } = parseTarget(url);
+ if (net.isIP(hostname)) {
+ return { skipped: 'WHOIS lookups apply to domains, not IP addresses' };
+ }
+ const target = baseDomain(hostname);
+ let results = {};
+ try {
+ results = await whoisDomain(target, { follow: 2, timeout: TIMEOUT });
+ } catch (error) {
+ log.debug(`whoisDomain failed for ${target}: ${error.message}`);
+ }
+ if (!hasUsefulData(results)) {
+ const rdap = await fetchRdapFallback(target);
+ if (rdap) results = { ...results, 'rdap.org': rdap };
+ }
+ if (!hasUsefulData(results)) {
+ return { skipped: 'No WHOIS data available for this domain' };
+ }
+ return {
+ domain: pick(results, 'Domain Name') || target,
+ registrar: pick(results, 'Registrar'),
+ registrarUrl: pick(results, 'Registrar URL'),
+ registrarIanaId: pick(results, 'Registrar IANA ID'),
+ registrarWhoisServer: pick(results, 'Registrar WHOIS Server'),
+ registryDomainId: pick(results, 'Registry Domain ID'),
+ created: toIso(pick(results, 'Created Date', 'Creation Date')),
+ updated: toIso(pick(results, 'Updated Date')),
+ expires: toIso(pick(results, 'Expiry Date', 'Registry Expiry Date', 'Expiration Date')),
+ nameservers: cleanNs(pick(results, 'Name Server')),
+ status: pick(results, 'Domain Status'),
+ dnssec: pick(results, 'DNSSEC'),
+ };
+};
+
+export const handler = middleware(whoisHandler);
+export default handler;
diff --git a/astro.config.mjs b/astro.config.mjs
new file mode 100644
index 000000000..0bbad1137
--- /dev/null
+++ b/astro.config.mjs
@@ -0,0 +1,78 @@
+import { defineConfig } from 'astro/config';
+import { loadEnv } from 'vite';
+
+// Integrations
+import svelte from '@astrojs/svelte';
+import react from '@astrojs/react';
+import sitemap from '@astrojs/sitemap';
+
+// Adapters
+import vercelAdapter from '@astrojs/vercel';
+import netlifyAdapter from '@astrojs/netlify';
+import nodeAdapter from '@astrojs/node';
+import cloudflareAdapter from '@astrojs/cloudflare';
+
+// Pre-load .env so values are available in this config, before Vite
+const fileEnv = loadEnv(process.env.NODE_ENV || 'development', process.cwd(), '');
+
+// Read an env var, preferring shell over .env, with a final fallback
+const unwrapEnvVar = (varName, fallbackValue) =>
+ process.env[varName] ?? fileEnv[varName] ?? fallbackValue;
+
+// Determine the deploy target (vercel, netlify, cloudflare, node)
+const deployTarget = unwrapEnvVar('PLATFORM', 'node').toLowerCase();
+
+// Determine the output mode (static or server). Mixed prerender supported in static mode
+const output = unwrapEnvVar('OUTPUT', 'static');
+
+// The FQDN of where the site is hosted (used for sitemaps & canonical URLs)
+const site = unwrapEnvVar('SITE_URL', 'https://web-check.xyz');
+
+// The base URL of the site (if serving from a subdirectory)
+const base = unwrapEnvVar('BASE_URL', '/');
+
+// Should run the app in boss-mode (requires extra configuration)
+const isBossServer = unwrapEnvVar('BOSS_SERVER', false);
+
+// Initialize Astro integrations
+const integrations = [svelte(), react(), sitemap()];
+
+// Set the appropriate adapter, based on the deploy target
+function getAdapter(target) {
+ switch (target) {
+ case 'vercel':
+ return vercelAdapter();
+ case 'netlify':
+ return netlifyAdapter();
+ case 'cloudflare':
+ return cloudflareAdapter();
+ case 'node':
+ return nodeAdapter({ mode: 'middleware' });
+ default:
+ throw new Error(`Unsupported deploy target: ${target}`);
+ }
+}
+const adapter = getAdapter(deployTarget);
+
+// Print build information to console
+console.log(
+ `\n\x1b[1m\x1b[35m Preparing to start build of Web Check.... \x1b[0m\n`,
+ `\x1b[35m\x1b[2mCompiling for "${deployTarget}" using "${output}" mode, ` +
+ `to deploy to "${site}" at "${base}"\x1b[0m\n`,
+ `\x1b[2m\x1b[36m🛟 For documentation and support, visit the GitHub repo: ` +
+ `https://github.com/lissy93/web-check \n`,
+ `💖 Found Web-Check useful? Consider sponsoring us on GitHub ` +
+ `to help fund maintenance & development.\x1b[0m\n`,
+);
+
+const redirects = {
+ '/about': '/check/about',
+};
+
+// Skip the marketing homepage for self-hosted users
+if (!isBossServer && isBossServer !== true) {
+ redirects['/'] = '/check';
+}
+
+// Export Astro configuration
+export default defineConfig({ output, base, integrations, site, adapter, redirects });
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..4266560f0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,8 @@
+version: '3.9'
+services:
+ web-check:
+ container_name: Web-Check
+ image: lissy93/web-check
+ ports:
+ - 3000:3000
+ restart: unless-stopped
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 000000000..a529b5409
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,17 @@
+app = 'web-check'
+primary_region = 'lhr'
+
+[build]
+
+[http_service]
+ internal_port = 3000
+ force_https = true
+ auto_stop_machines = true
+ auto_start_machines = true
+ min_machines_running = 0
+ processes = ['app']
+
+[[vm]]
+ memory = '1gb'
+ cpu_kind = 'shared'
+ cpus = 1
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 000000000..136485ec2
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,41 @@
+# Build settings and site core config
+[build]
+ base = "/"
+ command = "yarn build"
+ publish = "dist"
+ functions = "api"
+
+# Environmental variables and optional secrets
+[build.environment]
+ PLATFORM = "netlify"
+ PUBLIC_API_ENDPOINT = "/.netlify/functions"
+ # Build configuration env vars (uncomment if you want to conigure these)
+ # CI="false" # Set CI to false, to prevent warnings from exiting the build
+ # CHROME_PATH='/usr/bin/chromium' # Path to Chromium binary
+ # NODE_VERSION = "16.16.0" # Set the version of Node.js to use
+
+ # Optional secrets and API keys (uncomment if you want to add these)
+ # GOOGLE_CLOUD_API_KEY='' # Google Cloud API key, for running Lighthouse scans
+ # BUILT_WITH_API_KEY='' # BuiltWith API key, for detecting site features
+ # REACT_APP_SHODAN_API_KEY='' # Shodan API key, for using Shodan scan API
+ # REACT_APP_WHO_API_KEY='' # WhoAPI key, for iniiating client-side whois lookup
+
+# Redirect the /api/* path to the lambda functions
+[[redirects]]
+ from = "/api/*"
+ to = "/.netlify/functions/:splat"
+ status = 301
+ force = true
+
+# Plugins
+[[plugins]]
+package = "netlify-plugin-chromium"
+ [plugins.inputs]
+ packageManager = "yarn"
+
+# Set any security headers here
+[[headers]]
+ for = "/*"
+ [headers.values]
+ # Uncomment to enable Netlify user control. Requires premium plan.
+ # Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"
diff --git a/package.json b/package.json
index c51592a58..90fb43d23 100644
--- a/package.json
+++ b/package.json
@@ -1,51 +1,99 @@
{
"name": "web-check",
- "version": "0.1.0",
- "private": true,
- "dependencies": {
- "@testing-library/jest-dom": "^5.16.4",
- "@testing-library/react": "^13.3.0",
- "@testing-library/user-event": "^13.5.0",
- "@types/jest": "^27.5.2",
- "@types/node": "^16.11.39",
- "@types/react": "^18.0.12",
- "@types/react-dom": "^18.0.5",
- "@types/react-router-dom": "^5.3.3",
- "@types/styled-components": "^5.1.25",
- "jest-styled-components": "^7.0.8",
- "node-fetch": "2.6.1",
- "react": "^18.1.0",
- "react-dom": "^18.1.0",
- "react-router-dom": "^6.3.0",
- "react-scripts": "5.0.1",
- "react-tsparticles": "^2.0.6",
- "styled-components": "^5.3.5",
- "tsparticles": "^2.0.6",
- "typescript": "^4.7.3",
- "web-vitals": "^2.1.4"
+ "type": "module",
+ "version": "2.1.9",
+ "homepage": "https://web-check.xyz",
+ "engines": {
+ "node": ">=22"
},
"scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
- "eject": "react-scripts eject"
+ "start": "node server",
+ "build": "astro check && astro build",
+ "dev:vercel": "PLATFORM='vercel' npx vercel dev",
+ "dev:netlify": "PLATFORM='netlify' npx netlify dev",
+ "dev:api": "DISABLE_GUI='true' PORT='3001' nodemon server",
+ "dev:astro": "PUBLIC_API_ENDPOINT=http://localhost:3001/api astro dev",
+ "dev": "concurrently -c magenta,cyan -n backend,frontend 'yarn dev:api' 'yarn dev:astro'",
+ "typecheck": "astro check",
+ "lint": "eslint --config .config/eslint.config.js .",
+ "format:check": "prettier --check --ignore-unknown '!yarn.lock' '!**/*.md' .",
+ "format:fix": "prettier --write --ignore-unknown '!yarn.lock' '!**/*.md' .",
+ "hold-my-beer": "yarn format:fix && yarn lint && yarn typecheck"
},
- "eslintConfig": {
- "extends": [
- "react-app",
- "react-app/jest"
- ]
+ "dependencies": {
+ "@astrojs/check": "^0.9.9",
+ "@astrojs/react": "^5.0.4",
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@fortawesome/fontawesome-svg-core": "^7.2.0",
+ "@fortawesome/free-brands-svg-icons": "^7.2.0",
+ "@fortawesome/free-regular-svg-icons": "^7.2.0",
+ "@fortawesome/free-solid-svg-icons": "^7.2.0",
+ "@fortawesome/svelte-fontawesome": "^0.2.4",
+ "@sparticuz/chromium": "^148.0.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "astro": "^6.2.2",
+ "cheerio": "^1.2.0",
+ "chromium": "^3.0.3",
+ "cors": "^2.8.6",
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.5.0",
+ "framer-motion": "^12.38.0",
+ "prop-types": "^15.8.1",
+ "psl": "^1.15.0",
+ "puppeteer": "^24.42.0",
+ "puppeteer-core": "^24.42.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "react-router-dom": "^7.14.2",
+ "react-simple-maps": "^3.0.0",
+ "react-toastify": "^11.1.0",
+ "recharts": "^3.8.1",
+ "svelte": "^5.55.5",
+ "typescript": "^6.0.3",
+ "wappalyzer": "^6.10.66",
+ "whoiser": "^2.0.0-beta.10",
+ "xml2js": "^0.6.2"
},
- "browserslist": {
- "production": [
- ">0.2%",
- "not dead",
- "not op_mini all"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
+ "resolutions": {
+ "tar-fs": "^2.1.4",
+ "basic-ftp": "^5.2.0",
+ "picomatch": "^2.3.2",
+ "systeminformation": "^5.31.0",
+ "d3-color": "^3.1.0",
+ "braces": "^3.0.3",
+ "wappalyzer/**/ws": "^8.17.1",
+ "@vercel/routing-utils/path-to-regexp": "^6.3.0",
+ "uuid": "^14.0.0",
+ "yaml": "^2.8.3"
+ },
+ "devDependencies": {
+ "@astrojs/cloudflare": "^13.3.1",
+ "@astrojs/netlify": "^7.0.8",
+ "@astrojs/node": "^10.0.6",
+ "@astrojs/sitemap": "^3.7.2",
+ "@astrojs/svelte": "^8.1.0",
+ "@astrojs/ts-plugin": "^1.10.7",
+ "@astrojs/vercel": "^10.0.6",
+ "@typescript-eslint/parser": "^8.59.2",
+ "concurrently": "^9.2.1",
+ "eslint": "^10.3.0",
+ "eslint-plugin-astro": "^1.7.0",
+ "nodemon": "^3.1.14",
+ "prettier": "^3.8.3",
+ "prettier-plugin-astro": "^0.14.1",
+ "sass": "^1.99.0"
+ },
+ "prettier": {
+ "printWidth": 100,
+ "semi": true,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "plugins": [
+ "prettier-plugin-astro"
]
- }
+ },
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png
new file mode 100644
index 000000000..126f0fe9b
Binary files /dev/null and b/public/android-chrome-192x192.png differ
diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png
new file mode 100644
index 000000000..35ba8e8b1
Binary files /dev/null and b/public/android-chrome-512x512.png differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 000000000..ee7e75f40
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/assets/badges/dockerhub.svg b/public/assets/badges/dockerhub.svg
new file mode 100644
index 000000000..8463f325c
--- /dev/null
+++ b/public/assets/badges/dockerhub.svg
@@ -0,0 +1 @@
+DockerHub: Lissy93/Web Check DockerHub DockerHub Lissy93/Web Check Lissy93/Web Check
\ No newline at end of file
diff --git a/public/assets/badges/github.svg b/public/assets/badges/github.svg
new file mode 100644
index 000000000..a6bf45550
--- /dev/null
+++ b/public/assets/badges/github.svg
@@ -0,0 +1 @@
+GitHub: Lissy93/Web Check GitHub GitHub Lissy93/Web Check Lissy93/Web Check
\ No newline at end of file
diff --git a/public/assets/badges/sponsor.svg b/public/assets/badges/sponsor.svg
new file mode 100644
index 000000000..790c48658
--- /dev/null
+++ b/public/assets/badges/sponsor.svg
@@ -0,0 +1 @@
+Sponsor: Alicia Sykes Sponsor Sponsor Alicia Sykes Alicia Sykes
\ No newline at end of file
diff --git a/public/assets/badges/webcheck.svg b/public/assets/badges/webcheck.svg
new file mode 100644
index 000000000..b7154a041
--- /dev/null
+++ b/public/assets/badges/webcheck.svg
@@ -0,0 +1 @@
+Website: web-check.xyz Website Website web-check.xyz webcheck.zyz
diff --git a/public/assets/images/background-dots.svg b/public/assets/images/background-dots.svg
new file mode 100644
index 000000000..1ec84563c
--- /dev/null
+++ b/public/assets/images/background-dots.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/images/docker.svg b/public/assets/images/docker.svg
new file mode 100644
index 000000000..b8a5f2cc2
--- /dev/null
+++ b/public/assets/images/docker.svg
@@ -0,0 +1 @@
+Docker
diff --git a/public/assets/images/fly.svg b/public/assets/images/fly.svg
new file mode 100644
index 000000000..7800b6b04
--- /dev/null
+++ b/public/assets/images/fly.svg
@@ -0,0 +1 @@
+
diff --git a/public/assets/images/github.svg b/public/assets/images/github.svg
new file mode 100644
index 000000000..233497651
--- /dev/null
+++ b/public/assets/images/github.svg
@@ -0,0 +1 @@
+GitHub
diff --git a/public/assets/images/netlify.svg b/public/assets/images/netlify.svg
new file mode 100644
index 000000000..fd6acf88a
--- /dev/null
+++ b/public/assets/images/netlify.svg
@@ -0,0 +1 @@
+Netlify
diff --git a/public/assets/images/swagger.svg b/public/assets/images/swagger.svg
new file mode 100644
index 000000000..38af79df5
--- /dev/null
+++ b/public/assets/images/swagger.svg
@@ -0,0 +1 @@
+Swagger
diff --git a/public/assets/images/vercel.svg b/public/assets/images/vercel.svg
new file mode 100644
index 000000000..7e9d62e87
--- /dev/null
+++ b/public/assets/images/vercel.svg
@@ -0,0 +1 @@
+Vercel
diff --git a/public/assets/images/webauthn.svg b/public/assets/images/webauthn.svg
new file mode 100644
index 000000000..209807b33
--- /dev/null
+++ b/public/assets/images/webauthn.svg
@@ -0,0 +1 @@
+WebAuthn
diff --git a/public/banner.png b/public/banner.png
new file mode 100644
index 000000000..4ff203751
Binary files /dev/null and b/public/banner.png differ
diff --git a/public/error.html b/public/error.html
new file mode 100644
index 000000000..317896d93
--- /dev/null
+++ b/public/error.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+ Web-Check
+
+
+
+
+
+
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 000000000..d422a7ce7
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 000000000..8055a300a
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
index a11777cc4..cc8e24aad 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 000000000..ed4ef50c3
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/fonts/Hubot-Sans/LICENSE b/public/fonts/Hubot-Sans/LICENSE
new file mode 100644
index 000000000..49e454dc0
--- /dev/null
+++ b/public/fonts/Hubot-Sans/LICENSE
@@ -0,0 +1,93 @@
+Copyright (c) 2022, GitHub https://github.com/github/hubot-sans
+with Reserved Font Name "Hubot Sans"
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting — in part or in whole — any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-Bold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-Bold.ttf
new file mode 100644
index 000000000..f60d8052c
Binary files /dev/null and b/public/fonts/Hubot-Sans/TTF/HubotSans-Bold.ttf differ
diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-BoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-BoldItalic.ttf
new file mode 100644
index 000000000..7aac29a88
Binary files /dev/null and b/public/fonts/Hubot-Sans/TTF/HubotSans-BoldItalic.ttf differ
diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-Italic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-Italic.ttf
new file mode 100644
index 000000000..4e73f019f
Binary files /dev/null and b/public/fonts/Hubot-Sans/TTF/HubotSans-Italic.ttf differ
diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-Regular.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-Regular.ttf
new file mode 100644
index 000000000..293fb692f
Binary files /dev/null and b/public/fonts/Hubot-Sans/TTF/HubotSans-Regular.ttf differ
diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-SemiBold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-SemiBold.ttf
new file mode 100644
index 000000000..a57a347e1
Binary files /dev/null and b/public/fonts/Hubot-Sans/TTF/HubotSans-SemiBold.ttf differ
diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Bold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Bold.woff2
new file mode 100644
index 000000000..bda3db2c7
Binary files /dev/null and b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Bold.woff2 differ
diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-BoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-BoldItalic.woff2
new file mode 100644
index 000000000..c53405dfc
Binary files /dev/null and b/public/fonts/Hubot-Sans/WOFF2/HubotSans-BoldItalic.woff2 differ
diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Italic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Italic.woff2
new file mode 100644
index 000000000..93f2f5509
Binary files /dev/null and b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Italic.woff2 differ
diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Regular.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Regular.woff2
new file mode 100644
index 000000000..5c0568076
Binary files /dev/null and b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Regular.woff2 differ
diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBold.woff2
new file mode 100644
index 000000000..17143251f
Binary files /dev/null and b/public/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBold.woff2 differ
diff --git a/src/assets/fonts/PTMono-Regular.ttf b/public/fonts/PTMono-Regular.ttf
similarity index 100%
rename from src/assets/fonts/PTMono-Regular.ttf
rename to public/fonts/PTMono-Regular.ttf
diff --git a/public/fonts/PTMono-Regular.woff2 b/public/fonts/PTMono-Regular.woff2
new file mode 100644
index 000000000..6112591db
Binary files /dev/null and b/public/fonts/PTMono-Regular.woff2 differ
diff --git a/public/index.html b/public/index.html
deleted file mode 100644
index aa069f27c..000000000
--- a/public/index.html
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- React App
-
-
- You need to enable JavaScript to run this app.
-
-
-
-
diff --git a/public/logo192.png b/public/logo192.png
deleted file mode 100644
index fc44b0a37..000000000
Binary files a/public/logo192.png and /dev/null differ
diff --git a/public/logo512.png b/public/logo512.png
deleted file mode 100644
index a4e47a654..000000000
Binary files a/public/logo512.png and /dev/null differ
diff --git a/public/manifest.json b/public/manifest.json
index 080d6c77a..4babde752 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,25 +1,45 @@
{
- "short_name": "React App",
- "name": "Create React App Sample",
+ "name": "Web Check",
+ "short_name": "Web Check",
+ "description": "Web Check is the all-in-one OSINT and security tool, for revealing the inner workings of any website",
+ "id": "/",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "theme_color": "#d6fb41",
+ "background_color": "#111211",
"icons": [
{
- "src": "favicon.ico",
- "sizes": "64x64 32x32 24x24 16x16",
- "type": "image/x-icon"
+ "src": "/favicon.svg",
+ "type": "image/svg+xml",
+ "sizes": "any"
+ },
+ {
+ "src": "/favicon-16x16.png",
+ "type": "image/png",
+ "sizes": "16x16"
},
{
- "src": "logo192.png",
+ "src": "/favicon-32x32.png",
"type": "image/png",
- "sizes": "192x192"
+ "sizes": "32x32"
},
{
- "src": "logo512.png",
+ "src": "/apple-touch-icon.png",
"type": "image/png",
- "sizes": "512x512"
+ "sizes": "180x180"
+ },
+ {
+ "src": "/android-chrome-192x192.png",
+ "type": "image/png",
+ "sizes": "192x192",
+ "purpose": "any"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "type": "image/png",
+ "sizes": "512x512",
+ "purpose": "any"
}
- ],
- "start_url": ".",
- "display": "standalone",
- "theme_color": "#000000",
- "background_color": "#ffffff"
+ ]
}
diff --git a/public/placeholder.html b/public/placeholder.html
new file mode 100644
index 000000000..96c80210e
--- /dev/null
+++ b/public/placeholder.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Web-Check
+
+
+
+
+
+
diff --git a/public/resources/openapi-spec.yml b/public/resources/openapi-spec.yml
new file mode 100644
index 000000000..50bb7eaab
--- /dev/null
+++ b/public/resources/openapi-spec.yml
@@ -0,0 +1,3158 @@
+openapi: 3.0.0
+info:
+ title: Web Check 🕵
+ description: >
+ **API documentation for the [Web Check](https://github.com/lissy93/web-check) backend endpoints.**
+ _Web Check gives you x-ray vision, revealing the configration and inner workings of any website._
+
+ [](https://web-check.xyz/)
+ [](https://hub.docker.com/r/lissy93/web-check)
+ [](https://github.com/lissy93/web-check)
+ [](https://github.com/sponsors/Lissy93)
+
+ version: 1.0.0
+ license:
+ name: 'License: MIT'
+ url: https://github.com/Lissy93/web-check/blob/master/LICENSE
+ termsOfService: https://web-check.xyz/about#terms-info
+externalDocs:
+ description: 'Source: GitHub'
+ url: https://github.com/Lissy93/web-check
+servers:
+ - url: http://localhost:3001/api
+ description: Local (Development)
+ - url: http://localhost:3000/api
+ description: Local (Production)
+ - url: https://web-check.xyz/api
+ description: Public Demo (Vercel)
+ - url: https://web-check.as93.net/api
+ description: Public Demo (Netlify)
+tags:
+ - name: Quality & Info
+ description: Endpoints providing quality metrics, and general website information.
+ - name: Security
+ description: Endpoints related to website and server security configurations.
+ - name: Server Info
+ description: Endpoints providing information about the server hosting the website.
+ - name: Client-Side Information
+ description: Endpoints providing metrics about the website's client-side content.
+components:
+ responses:
+ Error:
+ description: Internal Server Error - An error occurred while processing the request.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ Skipped:
+ description: No Content - The request was successful, but no content is returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SkippedResponse'
+ MissingParam:
+ description: Bad Request - Missing or incorrect input parameters.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ Unauthorized:
+ description: Unauthorized - Authentication credentials were missing or incorrect.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ Forbidden:
+ description: Forbidden - The credentials provided do not grant the necessary permissions.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ TooManyRequests:
+ description: Too Many Requests - Rate limit exceeded.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ schemas:
+ ErrorResponse:
+ type: object
+ properties:
+ error:
+ type: string
+ description: A description of the error
+ SkippedResponse:
+ type: object
+ properties:
+ skipped:
+ type: string
+ description: A description of why the check was skipped
+paths:
+ /archives:
+ get:
+ summary: Retrieve archive data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ firstScan:
+ type: string
+ format: date-time
+ description: The timestamp of the first scan
+ example: '1996-12-21T17:11:14.000Z'
+ lastScan:
+ type: string
+ format: date-time
+ description: The timestamp of the last scan
+ example: '2024-05-14T13:45:47.000Z'
+ totalScans:
+ type: integer
+ description: The total number of scans
+ example: 3393
+ changeCount:
+ type: integer
+ description: The total number of changes
+ example: 1946
+ averagePageSize:
+ type: integer
+ description: The average page size in KB
+ example: 527
+ scanFrequency:
+ type: object
+ properties:
+ daysBetweenScans:
+ type: number
+ format: float
+ description: Average days between scans
+ example: 2.95
+ daysBetweenChanges:
+ type: number
+ format: float
+ description: Average days between changes
+ example: 5.14
+ scansPerDay:
+ type: number
+ format: float
+ description: Number of scans per day
+ example: 0.34
+ changesPerDay:
+ type: number
+ format: float
+ description: Number of changes per day
+ example: 0.19
+ scans:
+ type: array
+ items:
+ type: array
+ items:
+ type: string
+ description: List of scan details
+ example:
+ - ['19961221171114', '200', 'RX44GNWXPO4HX6ERA2LHBORWD4BJ2HUJ', '971', null]
+ - ['19970209085620', '200', '6PUZMQAGXXV3RCEQ65WWUCMIJVSG4OQI', '1025', null]
+ - ['19970209085620', '200', 'RX44GNWXPO4HX6ERA2LHBORWD4BJ2HUJ', '975', null]
+ - ['19970412142046', '200', 'RX44GNWXPO4HX6ERA2LHBORWD4BJ2HUJ', '1022', null]
+ - ['19980206180754', '200', 'B6ACYSFKSPWKXRPASZYOK3IBIBY7HB3I', '1102', null]
+ scanUrl:
+ type: string
+ format: uri
+ description: The URL to the scan
+ example: 'https://duck.com'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /block-lists:
+ get:
+ summary: Retrieve block lists data
+ tags:
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ blocklists:
+ type: array
+ items:
+ type: object
+ properties:
+ server:
+ type: string
+ description: The name of the blocklist server
+ example: 'AdGuard'
+ serverIp:
+ type: string
+ description: The IP address of the blocklist server
+ example: '176.103.130.130'
+ isBlocked:
+ type: boolean
+ description: Whether the URL is blocked by the server
+ example: false
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /carbon:
+ get:
+ summary: Retrieve carbon data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ statistics:
+ type: object
+ properties:
+ adjustedBytes:
+ type: number
+ format: float
+ description: Adjusted bytes transferred
+ example: 104.19
+ energy:
+ type: number
+ format: float
+ description: Energy consumption in kWh
+ example: 7.859794422984123e-8
+ co2:
+ type: object
+ properties:
+ grid:
+ type: object
+ properties:
+ grams:
+ type: number
+ format: float
+ description: CO2 emissions in grams from grid energy
+ example: 0.00003474029134958983
+ litres:
+ type: number
+ format: float
+ description: CO2 emissions in litres from grid energy
+ example: 0.00001932255004864186
+ renewable:
+ type: object
+ properties:
+ grams:
+ type: number
+ format: float
+ description: CO2 emissions in grams from renewable energy
+ example: 0.000030118732228875164
+ litres:
+ type: number
+ format: float
+ description: CO2 emissions in litres from renewable energy
+ example: 0.000016752038865700363
+ cleanerThan:
+ type: integer
+ description: Percentage of websites that are less clean than the queried site
+ example: 1
+ rating:
+ type: string
+ description: Environmental rating
+ example: 'A+'
+ green:
+ type: boolean
+ description: Whether the site is green
+ example: false
+ scanUrl:
+ type: string
+ format: uri
+ description: The URL to the scan
+ example: 'https://duck.com'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /cookies:
+ get:
+ summary: Retrieve cookies data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ headerCookies:
+ type: array
+ items:
+ type: string
+ description: List of cookies from the HTTP headers
+ example:
+ - 'SOCS=CAAaBgiAmZWyBg; expires=Sun, 15-Jun-2025 12:33:07 GMT; path=/; domain=.google.com; Secure; SameSite=lax'
+ - 'AEC=AQTF6HyLiMgX13QRJxvRyBXFos8vw-et4igVlyhgeZaeLlfDgvkgkhQmSg; expires=Tue, 12-Nov-2024 12:33:07 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax'
+ - '__Secure-ENID=19.SE=YMEDMLYol9GgV7AzLehnn5lFsrHNqD3emYqfTaVxHQPMmAHiIqeKLyqdjfwED8gPGFN5Gb7OqkGiGTki34F94cir_SXxYvVJXTFuUU9hPAMaxoluY4JeeWKP3ma4RlsBZDKDUrBBVIA-PoPwMMBRyy8Fu0m_nOPTGjlz8WMhm_jMuHdWEF8; expires=Mon, 16-Jun-2025 04:51:25 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax'
+ clientCookies:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: The name of the cookie
+ example: '__Secure-ENID'
+ value:
+ type: string
+ description: The value of the cookie
+ example: '19.SE=LZ6Zj5jPrWer7Kk1PqTOWpwREJDJlMaxrCes3es4eXfLZ3-9e-HQ0tnRU-RNA_ezSw_CSyFbUWAd4A3FnTJGGYRsjK2FcqJfIum0UEcSy-oFXM49VreKYjxFyUNEshqsY_VQnZw1lFuRqRyH2JA2V90uHmaL_AVOlL_Myv1_PXwcPYgOCqsBQTkxYPeDVpS6QyHO9g'
+ domain:
+ type: string
+ description: The domain of the cookie
+ example: '.google.com'
+ path:
+ type: string
+ description: The path of the cookie
+ example: '/'
+ expires:
+ type: number
+ format: float
+ description: The expiration time of the cookie in Unix time
+ example: 1750049486.285637
+ size:
+ type: integer
+ description: The size of the cookie
+ example: 217
+ httpOnly:
+ type: boolean
+ description: Whether the cookie is HttpOnly
+ example: true
+ secure:
+ type: boolean
+ description: Whether the cookie is Secure
+ example: true
+ session:
+ type: boolean
+ description: Whether the cookie is a session cookie
+ example: false
+ sameSite:
+ type: string
+ description: The SameSite attribute of the cookie
+ example: 'Lax'
+ priority:
+ type: string
+ description: The priority of the cookie
+ example: 'Medium'
+ sameParty:
+ type: boolean
+ description: Whether the cookie is SameParty
+ example: false
+ sourceScheme:
+ type: string
+ description: The source scheme of the cookie
+ example: 'Secure'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /dns-server:
+ get:
+ summary: Retrieve DNS server data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ domain:
+ type: string
+ description: The domain name queried
+ example: 'duck.com'
+ dns:
+ type: array
+ items:
+ type: object
+ properties:
+ address:
+ type: string
+ description: The IP address of the DNS server
+ example: '52.142.124.215'
+ hostname:
+ type: array
+ items:
+ type: string
+ description: Hostnames associated with the DNS server
+ nullable: true
+ example:
+ - 'lhr48s30-in-f14.1e100.net'
+ dohDirectSupports:
+ type: boolean
+ description: Whether the server supports DoH (DNS over HTTPS) directly
+ example: false
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /dns:
+ get:
+ summary: Retrieve DNS data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ A:
+ type: object
+ properties:
+ address:
+ type: string
+ description: IPv4 address
+ example: '52.142.124.215'
+ family:
+ type: integer
+ description: IP family
+ example: 4
+ AAAA:
+ type: array
+ items:
+ type: string
+ description: List of IPv6 addresses
+ example: ['52.142.124.215']
+ MX:
+ type: array
+ items:
+ type: string
+ description: List of mail exchange servers
+ example: []
+ TXT:
+ type: array
+ items:
+ type: object
+ properties:
+ exchange:
+ type: string
+ description: Exchange server
+ example: 'smtp-inbound1.duck.com'
+ priority:
+ type: integer
+ description: Priority of the exchange server
+ example: 5
+ description: List of TXT records
+ NS:
+ type: array
+ items:
+ type: array
+ items:
+ type: string
+ description: List of name servers
+ example:
+ - [
+ 'v=spf1 ip4:20.13.235.192/26 ip4:20.67.221.0/24 ip4:20.67.222.0/24 ip4:20.67.223.0/24 -all',
+ ]
+ - ['google-site-verification=xWLxaaNt2iGObwJ_jeX1E3Wn-xro--W75DBKWs5uufc']
+ CNAME:
+ type: array
+ items:
+ type: string
+ description: List of canonical names
+ example:
+ - 'dns1.p03.nsone.net'
+ - 'dns2.p03.nsone.net'
+ - 'dns3.p03.nsone.net'
+ - 'dns4.p03.nsone.net'
+ - 'ns01.quack-dns.com'
+ - 'ns02.quack-dns.com'
+ - 'ns03.quack-dns.com'
+ - 'ns04.quack-dns.com'
+ SOA:
+ type: array
+ items:
+ type: string
+ description: Start of Authority records
+ example: []
+ SRV:
+ type: object
+ properties:
+ nsname:
+ type: string
+ description: Name server
+ example: 'dns1.p03.nsone.net'
+ hostmaster:
+ type: string
+ description: Hostmaster email
+ example: 'hostmaster.nsone.net'
+ serial:
+ type: integer
+ description: Serial number
+ example: 1654974460
+ refresh:
+ type: integer
+ description: Refresh interval
+ example: 7200
+ retry:
+ type: integer
+ description: Retry interval
+ example: 7200
+ expire:
+ type: integer
+ description: Expiration time
+ example: 1209600
+ minttl:
+ type: integer
+ description: Minimum TTL
+ example: 14400
+ description: Service records
+ PTR:
+ type: array
+ items:
+ type: string
+ description: Pointer records
+ example: []
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /dnssec:
+ get:
+ summary: Retrieve DNSSEC data
+ tags:
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ DNSKEY:
+ type: object
+ properties:
+ isFound:
+ type: boolean
+ description: Whether the DNSKEY record is found
+ example: false
+ answer:
+ type: string
+ nullable: true
+ description: The DNSKEY answer (if any)
+ example: null
+ response:
+ type: object
+ properties:
+ Status:
+ type: integer
+ description: The status code of the response
+ example: 3
+ TC:
+ type: boolean
+ description: Truncated response flag
+ example: false
+ RD:
+ type: boolean
+ description: Recursion desired flag
+ example: true
+ RA:
+ type: boolean
+ description: Recursion available flag
+ example: true
+ AD:
+ type: boolean
+ description: Authentic data flag
+ example: false
+ CD:
+ type: boolean
+ description: Checking disabled flag
+ example: false
+ Question:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Question name
+ example: 'https://duck.com.'
+ type:
+ type: integer
+ description: Question type
+ example: 48
+ Authority:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Authority name
+ example: 'com.'
+ type:
+ type: integer
+ description: Authority type
+ example: 6
+ TTL:
+ type: integer
+ description: Time to live
+ example: 900
+ data:
+ type: string
+ description: Authority data
+ example: 'a.gtld-servers.net. nstld.verisign-grs.com. 1715938809 1800 900 604800 86400'
+ Comment:
+ type: string
+ description: Additional comments
+ example: 'Response from 192.41.162.30.'
+ DS:
+ type: object
+ properties:
+ isFound:
+ type: boolean
+ description: Whether the DS record is found
+ example: false
+ answer:
+ type: string
+ nullable: true
+ description: The DS answer (if any)
+ example: null
+ response:
+ type: object
+ properties:
+ Status:
+ type: integer
+ description: The status code of the response
+ example: 3
+ TC:
+ type: boolean
+ description: Truncated response flag
+ example: false
+ RD:
+ type: boolean
+ description: Recursion desired flag
+ example: true
+ RA:
+ type: boolean
+ description: Recursion available flag
+ example: true
+ AD:
+ type: boolean
+ description: Authentic data flag
+ example: false
+ CD:
+ type: boolean
+ description: Checking disabled flag
+ example: false
+ Question:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Question name
+ example: 'https://duck.com.'
+ type:
+ type: integer
+ description: Question type
+ example: 43
+ Authority:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Authority name
+ example: 'com.'
+ type:
+ type: integer
+ description: Authority type
+ example: 6
+ TTL:
+ type: integer
+ description: Time to live
+ example: 900
+ data:
+ type: string
+ description: Authority data
+ example: 'a.gtld-servers.net. nstld.verisign-grs.com. 1715938824 1800 900 604800 86400'
+ Comment:
+ type: string
+ description: Additional comments
+ example: 'Response from 192.52.178.30.'
+ RRSIG:
+ type: object
+ properties:
+ isFound:
+ type: boolean
+ description: Whether the RRSIG record is found
+ example: false
+ answer:
+ type: string
+ nullable: true
+ description: The RRSIG answer (if any)
+ example: null
+ response:
+ type: object
+ properties:
+ Status:
+ type: integer
+ description: The status code of the response
+ example: 3
+ TC:
+ type: boolean
+ description: Truncated response flag
+ example: false
+ RD:
+ type: boolean
+ description: Recursion desired flag
+ example: true
+ RA:
+ type: boolean
+ description: Recursion available flag
+ example: true
+ AD:
+ type: boolean
+ description: Authentic data flag
+ example: false
+ CD:
+ type: boolean
+ description: Checking disabled flag
+ example: false
+ Question:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Question name
+ example: 'https://duck.com.'
+ type:
+ type: integer
+ description: Question type
+ example: 46
+ Authority:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: Authority name
+ example: 'com.'
+ type:
+ type: integer
+ description: Authority type
+ example: 6
+ TTL:
+ type: integer
+ description: Time to live
+ example: 900
+ data:
+ type: string
+ description: Authority data
+ example: 'a.gtld-servers.net. nstld.verisign-grs.com. 1715938809 1800 900 604800 86400'
+ Comment:
+ type: string
+ description: Additional comments
+ example: 'Response from 2001:502:7094::30.'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /firewall:
+ get:
+ summary: Retrieve firewall data
+ tags:
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ hasWaf:
+ type: boolean
+ description: Whether a Web Application Firewall (WAF) is present
+ example: false
+ waf:
+ type: string
+ nullable: true
+ description: The name of the WAF, if present
+ example: 'Cloudflare'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /get-ip:
+ get:
+ summary: Retrieve IP data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ip:
+ type: string
+ description: The IP address
+ example: '52.142.124.215'
+ family:
+ type: integer
+ description: The IP family (4 for IPv4, 6 for IPv6)
+ example: 4
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /headers:
+ get:
+ summary: Retrieve headers data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties:
+ oneOf:
+ - type: string
+ - type: array
+ items:
+ type: string
+ example:
+ date: 'Fri, 17 May 2024 11:51:38 GMT'
+ content-type: 'text/html; charset=utf-8'
+ transfer-encoding: 'chunked'
+ connection: 'keep-alive'
+ cache-control: 'public, max-age=0, must-revalidate'
+ strict-transport-security: 'max-age=31536000; includeSubDomains'
+ permissions-policy: 'geolocation=(), camera=(), microphone=()'
+ referrer-policy: 'strict-origin-when-cross-origin'
+ x-content-type-options: 'nosniff'
+ x-frame-options: 'SAMEORIGIN'
+ x-gww-loc: 'EN-US'
+ x-pgs-loc: 'EN-US'
+ x-rm: 'GW'
+ x-xss-protection: '1; mode=block'
+ vary: 'Accept-Encoding'
+ server: 'cloudflare'
+ cf-ray: '8853658f9da5940b-LHR'
+ alt-svc: 'h3=":443"; ma=86400'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /hsts:
+ get:
+ summary: Retrieve HSTS data
+ tags:
+ - Server Info
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ description: A message regarding the HSTS status
+ example: 'HSTS header does not include all subdomains.'
+ compatible:
+ type: boolean
+ description: Whether the site is compatible with HSTS
+ example: false
+ hstsHeader:
+ type: string
+ nullable: true
+ description: The HSTS header if present
+ example: null
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /http-security:
+ get:
+ summary: Retrieve HTTP security data
+ tags:
+ - Server Info
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ strictTransportPolicy:
+ type: boolean
+ description: Whether Strict Transport Security is enabled
+ example: false
+ xFrameOptions:
+ type: boolean
+ description: Whether X-Frame-Options header is set
+ example: true
+ xContentTypeOptions:
+ type: boolean
+ description: Whether X-Content-Type-Options header is set
+ example: false
+ xXSSProtection:
+ type: boolean
+ description: Whether X-XSS-Protection header is set
+ example: true
+ contentSecurityPolicy:
+ type: boolean
+ description: Whether Content Security Policy is enabled
+ example: false
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /linked-pages:
+ get:
+ summary: Retrieve linked pages data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ internal:
+ type: array
+ items:
+ type: string
+ description: List of internal links
+ example:
+ - 'https://bbc.com/news/business'
+ - 'https://bbc.com/news/entertainment_and_arts'
+ - 'https://bbc.com/news/uk'
+ - 'https://bbc.com/news/world'
+ - 'https://bbc.com/#chameleon-global-navigation-more-menu'
+ - 'https://bbc.com/sport/golf'
+ - 'https://bbc.com/sport'
+ external:
+ type: array
+ items:
+ type: string
+ description: List of external links
+ example:
+ - 'https://www.bbc.co.uk/'
+ - 'https://www.bbc.co.uk/news'
+ - 'https://www.bbc.co.uk/sport'
+ - 'https://www.bbc.co.uk/weather'
+ - 'https://www.bbc.co.uk/iplayer'
+ - 'https://www.bbc.co.uk/sounds'
+ - 'https://www.bbc.co.uk/bitesize'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /mail-config:
+ get:
+ summary: Retrieve mail configuration data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ mxRecords:
+ type: array
+ items:
+ type: object
+ properties:
+ exchange:
+ type: string
+ description: The mail exchange server
+ example: 'smtp.google.com'
+ priority:
+ type: integer
+ description: The priority of the mail exchange server
+ example: 10
+ description: List of MX (Mail Exchange) records
+ txtRecords:
+ type: array
+ items:
+ type: array
+ items:
+ type: string
+ description: List of TXT records
+ example:
+ - ['v=spf1 include:_spf.google.com ~all']
+ - ['google-site-verification=wD8N7i1JTNTkezJ49swvWW48f8_9xveREV4oB-0Hf5o']
+ mailServices:
+ type: array
+ items:
+ type: object
+ properties:
+ provider:
+ type: string
+ description: The mail service provider
+ example: 'Google Workspace'
+ value:
+ type: string
+ description: The verification value for the mail service
+ example: 'wD8N7i1JTNTkezJ49swvWW48f8_9xveREV4oB-0Hf5o'
+ description: List of mail services and their verification values
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /ports:
+ get:
+ summary: Retrieve open and failed ports data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ openPorts:
+ type: array
+ items:
+ type: integer
+ description: List of open ports
+ example:
+ - 80
+ - 443
+ - 8080
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /quality:
+ get:
+ summary: Retrieve website quality metrics
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL of the website to analyze
+ schema:
+ type: string
+ - name: apiKey
+ in: query
+ required: true
+ description: The API key for accessing Google PageSpeed Insights
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ performance:
+ type: object
+ description: Performance category data
+ properties:
+ score:
+ type: number
+ format: float
+ description: Performance score
+ example: 0.85
+ accessibility:
+ type: object
+ description: Accessibility category data
+ properties:
+ score:
+ type: number
+ format: float
+ description: Accessibility score
+ example: 0.92
+ best_practices:
+ type: object
+ description: Best Practices category data
+ properties:
+ score:
+ type: number
+ format: float
+ description: Best Practices score
+ example: 0.88
+ seo:
+ type: object
+ description: SEO category data
+ properties:
+ score:
+ type: number
+ format: float
+ description: SEO score
+ example: 0.95
+ pwa:
+ type: object
+ description: Progressive Web App category data
+ properties:
+ score:
+ type: number
+ format: float
+ description: PWA score
+ example: 0.75
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /rank:
+ get:
+ summary: Retrieve rank data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch rank data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ranks:
+ type: array
+ items:
+ type: object
+ properties:
+ date:
+ type: string
+ format: date
+ description: The date of the rank
+ example: '2024-05-16'
+ rank:
+ type: integer
+ description: The rank value
+ example: 145896
+ description: List of rank entries
+ domain:
+ type: string
+ description: The domain name for which rank data is provided
+ example: 'duck.com'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /redirects:
+ get:
+ summary: Retrieve redirects data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch redirect data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ redirects:
+ type: array
+ items:
+ type: string
+ description: List of URLs the given URL redirects to
+ example:
+ - 'https://duck.com'
+ - 'https://duckduckgo.com/'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /robots-txt:
+ get:
+ summary: Retrieve robots.txt data
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch robots.txt data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ robots:
+ type: array
+ items:
+ type: object
+ properties:
+ lbl:
+ type: string
+ description: The label of the robots.txt entry (e.g., User-agent, Disallow, Allow)
+ example: 'User-agent'
+ val:
+ type: string
+ description: The value of the robots.txt entry
+ example: '*'
+ description: List of robots.txt entries
+ example:
+ - lbl: 'User-agent'
+ val: '*'
+ - lbl: 'Disallow'
+ val: '/lite'
+ - lbl: 'Disallow'
+ val: '/html'
+ - lbl: 'Disallow'
+ val: '/*?'
+ - lbl: 'Disallow'
+ val: '/chrome_newtab'
+ - lbl: 'Disallow'
+ val: '/email/'
+ - lbl: 'Allow'
+ val: '/email/$'
+ - lbl: 'Allow'
+ val: '/email/privacy-guarantees'
+ - lbl: 'Allow'
+ val: '/email/privacy-terms'
+ - lbl: 'Disallow'
+ val: '/2012-privacy-policy'
+ - lbl: 'User-agent'
+ val: 'ia_archiver'
+ - lbl: 'Disallow'
+ val: '/'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /screenshot:
+ get:
+ summary: Retrieve screenshot data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties: {}
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /security-txt:
+ get:
+ summary: Retrieve security.txt data
+ tags:
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch security.txt data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ isPresent:
+ type: boolean
+ description: Whether the security.txt file is present
+ example: true
+ foundIn:
+ type: string
+ nullable: true
+ description: The location where the security.txt file was found
+ example: '/.well-known/security.txt'
+ content:
+ type: string
+ nullable: true
+ description: The content of the security.txt file
+ example: "Contact: mailto:security@proton.me\nExpires: 2024-12-31T23:59:59.999Z\nEncryption: https://api.protonmail.ch/pks/lookup?op=get&search=security@proton.me\nPreferred-Languages: en\nCanonical: https://proton.me/.well-known/security.txt\nPolicy: https://proton.me/blog/protonmail-bug-bounty-program\nHiring: https://proton.me/careers\n"
+ isPgpSigned:
+ type: boolean
+ description: Whether the security.txt file is PGP signed
+ example: false
+ fields:
+ type: object
+ nullable: true
+ description: Key-value pairs of the security.txt fields
+ properties:
+ Contact:
+ type: string
+ description: Contact information
+ example: 'mailto:security@proton.me'
+ Expires:
+ type: string
+ format: date-time
+ description: Expiry date of the security.txt information
+ example: '2024-12-31T23:59:59.999Z'
+ Encryption:
+ type: string
+ description: Encryption key location
+ example: 'https://api.protonmail.ch/pks/lookup?op=get&search=security@proton.me'
+ Preferred-Languages:
+ type: string
+ description: Preferred languages for contact
+ example: 'en'
+ Canonical:
+ type: string
+ description: Canonical URL for the security.txt file
+ example: 'https://proton.me/.well-known/security.txt'
+ Policy:
+ type: string
+ description: Policy URL
+ example: 'https://proton.me/blog/protonmail-bug-bounty-program'
+ Hiring:
+ type: string
+ description: Hiring information URL
+ example: 'https://proton.me/careers'
+ Acknowledgments:
+ type: string
+ nullable: true
+ description: Acknowledgments information
+ example: 'https://hackerone.com/github/hacktivity'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /sitemap:
+ get:
+ summary: Retrieve sitemap data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch sitemap data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ urlset:
+ type: object
+ properties:
+ $:
+ type: object
+ properties:
+ xmlns:
+ type: string
+ description: XML namespace
+ example: 'http://www.sitemaps.org/schemas/sitemap/0.9'
+ xmlns:xsi:
+ type: string
+ description: XML Schema instance namespace
+ example: 'http://www.w3.org/2001/XMLSchema-instance'
+ xsi:schemaLocation:
+ type: string
+ description: Schema location
+ example: 'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd'
+ url:
+ type: array
+ items:
+ type: object
+ properties:
+ loc:
+ type: array
+ items:
+ type: string
+ description: The URL of the page
+ example: 'https://duckduckgo.com/'
+ lastmod:
+ type: array
+ items:
+ type: string
+ format: date
+ description: The last modification date of the page
+ example: '2023-08-22'
+ changefreq:
+ type: array
+ items:
+ type: string
+ description: The frequency of changes to the page
+ example: 'monthly'
+ priority:
+ type: array
+ items:
+ type: number
+ format: float
+ description: The priority of the page
+ example: 1.00
+ description: |
+ Note: The structure might need changing to show pages below each user-agent.
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /social-tags:
+ get:
+ summary: Retrieve social media tags data
+ tags:
+ - Quality & Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch social tags data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ title:
+ type: string
+ description: The title of the page
+ example: 'DuckDuckGo — Privacy, simplified.'
+ description:
+ type: string
+ description: The description of the page
+ example: 'The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.'
+ canonicalUrl:
+ type: string
+ description: The canonical URL of the page
+ example: 'https://duckduckgo.com'
+ ogTitle:
+ type: string
+ description: The Open Graph title of the page
+ example: 'DuckDuckGo — Privacy, simplified.'
+ ogType:
+ type: string
+ description: The Open Graph type of the page
+ example: 'website'
+ ogImage:
+ type: string
+ description: The Open Graph image URL of the page
+ example: 'https://duckduckgo.com/assets/logo_social-media.png'
+ ogUrl:
+ type: string
+ description: The Open Graph URL of the page
+ example: 'https://duckduckgo.com'
+ ogDescription:
+ type: string
+ description: The Open Graph description of the page
+ example: 'The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.'
+ ogSiteName:
+ type: string
+ description: The Open Graph site name of the page
+ example: 'DuckDuckGo'
+ twitterCard:
+ type: string
+ description: The Twitter card type of the page
+ example: 'summary_large_image'
+ twitterSite:
+ type: string
+ description: The Twitter handle of the site
+ example: '@duckduckgo'
+ twitterTitle:
+ type: string
+ description: The Twitter title of the page
+ example: 'DuckDuckGo — Privacy, simplified.'
+ twitterDescription:
+ type: string
+ description: The Twitter description of the page
+ example: 'The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.'
+ twitterImage:
+ type: string
+ description: The Twitter image URL of the page
+ example: 'https://duckduckgo.com/assets/logo_social-media.png'
+ viewport:
+ type: string
+ description: The viewport settings of the page
+ example: 'width=device-width, initial-scale=1, user-scalable=1 , viewport-fit=auto'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /ssl:
+ get:
+ summary: Retrieve SSL certificate data
+ tags:
+ - Security
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch SSL certificate data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ subject:
+ type: object
+ description: The subject of the SSL certificate
+ properties:
+ C:
+ type: string
+ description: Country
+ example: 'US'
+ ST:
+ type: string
+ description: State or province
+ example: 'Pennsylvania'
+ L:
+ type: string
+ description: Locality or city
+ example: 'Paoli'
+ O:
+ type: string
+ description: Organization
+ example: 'Duck Duck Go, Inc.'
+ CN:
+ type: string
+ description: Common Name
+ example: '*.duck.com'
+ issuer:
+ type: object
+ description: The issuer of the SSL certificate
+ properties:
+ C:
+ type: string
+ description: Country
+ example: 'US'
+ O:
+ type: string
+ description: Organization
+ example: 'DigiCert Inc'
+ CN:
+ type: string
+ description: Common Name
+ example: 'DigiCert Global G2 TLS RSA SHA256 2020 CA1'
+ subjectaltname:
+ type: string
+ description: Subject alternative name
+ example: 'DNS:*.duck.com, DNS:duck.com'
+ infoAccess:
+ type: object
+ description: Information Access details
+ properties:
+ 'OCSP - URI':
+ type: array
+ items:
+ type: string
+ description: OCSP URI
+ example:
+ - 'http://ocsp.digicert.com'
+ 'CA Issuers - URI':
+ type: array
+ items:
+ type: string
+ description: CA Issuers URI
+ example:
+ - 'http://cacerts.digicert.com/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt'
+ ca:
+ type: boolean
+ description: Whether it is a CA certificate
+ example: false
+ modulus:
+ type: string
+ description: The modulus of the public key
+ example: 'A4B4F5B418A538F2570010FEDA319C3F73400EF8C20BB36668B34AE776233C5F06BE0572E36180FD8064D8AC41BD54AD2C6BAAEDEB31308F37B11EED1CD5D2F046C8B6B16381C4E80E817352C16B6F2A8687589BB564BF4B3C87A45284DB9A8240805002846A067C8CCEB50290D2A799907DFBF6EB0432528F413C5CC913F0230374250062C027664240E5A3A8574B8914F26216CEF076CECE04427A489137EA5AACFCD446827432C18CFF5E31AF0F3999B60A303925082FDDB1535513D6B131497F003397FD1EA4D58C24640261E1C205F33A83FDEE9D8C48A638F98927CEFE42C7DD3852C62AD62ECA74A417DE79290755689B3A6D19E44152695A5872491F'
+ bits:
+ type: integer
+ description: The number of bits in the key
+ example: 2048
+ exponent:
+ type: string
+ description: The public exponent
+ example: '0x10001'
+ pubkey:
+ type: object
+ description: The public key
+ properties:
+ type:
+ type: string
+ description: Type of the public key data
+ example: 'Buffer'
+ data:
+ type: array
+ items:
+ type: integer
+ description: The public key data
+ example:
+ [
+ 48,
+ 130,
+ 1,
+ 34,
+ 48,
+ 13,
+ 6,
+ 9,
+ 42,
+ 134,
+ 72,
+ 134,
+ 247,
+ 13,
+ 1,
+ 1,
+ 1,
+ 5,
+ 0,
+ 3,
+ 130,
+ 1,
+ 15,
+ 0,
+ 48,
+ 130,
+ 1,
+ 10,
+ 2,
+ 130,
+ 1,
+ 1,
+ 0,
+ 164,
+ 180,
+ 245,
+ 180,
+ 24,
+ 165,
+ 56,
+ 242,
+ 87,
+ 0,
+ 16,
+ 254,
+ 218,
+ 49,
+ 156,
+ 63,
+ 115,
+ 64,
+ 14,
+ 248,
+ 194,
+ 11,
+ 179,
+ 102,
+ 104,
+ 179,
+ 74,
+ 231,
+ 118,
+ 35,
+ 60,
+ 95,
+ 6,
+ 190,
+ 5,
+ 114,
+ 227,
+ 97,
+ 128,
+ 253,
+ 128,
+ 100,
+ 216,
+ 172,
+ 65,
+ 189,
+ 84,
+ 173,
+ 44,
+ 107,
+ 170,
+ 237,
+ 235,
+ 49,
+ 48,
+ 143,
+ 55,
+ 177,
+ 30,
+ 237,
+ 28,
+ 213,
+ 210,
+ 240,
+ 70,
+ 200,
+ 182,
+ 177,
+ 99,
+ 129,
+ 196,
+ 232,
+ 14,
+ 129,
+ 115,
+ 82,
+ 193,
+ 107,
+ 111,
+ 42,
+ 134,
+ 135,
+ 88,
+ 155,
+ 181,
+ 100,
+ 191,
+ 75,
+ 60,
+ 135,
+ 164,
+ 82,
+ 132,
+ 219,
+ 154,
+ 130,
+ 64,
+ 128,
+ 80,
+ 2,
+ 132,
+ 106,
+ 6,
+ 124,
+ 140,
+ 206,
+ 181,
+ 2,
+ 144,
+ 210,
+ 167,
+ 153,
+ 144,
+ 125,
+ 251,
+ 246,
+ 235,
+ 4,
+ 50,
+ 82,
+ 143,
+ 65,
+ 60,
+ 92,
+ 201,
+ 19,
+ 240,
+ 35,
+ 3,
+ 116,
+ 37,
+ 0,
+ 98,
+ 192,
+ 39,
+ 102,
+ 66,
+ 64,
+ 229,
+ 163,
+ 168,
+ 87,
+ 75,
+ 137,
+ 20,
+ 242,
+ 98,
+ 22,
+ 206,
+ 240,
+ 118,
+ 206,
+ 206,
+ 4,
+ 66,
+ 122,
+ 72,
+ 145,
+ 55,
+ 234,
+ 90,
+ 172,
+ 252,
+ 212,
+ 70,
+ 130,
+ 116,
+ 50,
+ 193,
+ 140,
+ 255,
+ 94,
+ 49,
+ 175,
+ 15,
+ 57,
+ 153,
+ 182,
+ 10,
+ 48,
+ 57,
+ 37,
+ 8,
+ 47,
+ 221,
+ 177,
+ 83,
+ 85,
+ 19,
+ 214,
+ 177,
+ 49,
+ 73,
+ 127,
+ 0,
+ 51,
+ 151,
+ 253,
+ 30,
+ 164,
+ 213,
+ 140,
+ 36,
+ 100,
+ 2,
+ 97,
+ 225,
+ 194,
+ 5,
+ 243,
+ 58,
+ 131,
+ 253,
+ 238,
+ 157,
+ 140,
+ 72,
+ 166,
+ 56,
+ 249,
+ 137,
+ 39,
+ 206,
+ 254,
+ 66,
+ 199,
+ 221,
+ 56,
+ 82,
+ 198,
+ 42,
+ 214,
+ 46,
+ 202,
+ 116,
+ 164,
+ 23,
+ 222,
+ 121,
+ 41,
+ 7,
+ 85,
+ 104,
+ 155,
+ 58,
+ 109,
+ 25,
+ 228,
+ 65,
+ 82,
+ 105,
+ 90,
+ 88,
+ 114,
+ 73,
+ 31,
+ 2,
+ 3,
+ 1,
+ 0,
+ 1,
+ ]
+ valid_from:
+ type: string
+ description: The start date of the certificate's validity period
+ example: 'Oct 3 00:00:00 2023 GMT'
+ valid_to:
+ type: string
+ description: The end date of the certificate's validity period
+ example: 'Nov 2 23:59:59 2024 GMT'
+ fingerprint:
+ type: string
+ description: The SHA-1 fingerprint of the certificate
+ example: '54:3C:CB:82:A2:33:D0:CB:81:4D:7D:2C:AA:E6:84:CE:37:9A:0B:7A'
+ fingerprint256:
+ type: string
+ description: The SHA-256 fingerprint of the certificate
+ example: 'F3:64:C5:26:C1:BD:31:DE:67:BB:98:57:96:7B:B6:9B:C7:1A:CD:44:21:29:62:4C:CE:E2:C9:DF:49:73:DE:05'
+ fingerprint512:
+ type: string
+ description: The SHA-512 fingerprint of the certificate
+ example: 'FC:5A:8F:4D:27:DC:A1:4B:2B:31:8A:DD:37:DF:7E:7B:64:18:49:C9:B8:AB:29:9E:B2:5E:F6:43:36:D0:8B:9D:D0:3E:10:83:4D:B2:78:DF:E2:44:B3:7F:70:22:C7:1F:29:81:72:9E:D5:F2:7C:25:2A:02:FE:90:75:D6:35:CB'
+ ext_key_usage:
+ type: array
+ items:
+ type: string
+ description: Extended key usage
+ example:
+ - '1.3.6.1.5.5.7.3.1'
+ - '1.3.6.1.5.5.7.3.2'
+ serialNumber:
+ type: string
+ description: The serial number of the certificate
+ example: '0ABA674EF6840EFD177509B0BF82C4A5'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /status:
+ get:
+ summary: Retrieve website status
+ tags:
+ - Server Info
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to check the status of
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ isUp:
+ type: boolean
+ description: Whether the website is up
+ example: true
+ responseTime:
+ type: number
+ format: float
+ description: The response time in milliseconds
+ example: 105.34512500000005
+ responseCode:
+ type: integer
+ description: The HTTP response code
+ example: 302
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /tech-stack:
+ get:
+ summary: Retrieve technology stack data
+ tags:
+ - Quality & Info
+ - Client-Side Information
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch technology stack data about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ urls:
+ type: object
+ description: Status of different URLs
+ additionalProperties:
+ type: object
+ properties:
+ status:
+ type: integer
+ description: HTTP status code
+ example: 302
+ example:
+ 'https://duck.com/':
+ status: 302
+ 'https://duckduckgo.com/':
+ status: 200
+ technologies:
+ type: array
+ description: List of detected technologies
+ items:
+ type: object
+ properties:
+ slug:
+ type: string
+ description: Slug identifier for the technology
+ example: 'node-js'
+ name:
+ type: string
+ description: Name of the technology
+ example: 'Node.js'
+ description:
+ type: string
+ nullable: true
+ description: Description of the technology
+ example: 'Node.js is an open-source, cross-platform, JavaScript runtime environment that executes JavaScript code outside a web browser.'
+ confidence:
+ type: integer
+ description: Confidence level of the detection
+ example: 100
+ version:
+ type: string
+ nullable: true
+ description: Detected version of the technology
+ example: '12.3.4'
+ icon:
+ type: string
+ description: Icon file name for the technology
+ example: 'Node.js.svg'
+ website:
+ type: string
+ description: Official website for the technology
+ example: 'https://nodejs.org'
+ cpe:
+ type: string
+ nullable: true
+ description: Common Platform Enumeration (CPE) identifier
+ example: 'cpe:2.3:a:nodejs:node.js:*:*:*:*:*:*:*:*'
+ categories:
+ type: array
+ description: List of categories the technology belongs to
+ items:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: Category ID
+ example: 27
+ slug:
+ type: string
+ description: Category slug
+ example: 'programming-languages'
+ name:
+ type: string
+ description: Category name
+ example: 'Programming languages'
+ rootPath:
+ type: boolean
+ description: Whether the technology is detected at the root path
+ example: true
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /threats:
+ get:
+ summary: Retrieve threats data
+ tags:
+ - Security
+ parameters:
+ - name: url
+ in: query
+ required: true
+ description: The URL to fetch results about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties: {}
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /tls:
+ get:
+ summary: Retrieve TLS information for a target
+ tags:
+ - Security
+ - Server Info
+ parameters:
+ - name: target
+ in: query
+ required: true
+ description: The target domain to fetch TLS information about
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: The ID of the TLS scan
+ example: 57369181
+ timestamp:
+ type: string
+ format: date-time
+ description: Timestamp of the scan
+ example: '2024-05-18T13:52:49.230114Z'
+ target:
+ type: string
+ description: Target domain of the TLS scan
+ example: 'duck.com'
+ replay:
+ type: integer
+ description: Replay value
+ example: -1
+ has_tls:
+ type: boolean
+ description: Indicates if TLS is present
+ example: true
+ cert_id:
+ type: integer
+ description: Certificate ID
+ example: 189148642
+ trust_id:
+ type: integer
+ description: Trust ID
+ example: 343378161
+ is_valid:
+ type: boolean
+ description: Indicates if the TLS certificate is valid
+ example: true
+ completion_perc:
+ type: integer
+ description: Completion percentage of the scan
+ example: 100
+ connection_info:
+ type: object
+ description: Connection information of the TLS scan
+ properties:
+ scanIP:
+ type: string
+ description: Scan IP address
+ example: '52.250.42.157'
+ serverside:
+ type: boolean
+ description: Indicates if the scan is server-side
+ example: true
+ ciphersuite:
+ type: array
+ description: List of cipher suites used
+ items:
+ type: object
+ properties:
+ cipher:
+ type: string
+ description: Name of the cipher suite
+ example: 'ECDHE-RSA-AES128-GCM-SHA256'
+ code:
+ type: integer
+ description: Cipher code
+ example: 49199
+ protocols:
+ type: array
+ description: Supported protocols
+ items:
+ type: string
+ example: 'TLSv1.2'
+ pubkey:
+ type: integer
+ description: Public key length
+ example: 2048
+ sigalg:
+ type: string
+ description: Signature algorithm
+ example: 'sha256WithRSAEncryption'
+ ticket_hint:
+ type: string
+ description: Ticket hint
+ example: '1200'
+ ocsp_stapling:
+ type: boolean
+ description: Indicates if OCSP stapling is enabled
+ example: false
+ pfs:
+ type: string
+ description: Perfect forward secrecy information
+ example: 'ECDH,P-256,256bits'
+ curves:
+ type: array
+ description: Supported curves
+ items:
+ type: string
+ example: 'prime256v1'
+ curvesFallback:
+ type: boolean
+ description: Indicates if curve fallback is used
+ example: false
+ analysis:
+ type: array
+ description: Analysis results
+ items:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: Analyzer ID
+ example: 157573176
+ analyzer:
+ type: string
+ description: Analyzer name
+ example: 'awsCertlint'
+ result:
+ type: object
+ description: Analyzer result
+ properties:
+ bugs:
+ type: array
+ nullable: true
+ description: List of bugs found
+ items:
+ type: string
+ errors:
+ type: array
+ nullable: true
+ description: List of errors found
+ items:
+ type: string
+ notices:
+ type: array
+ nullable: true
+ description: List of notices found
+ items:
+ type: string
+ warnings:
+ type: array
+ nullable: true
+ description: List of warnings found
+ items:
+ type: string
+ fatalErrors:
+ type: array
+ nullable: true
+ description: List of fatal errors found
+ items:
+ type: string
+ informational:
+ type: array
+ nullable: true
+ description: List of informational messages
+ items:
+ type: string
+ host:
+ type: string
+ description: Host information
+ example: ''
+ issue:
+ type: array
+ nullable: true
+ description: List of issues found
+ items:
+ type: string
+ has_caa:
+ type: boolean
+ description: Indicates if CAA is present
+ example: false
+ issuewild:
+ type: array
+ nullable: true
+ description: List of wildcard issues found
+ items:
+ type: string
+ revoked:
+ type: boolean
+ description: Indicates if the certificate is revoked
+ example: false
+ RevocationTime:
+ type: string
+ format: date-time
+ description: Time of revocation
+ example: '0001-01-01T00:00:00Z'
+ level:
+ type: string
+ description: Mozilla evaluation level
+ example: 'intermediate'
+ failures:
+ type: object
+ description: List of failures
+ properties:
+ bad:
+ type: array
+ nullable: true
+ description: List of bad failures
+ items:
+ type: string
+ old:
+ type: array
+ nullable: true
+ description: List of old failures
+ items:
+ type: string
+ example:
+ [
+ 'sha256WithRSAEncryption is not an old certificate signature, use sha1WithRSAEncryption',
+ 'consider adding ciphers ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, DHE-DSS-AES128-GCM-SHA256, DHE-DSS-AES256-GCM-SHA384, DHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-AES256-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA, DHE-DSS-AES128-SHA256, DHE-RSA-AES256-SHA256, DHE-DSS-AES256-SHA, DHE-RSA-AES256-SHA, ECDHE-RSA-DES-CBC3-SHA, ECDHE-ECDSA-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, DHE-DSS-AES256-SHA256, DHE-DSS-AES128-SHA, DES-CBC3-SHA, DHE-RSA-CHACHA20-POLY1305, ECDHE-RSA-CAMELLIA256-SHA384, ECDHE-ECDSA-CAMELLIA256-SHA384, DHE-RSA-CAMELLIA256-SHA256, DHE-DSS-CAMELLIA256-SHA256, DHE-RSA-CAMELLIA256-SHA, DHE-DSS-CAMELLIA256-SHA, CAMELLIA256-SHA256, CAMELLIA256-SHA, ECDHE-RSA-CAMELLIA128-SHA256, ECDHE-ECDSA-CAMELLIA128-SHA256, DHE-RSA-CAMELLIA128-SHA256, DHE-DSS-CAMELLIA128-SHA256, DHE-RSA-CAMELLIA128-SHA, DHE-DSS-CAMELLIA128-SHA, CAMELLIA128-SHA256, CAMELLIA128-SHA, DHE-RSA-SEED-SHA, DHE-DSS-SEED-SHA, SEED-SHA',
+ 'add protocols TLSv1.1, TLSv1, SSLv3',
+ 'consider enabling OCSP stapling',
+ 'add cipher DES-CBC3-SHA for backward compatibility',
+ ]
+ modern:
+ type: array
+ nullable: true
+ description: List of modern failures
+ items:
+ type: string
+ example:
+ [
+ 'remove ciphersuites ECDHE-RSA-AES128-SHA, AES128-GCM-SHA256, AES128-SHA256, AES128-SHA, ECDHE-RSA-AES256-SHA, AES256-GCM-SHA384, AES256-SHA256, AES256-SHA',
+ 'consider adding ciphers ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-AES128-SHA256',
+ 'consider enabling OCSP stapling',
+ 'use a certificate of type ecdsa, not RSA',
+ ]
+ intermediate:
+ type: array
+ nullable: true
+ description: List of intermediate failures
+ items:
+ type: string
+ example:
+ [
+ 'consider adding ciphers ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, DHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES128-SHA256, ECDHE-ECDSA-AES128-SHA, ECDHE-ECDSA-AES256-SHA384, ECDHE-ECDSA-AES256-SHA, DHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA, DHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA, ECDHE-ECDSA-DES-CBC3-SHA, ECDHE-RSA-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA, DES-CBC3-SHA',
+ 'add protocols TLSv1.1, TLSv1',
+ 'consider enabling OCSP stapling',
+ 'increase priority of ECDHE-RSA-AES256-GCM-SHA384 over AES128-SHA',
+ 'fix ciphersuite ordering, use recommended intermediate ciphersuite',
+ ]
+ grade:
+ type: integer
+ description: Mozilla grading worker grade
+ example: 93
+ lettergrade:
+ type: string
+ description: Mozilla grading worker letter grade
+ example: 'A'
+ rank:
+ type: integer
+ description: Rank of the target domain
+ example: 2147483647
+ domain:
+ type: string
+ description: Domain name
+ example: 'duck.com'
+ alexa_rank:
+ type: integer
+ description: Alexa rank of the domain
+ example: 2147483647
+ cisco_rank:
+ type: integer
+ description: Cisco rank of the domain
+ example: 2147483647
+ alexa_domain:
+ type: string
+ description: Alexa domain name
+ example: 'duck.com'
+ cisco_domain:
+ type: string
+ description: Cisco domain name
+ example: 'duck.com'
+ reasons:
+ type: array
+ description: List of reasons for distrust
+ items:
+ type: string
+ example:
+ [
+ 'path uses a blacklisted cert: C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=(c) 2006 VeriSign, Inc. - For authorized use only, CN=VeriSign Class 3 Public Primary Certification Authority - G5 (id=123)',
+ 'whitelisted intermediate C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G2 (id=187368699) override blacklisting of 123',
+ 'path uses a blacklisted cert: C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=(c) 2006 VeriSign, Inc. - For authorized use only, CN=VeriSign Class 3 Public Primary Certification Authority - G5 (id=188669208)',
+ 'whitelisted intermediate C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G2 (id=187368699) override blacklisting of 188669208',
+ 'path uses a root not trusted by Mozilla: C=IE, O=Baltimore, OU=CyberTrust, CN=Baltimore CyberTrust Root (id=16)',
+ 'whitelisted intermediate C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G2 (id=189284449) override blacklisting of 34174538',
+ 'whitelisted intermediate C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G2 (id=189094508) override blacklisting of 34174538',
+ 'path uses a blacklisted cert: C=US, O=VeriSign, Inc., OU=VeriSign Trust Network, OU=(c) 2008 VeriSign, Inc. - For authorized use only, CN=VeriSign Universal Root Certification Authority (id=124)',
+ 'whitelisted intermediate C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G2 (id=188535492) override blacklisting of 124',
+ ]
+ isDistrusted:
+ type: boolean
+ description: Indicates if the certificate is distrusted
+ example: false
+ ack:
+ type: boolean
+ description: Acknowledgment flag
+ example: true
+ attempts:
+ type: integer
+ description: Number of attempts
+ example: 1
+ analysis_params:
+ type: object
+ description: Parameters used for the analysis
+ example: {}
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /trace-route:
+ get:
+ summary: Perform a traceroute to the specified URL
+ tags:
+ - Server Info
+ parameters:
+ - name: urlString
+ in: query
+ required: true
+ description: The URL to trace the route to
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ description: The status message of the traceroute
+ example: 'Traceroute completed!'
+ result:
+ type: array
+ description: The result of the traceroute
+ items:
+ oneOf:
+ - type: object
+ additionalProperties:
+ type: array
+ items:
+ type: number
+ description: The response time in milliseconds
+ example: 2.015
+ - type: boolean
+ description: Indicates if a hop was not found
+ example: false
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /txt-records:
+ get:
+ summary: Retrieve the TXT records for a specified domain
+ tags:
+ - Server Info
+ parameters:
+ - name: domain
+ in: query
+ required: true
+ description: The domain to retrieve TXT records for
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties:
+ type: string
+ description: The value of the TXT record
+ example:
+ brave-ledger-verification: 'b9067df1ce0bb02c06924b88b62143d0c3c0b9c03a8f5c4aba3c0d0ec99f0a98'
+ google-site-verification: 'qFBMUPljNGHE2S-NwmQmYvxpa58UfmmIidAbJG9BiuM'
+ pinterest-site-verification: 'c71cdf6ff37b315830c4af9d98cf2add'
+ protonmail-verification: '2493afe89892e5b986b59415cdb104152c24f29e'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
+ /whois:
+ get:
+ summary: Retrieve WHOIS information for a specified domain
+ tags:
+ - Server Info
+ parameters:
+ - name: domain
+ in: query
+ required: true
+ description: The domain to retrieve WHOIS information for
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Successful response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ internicData:
+ type: object
+ properties:
+ Domain_Name:
+ type: string
+ Registry_Domain_ID:
+ type: string
+ Registrar_WHOIS_Server:
+ type: string
+ Registrar_URL:
+ type: string
+ Updated_Date:
+ type: string
+ format: date-time
+ Creation_Date:
+ type: string
+ format: date-time
+ Registry_Expiry_Date:
+ type: string
+ format: date-time
+ Registrar:
+ type: string
+ Registrar_IANA_ID:
+ type: string
+ Domain_Status:
+ type: string
+ Name_Server:
+ type: string
+ DNSSEC:
+ type: string
+ URL_of_the_ICANN_Whois_Inaccuracy_Complaint_Form:
+ type: string
+ _Last_update_of_whois_database:
+ type: string
+ format: date-time
+ For_more_information_on_Whois_status_codes_please_visit_https:
+ type: string
+ NOTICE:
+ type: string
+ TERMS_OF_USE:
+ type: string
+ by_the_following_terms_of_use:
+ type: string
+ to:
+ type: string
+ whoisData:
+ type: object
+ properties:
+ domain:
+ type: object
+ properties:
+ id:
+ type: string
+ domain:
+ type: string
+ name:
+ type: string
+ extension:
+ type: string
+ whois_server:
+ type: string
+ status:
+ type: array
+ items:
+ type: string
+ name_servers:
+ type: array
+ items:
+ type: string
+ created_date:
+ type: string
+ format: date-time
+ updated_date:
+ type: string
+ format: date-time
+ expiration_date:
+ type: string
+ format: date-time
+ registrar:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ phone:
+ type: string
+ email:
+ type: string
+ referral_url:
+ type: string
+ registrant:
+ type: object
+ properties:
+ name:
+ type: string
+ organization:
+ type: string
+ street:
+ type: string
+ city:
+ type: string
+ province:
+ type: string
+ postal_code:
+ type: string
+ country:
+ type: string
+ phone:
+ type: string
+ phone_ext:
+ type: string
+ fax:
+ type: string
+ fax_ext:
+ type: string
+ email:
+ type: string
+ administrative:
+ type: object
+ properties:
+ name:
+ type: string
+ organization:
+ type: string
+ street:
+ type: string
+ city:
+ type: string
+ province:
+ type: string
+ postal_code:
+ type: string
+ country:
+ type: string
+ phone:
+ type: string
+ phone_ext:
+ type: string
+ fax:
+ type: string
+ fax_ext:
+ type: string
+ email:
+ type: string
+ technical:
+ type: object
+ properties:
+ name:
+ type: string
+ organization:
+ type: string
+ street:
+ type: string
+ city:
+ type: string
+ province:
+ type: string
+ postal_code:
+ type: string
+ country:
+ type: string
+ phone:
+ type: string
+ phone_ext:
+ type: string
+ fax:
+ type: string
+ fax_ext:
+ type: string
+ email:
+ type: string
+ billing:
+ type: object
+ properties:
+ name:
+ type: string
+ organization:
+ type: string
+ street:
+ type: string
+ city:
+ type: string
+ province:
+ type: string
+ postal_code:
+ type: string
+ country:
+ type: string
+ phone:
+ type: string
+ phone_ext:
+ type: string
+ fax:
+ type: string
+ fax_ext:
+ type: string
+ email:
+ type: string
+ example:
+ internicData:
+ Domain_Name: 'AS93.NET'
+ Registry_Domain_ID: '1722029712_DOMAIN_NET-VRSN'
+ Registrar_WHOIS_Server: 'whois.cloudflare.com'
+ Registrar_URL: 'http://www.cloudflare.com'
+ Updated_Date: '2019-05-07T04:20:11Z'
+ Creation_Date: '2012-05-22T16:36:05Z'
+ Registry_Expiry_Date: '2025-05-22T16:36:05Z'
+ Registrar: 'CloudFlare, Inc.'
+ Registrar_IANA_ID: '1910'
+ Domain_Status: 'clientTransferProhibited https://icann.org/epp#clientTransferProhibited'
+ Name_Server: 'ALEX.NS.CLOUDFLARE.COM'
+ DNSSEC: 'unsigned'
+ URL_of_the_ICANN_Whois_Inaccuracy_Complaint_Form: 'https://www.icann.org/wicf/'
+ _Last_update_of_whois_database: '2024-05-18T13:59:10Z <<< '
+ For_more_information_on_Whois_status_codes_please_visit_https: '//icann.org/epp '
+ NOTICE: "The expiration date displayed in this record is the date the registrar's sponsorship of the domain name registration in the registry is currently set to expire. This date does not necessarily reflect the expiration date of the domain name registrant's agreement with the sponsoring registrar. Users may consult the sponsoring registrar's Whois database to view the registrar's reported date of expiration for this registration. "
+ TERMS_OF_USE: 'You are not authorized to access or query our Whois database through the use of electronic processes that are high-volume and automated except as reasonably necessary to register domain names or modify existing registrations; the Data in VeriSign Global Registry Services'' ("VeriSign") Whois database is provided by VeriSign for information purposes only, and to assist persons in obtaining information about or related to a domain name registration record. VeriSign does not guarantee its accuracy. By submitting a Whois query, you agree to abide'
+ by_the_following_terms_of_use: 'You agree that you may use this Data only for lawful purposes and that under no circumstances will you use this Data'
+ to: '(1) allow, enable, or otherwise support the transmission of mass unsolicited, commercial advertising or solicitations via e-mail, telephone, or facsimile; or (2) enable high volume, automated, electronic processes that apply to VeriSign (or its computer systems). The compilation, repackaging, dissemination or other use of this Data is expressly prohibited without the prior written consent of VeriSign. You agree not to use electronic processes that are automated and high-volume to access or query the Whois database except as reasonably necessary to register domain names or modify existing registrations. VeriSign reserves the right to restrict your access to the Whois database in its sole discretion to ensure operational stability. VeriSign may restrict or terminate your access to the Whois database for failure to abide by these terms of use. VeriSign reserves the right to modify these terms at any time. The Registry database contains ONLY .COM, .NET, .EDU domains and Registrars. '
+ whoisData:
+ domain:
+ id: '1722029712_DOMAIN_NET-VRSN'
+ domain: 'as93.net'
+ name: 'as93'
+ extension: 'net'
+ whois_server: 'whois.cloudflare.com'
+ status:
+ - 'clienttransferprohibited'
+ name_servers:
+ - 'adrian.ns.cloudflare.com'
+ - 'alex.ns.cloudflare.com'
+ created_date: '2012-05-22T16:36:05Z'
+ updated_date: '2019-05-07T04:20:11Z'
+ expiration_date: '2025-05-22T16:36:05Z'
+ registrar:
+ id: '1910'
+ name: 'Cloudflare, Inc.'
+ phone: '+1.4153197517'
+ email: 'registrar-abuse@cloudflare.com'
+ referral_url: 'https://www.cloudflare.com'
+ registrant:
+ name: 'DATA REDACTED'
+ organization: 'DATA REDACTED'
+ street: 'DATA REDACTED'
+ city: 'DATA REDACTED'
+ province: 'London'
+ postal_code: 'DATA REDACTED'
+ country: 'GB'
+ phone: 'DATA REDACTED'
+ phone_ext: 'DATA REDACTED'
+ fax: 'DATA REDACTED'
+ fax_ext: 'DATA REDACTED'
+ email: 'https://domaincontact.cloudflareregistrar.com/as93.net'
+ administrative:
+ name: 'DATA REDACTED'
+ organization: 'DATA REDACTED'
+ street: 'DATA REDACTED'
+ city: 'DATA REDACTED'
+ province: 'DATA REDACTED'
+ postal_code: 'DATA REDACTED'
+ country: 'DATA REDACTED'
+ phone: 'DATA REDACTED'
+ phone_ext: 'DATA REDACTED'
+ fax: 'DATA REDACTED'
+ fax_ext: 'DATA REDACTED'
+ email: 'https://domaincontact.cloudflareregistrar.com/as93.net'
+ technical:
+ name: 'DATA REDACTED'
+ organization: 'DATA REDACTED'
+ street: 'DATA REDACTED'
+ city: 'DATA REDACTED'
+ province: 'DATA REDACTED'
+ postal_code: 'DATA REDACTED'
+ country: 'DATA REDACTED'
+ phone: 'DATA REDACTED'
+ phone_ext: 'DATA REDACTED'
+ fax: 'DATA REDACTED'
+ fax_ext: 'DATA REDACTED'
+ email: 'https://domaincontact.cloudflareregistrar.com/as93.net'
+ billing:
+ name: 'DATA REDACTED'
+ organization: 'DATA REDACTED'
+ street: 'DATA REDACTED'
+ city: 'DATA REDACTED'
+ province: 'DATA REDACTED'
+ postal_code: 'DATA REDACTED'
+ country: 'DATA REDACTED'
+ phone: 'DATA REDACTED'
+ phone_ext: 'DATA REDACTED'
+ fax: 'DATA REDACTED'
+ fax_ext: 'DATA REDACTED'
+ email: 'https://domaincontact.cloudflareregistrar.com/as93.net'
+ '204':
+ $ref: '#/components/responses/Skipped'
+ '500':
+ $ref: '#/components/responses/Error'
+ '400':
+ $ref: '#/components/responses/MissingParam'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/TooManyRequests'
diff --git a/public/security.txt b/public/security.txt
new file mode 100644
index 000000000..79415368d
--- /dev/null
+++ b/public/security.txt
@@ -0,0 +1,6 @@
+Contact: mailto:security@as93.net
+Contact: mailto:alicia@omg.lol
+Expires: 2024-12-31T23:59:00.000Z
+Encryption: https://keybase.io/aliciasykes/pgp_keys.asc?fingerprint=0688f8d34587d954e9e51fb8fedb68f55c0283a7
+Preferred-Languages: en
+Canonical: /security.txt
diff --git a/public/web-check.png b/public/web-check.png
new file mode 100644
index 000000000..5b1384764
Binary files /dev/null and b/public/web-check.png differ
diff --git a/server.js b/server.js
new file mode 100644
index 000000000..49f33328a
--- /dev/null
+++ b/server.js
@@ -0,0 +1,226 @@
+import fs from 'fs';
+import path from 'path';
+import cors from 'cors';
+import dotenv from 'dotenv';
+import express from 'express';
+import rateLimit from 'express-rate-limit';
+
+// Load environment variables from .env file
+dotenv.config();
+
+// Create the Express app
+const app = express();
+
+const trustProxy = process.env.TRUST_PROXY;
+if (trustProxy) {
+ const parsed = /^\d+$/.test(trustProxy)
+ ? parseInt(trustProxy, 10)
+ : trustProxy === 'true'
+ ? true
+ : trustProxy === 'false'
+ ? false
+ : trustProxy;
+ app.set('trust proxy', parsed);
+}
+
+const __filename = new URL(import.meta.url).pathname;
+const __dirname = path.dirname(__filename);
+
+const port = process.env.PORT || 3000; // The port to run the server on
+const API_DIR = '/api'; // Name of the dir containing the lambda functions
+const dirPath = path.join(__dirname, API_DIR); // Path to the lambda functions dir
+const guiPath = path.join(__dirname, 'dist', 'client');
+const placeholderFilePath = path.join(__dirname, 'public', 'placeholder.html');
+const handlers = {}; // Will store list of API endpoints
+process.env.WC_SERVER = 'true'; // Tells middleware to return in non-lambda mode
+
+// Enable CORS
+app.use(
+ cors({
+ origin: process.env.API_CORS_ORIGIN || '*',
+ }),
+);
+
+// Define max requests within each time frame
+const limits = [
+ { timeFrame: 10 * 60, max: 100, messageTime: '10 minutes' },
+ { timeFrame: 60 * 60, max: 250, messageTime: '1 hour' },
+ { timeFrame: 12 * 60 * 60, max: 500, messageTime: '12 hours' },
+];
+
+// Construct a message to be returned if the user has been rate-limited
+const makeLimiterResponseMsg = (retryAfter) => {
+ const why =
+ 'This keeps the service running smoothly for everyone. ' +
+ 'You can get around these limits by running your own instance of Web Check.';
+ return `You've been rate-limited, please try again in ${retryAfter} seconds.\n${why}`;
+};
+
+// Create rate limiters for each time frame
+const limiters = limits.map((limit) =>
+ rateLimit({
+ windowMs: limit.timeFrame * 1000,
+ limit: limit.max,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { error: makeLimiterResponseMsg(limit.messageTime) },
+ }),
+);
+
+// If rate-limiting enabled, then apply the limiters to the /api endpoint
+if (process.env.API_ENABLE_RATE_LIMIT === 'true') {
+ app.use(API_DIR, limiters);
+}
+
+// Read and register each API function as an Express routes
+fs.readdirSync(dirPath, { withFileTypes: true })
+ .filter((dirent) => dirent.isFile() && dirent.name.endsWith('.js'))
+ .forEach(async (dirent) => {
+ const routeName = dirent.name.split('.')[0];
+ const route = `${API_DIR}/${routeName}`;
+ // const handler = require(path.join(dirPath, dirent.name));
+
+ const handlerModule = await import(path.join(dirPath, dirent.name));
+ const handler = handlerModule.default || handlerModule;
+ handlers[route] = handler;
+
+ app.get(route, async (req, res) => {
+ try {
+ await handler(req, res);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+ });
+ });
+
+const renderPlaceholderPage = async (res, msgId, logs) => {
+ const errorMessages = {
+ notCompiled:
+ 'Looks like the GUI app has not yet been compiled. ' +
+ 'Run yarn build to continue, then restart the server.',
+ notCompiledSsrHandler:
+ 'Server-side rendering failed to initiate, as SSR handler not found. ' +
+ 'This can be fixed by running yarn build, then restarting the server. ',
+ disabledGui:
+ 'Web-Check API is up and running! Access the endpoints at ' +
+ `${API_DIR} `,
+ };
+ const logOutput = logs ? `${logs}
` : '';
+ const errorMessage = (errorMessages[msgId] || 'An mystery error occurred.') + logOutput;
+ const placeholderContent = await fs.promises.readFile(placeholderFilePath, 'utf-8');
+ const htmlContent = placeholderContent.replace('', errorMessage);
+ res.status(500).send(htmlContent);
+};
+
+// Create a single API endpoint to execute all lambda functions
+app.get(API_DIR, async (req, res) => {
+ const results = {};
+ const { url } = req.query;
+ const maxExecutionTime = process.env.PUBLIC_API_TIMEOUT_LIMIT || 60000;
+
+ const executeHandler = async (handler, req) => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const mockRes = {
+ status: () => mockRes,
+ json: (body) => resolve({ body }),
+ };
+ await handler({ ...req, query: { url } }, mockRes);
+ } catch (err) {
+ reject(err);
+ }
+ });
+ };
+
+ const timeout = (ms, jobName = null) => {
+ return new Promise((_, reject) => {
+ setTimeout(() => {
+ reject(
+ new Error(
+ `Timed out after ${ms / 1000} seconds${jobName ? `, when executing ${jobName}` : ''}`,
+ ),
+ );
+ }, ms);
+ });
+ };
+
+ const handlerPromises = Object.entries(handlers).map(async ([route, handler]) => {
+ const routeName = route.replace(`${API_DIR}/`, '');
+
+ try {
+ const result = await Promise.race([
+ executeHandler(handler, req, res),
+ timeout(maxExecutionTime, routeName),
+ ]);
+ results[routeName] = result.body;
+ } catch (err) {
+ results[routeName] = { error: err.message };
+ }
+ });
+
+ await Promise.all(handlerPromises);
+ res.json(results);
+});
+
+// Skip the marketing homepage, for self-hosted users
+app.use((req, res, next) => {
+ if (req.path === '/' && process.env.BOSS_SERVER !== 'true' && !process.env.DISABLE_GUI) {
+ return res.redirect(302, '/check');
+ }
+ next();
+});
+
+// Serve up the GUI - if build dir exists, and GUI feature enabled
+if (process.env.DISABLE_GUI && process.env.DISABLE_GUI !== 'false') {
+ app.get('/', async (req, res) => {
+ renderPlaceholderPage(res, 'disabledGui');
+ });
+} else if (!fs.existsSync(guiPath)) {
+ app.get('/', async (req, res) => {
+ renderPlaceholderPage(res, 'notCompiled');
+ });
+} else {
+ // GUI enabled, and build files present, let's go!!
+ app.use(express.static('dist/client/'));
+ app.use(async (req, res, next) => {
+ const ssrHandlerPath = path.join(__dirname, 'dist', 'server', 'entry.mjs');
+ import(ssrHandlerPath)
+ .then(({ handler: ssrHandler }) => {
+ ssrHandler(req, res, next);
+ })
+ .catch(async (err) => {
+ renderPlaceholderPage(res, 'notCompiledSsrHandler', err.message);
+ });
+ });
+}
+
+// Anything left unhandled (which isn't an API endpoint), return a 404
+app.use((req, res, next) => {
+ if (!req.path.startsWith(`${API_DIR}/`)) {
+ res.status(404).sendFile(path.join(__dirname, 'public', 'error.html'));
+ } else {
+ next();
+ }
+});
+
+// Print nice welcome message to user
+const printMessage = () => {
+ console.log(
+ `\x1b[36m\n` +
+ ' __ __ _ ___ _ _ \n' +
+ ' \\ \\ / /__| |__ ___ / __| |_ ___ __| |__\n' +
+ " \\ \\/\\/ / -_) '_ \\___| (__| ' \\/ -_) _| / /\n" +
+ ' \\_/\\_/\\___|_.__/ \\___|_||_\\___\\__|_\\_\\\n' +
+ `\x1b[0m\n`,
+ `\x1b[1m\x1b[32m🚀 Web-Check is up and running at http://localhost:${port} \x1b[0m\n\n`,
+ `\x1b[2m\x1b[36m🛟 For documentation and support, visit the GitHub repo: ` +
+ `https://github.com/lissy93/web-check \n`,
+ `💖 Found Web-Check useful? Consider sponsoring us on GitHub ` +
+ `to help fund maintenance & development.\x1b[0m`,
+ );
+};
+
+// Create server
+app.listen(port, () => {
+ printMessage();
+});
diff --git a/src/App.test.tsx b/src/App.test.tsx
deleted file mode 100644
index 2a68616d9..000000000
--- a/src/App.test.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import App from './App';
-
-test('renders learn react link', () => {
- render( );
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
-});
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index df558c04c..000000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Route, Routes } from 'react-router-dom';
-import Styled from 'styled-components';
-import Home from 'pages/Home';
-import colors from 'styles/colors';
-
-const Container = Styled.main`
- background: ${colors.background};
- color: ${colors.textColor};
- width: 100vw;
- height: 100vh;
- margin: 0;
-`;
-
-function App() {
- return (
-
-
- } />
-
-
- );
-}
-
-export default App;
diff --git a/src/client/App.tsx b/src/client/App.tsx
new file mode 100644
index 000000000..7b2aff129
--- /dev/null
+++ b/src/client/App.tsx
@@ -0,0 +1,34 @@
+import { Routes, Route, Outlet } from 'react-router-dom';
+
+import Home from 'client/views/Home.tsx';
+import Results from 'client/views/Results.tsx';
+import About from 'client/views/About.tsx';
+import NotFound from 'client/views/NotFound.tsx';
+
+import ErrorBoundary from 'client/components/boundaries/PageError.tsx';
+import GlobalStyles from './styles/globals.tsx';
+
+const Layout = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default function App() {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/src/client/analysis/helpers.ts b/src/client/analysis/helpers.ts
new file mode 100644
index 000000000..f7c4cd261
--- /dev/null
+++ b/src/client/analysis/helpers.ts
@@ -0,0 +1,7 @@
+// Days until an ISO/parseable date string, or null when unparseable
+export const daysUntil = (raw: unknown): number | null => {
+ if (typeof raw !== 'string') return null;
+ const t = Date.parse(raw);
+ if (!Number.isFinite(t)) return null;
+ return Math.floor((t - Date.now()) / 86_400_000);
+};
diff --git a/src/client/analysis/registry.ts b/src/client/analysis/registry.ts
new file mode 100644
index 000000000..efd30a060
--- /dev/null
+++ b/src/client/analysis/registry.ts
@@ -0,0 +1,69 @@
+import type { JobsState } from 'client/jobs/types';
+import { allCards } from 'client/jobs/registry';
+import type { Analyzer, Finding } from './types';
+
+import httpSecurity from './rules/http-security';
+import hsts from './rules/hsts';
+import ssl from './rules/ssl';
+import dnssec from './rules/dnssec';
+import securityTxt from './rules/security-txt';
+import threats from './rules/threats';
+import blockLists from './rules/block-lists';
+import firewall from './rules/firewall';
+import cookies from './rules/cookies';
+import headers from './rules/headers';
+import ports from './rules/ports';
+import mailConfig from './rules/mail-config';
+import txtRecords from './rules/txt-records';
+import tlsConnection from './rules/tls-connection';
+import tlsSecurityAudit from './rules/tls-security-audit';
+import quality from './rules/quality';
+import socialTags from './rules/social-tags';
+import whois from './rules/whois';
+import status from './rules/status';
+import redirects from './rules/redirects';
+import serverInfo from './rules/server-info';
+import robotsTxt from './rules/robots-txt';
+import tlsClientCompat from './rules/tls-client-compat';
+
+/* Map of card id to its pure analyzer */
+export const analyzers: Record = {
+ 'http-security': httpSecurity,
+ hsts,
+ ssl,
+ dnssec,
+ 'security-txt': securityTxt,
+ threats,
+ 'block-lists': blockLists,
+ firewall,
+ cookies,
+ headers,
+ ports,
+ 'mail-config': mailConfig,
+ 'txt-records': txtRecords,
+ 'tls-connection': tlsConnection,
+ 'tls-security-audit': tlsSecurityAudit,
+ quality,
+ 'social-tags': socialTags,
+ whois,
+ status,
+ redirects,
+ 'server-info': serverInfo,
+ 'robots-txt': robotsTxt,
+ 'tls-client-compat': tlsClientCompat,
+};
+
+/* Run each analyzer against successful job state with valid object payload */
+export const runAnalysis = (state: JobsState): Finding[] =>
+ allCards.flatMap(({ card }) => {
+ const fn = analyzers[card.id];
+ const entry = state[card.id];
+ if (!fn || entry?.state !== 'success') return [];
+ const raw = entry.raw;
+ if (raw == null || typeof raw !== 'object') return [];
+ try {
+ return fn(raw).map((f) => ({ ...f, cardId: card.id }));
+ } catch {
+ return [];
+ }
+ });
diff --git a/src/client/analysis/rules/block-lists.ts b/src/client/analysis/rules/block-lists.ts
new file mode 100644
index 000000000..5e248bcc2
--- /dev/null
+++ b/src/client/analysis/rules/block-lists.ts
@@ -0,0 +1,19 @@
+import type { Analyzer } from '../types';
+
+// Domain is suspicious when blocked by family/security DNS resolvers
+const blockLists: Analyzer = (d) => {
+ if (!d || !Array.isArray(d.blocklists)) return [];
+ const blocked = d.blocklists.filter((b: any) => b?.isBlocked);
+ if (!blocked.length) return [{ severity: 'pass', title: 'Not on any tested DNS blocklist' }];
+ const names = blocked.map((b: any) => b.server).join(', ');
+ const severity = blocked.length >= 3 ? 'critical' : 'issue';
+ return [
+ {
+ severity,
+ title: `Blocked by ${blocked.length} DNS resolver(s)`,
+ detail: `Listed by ${names}`,
+ },
+ ];
+};
+
+export default blockLists;
diff --git a/src/client/analysis/rules/cookies.ts b/src/client/analysis/rules/cookies.ts
new file mode 100644
index 000000000..cfcf14dd0
--- /dev/null
+++ b/src/client/analysis/rules/cookies.ts
@@ -0,0 +1,39 @@
+import type { Analyzer } from '../types';
+
+// Parse Set-Cookie headers into name + attribute map (analysis-local copy)
+const parseCookies = (raw: unknown): Array<{ name: string; attrs: string[] }> => {
+ if (!Array.isArray(raw)) return [];
+ return raw.flatMap((line) =>
+ String(line)
+ .split(/,(?=\s[A-Za-z0-9]+=)/)
+ .map((cookie) => {
+ const parts = cookie.split('; ').map((p) => p.trim());
+ const name = parts[0]?.split('=')[0] || 'cookie';
+ const attrs = parts.slice(1).map((p) => p.split('=')[0].toLowerCase());
+ return { name, attrs };
+ }),
+ );
+};
+
+// Audit Set-Cookie attributes for Secure, HttpOnly, SameSite
+const cookies: Analyzer = (d) => {
+ const parsed = parseCookies(d.headerCookies);
+ if (!parsed.length) return [];
+ const out: ReturnType = [];
+ for (const { name, attrs } of parsed) {
+ if (!attrs.includes('secure')) {
+ out.push({ severity: 'issue', title: `Cookie "${name}" missing Secure flag` });
+ }
+ if (!attrs.includes('httponly')) {
+ out.push({ severity: 'warning', title: `Cookie "${name}" missing HttpOnly flag` });
+ }
+ if (!attrs.includes('samesite')) {
+ out.push({ severity: 'warning', title: `Cookie "${name}" missing SameSite flag` });
+ }
+ }
+ if (!out.length)
+ out.push({ severity: 'pass', title: 'All cookies use Secure/HttpOnly/SameSite' });
+ return out;
+};
+
+export default cookies;
diff --git a/src/client/analysis/rules/dnssec.ts b/src/client/analysis/rules/dnssec.ts
new file mode 100644
index 000000000..24740ed53
--- /dev/null
+++ b/src/client/analysis/rules/dnssec.ts
@@ -0,0 +1,19 @@
+import type { Analyzer } from '../types';
+
+// DNSSEC enabled when DNSKEY + DS records exist for the zone
+const dnssec: Analyzer = (d) => {
+ const dnskey = !!d.DNSKEY?.isFound;
+ const ds = !!d.DS?.isFound;
+ if (dnskey && ds) {
+ return [{ severity: 'pass', title: 'DNSSEC enabled' }];
+ }
+ return [
+ {
+ severity: 'warning',
+ title: 'DNSSEC not enabled',
+ detail: 'Sign DNS records to prevent spoofing and cache poisoning',
+ },
+ ];
+};
+
+export default dnssec;
diff --git a/src/client/analysis/rules/firewall.ts b/src/client/analysis/rules/firewall.ts
new file mode 100644
index 000000000..c5b22d5d9
--- /dev/null
+++ b/src/client/analysis/rules/firewall.ts
@@ -0,0 +1,17 @@
+import type { Analyzer } from '../types';
+
+// Lack of a detectable WAF is a hardening gap, not a hard fail
+const firewall: Analyzer = (d) => {
+ if (d.hasWaf) {
+ return [{ severity: 'pass', title: `WAF detected: ${d.waf || 'unknown'}` }];
+ }
+ return [
+ {
+ severity: 'warning',
+ title: 'No web application firewall detected',
+ detail: 'Consider Cloudflare, AWS WAF or similar to filter malicious traffic',
+ },
+ ];
+};
+
+export default firewall;
diff --git a/src/client/analysis/rules/headers.ts b/src/client/analysis/rules/headers.ts
new file mode 100644
index 000000000..f707221c8
--- /dev/null
+++ b/src/client/analysis/rules/headers.ts
@@ -0,0 +1,21 @@
+import type { Analyzer } from '../types';
+
+const LEAK_HEADERS = ['x-powered-by', 'server', 'x-aspnet-version', 'x-aspnetmvc-version'];
+
+// Surface server fingerprint headers as informational findings
+const headers: Analyzer = (d) => {
+ const out: ReturnType = [];
+ for (const key of LEAK_HEADERS) {
+ const val = d[key];
+ if (val) {
+ out.push({
+ severity: 'info',
+ title: `Server discloses ${key}`,
+ detail: `Value: ${String(val).slice(0, 80)}`,
+ });
+ }
+ }
+ return out;
+};
+
+export default headers;
diff --git a/src/client/analysis/rules/hsts.ts b/src/client/analysis/rules/hsts.ts
new file mode 100644
index 000000000..ac8afd0f2
--- /dev/null
+++ b/src/client/analysis/rules/hsts.ts
@@ -0,0 +1,44 @@
+import type { Analyzer } from '../types';
+
+const MIN_MAX_AGE = 10886400;
+
+// Check HSTS presence, max-age, includeSubDomains, preload
+const hsts: Analyzer = (d) => {
+ if (!d.hstsHeader) {
+ return [
+ {
+ severity: 'issue',
+ title: 'No HSTS header',
+ detail: 'Add Strict-Transport-Security to enforce HTTPS for clients',
+ },
+ ];
+ }
+ const header = String(d.hstsHeader).toLowerCase();
+ const maxAge = parseInt(header.match(/max-age=(\d+)/)?.[1] || '0', 10);
+ const out: ReturnType = [];
+ if (maxAge < MIN_MAX_AGE) {
+ out.push({
+ severity: 'warning',
+ title: `HSTS max-age below ${MIN_MAX_AGE}`,
+ detail: `Current max-age is ${maxAge}, raise it for preload eligibility`,
+ });
+ }
+ if (!header.includes('includesubdomains')) {
+ out.push({
+ severity: 'warning',
+ title: 'HSTS missing includeSubDomains',
+ detail: 'Add includeSubDomains to protect every subdomain',
+ });
+ }
+ if (!header.includes('preload')) {
+ out.push({
+ severity: 'info',
+ title: 'HSTS missing preload directive',
+ detail: 'Add preload to qualify for the HSTS preload list',
+ });
+ }
+ if (d.compatible) out.push({ severity: 'pass', title: 'HSTS preload compatible' });
+ return out;
+};
+
+export default hsts;
diff --git a/src/client/analysis/rules/http-security.ts b/src/client/analysis/rules/http-security.ts
new file mode 100644
index 000000000..982e380e6
--- /dev/null
+++ b/src/client/analysis/rules/http-security.ts
@@ -0,0 +1,44 @@
+import type { Analyzer } from '../types';
+
+const CRITICAL: Array<[string, string]> = [
+ ['contentSecurityPolicy', 'Content-Security-Policy'],
+ ['strictTransportPolicy', 'Strict-Transport-Security'],
+ ['xContentTypeOptions', 'X-Content-Type-Options'],
+ ['xFrameOptions', 'X-Frame-Options'],
+];
+
+const RECOMMENDED: Array<[string, string]> = [
+ ['referrerPolicy', 'Referrer-Policy'],
+ ['permissionsPolicy', 'Permissions-Policy'],
+ ['crossOriginOpenerPolicy', 'Cross-Origin-Opener-Policy'],
+ ['crossOriginResourcePolicy', 'Cross-Origin-Resource-Policy'],
+ ['crossOriginEmbedderPolicy', 'Cross-Origin-Embedder-Policy'],
+];
+
+// Flag missing critical headers as issues, missing recommended as warnings
+const httpSecurity: Analyzer = (d) => {
+ const out: ReturnType = [];
+ for (const [key, label] of CRITICAL) {
+ out.push(
+ d[key]
+ ? { severity: 'pass', title: `${label} set` }
+ : {
+ severity: 'issue',
+ title: `Missing ${label}`,
+ detail: `Set the ${label} response header`,
+ },
+ );
+ }
+ for (const [key, label] of RECOMMENDED) {
+ if (!d[key]) {
+ out.push({
+ severity: 'warning',
+ title: `Missing ${label}`,
+ detail: `Consider adding the ${label} response header`,
+ });
+ }
+ }
+ return out;
+};
+
+export default httpSecurity;
diff --git a/src/client/analysis/rules/mail-config.ts b/src/client/analysis/rules/mail-config.ts
new file mode 100644
index 000000000..036d82f17
--- /dev/null
+++ b/src/client/analysis/rules/mail-config.ts
@@ -0,0 +1,72 @@
+import type { Analyzer } from '../types';
+
+// Locate first TXT record matching prefix (case-insensitive), null when absent
+const findTxt = (records: string[][], prefix: string): string | null => {
+ if (!Array.isArray(records)) return null;
+ const re = new RegExp(`^${prefix}`, 'i');
+ for (const chunks of records) {
+ const full = Array.isArray(chunks) ? chunks.join('') : String(chunks);
+ if (re.test(full)) return full;
+ }
+ return null;
+};
+
+// Audit SPF, DMARC, DKIM presence and DMARC policy strength
+const mailConfig: Analyzer = (d) => {
+ const txt = d.txtRecords || [];
+ const out: ReturnType = [];
+
+ const spf = findTxt(txt, 'v=spf1');
+ if (!spf) {
+ out.push({
+ severity: 'issue',
+ title: 'No SPF record found',
+ detail: 'Publish v=spf1 to authorise legitimate mail senders',
+ });
+ } else if (/[+?]all\b/i.test(spf)) {
+ out.push({
+ severity: 'warning',
+ title: 'SPF policy permits unauthorised senders',
+ detail: 'Tighten the SPF policy to ~all or -all',
+ });
+ } else {
+ out.push({ severity: 'pass', title: 'SPF record published' });
+ }
+
+ const dmarc = findTxt(txt, 'v=DMARC1');
+ if (!dmarc) {
+ out.push({
+ severity: 'issue',
+ title: 'No DMARC record found',
+ detail: 'Publish v=DMARC1 on _dmarc subdomain to prevent spoofing',
+ });
+ } else {
+ const policy = dmarc.match(/p=(\w+)/i)?.[1]?.toLowerCase();
+ if (policy === 'reject') out.push({ severity: 'pass', title: 'DMARC policy: reject' });
+ else if (policy === 'quarantine')
+ out.push({ severity: 'info', title: 'DMARC policy: quarantine' });
+ else if (policy === 'none') {
+ out.push({
+ severity: 'warning',
+ title: 'DMARC policy is monitor-only',
+ detail: 'Move from p=none to p=quarantine or p=reject when ready',
+ });
+ }
+ }
+
+ // DKIM detection is best-effort: only flag absence as a soft warning
+ const hasDkim = txt.some((r: string[]) => Array.isArray(r) && /v=DKIM1/i.test(r.join('')));
+ if (!hasDkim) {
+ out.push({
+ severity: 'warning',
+ title: 'No DKIM record discovered on common selectors',
+ detail: 'Publish a DKIM key so receivers can verify message signatures',
+ });
+ } else {
+ out.push({ severity: 'pass', title: 'DKIM key found' });
+ }
+
+ return out;
+};
+
+export default mailConfig;
diff --git a/src/client/analysis/rules/ports.ts b/src/client/analysis/rules/ports.ts
new file mode 100644
index 000000000..99bfc3aa7
--- /dev/null
+++ b/src/client/analysis/rules/ports.ts
@@ -0,0 +1,33 @@
+import type { Analyzer, Severity } from '../types';
+
+// Port -> [severity, description]. Anything not listed is informational
+const RISKY: Record = {
+ 21: ['warning', 'FTP (cleartext file transfer)'],
+ 23: ['critical', 'Telnet (cleartext shell)'],
+ 25: ['info', 'SMTP (mail server)'],
+ 110: ['warning', 'POP3 (cleartext mail)'],
+ 143: ['warning', 'IMAP (cleartext mail)'],
+ 3306: ['critical', 'MySQL exposed to the internet'],
+ 3389: ['warning', 'RDP exposed to the internet'],
+ 5900: ['warning', 'VNC exposed to the internet'],
+};
+
+// Flag risky open ports, ignore the safe defaults like 80/443
+const ports: Analyzer = (d) => {
+ if (!d || !Array.isArray(d.openPorts)) return [];
+ const out: ReturnType = [];
+ for (const p of d.openPorts) {
+ const port = Number(p);
+ const known = RISKY[port];
+ if (known) {
+ out.push({
+ severity: known[0],
+ title: `Port ${port} open: ${known[1]}`,
+ detail: 'Close it or restrict access by firewall if not required',
+ });
+ }
+ }
+ return out;
+};
+
+export default ports;
diff --git a/src/client/analysis/rules/quality.ts b/src/client/analysis/rules/quality.ts
new file mode 100644
index 000000000..a354cef8e
--- /dev/null
+++ b/src/client/analysis/rules/quality.ts
@@ -0,0 +1,37 @@
+import type { Analyzer, Severity } from '../types';
+
+const LABELS: Record = {
+ performance: 'Performance',
+ accessibility: 'Accessibility',
+ 'best-practices': 'Best Practices',
+ seo: 'SEO',
+};
+
+// Convert a 0..1 lighthouse score to a severity bucket
+const scoreSeverity = (score: number): Severity => {
+ if (score >= 0.9) return 'pass';
+ if (score >= 0.7) return 'info';
+ if (score >= 0.5) return 'warning';
+ return 'issue';
+};
+
+// One finding per Lighthouse category, mirroring the score colour
+const quality: Analyzer = (d) => {
+ const cats = d?.categories;
+ if (!cats || typeof cats !== 'object') return [];
+ const out: ReturnType = [];
+ for (const [key, label] of Object.entries(LABELS)) {
+ const score = cats[key]?.score;
+ if (typeof score !== 'number') continue;
+ const pct = Math.round(score * 100);
+ out.push({
+ severity: scoreSeverity(score),
+ title: `${label} score: ${pct}`,
+ detail:
+ score < 0.9 ? `Lighthouse flagged ${label.toLowerCase()} as below recommended` : undefined,
+ });
+ }
+ return out;
+};
+
+export default quality;
diff --git a/src/client/analysis/rules/redirects.ts b/src/client/analysis/rules/redirects.ts
new file mode 100644
index 000000000..44e873bde
--- /dev/null
+++ b/src/client/analysis/rules/redirects.ts
@@ -0,0 +1,37 @@
+import type { Analyzer } from '../types';
+
+// Inspect the HTTP redirect chain for length and HTTPS upgrade
+const redirects: Analyzer = (d) => {
+ const chain: string[] = Array.isArray(d?.redirects)
+ ? d.redirects.filter((u: unknown) => typeof u === 'string')
+ : [];
+ if (!chain.length) return [];
+ const hops = chain.length - 1;
+ const out: ReturnType = [];
+
+ if (hops >= 4) {
+ out.push({
+ severity: 'warning',
+ title: `Long redirect chain: ${hops} hops`,
+ detail: 'Collapse intermediate redirects to reduce latency',
+ });
+ } else if (hops > 0) {
+ out.push({ severity: 'info', title: `${hops} redirect hop(s)` });
+ }
+
+ const startsHttp = /^http:\/\//i.test(chain[0]);
+ const endsHttps = /^https:\/\//i.test(chain[chain.length - 1]);
+ if (startsHttp && endsHttps) {
+ out.push({ severity: 'pass', title: 'HTTP requests are redirected to HTTPS' });
+ } else if (startsHttp && !endsHttps) {
+ out.push({
+ severity: 'critical',
+ title: 'Site does not enforce HTTPS',
+ detail: 'Add a permanent redirect from http:// to https://',
+ });
+ }
+
+ return out;
+};
+
+export default redirects;
diff --git a/src/client/analysis/rules/robots-txt.ts b/src/client/analysis/rules/robots-txt.ts
new file mode 100644
index 000000000..f2f52ba79
--- /dev/null
+++ b/src/client/analysis/rules/robots-txt.ts
@@ -0,0 +1,29 @@
+import type { Analyzer } from '../types';
+
+// Detect a wildcard User-agent followed by a full-site Disallow
+const blocksAllCrawlers = (rules: Array<{ lbl: string; val: string }>): boolean => {
+ let wildcardActive = false;
+ for (const { lbl, val } of rules) {
+ const label = lbl?.toLowerCase();
+ if (label === 'user-agent') wildcardActive = val?.trim() === '*';
+ else if (wildcardActive && label === 'disallow' && val?.trim() === '/') return true;
+ }
+ return false;
+};
+
+// Flag robots.txt rules that hide the site from every crawler
+const robotsTxt: Analyzer = (d) => {
+ if (!d || !Array.isArray(d.robots) || !d.robots.length) return [];
+ if (blocksAllCrawlers(d.robots)) {
+ return [
+ {
+ severity: 'warning',
+ title: 'robots.txt blocks every crawler from the entire site',
+ detail: 'Confirm this is intentional, otherwise search engines will not index the site',
+ },
+ ];
+ }
+ return [];
+};
+
+export default robotsTxt;
diff --git a/src/client/analysis/rules/security-txt.ts b/src/client/analysis/rules/security-txt.ts
new file mode 100644
index 000000000..05c352ac2
--- /dev/null
+++ b/src/client/analysis/rules/security-txt.ts
@@ -0,0 +1,25 @@
+import type { Analyzer } from '../types';
+
+// Flag missing security.txt and surface useful presence detail
+const securityTxt: Analyzer = (d) => {
+ if (!d.isPresent) {
+ return [
+ {
+ severity: 'warning',
+ title: 'No security.txt published',
+ detail: 'Add /.well-known/security.txt with disclosure contact info',
+ },
+ ];
+ }
+ const out: ReturnType = [{ severity: 'pass', title: 'security.txt found' }];
+ if (!d.isPgpSigned) {
+ out.push({
+ severity: 'info',
+ title: 'security.txt not PGP signed',
+ detail: 'Sign the file to let researchers verify authenticity',
+ });
+ }
+ return out;
+};
+
+export default securityTxt;
diff --git a/src/client/analysis/rules/server-info.ts b/src/client/analysis/rules/server-info.ts
new file mode 100644
index 000000000..829ce7759
--- /dev/null
+++ b/src/client/analysis/rules/server-info.ts
@@ -0,0 +1,19 @@
+import type { Analyzer } from '../types';
+
+const MAX_LISTED = 8;
+
+// Surface CVEs Shodan attributes to this host
+const serverInfo: Analyzer = (d) => {
+ if (!d || !Array.isArray(d.vulns) || !d.vulns.length) return [];
+ const cves = d.vulns.slice(0, MAX_LISTED).join(', ');
+ const more = d.vulns.length > MAX_LISTED ? ` (+${d.vulns.length - MAX_LISTED} more)` : '';
+ return [
+ {
+ severity: 'critical',
+ title: `Shodan reports ${d.vulns.length} CVE(s) on this host`,
+ detail: `${cves}${more}. Patch affected services or block at the firewall`,
+ },
+ ];
+};
+
+export default serverInfo;
diff --git a/src/client/analysis/rules/social-tags.ts b/src/client/analysis/rules/social-tags.ts
new file mode 100644
index 000000000..b52eadcdf
--- /dev/null
+++ b/src/client/analysis/rules/social-tags.ts
@@ -0,0 +1,23 @@
+import type { Analyzer } from '../types';
+
+const REQUIRED: Array<[string, string]> = [
+ ['ogTitle', 'OpenGraph title'],
+ ['ogDescription', 'OpenGraph description'],
+ ['ogImage', 'OpenGraph image'],
+ ['twitterCard', 'Twitter card type'],
+];
+
+// Flag missing share-preview metadata
+const socialTags: Analyzer = (d) => {
+ const missing = REQUIRED.filter(([k]) => !d[k]).map(([, l]) => l);
+ if (!missing.length) return [{ severity: 'pass', title: 'Social share metadata complete' }];
+ return [
+ {
+ severity: 'warning',
+ title: `Missing social tags: ${missing.length}`,
+ detail: `Add ${missing.join(', ')} for cleaner share previews`,
+ },
+ ];
+};
+
+export default socialTags;
diff --git a/src/client/analysis/rules/ssl.ts b/src/client/analysis/rules/ssl.ts
new file mode 100644
index 000000000..d0a590924
--- /dev/null
+++ b/src/client/analysis/rules/ssl.ts
@@ -0,0 +1,46 @@
+import type { Analyzer } from '../types';
+import { daysUntil } from '../helpers';
+
+// Check certificate validity and expiry window
+const ssl: Analyzer = (d) => {
+ const out: ReturnType = [];
+ if (d.isValid === false) {
+ out.push({
+ severity: 'critical',
+ title: 'SSL certificate invalid',
+ detail: d.authError || 'Certificate failed validation',
+ });
+ } else if (d.isValid === true) {
+ out.push({ severity: 'pass', title: 'SSL certificate valid' });
+ }
+ const days = daysUntil(d.valid_to);
+ if (days === null) return out;
+ if (days < 0) {
+ out.push({
+ severity: 'critical',
+ title: 'SSL certificate expired',
+ detail: `Expired ${-days} day(s) ago`,
+ });
+ } else if (days <= 7) {
+ out.push({
+ severity: 'critical',
+ title: 'SSL certificate expiring within a week',
+ detail: `Expires in ${days} day(s), renew immediately`,
+ });
+ } else if (days <= 14) {
+ out.push({
+ severity: 'issue',
+ title: 'SSL certificate expiring soon',
+ detail: `Expires in ${days} day(s), schedule renewal`,
+ });
+ } else if (days <= 30) {
+ out.push({
+ severity: 'warning',
+ title: 'SSL certificate renews within a month',
+ detail: `Expires in ${days} day(s)`,
+ });
+ }
+ return out;
+};
+
+export default ssl;
diff --git a/src/client/analysis/rules/status.ts b/src/client/analysis/rules/status.ts
new file mode 100644
index 000000000..ce984be47
--- /dev/null
+++ b/src/client/analysis/rules/status.ts
@@ -0,0 +1,28 @@
+import type { Analyzer } from '../types';
+
+const SLOW_MS = 2000;
+const VERY_SLOW_MS = 5000;
+
+// Reachability + latency. Non-success codes never reach here, the API throws
+const status: Analyzer = (d) => {
+ const out: ReturnType = [];
+ const code = Number(d.responseCode);
+ if (Number.isFinite(code)) {
+ out.push({ severity: 'pass', title: `Site responded with ${code}` });
+ }
+ const t = Number(d.responseTime);
+ if (Number.isFinite(t)) {
+ if (t >= VERY_SLOW_MS) {
+ out.push({
+ severity: 'warning',
+ title: `Slow response time: ${Math.round(t)}ms`,
+ detail: 'Investigate server performance, caching or CDN coverage',
+ });
+ } else if (t >= SLOW_MS) {
+ out.push({ severity: 'info', title: `Response time over ${SLOW_MS}ms` });
+ }
+ }
+ return out;
+};
+
+export default status;
diff --git a/src/client/analysis/rules/threats.ts b/src/client/analysis/rules/threats.ts
new file mode 100644
index 000000000..6e7452675
--- /dev/null
+++ b/src/client/analysis/rules/threats.ts
@@ -0,0 +1,29 @@
+import type { Analyzer } from '../types';
+
+// Any positive hit on a reputable threat feed is critical
+const threats: Analyzer = (d) => {
+ const out: ReturnType = [];
+ if (d.safeBrowsing?.unsafe) {
+ out.push({
+ severity: 'critical',
+ title: 'Listed by Google Safe Browsing',
+ detail: 'Site flagged for malware, phishing or unwanted software',
+ });
+ }
+ if (Array.isArray(d.urlHaus?.urls) && d.urlHaus.urls.length) {
+ out.push({ severity: 'critical', title: 'Listed on URLhaus malware feed' });
+ }
+ const phish = d.phishTank?.url0;
+ const inDb = phish?.in_database === 'true' || phish?.in_database === true;
+ const valid = phish?.valid === 'true' || phish?.valid === true;
+ if (inDb && valid) {
+ out.push({ severity: 'critical', title: 'Listed on PhishTank' });
+ }
+ if (d.cloudmersive?.CleanResult === false) {
+ out.push({ severity: 'critical', title: 'Cloudmersive flagged this site as unsafe' });
+ }
+ if (!out.length) out.push({ severity: 'pass', title: 'No threat feed matches' });
+ return out;
+};
+
+export default threats;
diff --git a/src/client/analysis/rules/tls-client-compat.ts b/src/client/analysis/rules/tls-client-compat.ts
new file mode 100644
index 000000000..5a0ebf92d
--- /dev/null
+++ b/src/client/analysis/rules/tls-client-compat.ts
@@ -0,0 +1,38 @@
+import type { Analyzer } from '../types';
+
+interface Sim {
+ client?: { name?: string; version?: string };
+ errorCode?: number;
+}
+
+// Collect handshake-failure simulations from the SSL Labs report
+const failures = (d: any): Sim[] => {
+ if (!Array.isArray(d?.endpoints)) return [];
+ const out: Sim[] = [];
+ for (const e of d.endpoints) {
+ const sims = e?.details?.sims?.results;
+ if (!Array.isArray(sims)) continue;
+ for (const s of sims) if (s?.errorCode && s.errorCode !== 0) out.push(s);
+ }
+ return out;
+};
+
+// Surface clients that cannot negotiate TLS with this host
+const tlsClientCompat: Analyzer = (d) => {
+ const fails = failures(d);
+ if (!fails.length) return [];
+ const sample = fails
+ .slice(0, 5)
+ .map((s) => `${s.client?.name || 'client'} ${s.client?.version || ''}`.trim())
+ .join(', ');
+ const more = fails.length > 5 ? ` (+${fails.length - 5} more)` : '';
+ return [
+ {
+ severity: 'warning',
+ title: `${fails.length} simulated client(s) cannot connect`,
+ detail: `${sample}${more}. Drop legacy ciphers/protocols only after weighing reach`,
+ },
+ ];
+};
+
+export default tlsClientCompat;
diff --git a/src/client/analysis/rules/tls-connection.ts b/src/client/analysis/rules/tls-connection.ts
new file mode 100644
index 000000000..2a938d1fb
--- /dev/null
+++ b/src/client/analysis/rules/tls-connection.ts
@@ -0,0 +1,43 @@
+import type { Analyzer } from '../types';
+
+// Inspect negotiated protocol, forward secrecy, ALPN, OCSP stapling
+const tlsConnection: Analyzer = (d) => {
+ const out: ReturnType = [];
+ const protocol = String(d.protocol || '');
+
+ if (/^SSLv|TLSv1(\.0)?$|TLSv1\.1/.test(protocol)) {
+ out.push({
+ severity: 'critical',
+ title: `Outdated TLS protocol negotiated: ${protocol}`,
+ detail: 'Disable TLS 1.0 and 1.1 on the server',
+ });
+ } else if (protocol === 'TLSv1.2') {
+ out.push({ severity: 'info', title: 'TLS 1.2 in use, consider enabling TLS 1.3' });
+ } else if (protocol === 'TLSv1.3') {
+ out.push({ severity: 'pass', title: 'TLS 1.3 negotiated' });
+ }
+
+ if (d.forwardSecrecy === false) {
+ out.push({
+ severity: 'warning',
+ title: 'No forward secrecy in negotiated cipher',
+ detail: 'Prefer ECDHE or DHE cipher suites',
+ });
+ }
+
+ if (d.ocspStapled === false) {
+ out.push({
+ severity: 'info',
+ title: 'OCSP stapling not enabled',
+ detail: 'Enable OCSP stapling to speed up cert revocation checks',
+ });
+ }
+
+ if (d.alpnProtocol === 'h2') {
+ out.push({ severity: 'pass', title: 'HTTP/2 negotiated via ALPN' });
+ }
+
+ return out;
+};
+
+export default tlsConnection;
diff --git a/src/client/analysis/rules/tls-security-audit.ts b/src/client/analysis/rules/tls-security-audit.ts
new file mode 100644
index 000000000..a188981a6
--- /dev/null
+++ b/src/client/analysis/rules/tls-security-audit.ts
@@ -0,0 +1,44 @@
+import type { Analyzer, Severity } from '../types';
+
+// Map SSL Labs grade to severity. Labs uses A+/A/A-/B/C/T/F/M (no D/E)
+const GRADE_SEVERITY: Record = {
+ 'A+': 'pass',
+ A: 'pass',
+ 'A-': 'pass',
+ B: 'warning',
+ C: 'issue',
+ F: 'critical',
+ T: 'critical',
+ M: 'critical',
+};
+
+const RANK: Severity[] = ['pass', 'info', 'warning', 'issue', 'critical'];
+const rank = (s: Severity) => RANK.indexOf(s);
+
+// Surface the worst SSL Labs endpoint grade for this host
+const tlsSecurityAudit: Analyzer = (d) => {
+ if (!d || !Array.isArray(d.endpoints) || !d.endpoints.length) return [];
+ const grades: string[] = [];
+ for (const e of d.endpoints) {
+ if (e && typeof e.grade === 'string') grades.push(e.grade);
+ }
+ if (!grades.length) return [];
+ let severity: Severity = 'pass';
+ for (const g of grades) {
+ const sev = GRADE_SEVERITY[g] || 'info';
+ if (rank(sev) > rank(severity)) severity = sev;
+ }
+ const worstGrade = grades.find((g) => GRADE_SEVERITY[g] === severity) || grades[0];
+ if (severity === 'pass') {
+ return [{ severity: 'pass', title: `SSL Labs grade ${worstGrade}` }];
+ }
+ return [
+ {
+ severity,
+ title: `SSL Labs grade ${worstGrade}`,
+ detail: 'Review cipher suites, protocol versions and key strength',
+ },
+ ];
+};
+
+export default tlsSecurityAudit;
diff --git a/src/client/analysis/rules/txt-records.ts b/src/client/analysis/rules/txt-records.ts
new file mode 100644
index 000000000..328960172
--- /dev/null
+++ b/src/client/analysis/rules/txt-records.ts
@@ -0,0 +1,22 @@
+import type { Analyzer } from '../types';
+
+// Surface raw-domain SPF policy strength in addition to the mail-config view
+const txtRecords: Analyzer = (d) => {
+ const spf = Object.entries(d).find(
+ ([k, v]) => /^v_*$/.test(k) && typeof v === 'string' && v.startsWith('spf1'),
+ );
+ if (!spf) return [];
+ const value = String(spf[1]);
+ if (/[+?]all\b/i.test(value)) {
+ return [
+ {
+ severity: 'warning',
+ title: 'Root SPF record is overly permissive',
+ detail: 'Replace +all/?all with ~all or -all to reject spoofed mail',
+ },
+ ];
+ }
+ return [];
+};
+
+export default txtRecords;
diff --git a/src/client/analysis/rules/whois.ts b/src/client/analysis/rules/whois.ts
new file mode 100644
index 000000000..483a1c3c2
--- /dev/null
+++ b/src/client/analysis/rules/whois.ts
@@ -0,0 +1,38 @@
+import type { Analyzer } from '../types';
+import { daysUntil } from '../helpers';
+
+// Warn when a domain is close to expiring so renewal can happen on time
+const whois: Analyzer = (d) => {
+ const days = daysUntil(d.expires);
+ if (days === null) return [];
+ if (days < 0) {
+ return [
+ {
+ severity: 'critical',
+ title: 'Domain registration expired',
+ detail: `Expired ${-days} day(s) ago, renew before it drops`,
+ },
+ ];
+ }
+ if (days <= 7) {
+ return [
+ {
+ severity: 'critical',
+ title: 'Domain expires within a week',
+ detail: `Expires in ${days} day(s), renew immediately`,
+ },
+ ];
+ }
+ if (days <= 30) {
+ return [
+ {
+ severity: 'warning',
+ title: 'Domain expires within a month',
+ detail: `Expires in ${days} day(s)`,
+ },
+ ];
+ }
+ return [{ severity: 'pass', title: 'Domain registration is valid' }];
+};
+
+export default whois;
diff --git a/src/client/analysis/types.ts b/src/client/analysis/types.ts
new file mode 100644
index 000000000..628ad062a
--- /dev/null
+++ b/src/client/analysis/types.ts
@@ -0,0 +1,10 @@
+export type Severity = 'critical' | 'issue' | 'warning' | 'info' | 'pass';
+
+export interface Finding {
+ cardId: string;
+ severity: Severity;
+ title: string;
+ detail?: string;
+}
+
+export type Analyzer = (data: any) => Omit[];
diff --git a/src/client/assets/data/map-features.json b/src/client/assets/data/map-features.json
new file mode 100644
index 000000000..6f905c228
--- /dev/null
+++ b/src/client/assets/data/map-features.json
@@ -0,0 +1,11241 @@
+{
+ "type": "Topology",
+ "objects": {
+ "world": {
+ "type": "GeometryCollection",
+ "geometries": [
+ {
+ "type": "Polygon",
+ "arcs": [[0, 1, 2, 3, 4, 5]],
+ "id": "AFG",
+ "properties": { "name": "Afghanistan" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[6, 7, 8, 9]], [[10, 11, 12]]],
+ "id": "AGO",
+ "properties": { "name": "Angola" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[13, 14, 15, 16, 17]],
+ "id": "ALB",
+ "properties": { "name": "Albania" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[18, 19, 20, 21, 22]],
+ "id": "ARE",
+ "properties": { "name": "United Arab Emirates" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[23, 24]], [[25, 26, 27, 28, 29, 30]]],
+ "id": "ARG",
+ "properties": { "name": "Argentina" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[31, 32, 33, 34, 35]],
+ "id": "ARM",
+ "properties": { "name": "Armenia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[36]],
+ "id": "ATF",
+ "properties": { "name": "French Southern Territories" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[37]], [[38]]],
+ "id": "AUS",
+ "properties": { "name": "Australia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49]],
+ "id": "AUT",
+ "properties": { "name": "Austria" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[50, -35]], [[51, 52, -33, 53, 54]]],
+ "id": "AZE",
+ "properties": { "name": "Azerbaijan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[55, 56, 57, 58]],
+ "id": "BDI",
+ "properties": { "name": "Burundi" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[59, 60, 61, 62, 63]],
+ "id": "BEL",
+ "properties": { "name": "Belgium" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[64, 65, 66, 67, 68]],
+ "id": "BEN",
+ "properties": { "name": "Benin" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[69, 70, 71, -67, 72, 73]],
+ "id": "BFA",
+ "properties": { "name": "Burkina Faso" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[74, 75, 76]],
+ "id": "BGD",
+ "properties": { "name": "Bangladesh" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[77, 78, 79, 80, 81, 82]],
+ "id": "BGR",
+ "properties": { "name": "Bulgaria" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[83]], [[84]], [[85]]],
+ "id": "BHS",
+ "properties": { "name": "Bahamas" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[86, 87, 88, 89]],
+ "id": "BIH",
+ "properties": { "name": "Bosnia and Herzegovina" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[90, 91, 92, 93, 94, 95, 96, 97]],
+ "id": "BLR",
+ "properties": { "name": "Belarus" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[98, 99, 100]],
+ "id": "BLZ",
+ "properties": { "name": "Belize" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[101, 102, 103, 104, -31]],
+ "id": "BOL",
+ "properties": { "name": "Bolivia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-27, 105, -104, 106, 107, 108, 109, 110, 111, 112, 113]],
+ "id": "BRA",
+ "properties": { "name": "Brazil" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[114, 115]],
+ "id": "BRN",
+ "properties": { "name": "Brunei" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[116, 117]],
+ "id": "BTN",
+ "properties": { "name": "Bhutan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[118, 119, 120, 121]],
+ "id": "BWA",
+ "properties": { "name": "Botswana" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[122, 123, 124, 125, 126, 127, 128]],
+ "id": "CAF",
+ "properties": { "name": "Central African Republic" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [
+ [[129]],
+ [[130]],
+ [[131]],
+ [[132]],
+ [[133]],
+ [[134]],
+ [[135]],
+ [[136]],
+ [[137]],
+ [[138]],
+ [[139, 140, 141, 142, 143, 144]],
+ [[145]],
+ [[146]],
+ [[147]],
+ [[148]],
+ [[149]],
+ [[150]],
+ [[151]],
+ [[152]],
+ [[153]],
+ [[154]],
+ [[155]],
+ [[156]],
+ [[157]],
+ [[158]],
+ [[159]],
+ [[160]],
+ [[161]],
+ [[162]],
+ [[163]]
+ ],
+ "id": "CAN",
+ "properties": { "name": "Canada" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-47, 164, 165, 166, -43, 167, 168, 169]],
+ "id": "CHE",
+ "properties": { "name": "Switzerland" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[-24, 170]], [[-30, 171, 172, -102]]],
+ "id": "CHL",
+ "properties": { "name": "Chile" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [
+ [[173]],
+ [
+ [
+ 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, -118, 188,
+ 189, 190, 191, -4, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202
+ ]
+ ]
+ ],
+ "id": "CHN",
+ "properties": { "name": "China" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[203, 204, 205, 206, -70, 207]],
+ "id": "CIV",
+ "properties": { "name": "Cote d'Ivoire" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[208, 209, 210, 211, 212, 213, 214, -129, 215]],
+ "id": "CMR",
+ "properties": { "name": "Cameroon" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[216, 217, 218, 219, -56, 220, 221, 222, -10, 223, -13, 224, -127, 225, 226]],
+ "id": "COD",
+ "properties": { "name": "Democratic Republic of Congo" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-12, 227, 228, -216, -128, -225]],
+ "id": "COG",
+ "properties": { "name": "Congo" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[229, 230, 231, 232, 233, -108, 234]],
+ "id": "COL",
+ "properties": { "name": "Colombia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[235, 236, 237, 238]],
+ "id": "CRI",
+ "properties": { "name": "Costa Rica" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[239]],
+ "id": "CUB",
+ "properties": { "name": "Cuba" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[240]],
+ "id": "CYP",
+ "properties": { "name": "Cyprus" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-49, 241, 242, 243]],
+ "id": "CZE",
+ "properties": { "name": "Czechia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[244, 245, -242, -48, -170, 246, 247, -61, 248, 249, 250]],
+ "id": "DEU",
+ "properties": { "name": "Germany" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[251, 252, 253, 254]],
+ "id": "DJI",
+ "properties": { "name": "Djibouti" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[255]], [[-251, 256]]],
+ "id": "DNK",
+ "properties": { "name": "Denmark" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[257]]],
+ "id": "GRL",
+ "properties": { "name": "Greenland" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[258, 259]],
+ "id": "DOM",
+ "properties": { "name": "Dominican Republic" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[260, 261, 262, 263, 264, 265, 266, 267]],
+ "id": "DZA",
+ "properties": { "name": "Algeria" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[268, -230, 269]],
+ "id": "ECU",
+ "properties": { "name": "Ecuador" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[270, 271, 272]],
+ "id": "EGY",
+ "properties": { "name": "Egypt" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[273, 274, 275, 276, 277, 278, 279, -255]],
+ "id": "ERI",
+ "properties": { "name": "Eritrea" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[280, 281, 282, 283]],
+ "id": "ESP",
+ "properties": { "name": "Spain" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[284, 285, 286, 287]],
+ "id": "EST",
+ "properties": { "name": "Estonia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[288, 289, -274, -254, 290, 291, 292, 293, 294, 295, 296, 297, -277]],
+ "id": "ETH",
+ "properties": { "name": "Ethiopia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[298, 299, 300, 301]],
+ "id": "FIN",
+ "properties": { "name": "Finland" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[302]], [[303]], [[304]]],
+ "id": "FJI",
+ "properties": { "name": "Fiji" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[305]], [[306, -247, -169, 307, 308, -282, 309, -63]]],
+ "id": "FRA",
+ "properties": { "name": "France" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[310, 311, 312, -112]],
+ "id": "GUF",
+ "properties": { "name": "French Guiana" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[313, 314, -209, -229]],
+ "id": "GAB",
+ "properties": { "name": "Gabon" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[315, 316]], [[317]]],
+ "id": "GBR",
+ "properties": { "name": "United Kingdom" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[318, 319, 320, 321, 322, -54, -32, 323]],
+ "id": "GEO",
+ "properties": { "name": "Georgia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[324, -208, -74, 325]],
+ "id": "GHA",
+ "properties": { "name": "Ghana" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[326, 327, 328, 329, 330, 331, -206]],
+ "id": "GIN",
+ "properties": { "name": "Guinea" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[332, 333]],
+ "id": "GMB",
+ "properties": { "name": "Gambia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[334, 335, -330]],
+ "id": "GNB",
+ "properties": { "name": "Guinea-Bissau" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[336, -210, -315]],
+ "id": "GNQ",
+ "properties": { "name": "Equatorial Guinea" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[337]], [[338, -14, 339, -81, 340]]],
+ "id": "GRC",
+ "properties": { "name": "Greece" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[341, 342, -101, 343, 344, 345]],
+ "id": "GTM",
+ "properties": { "name": "Guatemala" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[346, 347, -110, 348]],
+ "id": "GUY",
+ "properties": { "name": "Guyana" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[349, 350, -345, 351, 352]],
+ "id": "HND",
+ "properties": { "name": "Honduras" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[353, 354, 355, -90, 356, 357, 358]],
+ "id": "HRV",
+ "properties": { "name": "Croatia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-260, 359]],
+ "id": "HTI",
+ "properties": { "name": "Haiti" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-40, 360, 361, 362, 363, 364, -359, 365]],
+ "id": "HUN",
+ "properties": { "name": "Hungary" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [
+ [[366]],
+ [[367, 368]],
+ [[369]],
+ [[370]],
+ [[371]],
+ [[372]],
+ [[373]],
+ [[374]],
+ [[375, 376]],
+ [[377]],
+ [[378]],
+ [[379, 380]],
+ [[381]]
+ ],
+ "id": "IDN",
+ "properties": { "name": "Indonesia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-191, 382, -189, -117, -188, 383, -77, 384, 385]],
+ "id": "IND",
+ "properties": { "name": "India" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[386, -316]],
+ "id": "IRL",
+ "properties": { "name": "Ireland" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[387, -6, 388, 389, 390, 391, 392, -51, -34, -53, 393]],
+ "id": "IRN",
+ "properties": { "name": "Iran" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-391, 394, 395, 396, 397, 398, 399, 400]],
+ "id": "IRQ",
+ "properties": { "name": "Iraq" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[401]],
+ "id": "ISL",
+ "properties": { "name": "Iceland" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[402, 403, 404, 405, 406, 407]],
+ "id": "ISR",
+ "properties": { "name": "Israel" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[408]], [[409]], [[410, 411, -308, -168, -42]]],
+ "id": "ITA",
+ "properties": { "name": "Italy" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[412]],
+ "id": "JAM",
+ "properties": { "name": "Jamaica" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-403, 413, -398, 414, 415, -405, 416]],
+ "id": "JOR",
+ "properties": { "name": "Jordan" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[417]], [[418]], [[419]]],
+ "id": "JPN",
+ "properties": { "name": "Japan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, -195, 433]],
+ "id": "KAZ",
+ "properties": { "name": "Kazakhstan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[434, 435, 436, 437, -295, 438]],
+ "id": "KEN",
+ "properties": { "name": "Kenya" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-434, -194, 439, 440]],
+ "id": "KGZ",
+ "properties": { "name": "Kyrgyzstan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[441, 442, 443, 444]],
+ "id": "KHM",
+ "properties": { "name": "Cambodia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[445, 446]],
+ "id": "KOR",
+ "properties": { "name": "South Korea" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[447, 448, 449, 450, 451]],
+ "id": "XXK",
+ "properties": { "name": "Kosovo" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[452, 453, -396]],
+ "id": "KWT",
+ "properties": { "name": "Kuwait" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[454, 455, -186, 456, -443]],
+ "id": "LAO",
+ "properties": { "name": "Laos" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-407, 457, 458]],
+ "id": "LBN",
+ "properties": { "name": "Lebanon" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[459, 460, -327, -205]],
+ "id": "LBR",
+ "properties": { "name": "Liberia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[461, -268, 462, 463, -272, 464, 465]],
+ "id": "LBY",
+ "properties": { "name": "Libya" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[466]],
+ "id": "LKA",
+ "properties": { "name": "Sri Lanka" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[467]],
+ "id": "LSO",
+ "properties": { "name": "Lesotho" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[468, 469, 470, -91, 471]],
+ "id": "LTU",
+ "properties": { "name": "Lithuania" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-248, -307, -62]],
+ "id": "LUX",
+ "properties": { "name": "Luxembourg" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[472, -288, 473, -92, -471]],
+ "id": "LVA",
+ "properties": { "name": "Latvia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-265, 474, 475]],
+ "id": "MAR",
+ "properties": { "name": "Morocco" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[476, 477]],
+ "id": "MDA",
+ "properties": { "name": "Moldova" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[478]],
+ "id": "MDG",
+ "properties": { "name": "Madagascar" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-99, -343, 479, 480, 481]],
+ "id": "MEX",
+ "properties": { "name": "Mexico" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-452, 482, -82, -340, 483]],
+ "id": "MKD",
+ "properties": { "name": "North Macedonia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[484, -262, 485, -71, -207, -332, 486]],
+ "id": "MLI",
+ "properties": { "name": "Mali" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[487, -75, -384, -187, -456, 488]],
+ "id": "MMR",
+ "properties": { "name": "Myanmar" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[489, -89, 490, -450, -16]],
+ "id": "MNE",
+ "properties": { "name": "Montenegro" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[491, 492, 493, 494, 495, 496, 497, 498, -197]],
+ "id": "MNG",
+ "properties": { "name": "Mongolia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[499, 500, 501, 502, 503, 504, 505, 506, 507]],
+ "id": "MOZ",
+ "properties": { "name": "Mozambique" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[508, 509, 510, -263, -485]],
+ "id": "MRT",
+ "properties": { "name": "Mauritania" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-508, 511, 512, 513]],
+ "id": "MWI",
+ "properties": { "name": "Malawi" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[514, 515, 516, 517]], [[-380, 518, -116, 519]]],
+ "id": "MYS",
+ "properties": { "name": "Malaysia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[520, -8, 521, -120, 522]],
+ "id": "NAM",
+ "properties": { "name": "Namibia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[523]],
+ "id": "NCL",
+ "properties": { "name": "New Caledonia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-72, -486, -261, -462, 524, 525, 526, 527, 528, -213, 529, -68]],
+ "id": "NER",
+ "properties": { "name": "Niger" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[530, -69, -530, -212]],
+ "id": "NGA",
+ "properties": { "name": "Nigeria" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[531, -353, 532, -237]],
+ "id": "NIC",
+ "properties": { "name": "Nicaragua" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-249, -60, 533]],
+ "id": "NLD",
+ "properties": { "name": "Netherlands" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[-302, 534, 535]], [[536]], [[537]], [[538]]],
+ "id": "NOR",
+ "properties": { "name": "Norway" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-383, -190]],
+ "id": "NPL",
+ "properties": { "name": "Nepal" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[539]], [[540]]],
+ "id": "NZL",
+ "properties": { "name": "New Zealand" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[541, 542, -22, 543]], [[-20, 544]]],
+ "id": "OMN",
+ "properties": { "name": "Oman" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-192, -386, 545, -389, -5]],
+ "id": "PAK",
+ "properties": { "name": "Pakistan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[546, -239, 547, -232]],
+ "id": "PAN",
+ "properties": { "name": "Panama" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-173, 548, -270, -235, -107, -103]],
+ "id": "PER",
+ "properties": { "name": "Peru" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[549]], [[550]], [[551]], [[552]], [[553]], [[554]], [[555]]],
+ "id": "PHL",
+ "properties": { "name": "Philippines" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[556]], [[557]], [[-376, 558]], [[559]]],
+ "id": "PNG",
+ "properties": { "name": "Papua New Guinea" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-246, 560, 561, -472, -98, 562, 563, -243]],
+ "id": "POL",
+ "properties": { "name": "Poland" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[564]],
+ "id": "PRI",
+ "properties": { "name": "Puerto Rico" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[565, -447, 566, -183]],
+ "id": "PRK",
+ "properties": { "name": "North Korea" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-284, 567]],
+ "id": "PRT",
+ "properties": { "name": "Portugal" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-105, -106, -26]],
+ "id": "PRY",
+ "properties": { "name": "Paraguay" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[568, 569]],
+ "id": "QAT",
+ "properties": { "name": "Qatar" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[570, -478, 571, 572, -78, 573, -363]],
+ "id": "ROU",
+ "properties": { "name": "Romania" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [
+ [[574]],
+ [[-562, 575, -469]],
+ [[576]],
+ [[577]],
+ [[578]],
+ [[579]],
+ [[580]],
+ [[581]],
+ [[582]],
+ [
+ [
+ -181, 583, 584, 585, -177, 586, -175, 587, -202, 588, -200, 589, -198, -499, 590,
+ 591, -496, 592, -494, 593, -492, -196, -433, 594, -431, 595, 596, -428, 597, -426,
+ 598, 599, 600, -55, 601, -322, 602, -320, 603, 604, 605, 606, 607, 608, 609, 610,
+ 611, -95, 612, -93, -474, -287, 613, 614, -299, 615
+ ]
+ ],
+ [[616]],
+ [[617]],
+ [[618]]
+ ],
+ "id": "RUS",
+ "properties": { "name": "Russia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[619, 620, -57, -220, 621]],
+ "id": "RWA",
+ "properties": { "name": "Rwanda" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-475, -264, -511, 622]],
+ "id": "ESH",
+ "properties": { "name": "Western Sahara" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[623, -415, -397, -454, 624, -570, 625, -23, -543, 626]],
+ "id": "SAU",
+ "properties": { "name": "Saudi Arabia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[627, 628, -124, 629, -465, -271, 630, -279, 631, -298, 632]],
+ "id": "SDN",
+ "properties": { "name": "Sudan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[633, -296, -438, 634, 635, -226, -126, 636, -628]],
+ "id": "SSD",
+ "properties": { "name": "South Sudan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[637, -509, -487, -331, -336, 638, -334]],
+ "id": "SEN",
+ "properties": { "name": "Senegal" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[639]], [[640]], [[641]], [[642]], [[643]]],
+ "id": "SLB",
+ "properties": { "name": "Solomon Islands" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[644, -328, -461]],
+ "id": "SLE",
+ "properties": { "name": "Sierra Leone" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[645, -346, -351]],
+ "id": "SLV",
+ "properties": { "name": "El Salvador" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[646, 647, -291, -253, 648, -439, -294]],
+ "id": "SOM",
+ "properties": { "name": "Somalia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-83, -483, -451, -491, -88, 649, -355, 650, -364, -574]],
+ "id": "SRB",
+ "properties": { "name": "Serbia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[651, -312, 652, -111, -348]],
+ "id": "SUR",
+ "properties": { "name": "Suriname" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-564, 653, -361, -50, -244]],
+ "id": "SVK",
+ "properties": { "name": "Slovakia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-41, -366, -358, 654, -411]],
+ "id": "SVN",
+ "properties": { "name": "Slovenia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-535, -301, 655]],
+ "id": "SWE",
+ "properties": { "name": "Sweden" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[656, -504]],
+ "id": "SWZ",
+ "properties": { "name": "Eswatini" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-414, -408, -459, 657, 658, -399]],
+ "id": "SYR",
+ "properties": { "name": "Syria" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-529, 659, -527, 660, -525, -466, -630, -123, -215, 661]],
+ "id": "TCD",
+ "properties": { "name": "Chad" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[662, -326, -73, -66]],
+ "id": "TGO",
+ "properties": { "name": "Togo" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[663, -518, 664, -489, -455, -442]],
+ "id": "THA",
+ "properties": { "name": "Thailand" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-440, -193, -3, 665]],
+ "id": "TJK",
+ "properties": { "name": "Tajikistan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-388, 666, -422, 667, -1]],
+ "id": "TKM",
+ "properties": { "name": "Turkmenistan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[668, -368]],
+ "id": "TLS",
+ "properties": { "name": "Timor" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[669]],
+ "id": "TTO",
+ "properties": { "name": "Trinidad and Tobago" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-267, 670, -463]],
+ "id": "TUN",
+ "properties": { "name": "Tunisia" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[-324, -36, -393, 671, -400, -659, 672]], [[-341, -80, 673]]],
+ "id": "TUR",
+ "properties": { "name": "Turkey" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[674]],
+ "id": "TWN",
+ "properties": { "name": "Taiwan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-436, 675, 676, -500, 677, 678, 679, 680, -221, -59, 681, -620, 682]],
+ "id": "TZA",
+ "properties": { "name": "Tanzania" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-622, -219, 683, -217, 684, -635, -437, -683]],
+ "id": "UGA",
+ "properties": { "name": "Uganda" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [
+ [
+ 685, -611, 686, -609, 687, 688, 689, -605, 690, -572, -477, -571, -362, -654, -563,
+ -97
+ ]
+ ],
+ "id": "UKR",
+ "properties": { "name": "Ukraine" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-114, 691, -28]],
+ "id": "URY",
+ "properties": { "name": "Uruguay" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [
+ [[692]],
+ [[693]],
+ [[694]],
+ [[695]],
+ [[696]],
+ [[697, -481, 698, -140]],
+ [[699]],
+ [[700]],
+ [[701]],
+ [[-144, 702, -142, 703]]
+ ],
+ "id": "USA",
+ "properties": { "name": "United States" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-668, -421, -441, -666, -2]],
+ "id": "UZB",
+ "properties": { "name": "Uzbekistan" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[704, -349, -109, -234]],
+ "id": "VEN",
+ "properties": { "name": "Venezuela" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[705, -444, -457, -185]],
+ "id": "VNM",
+ "properties": { "name": "Vietnam" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[706]], [[707]]],
+ "id": "VUT",
+ "properties": { "name": "Vanuatu" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-417, -404]],
+ "id": "PSX",
+ "properties": { "name": "West Bank" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[708, -627, -542]],
+ "id": "YEM",
+ "properties": { "name": "Yemen" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-523, -119, 709, -505, -657, -503, 710], [-468]],
+ "id": "ZAF",
+ "properties": { "name": "South Africa" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-512, -507, 711, -121, -522, -7, -223, 712, -680]],
+ "id": "ZMB",
+ "properties": { "name": "Zambia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-710, -122, -712, -506]],
+ "id": "ZWE",
+ "properties": { "name": "Zimbabwe" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[713]], [[714]], [[715]], [[716]], [[717]], [[718]], [[719]], [[720]]],
+ "id": "CPV",
+ "properties": { "name": "Cape Verde" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[721]], [[722]], [[723]]],
+ "id": "COM",
+ "properties": { "name": "Comoros" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[724]],
+ "id": "MUS",
+ "properties": { "name": "Mauritius" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[725]],
+ "id": "SYC",
+ "properties": { "name": "Seychelles" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[726]],
+ "id": "BHR",
+ "properties": { "name": "Bahrain" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[727]], [[728]]],
+ "id": "MDV",
+ "properties": { "name": "Maldives" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[729]], [[730]]],
+ "id": "MHL",
+ "properties": { "name": "Marshall Islands" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[731]], [[732]], [[733]], [[734]], [[735]]],
+ "id": "FSM",
+ "properties": { "name": "Micronesia (country)" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[736]],
+ "id": "NRU",
+ "properties": { "name": "Nauru" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[737]],
+ "id": "PLW",
+ "properties": { "name": "Palau" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[738]], [[739]]],
+ "id": "WSM",
+ "properties": { "name": "Samoa" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[515, 740]],
+ "id": "SGP",
+ "properties": { "name": "Singapore" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[741]], [[742]], [[743]]],
+ "id": "TON",
+ "properties": { "name": "Tonga" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[744]], [[745]], [[746]], [[747]], [[748]], [[749]], [[750]]],
+ "id": "TUV",
+ "properties": { "name": "Tuvalu" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[751]], [[752]]],
+ "id": "ATG",
+ "properties": { "name": "Antigua and Barbuda" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[753]],
+ "id": "BRB",
+ "properties": { "name": "Barbados" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[754]],
+ "id": "DMA",
+ "properties": { "name": "Dominica" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[755]],
+ "id": "GRD",
+ "properties": { "name": "Grenada" }
+ },
+ {
+ "type": "MultiPolygon",
+ "arcs": [[[756]], [[757]]],
+ "id": "KNA",
+ "properties": { "name": "Saint Kitts and Nevis" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[758]],
+ "id": "LCA",
+ "properties": { "name": "Saint Lucia" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[759]],
+ "id": "VCT",
+ "properties": { "name": "Saint Vincent and the Grenadines" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[760]],
+ "id": "AND",
+ "properties": { "name": "Andorra" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[-45, 761, -166, 762]],
+ "id": "LIE",
+ "properties": { "name": "Liechtenstein" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[763]],
+ "id": "MLT",
+ "properties": { "name": "Malta" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[764]],
+ "id": "MCO",
+ "properties": { "name": "Monaco" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[765]],
+ "id": "SMR",
+ "properties": { "name": "San Marino" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[766]],
+ "id": "KIR",
+ "properties": { "name": "Kiribati" }
+ },
+ {
+ "type": "Polygon",
+ "arcs": [[767]],
+ "id": "STP",
+ "properties": { "name": "Sao Tome and Principe" }
+ }
+ ]
+ }
+ },
+ "arcs": [
+ [
+ [61.21, 35.65],
+ [62.23, 35.27],
+ [62.98, 35.4],
+ [63.19, 35.86],
+ [63.98, 36.01],
+ [64.55, 36.31],
+ [64.75, 37.11],
+ [65.59, 37.31],
+ [65.75, 37.66],
+ [66.22, 37.39],
+ [66.52, 37.36]
+ ],
+ [
+ [66.52, 37.36],
+ [67.08, 37.36],
+ [67.83, 37.14]
+ ],
+ [
+ [67.83, 37.14],
+ [68.14, 37.02],
+ [68.86, 37.34],
+ [69.2, 37.15],
+ [69.52, 37.61],
+ [70.12, 37.59],
+ [70.27, 37.74],
+ [70.38, 38.14],
+ [70.81, 38.49],
+ [71.35, 38.26],
+ [71.24, 37.95],
+ [71.54, 37.91],
+ [71.45, 37.07],
+ [71.84, 36.74],
+ [72.19, 36.95],
+ [72.64, 37.05],
+ [73.26, 37.5],
+ [73.95, 37.42],
+ [74.98, 37.42]
+ ],
+ [
+ [74.98, 37.42],
+ [75.16, 37.13]
+ ],
+ [
+ [75.16, 37.13],
+ [74.58, 37.02],
+ [74.07, 36.84],
+ [72.92, 36.72],
+ [71.85, 36.51],
+ [71.26, 36.07],
+ [71.5, 35.65],
+ [71.61, 35.15],
+ [71.12, 34.73],
+ [71.16, 34.35],
+ [70.88, 33.99],
+ [69.93, 34.02],
+ [70.32, 33.36],
+ [69.69, 33.11],
+ [69.26, 32.5],
+ [69.32, 31.9],
+ [68.93, 31.62],
+ [68.56, 31.71],
+ [67.79, 31.58],
+ [67.68, 31.3],
+ [66.94, 31.3],
+ [66.38, 30.74],
+ [66.35, 29.89],
+ [65.05, 29.47],
+ [64.35, 29.56],
+ [64.15, 29.34],
+ [63.55, 29.47],
+ [62.55, 29.32],
+ [60.87, 29.83]
+ ],
+ [
+ [60.87, 29.83],
+ [61.78, 30.74],
+ [61.7, 31.38],
+ [60.94, 31.55],
+ [60.86, 32.18],
+ [60.54, 32.98],
+ [60.96, 33.53],
+ [60.53, 33.68],
+ [60.8, 34.4],
+ [61.21, 35.65]
+ ],
+ [
+ [23.91, -10.93],
+ [24.02, -11.24],
+ [23.9, -11.72],
+ [24.08, -12.19],
+ [23.93, -12.57],
+ [24.02, -12.91],
+ [21.93, -12.9],
+ [21.89, -16.08],
+ [22.56, -16.9],
+ [23.22, -17.52]
+ ],
+ [
+ [23.22, -17.52],
+ [21.38, -17.93],
+ [18.96, -17.79],
+ [18.26, -17.31],
+ [14.21, -17.35],
+ [14.06, -17.42],
+ [13.46, -16.97],
+ [12.81, -16.94],
+ [12.22, -17.11],
+ [11.73, -17.3]
+ ],
+ [
+ [11.73, -17.3],
+ [11.64, -16.67],
+ [11.78, -15.79],
+ [12.12, -14.88],
+ [12.18, -14.45],
+ [12.5, -13.55],
+ [12.74, -13.14],
+ [13.31, -12.48],
+ [13.63, -12.04],
+ [13.74, -11.3],
+ [13.69, -10.73],
+ [13.39, -10.37],
+ [13.12, -9.77],
+ [12.88, -9.17],
+ [12.93, -8.96],
+ [13.24, -8.56],
+ [12.93, -7.6],
+ [12.73, -6.93],
+ [12.23, -6.29],
+ [12.32, -6.1]
+ ],
+ [
+ [12.32, -6.1],
+ [12.74, -5.97],
+ [13.02, -5.98],
+ [13.38, -5.86],
+ [16.33, -5.88],
+ [16.57, -6.62],
+ [16.86, -7.22],
+ [17.09, -7.55],
+ [17.47, -8.07],
+ [18.13, -7.99],
+ [18.46, -7.85],
+ [19.02, -7.99],
+ [19.17, -7.74],
+ [19.42, -7.16],
+ [20.04, -7.12],
+ [20.09, -6.94],
+ [20.6, -6.94],
+ [20.51, -7.3],
+ [21.73, -7.29],
+ [21.75, -7.92],
+ [21.95, -8.31],
+ [21.8, -8.91],
+ [21.88, -9.52],
+ [22.21, -9.89],
+ [22.16, -11.08],
+ [22.4, -10.99],
+ [22.84, -11.02],
+ [23.46, -10.87],
+ [23.91, -10.93]
+ ],
+ [
+ [12.18, -5.79],
+ [11.91, -5.04]
+ ],
+ [
+ [11.91, -5.04],
+ [12.32, -4.61],
+ [12.62, -4.44],
+ [13, -4.78]
+ ],
+ [
+ [13, -4.78],
+ [12.63, -4.99],
+ [12.47, -5.25],
+ [12.44, -5.68],
+ [12.18, -5.79]
+ ],
+ [
+ [21.02, 40.84],
+ [21, 40.58],
+ [20.67, 40.44],
+ [20.62, 40.11],
+ [20.15, 39.62]
+ ],
+ [
+ [20.15, 39.62],
+ [19.98, 39.69],
+ [19.96, 39.92],
+ [19.41, 40.25],
+ [19.32, 40.73],
+ [19.4, 41.41],
+ [19.54, 41.72],
+ [19.37, 41.88],
+ [19.3, 42.2],
+ [19.74, 42.69]
+ ],
+ [
+ [19.74, 42.69],
+ [19.8, 42.5],
+ [20.07, 42.59]
+ ],
+ [
+ [20.07, 42.59],
+ [20.28, 42.32],
+ [20.52, 42.22]
+ ],
+ [
+ [20.52, 42.22],
+ [20.59, 41.86],
+ [20.46, 41.52],
+ [20.61, 41.09],
+ [21.02, 40.84]
+ ],
+ [
+ [51.58, 24.25],
+ [51.76, 24.29],
+ [51.79, 24.02],
+ [52.58, 24.18],
+ [53.4, 24.15],
+ [54.01, 24.12],
+ [54.69, 24.8],
+ [55.44, 25.44],
+ [56.07, 26.06]
+ ],
+ [
+ [56.07, 26.06],
+ [56.26, 25.71]
+ ],
+ [
+ [56.26, 25.71],
+ [56.4, 24.92]
+ ],
+ [
+ [56.4, 24.92],
+ [55.89, 24.92],
+ [55.8, 24.27],
+ [55.98, 24.13],
+ [55.53, 23.93],
+ [55.53, 23.52],
+ [55.23, 23.11],
+ [55.21, 22.71]
+ ],
+ [
+ [55.21, 22.71],
+ [55.01, 22.5],
+ [52, 23],
+ [51.62, 24.01],
+ [51.58, 24.25]
+ ],
+ [
+ [-66.96, -54.9],
+ [-67.56, -54.87],
+ [-68.63, -54.87],
+ [-68.63, -52.64]
+ ],
+ [
+ [-68.63, -52.64],
+ [-68.25, -53.1],
+ [-67.75, -53.85],
+ [-66.45, -54.45],
+ [-65.05, -54.7],
+ [-65.5, -55.2],
+ [-66.45, -55.25],
+ [-66.96, -54.9]
+ ],
+ [
+ [-62.69, -22.25],
+ [-60.85, -23.88],
+ [-60.03, -24.03],
+ [-58.81, -24.77],
+ [-57.78, -25.16],
+ [-57.63, -25.6],
+ [-58.62, -27.12],
+ [-57.61, -27.4],
+ [-56.49, -27.55],
+ [-55.7, -27.39],
+ [-54.79, -26.62],
+ [-54.63, -25.74]
+ ],
+ [
+ [-54.63, -25.74],
+ [-54.13, -25.55],
+ [-53.63, -26.12],
+ [-53.65, -26.92],
+ [-54.49, -27.47],
+ [-55.16, -27.88],
+ [-56.29, -28.85],
+ [-57.63, -30.22]
+ ],
+ [
+ [-57.63, -30.22],
+ [-57.87, -31.02],
+ [-58.14, -32.04],
+ [-58.13, -33.04],
+ [-58.35, -33.26],
+ [-58.43, -33.91]
+ ],
+ [
+ [-58.43, -33.91],
+ [-58.5, -34.43],
+ [-57.23, -35.29],
+ [-57.36, -35.98],
+ [-56.74, -36.41],
+ [-56.79, -36.9],
+ [-57.75, -38.18],
+ [-59.23, -38.72],
+ [-61.24, -38.93],
+ [-62.34, -38.83],
+ [-62.13, -39.42],
+ [-62.33, -40.17],
+ [-62.15, -40.68],
+ [-62.75, -41.03],
+ [-63.77, -41.17],
+ [-64.73, -40.8],
+ [-65.12, -41.06],
+ [-64.98, -42.06],
+ [-64.3, -42.36],
+ [-63.76, -42.04],
+ [-63.46, -42.56],
+ [-64.38, -42.87],
+ [-65.18, -43.5],
+ [-65.33, -44.5],
+ [-65.57, -45.04],
+ [-66.51, -45.04],
+ [-67.29, -45.55],
+ [-67.58, -46.3],
+ [-66.6, -47.03],
+ [-65.64, -47.24],
+ [-65.99, -48.13],
+ [-67.17, -48.7],
+ [-67.82, -49.87],
+ [-68.73, -50.26],
+ [-69.14, -50.73],
+ [-68.82, -51.77],
+ [-68.15, -52.35],
+ [-68.57, -52.3]
+ ],
+ [
+ [-68.57, -52.3],
+ [-69.5, -52.14],
+ [-71.91, -52.01],
+ [-72.33, -51.43],
+ [-72.31, -50.68],
+ [-72.98, -50.74],
+ [-73.33, -50.38],
+ [-73.42, -49.32],
+ [-72.65, -48.88],
+ [-72.33, -48.24],
+ [-72.45, -47.74],
+ [-71.92, -46.88],
+ [-71.55, -45.56],
+ [-71.66, -44.97],
+ [-71.22, -44.78],
+ [-71.33, -44.41],
+ [-71.79, -44.21],
+ [-71.46, -43.79],
+ [-71.92, -43.41],
+ [-72.15, -42.25],
+ [-71.75, -42.05],
+ [-71.92, -40.83],
+ [-71.68, -39.81],
+ [-71.41, -38.92],
+ [-70.81, -38.55],
+ [-71.12, -37.58],
+ [-71.12, -36.66],
+ [-70.36, -36.01],
+ [-70.39, -35.17],
+ [-69.82, -34.19],
+ [-69.81, -33.27],
+ [-70.07, -33.09],
+ [-70.54, -31.37],
+ [-69.92, -30.34],
+ [-70.01, -29.37],
+ [-69.66, -28.46],
+ [-69, -27.52],
+ [-68.3, -26.9],
+ [-68.59, -26.51],
+ [-68.39, -26.19],
+ [-68.42, -24.52],
+ [-67.33, -24.03],
+ [-66.99, -22.99],
+ [-67.11, -22.74]
+ ],
+ [
+ [-67.11, -22.74],
+ [-66.27, -21.83],
+ [-64.96, -22.08],
+ [-64.38, -22.8],
+ [-63.99, -21.99],
+ [-62.85, -22.03],
+ [-62.69, -22.25]
+ ],
+ [
+ [43.58, 41.09],
+ [44.97, 41.25]
+ ],
+ [
+ [44.97, 41.25],
+ [45.18, 40.99],
+ [45.56, 40.81],
+ [45.36, 40.56],
+ [45.89, 40.22],
+ [45.61, 39.9],
+ [46.03, 39.63],
+ [46.48, 39.46],
+ [46.51, 38.77]
+ ],
+ [
+ [46.51, 38.77],
+ [46.14, 38.74]
+ ],
+ [
+ [46.14, 38.74],
+ [45.74, 39.32],
+ [45.74, 39.47],
+ [45.3, 39.47],
+ [45, 39.74],
+ [44.79, 39.71]
+ ],
+ [
+ [44.79, 39.71],
+ [44.4, 40.01],
+ [43.66, 40.25],
+ [43.75, 40.74],
+ [43.58, 41.09]
+ ],
+ [
+ [68.94, -48.62],
+ [69.58, -48.94],
+ [70.53, -49.06],
+ [70.56, -49.25],
+ [70.28, -49.71],
+ [68.75, -49.77],
+ [68.72, -49.24],
+ [68.87, -48.83],
+ [68.94, -48.62]
+ ],
+ [
+ [145.4, -40.79],
+ [146.36, -41.14],
+ [146.91, -41],
+ [147.69, -40.81],
+ [148.29, -40.88],
+ [148.36, -42.06],
+ [148.02, -42.41],
+ [147.91, -43.21],
+ [147.56, -42.94],
+ [146.87, -43.63],
+ [146.66, -43.58],
+ [146.05, -43.55],
+ [145.43, -42.69],
+ [145.3, -42.03],
+ [144.72, -41.16],
+ [144.74, -40.7],
+ [145.4, -40.79]
+ ],
+ [
+ [143.56, -13.76],
+ [143.92, -14.55],
+ [144.56, -14.17],
+ [144.89, -14.59],
+ [145.37, -14.98],
+ [145.27, -15.43],
+ [145.49, -16.29],
+ [145.64, -16.78],
+ [145.89, -16.91],
+ [146.16, -17.76],
+ [146.06, -18.28],
+ [146.39, -18.96],
+ [147.47, -19.48],
+ [148.18, -19.96],
+ [148.85, -20.39],
+ [148.72, -20.63],
+ [149.29, -21.26],
+ [149.68, -22.34],
+ [150.08, -22.12],
+ [150.48, -22.56],
+ [150.73, -22.4],
+ [150.9, -23.46],
+ [151.61, -24.08],
+ [152.07, -24.46],
+ [152.86, -25.27],
+ [153.14, -26.07],
+ [153.16, -26.64],
+ [153.09, -27.26],
+ [153.57, -28.11],
+ [153.51, -29],
+ [153.34, -29.46],
+ [153.07, -30.35],
+ [153.09, -30.92],
+ [152.89, -31.64],
+ [152.45, -32.55],
+ [151.71, -33.04],
+ [151.34, -33.82],
+ [151.01, -34.31],
+ [150.71, -35.17],
+ [150.33, -35.67],
+ [150.08, -36.42],
+ [149.95, -37.11],
+ [150, -37.43],
+ [149.42, -37.77],
+ [148.3, -37.81],
+ [147.38, -38.22],
+ [146.92, -38.61],
+ [146.32, -39.04],
+ [145.49, -38.59],
+ [144.88, -38.42],
+ [145.03, -37.9],
+ [144.49, -38.09],
+ [143.61, -38.81],
+ [142.75, -38.54],
+ [142.18, -38.38],
+ [141.61, -38.31],
+ [140.64, -38.02],
+ [139.99, -37.4],
+ [139.81, -36.64],
+ [139.57, -36.14],
+ [139.08, -35.73],
+ [138.12, -35.61],
+ [138.45, -35.13],
+ [138.21, -34.38],
+ [137.72, -35.08],
+ [136.83, -35.26],
+ [137.35, -34.71],
+ [137.5, -34.13],
+ [137.89, -33.64],
+ [137.81, -32.9],
+ [137, -33.75],
+ [136.37, -34.09],
+ [135.99, -34.89],
+ [135.21, -34.48],
+ [135.24, -33.95],
+ [134.61, -33.22],
+ [134.09, -32.85],
+ [134.27, -32.62],
+ [132.99, -32.01],
+ [132.29, -31.98],
+ [131.33, -31.5],
+ [129.54, -31.59],
+ [128.24, -31.95],
+ [127.1, -32.28],
+ [126.15, -32.22],
+ [125.09, -32.73],
+ [124.22, -32.96],
+ [124.03, -33.48],
+ [123.66, -33.89],
+ [122.81, -33.91],
+ [122.18, -34],
+ [121.3, -33.82],
+ [120.58, -33.93],
+ [119.89, -33.98],
+ [119.3, -34.51],
+ [119.01, -34.46],
+ [118.51, -34.75],
+ [118.02, -35.06],
+ [117.3, -35.03],
+ [116.63, -35.03],
+ [115.56, -34.39],
+ [115.03, -34.2],
+ [115.05, -33.62],
+ [115.55, -33.49],
+ [115.71, -33.26],
+ [115.68, -32.9],
+ [115.8, -32.21],
+ [115.69, -31.61],
+ [115.16, -30.6],
+ [115, -30.03],
+ [115.04, -29.46],
+ [114.64, -28.81],
+ [114.62, -28.52],
+ [114.17, -28.12],
+ [114.05, -27.33],
+ [113.48, -26.54],
+ [113.34, -26.12],
+ [113.78, -26.55],
+ [113.44, -25.62],
+ [113.94, -25.91],
+ [114.23, -26.3],
+ [114.22, -25.79],
+ [113.72, -25],
+ [113.63, -24.68],
+ [113.39, -24.38],
+ [113.5, -23.81],
+ [113.71, -23.56],
+ [113.84, -23.06],
+ [113.74, -22.48],
+ [114.15, -21.76],
+ [114.23, -22.52],
+ [114.65, -21.83],
+ [115.46, -21.5],
+ [115.95, -21.07],
+ [116.71, -20.7],
+ [117.17, -20.62],
+ [117.44, -20.75],
+ [118.23, -20.37],
+ [118.84, -20.26],
+ [118.99, -20.04],
+ [119.25, -19.95],
+ [119.81, -19.98],
+ [120.86, -19.68],
+ [121.4, -19.24],
+ [121.66, -18.71],
+ [122.24, -18.2],
+ [122.29, -17.8],
+ [122.31, -17.25],
+ [123.01, -16.41],
+ [123.43, -17.27],
+ [123.86, -17.07],
+ [123.5, -16.6],
+ [123.82, -16.11],
+ [124.26, -16.33],
+ [124.38, -15.57],
+ [124.93, -15.08],
+ [125.17, -14.68],
+ [125.67, -14.51],
+ [125.69, -14.23],
+ [126.13, -14.35],
+ [126.14, -14.1],
+ [126.58, -13.95],
+ [127.07, -13.82],
+ [127.8, -14.28],
+ [128.36, -14.87],
+ [128.99, -14.88],
+ [129.62, -14.97],
+ [129.41, -14.42],
+ [129.89, -13.62],
+ [130.34, -13.36],
+ [130.18, -13.11],
+ [130.62, -12.54],
+ [131.22, -12.18],
+ [131.74, -12.3],
+ [132.58, -12.11],
+ [132.56, -11.6],
+ [131.82, -11.27],
+ [132.36, -11.13],
+ [133.02, -11.38],
+ [133.55, -11.79],
+ [134.39, -12.04],
+ [134.68, -11.94],
+ [135.3, -12.25],
+ [135.88, -11.96],
+ [136.26, -12.05],
+ [136.49, -11.86],
+ [136.95, -12.35],
+ [136.69, -12.89],
+ [136.31, -13.29],
+ [135.96, -13.32],
+ [136.08, -13.72],
+ [135.78, -14.22],
+ [135.43, -14.72],
+ [135.5, -15],
+ [136.3, -15.55],
+ [137.07, -15.87],
+ [137.58, -16.22],
+ [138.3, -16.81],
+ [138.59, -16.81],
+ [139.11, -17.06],
+ [139.26, -17.37],
+ [140.22, -17.71],
+ [140.88, -17.37],
+ [141.07, -16.83],
+ [141.27, -16.39],
+ [141.4, -15.84],
+ [141.7, -15.04],
+ [141.56, -14.56],
+ [141.64, -14.27],
+ [141.52, -13.7],
+ [141.65, -12.94],
+ [141.84, -12.74],
+ [141.69, -12.41],
+ [141.93, -11.88],
+ [142.12, -11.33],
+ [142.14, -11.04],
+ [142.52, -10.67],
+ [142.8, -11.16],
+ [142.87, -11.78],
+ [143.12, -11.91],
+ [143.16, -12.33],
+ [143.52, -12.83],
+ [143.6, -13.4],
+ [143.56, -13.76]
+ ],
+ [
+ [16.98, 48.12],
+ [16.9, 47.71],
+ [16.34, 47.71],
+ [16.53, 47.5],
+ [16.2, 46.85]
+ ],
+ [
+ [16.2, 46.85],
+ [16.01, 46.68],
+ [15.14, 46.66],
+ [14.63, 46.43],
+ [13.81, 46.51]
+ ],
+ [
+ [13.81, 46.51],
+ [12.38, 46.77],
+ [12.15, 47.12],
+ [11.16, 46.94],
+ [11.05, 46.75],
+ [10.44, 46.89]
+ ],
+ [
+ [10.44, 46.89],
+ [9.88, 46.93],
+ [9.87, 47.02]
+ ],
+ [
+ [9.87, 47.02],
+ [9.61, 47.06]
+ ],
+ [
+ [9.61, 47.06],
+ [9.64, 47.1],
+ [9.62, 47.15],
+ [9.56, 47.17],
+ [9.58, 47.21]
+ ],
+ [
+ [9.58, 47.21],
+ [9.53, 47.27],
+ [9.63, 47.36]
+ ],
+ [
+ [9.63, 47.36],
+ [9.59, 47.53]
+ ],
+ [
+ [9.59, 47.53],
+ [9.9, 47.58],
+ [10.4, 47.3],
+ [10.54, 47.57],
+ [11.43, 47.52],
+ [12.14, 47.7],
+ [12.62, 47.67],
+ [12.93, 47.47],
+ [13.03, 47.64],
+ [12.88, 48.29],
+ [13.24, 48.42],
+ [13.6, 48.88]
+ ],
+ [
+ [13.6, 48.88],
+ [14.34, 48.56],
+ [14.9, 48.96],
+ [15.25, 49.04],
+ [16.03, 48.73],
+ [16.5, 48.79],
+ [16.96, 48.6]
+ ],
+ [
+ [16.96, 48.6],
+ [16.88, 48.47],
+ [16.98, 48.12]
+ ],
+ [
+ [46.14, 38.74],
+ [45.46, 38.87],
+ [44.95, 39.34],
+ [44.79, 39.71]
+ ],
+ [
+ [47.99, 41.41],
+ [48.58, 41.81],
+ [49.11, 41.28],
+ [49.62, 40.57],
+ [50.08, 40.53],
+ [50.39, 40.26],
+ [49.57, 40.18],
+ [49.4, 39.4],
+ [49.22, 39.05],
+ [48.86, 38.82],
+ [48.88, 38.32]
+ ],
+ [
+ [48.88, 38.32],
+ [48.63, 38.27],
+ [48.01, 38.79],
+ [48.36, 39.29],
+ [48.06, 39.58],
+ [47.69, 39.51],
+ [46.51, 38.77]
+ ],
+ [
+ [44.97, 41.25],
+ [45.22, 41.41],
+ [45.96, 41.12],
+ [46.5, 41.06],
+ [46.64, 41.18],
+ [46.15, 41.72],
+ [46.4, 41.86]
+ ],
+ [
+ [46.4, 41.86],
+ [46.69, 41.83],
+ [47.37, 41.22],
+ [47.82, 41.15],
+ [47.99, 41.41]
+ ],
+ [
+ [29.34, -4.5],
+ [29.28, -3.29],
+ [29.02, -2.84]
+ ],
+ [
+ [29.02, -2.84],
+ [29.63, -2.92],
+ [29.94, -2.35],
+ [30.47, -2.41]
+ ],
+ [
+ [30.47, -2.41],
+ [30.53, -2.81],
+ [30.74, -3.03],
+ [30.75, -3.36],
+ [30.51, -3.57],
+ [30.12, -4.09],
+ [29.75, -4.45]
+ ],
+ [
+ [29.75, -4.45],
+ [29.34, -4.5]
+ ],
+ [
+ [4.05, 51.27],
+ [4.97, 51.48],
+ [5.61, 51.04],
+ [6.16, 50.8]
+ ],
+ [
+ [6.16, 50.8],
+ [6.04, 50.13]
+ ],
+ [
+ [6.04, 50.13],
+ [5.78, 50.09],
+ [5.67, 49.53]
+ ],
+ [
+ [5.67, 49.53],
+ [4.8, 49.99],
+ [4.29, 49.91],
+ [3.59, 50.38],
+ [3.12, 50.78],
+ [2.66, 50.8],
+ [2.51, 51.15]
+ ],
+ [
+ [2.51, 51.15],
+ [3.31, 51.35],
+ [4.05, 51.27]
+ ],
+ [
+ [2.69, 6.26],
+ [1.87, 6.14]
+ ],
+ [
+ [1.87, 6.14],
+ [1.62, 6.83],
+ [1.66, 9.13],
+ [1.46, 9.33],
+ [1.43, 9.83],
+ [1.08, 10.18],
+ [0.77, 10.47],
+ [0.9, 11]
+ ],
+ [
+ [0.9, 11],
+ [1.24, 11.11],
+ [1.45, 11.55],
+ [1.94, 11.64],
+ [2.15, 11.94]
+ ],
+ [
+ [2.15, 11.94],
+ [2.49, 12.23],
+ [2.85, 12.24],
+ [3.61, 11.66]
+ ],
+ [
+ [3.61, 11.66],
+ [3.57, 11.33],
+ [3.8, 10.73],
+ [3.6, 10.33],
+ [3.71, 10.06],
+ [3.22, 9.44],
+ [2.91, 9.14],
+ [2.72, 8.51],
+ [2.75, 7.87],
+ [2.69, 6.26]
+ ],
+ [
+ [-2.83, 9.64],
+ [-3.51, 9.9],
+ [-3.98, 9.86],
+ [-4.33, 9.61],
+ [-4.78, 9.82],
+ [-4.95, 10.15],
+ [-5.4, 10.37]
+ ],
+ [
+ [-5.4, 10.37],
+ [-5.47, 10.95],
+ [-5.2, 11.38],
+ [-5.22, 11.71],
+ [-4.43, 12.54],
+ [-4.28, 13.23],
+ [-4.01, 13.47],
+ [-3.52, 13.34],
+ [-3.1, 13.54],
+ [-2.97, 13.8],
+ [-2.19, 14.25],
+ [-2, 14.56],
+ [-1.07, 14.97],
+ [-0.52, 15.12],
+ [-0.27, 14.92],
+ [0.37, 14.93]
+ ],
+ [
+ [0.37, 14.93],
+ [0.3, 14.44],
+ [0.43, 13.99],
+ [0.99, 13.34],
+ [1.02, 12.85],
+ [2.18, 12.63],
+ [2.15, 11.94]
+ ],
+ [
+ [0.9, 11],
+ [0.02, 11.02]
+ ],
+ [
+ [0.02, 11.02],
+ [-0.44, 11.1],
+ [-0.76, 10.94],
+ [-1.2, 11.01],
+ [-2.94, 10.96],
+ [-2.96, 10.4],
+ [-2.83, 9.64]
+ ],
+ [
+ [92.67, 22.04],
+ [92.65, 21.32],
+ [92.3, 21.48],
+ [92.37, 20.67]
+ ],
+ [
+ [92.37, 20.67],
+ [92.08, 21.19],
+ [92.03, 21.7],
+ [91.83, 22.18],
+ [91.42, 22.77],
+ [90.5, 22.81],
+ [90.59, 22.39],
+ [90.27, 21.84],
+ [89.85, 22.04],
+ [89.7, 21.86],
+ [89.42, 21.97],
+ [89.03, 22.06]
+ ],
+ [
+ [89.03, 22.06],
+ [88.88, 22.88],
+ [88.53, 23.63],
+ [88.7, 24.23],
+ [88.08, 24.5],
+ [88.31, 24.87],
+ [88.93, 25.24],
+ [88.21, 25.77],
+ [88.56, 26.45],
+ [89.36, 26.01],
+ [89.83, 25.97],
+ [89.92, 25.27],
+ [90.87, 25.13],
+ [91.8, 25.15],
+ [92.38, 24.98],
+ [91.92, 24.13],
+ [91.47, 24.07],
+ [91.16, 23.5],
+ [91.71, 22.99],
+ [91.87, 23.62],
+ [92.15, 23.63],
+ [92.67, 22.04]
+ ],
+ [
+ [22.66, 44.23],
+ [22.94, 43.82],
+ [23.33, 43.9],
+ [24.1, 43.74],
+ [25.57, 43.69],
+ [26.07, 43.94],
+ [27.24, 44.18],
+ [27.97, 43.81],
+ [28.56, 43.71]
+ ],
+ [
+ [28.56, 43.71],
+ [28.04, 43.29],
+ [27.67, 42.58],
+ [28, 42.01]
+ ],
+ [
+ [28, 42.01],
+ [27.14, 42.14],
+ [26.12, 41.83]
+ ],
+ [
+ [26.12, 41.83],
+ [26.11, 41.33],
+ [25.2, 41.23],
+ [24.49, 41.58],
+ [23.69, 41.31],
+ [22.95, 41.34]
+ ],
+ [
+ [22.95, 41.34],
+ [22.88, 42],
+ [22.38, 42.32]
+ ],
+ [
+ [22.38, 42.32],
+ [22.55, 42.46],
+ [22.44, 42.58],
+ [22.6, 42.9],
+ [22.99, 43.21],
+ [22.5, 43.64],
+ [22.41, 44.01],
+ [22.66, 44.23]
+ ],
+ [
+ [-77.53, 23.76],
+ [-77.78, 23.71],
+ [-78.03, 24.29],
+ [-78.41, 24.58],
+ [-78.19, 25.21],
+ [-77.89, 25.17],
+ [-77.54, 24.34],
+ [-77.53, 23.76]
+ ],
+ [
+ [-77.82, 26.58],
+ [-78.91, 26.42],
+ [-78.98, 26.79],
+ [-78.51, 26.87],
+ [-77.85, 26.84],
+ [-77.82, 26.58]
+ ],
+ [
+ [-77, 26.59],
+ [-77.17, 25.88],
+ [-77.36, 26.01],
+ [-77.34, 26.53],
+ [-77.79, 26.93],
+ [-77.79, 27.04],
+ [-77, 26.59]
+ ],
+ [
+ [19.01, 44.86],
+ [19.37, 44.86]
+ ],
+ [
+ [19.37, 44.86],
+ [19.12, 44.42],
+ [19.6, 44.04],
+ [19.45, 43.57],
+ [19.22, 43.52]
+ ],
+ [
+ [19.22, 43.52],
+ [19.03, 43.43],
+ [18.71, 43.2],
+ [18.56, 42.65]
+ ],
+ [
+ [18.56, 42.65],
+ [17.67, 43.03],
+ [17.3, 43.45],
+ [16.92, 43.67],
+ [16.46, 44.04],
+ [16.24, 44.35],
+ [15.75, 44.82],
+ [15.96, 45.23],
+ [16.32, 45],
+ [16.53, 45.21],
+ [17, 45.23],
+ [17.86, 45.07],
+ [18.55, 45.08],
+ [19.01, 44.86]
+ ],
+ [
+ [23.48, 53.91],
+ [24.45, 53.91],
+ [25.54, 54.28],
+ [25.77, 54.85],
+ [26.59, 55.17],
+ [26.49, 55.62]
+ ],
+ [
+ [26.49, 55.62],
+ [27.1, 55.78],
+ [28.18, 56.17]
+ ],
+ [
+ [28.18, 56.17],
+ [29.23, 55.92],
+ [29.37, 55.67],
+ [29.9, 55.79],
+ [30.87, 55.55],
+ [30.97, 55.08],
+ [30.76, 54.81],
+ [31.38, 54.16],
+ [31.79, 53.97],
+ [31.73, 53.79],
+ [32.41, 53.62],
+ [32.69, 53.35],
+ [32.3, 53.13]
+ ],
+ [
+ [32.3, 53.13],
+ [31.5, 53.17],
+ [31.31, 53.07]
+ ],
+ [
+ [31.31, 53.07],
+ [31.54, 52.74]
+ ],
+ [
+ [31.54, 52.74],
+ [31.79, 52.1]
+ ],
+ [
+ [31.79, 52.1],
+ [30.93, 52.04],
+ [30.62, 51.82],
+ [30.56, 51.32],
+ [30.16, 51.42],
+ [29.25, 51.37],
+ [28.99, 51.6],
+ [28.62, 51.43],
+ [28.24, 51.57],
+ [27.45, 51.59],
+ [26.34, 51.83],
+ [25.33, 51.91],
+ [24.55, 51.89],
+ [24.01, 51.62],
+ [23.53, 51.58]
+ ],
+ [
+ [23.53, 51.58],
+ [23.51, 52.02],
+ [23.2, 52.49],
+ [23.8, 52.69],
+ [23.8, 53.09],
+ [23.53, 53.47],
+ [23.48, 53.91]
+ ],
+ [
+ [-89.14, 17.81],
+ [-89.15, 17.96],
+ [-89.03, 18],
+ [-88.85, 17.88],
+ [-88.49, 18.49],
+ [-88.3, 18.5]
+ ],
+ [
+ [-88.3, 18.5],
+ [-88.3, 18.35],
+ [-88.11, 18.35],
+ [-88.12, 18.08],
+ [-88.29, 17.64],
+ [-88.2, 17.49],
+ [-88.3, 17.13],
+ [-88.24, 17.04],
+ [-88.36, 16.53],
+ [-88.55, 16.27],
+ [-88.73, 16.23],
+ [-88.93, 15.89]
+ ],
+ [
+ [-88.93, 15.89],
+ [-89.23, 15.89],
+ [-89.15, 17.02],
+ [-89.14, 17.81]
+ ],
+ [
+ [-67.11, -22.74],
+ [-67.83, -22.87],
+ [-68.22, -21.49],
+ [-68.76, -20.37],
+ [-68.44, -19.41],
+ [-68.97, -18.98],
+ [-69.1, -18.26],
+ [-69.59, -17.58]
+ ],
+ [
+ [-69.59, -17.58],
+ [-68.96, -16.5],
+ [-69.39, -15.66],
+ [-69.16, -15.32],
+ [-69.34, -14.95],
+ [-68.95, -14.45],
+ [-68.93, -13.6],
+ [-68.88, -12.9],
+ [-68.67, -12.56],
+ [-69.53, -10.95]
+ ],
+ [
+ [-69.53, -10.95],
+ [-68.79, -11.04],
+ [-68.27, -11.01],
+ [-68.05, -10.71],
+ [-67.17, -10.31],
+ [-66.65, -9.93],
+ [-65.34, -9.76],
+ [-65.44, -10.51],
+ [-65.32, -10.9],
+ [-65.4, -11.57],
+ [-64.32, -12.46],
+ [-63.2, -12.63],
+ [-62.8, -13],
+ [-62.13, -13.2],
+ [-61.71, -13.49],
+ [-61.08, -13.48],
+ [-60.5, -13.78],
+ [-60.46, -14.35],
+ [-60.26, -14.65],
+ [-60.25, -15.08],
+ [-60.54, -15.09],
+ [-60.16, -16.26],
+ [-58.24, -16.3],
+ [-58.39, -16.88],
+ [-58.28, -17.27],
+ [-57.73, -17.55],
+ [-57.5, -18.17],
+ [-57.68, -18.96],
+ [-57.95, -19.4],
+ [-57.85, -19.97],
+ [-58.17, -20.18]
+ ],
+ [
+ [-58.17, -20.18],
+ [-58.18, -19.87],
+ [-59.12, -19.36],
+ [-60.04, -19.34],
+ [-61.79, -19.63],
+ [-62.27, -20.51],
+ [-62.29, -21.05],
+ [-62.69, -22.25]
+ ],
+ [
+ [-54.63, -25.74],
+ [-54.43, -25.16],
+ [-54.29, -24.57],
+ [-54.29, -24.02],
+ [-54.65, -23.84],
+ [-55.03, -24],
+ [-55.4, -23.96],
+ [-55.52, -23.57],
+ [-55.61, -22.66],
+ [-55.8, -22.36],
+ [-56.47, -22.09],
+ [-56.88, -22.28],
+ [-57.94, -22.09],
+ [-57.87, -20.73],
+ [-58.17, -20.18]
+ ],
+ [
+ [-69.53, -10.95],
+ [-70.09, -11.12],
+ [-70.55, -11.01],
+ [-70.48, -9.49],
+ [-71.3, -10.08],
+ [-72.18, -10.05],
+ [-72.56, -9.52],
+ [-73.23, -9.46],
+ [-73.02, -9.03],
+ [-73.57, -8.42],
+ [-73.99, -7.52],
+ [-73.72, -7.34],
+ [-73.72, -6.92],
+ [-73.12, -6.63],
+ [-73.22, -6.09],
+ [-72.96, -5.74],
+ [-72.89, -5.27],
+ [-71.75, -4.59],
+ [-70.93, -4.4],
+ [-70.79, -4.25],
+ [-69.89, -4.3]
+ ],
+ [
+ [-69.89, -4.3],
+ [-69.44, -1.56],
+ [-69.42, -1.12],
+ [-69.58, -0.55],
+ [-70.02, -0.19],
+ [-70.02, 0.54],
+ [-69.45, 0.71],
+ [-69.25, 0.6],
+ [-69.22, 0.99],
+ [-69.8, 1.09],
+ [-69.82, 1.71],
+ [-67.87, 1.69],
+ [-67.54, 2.04],
+ [-67.26, 1.72],
+ [-67.07, 1.13],
+ [-66.88, 1.25]
+ ],
+ [
+ [-66.88, 1.25],
+ [-66.33, 0.72],
+ [-65.55, 0.79],
+ [-65.35, 1.1],
+ [-64.61, 1.33],
+ [-64.2, 1.49],
+ [-64.08, 1.92],
+ [-63.37, 2.2],
+ [-63.42, 2.41],
+ [-64.27, 2.5],
+ [-64.41, 3.13],
+ [-64.37, 3.8],
+ [-64.82, 4.06],
+ [-64.63, 4.15],
+ [-63.89, 4.02],
+ [-63.09, 3.77],
+ [-62.8, 4.01],
+ [-62.09, 4.16],
+ [-60.97, 4.54],
+ [-60.6, 4.92],
+ [-60.73, 5.2]
+ ],
+ [
+ [-60.73, 5.2],
+ [-60.21, 5.24],
+ [-59.98, 5.01],
+ [-60.11, 4.57],
+ [-59.77, 4.42],
+ [-59.54, 3.96],
+ [-59.82, 3.61],
+ [-59.97, 2.76],
+ [-59.72, 2.25],
+ [-59.65, 1.79],
+ [-59.03, 1.32],
+ [-58.54, 1.27],
+ [-58.43, 1.46],
+ [-58.11, 1.51],
+ [-57.66, 1.68],
+ [-57.34, 1.95],
+ [-56.78, 1.86],
+ [-56.54, 1.9]
+ ],
+ [
+ [-56.54, 1.9],
+ [-56, 1.82],
+ [-55.91, 2.02],
+ [-56.07, 2.22],
+ [-55.97, 2.51],
+ [-55.57, 2.42],
+ [-55.1, 2.52],
+ [-54.52, 2.31]
+ ],
+ [
+ [-54.52, 2.31],
+ [-54.09, 2.11],
+ [-53.78, 2.38],
+ [-53.55, 2.33],
+ [-53.42, 2.05],
+ [-52.94, 2.12],
+ [-52.56, 2.5],
+ [-52.25, 3.24],
+ [-51.66, 4.16]
+ ],
+ [
+ [-51.66, 4.16],
+ [-51.32, 4.2],
+ [-51.07, 3.65],
+ [-50.51, 1.9],
+ [-49.97, 1.74],
+ [-49.95, 1.05],
+ [-50.7, 0.22],
+ [-50.39, -0.08],
+ [-48.62, -0.24],
+ [-48.58, -1.24],
+ [-47.82, -0.58],
+ [-46.57, -0.94],
+ [-44.91, -1.55],
+ [-44.42, -2.14],
+ [-44.58, -2.69],
+ [-43.42, -2.38],
+ [-41.47, -2.91],
+ [-39.98, -2.87],
+ [-38.5, -3.7],
+ [-37.22, -4.82],
+ [-36.45, -5.11],
+ [-35.6, -5.15],
+ [-35.24, -5.46],
+ [-34.9, -6.74],
+ [-34.73, -7.34],
+ [-35.13, -9],
+ [-35.64, -9.65],
+ [-37.05, -11.04],
+ [-37.68, -12.17],
+ [-38.42, -13.04],
+ [-38.67, -13.06],
+ [-38.95, -13.79],
+ [-38.88, -15.67],
+ [-39.16, -17.21],
+ [-39.27, -17.87],
+ [-39.58, -18.26],
+ [-39.76, -19.6],
+ [-40.77, -20.9],
+ [-40.94, -21.94],
+ [-41.75, -22.37],
+ [-41.99, -22.97],
+ [-43.07, -22.97],
+ [-44.65, -23.35],
+ [-45.35, -23.8],
+ [-46.47, -24.09],
+ [-47.65, -24.89],
+ [-48.5, -25.88],
+ [-48.64, -26.62],
+ [-48.47, -27.18],
+ [-48.66, -28.19],
+ [-48.89, -28.67],
+ [-49.59, -29.22],
+ [-50.7, -30.98],
+ [-51.58, -31.78],
+ [-52.26, -32.25],
+ [-52.71, -33.2],
+ [-53.37, -33.77]
+ ],
+ [
+ [-53.37, -33.77],
+ [-53.65, -33.2],
+ [-53.21, -32.73],
+ [-53.79, -32.05],
+ [-54.57, -31.49],
+ [-55.6, -30.85],
+ [-55.97, -30.88],
+ [-56.98, -30.11],
+ [-57.63, -30.22]
+ ],
+ [
+ [114.2, 4.53],
+ [114.6, 4.9],
+ [115.45, 5.45]
+ ],
+ [
+ [115.45, 5.45],
+ [115.41, 4.96],
+ [115.35, 4.32],
+ [114.87, 4.35],
+ [114.66, 4.01],
+ [114.2, 4.53]
+ ],
+ [
+ [91.7, 27.77],
+ [92.1, 27.45],
+ [92.03, 26.84],
+ [91.22, 26.81],
+ [90.37, 26.88],
+ [89.74, 26.72],
+ [88.84, 27.1],
+ [88.81, 27.3]
+ ],
+ [
+ [88.81, 27.3],
+ [89.48, 28.04],
+ [90.02, 28.3],
+ [90.73, 28.06],
+ [91.26, 28.04],
+ [91.7, 27.77]
+ ],
+ [
+ [29.43, -22.09],
+ [28.02, -22.83],
+ [27.12, -23.57],
+ [26.79, -24.24],
+ [26.49, -24.62],
+ [25.94, -24.7],
+ [25.77, -25.17],
+ [25.66, -25.49],
+ [25.03, -25.72],
+ [24.21, -25.67],
+ [23.73, -25.39],
+ [23.31, -25.27],
+ [22.82, -25.5],
+ [22.58, -25.98],
+ [22.11, -26.28],
+ [21.61, -26.73],
+ [20.89, -26.83],
+ [20.67, -26.48],
+ [20.76, -25.87],
+ [20.17, -24.92],
+ [19.9, -24.77]
+ ],
+ [
+ [19.9, -24.77],
+ [19.9, -21.85],
+ [20.88, -21.81],
+ [20.91, -18.25],
+ [21.66, -18.22],
+ [23.2, -17.87],
+ [23.58, -18.28],
+ [24.22, -17.89],
+ [24.52, -17.89],
+ [25.08, -17.66]
+ ],
+ [
+ [25.08, -17.66],
+ [25.26, -17.74]
+ ],
+ [
+ [25.26, -17.74],
+ [25.65, -18.54],
+ [25.85, -18.71],
+ [26.16, -19.29],
+ [27.3, -20.39],
+ [27.72, -20.5],
+ [27.73, -20.85],
+ [28.02, -21.49],
+ [28.79, -21.64],
+ [29.43, -22.09]
+ ],
+ [
+ [15.28, 7.42],
+ [16.11, 7.5],
+ [16.29, 7.75],
+ [16.46, 7.73],
+ [16.71, 7.51],
+ [17.96, 7.89],
+ [18.39, 8.28],
+ [18.91, 8.63],
+ [18.81, 8.98],
+ [19.09, 9.07],
+ [20.06, 9.01],
+ [21, 9.48],
+ [21.72, 10.57],
+ [22.23, 10.97],
+ [22.86, 11.14]
+ ],
+ [
+ [22.86, 11.14],
+ [22.98, 10.71],
+ [23.55, 10.09],
+ [23.56, 9.68],
+ [23.39, 9.27],
+ [23.46, 8.95],
+ [23.81, 8.67]
+ ],
+ [
+ [23.81, 8.67],
+ [24.57, 8.23]
+ ],
+ [
+ [24.57, 8.23],
+ [25.11, 7.83],
+ [25.12, 7.5],
+ [25.8, 6.98],
+ [26.21, 6.55],
+ [26.47, 5.95],
+ [27.21, 5.55],
+ [27.37, 5.23]
+ ],
+ [
+ [27.37, 5.23],
+ [27.04, 5.13],
+ [26.4, 5.15],
+ [25.65, 5.26],
+ [25.28, 5.17],
+ [25.13, 4.93],
+ [24.81, 4.9],
+ [24.41, 5.11],
+ [23.3, 4.61],
+ [22.84, 4.71],
+ [22.7, 4.63],
+ [22.41, 4.03],
+ [21.66, 4.22],
+ [20.93, 4.32],
+ [20.29, 4.69],
+ [19.47, 5.03],
+ [18.93, 4.71],
+ [18.54, 4.2],
+ [18.45, 3.5]
+ ],
+ [
+ [18.45, 3.5],
+ [17.81, 3.56],
+ [17.13, 3.73],
+ [16.54, 3.2],
+ [16.01, 2.27]
+ ],
+ [
+ [16.01, 2.27],
+ [15.91, 2.56],
+ [15.86, 3.01],
+ [15.41, 3.34],
+ [15.04, 3.85],
+ [14.95, 4.21],
+ [14.48, 4.73],
+ [14.56, 5.03],
+ [14.46, 5.45],
+ [14.54, 6.23],
+ [14.78, 6.41],
+ [15.28, 7.42]
+ ],
+ [
+ [-63.66, 46.55],
+ [-62.94, 46.42],
+ [-62.01, 46.44],
+ [-62.5, 46.03],
+ [-62.87, 45.97],
+ [-64.14, 46.39],
+ [-64.39, 46.73],
+ [-64.01, 47.04],
+ [-63.66, 46.55]
+ ],
+ [
+ [-61.81, 49.11],
+ [-62.29, 49.09],
+ [-63.59, 49.4],
+ [-64.52, 49.87],
+ [-64.17, 49.96],
+ [-62.86, 49.71],
+ [-61.84, 49.29],
+ [-61.81, 49.11]
+ ],
+ [
+ [-123.51, 48.51],
+ [-124.01, 48.37],
+ [-125.66, 48.83],
+ [-125.95, 49.18],
+ [-126.85, 49.53],
+ [-127.03, 49.81],
+ [-128.06, 49.99],
+ [-128.44, 50.54],
+ [-128.36, 50.77],
+ [-127.31, 50.55],
+ [-126.7, 50.4],
+ [-125.76, 50.3],
+ [-125.42, 49.95],
+ [-124.92, 49.48],
+ [-123.92, 49.06],
+ [-123.51, 48.51]
+ ],
+ [
+ [-56.13, 50.69],
+ [-56.8, 49.81],
+ [-56.14, 50.15],
+ [-55.47, 49.94],
+ [-55.82, 49.59],
+ [-54.94, 49.31],
+ [-54.47, 49.56],
+ [-53.48, 49.25],
+ [-53.79, 48.52],
+ [-53.09, 48.69],
+ [-52.96, 48.16],
+ [-52.65, 47.54],
+ [-53.07, 46.66],
+ [-53.52, 46.62],
+ [-54.18, 46.81],
+ [-53.96, 47.63],
+ [-54.24, 47.75],
+ [-55.4, 46.88],
+ [-56, 46.92],
+ [-55.29, 47.39],
+ [-56.25, 47.63],
+ [-57.33, 47.57],
+ [-59.27, 47.6],
+ [-59.42, 47.9],
+ [-58.8, 48.25],
+ [-59.23, 48.52],
+ [-58.39, 49.13],
+ [-57.36, 50.72],
+ [-56.74, 51.29],
+ [-55.87, 51.63],
+ [-55.41, 51.59],
+ [-55.6, 51.32],
+ [-56.13, 50.69]
+ ],
+ [
+ [-132.71, 54.04],
+ [-132.71, 54.04],
+ [-132.71, 54.04],
+ [-132.71, 54.04],
+ [-131.75, 54.12],
+ [-132.05, 52.98],
+ [-131.18, 52.18],
+ [-131.58, 52.18],
+ [-132.18, 52.64],
+ [-132.55, 53.1],
+ [-133.05, 53.41],
+ [-133.24, 53.85],
+ [-133.18, 54.17],
+ [-132.71, 54.04]
+ ],
+ [
+ [-79.27, 62.16],
+ [-79.66, 61.63],
+ [-80.1, 61.72],
+ [-80.36, 62.02],
+ [-80.32, 62.09],
+ [-79.93, 62.39],
+ [-79.52, 62.36],
+ [-79.27, 62.16]
+ ],
+ [
+ [-81.9, 62.71],
+ [-83.07, 62.16],
+ [-83.77, 62.18],
+ [-83.99, 62.45],
+ [-83.25, 62.91],
+ [-81.88, 62.9],
+ [-81.9, 62.71]
+ ],
+ [
+ [-85.16, 65.66],
+ [-84.98, 65.22],
+ [-84.46, 65.37],
+ [-83.88, 65.11],
+ [-82.79, 64.77],
+ [-81.64, 64.46],
+ [-81.55, 63.98],
+ [-80.82, 64.06],
+ [-80.1, 63.73],
+ [-80.99, 63.41],
+ [-82.55, 63.65],
+ [-83.11, 64.1],
+ [-84.1, 63.57],
+ [-85.52, 63.05],
+ [-85.87, 63.64],
+ [-87.22, 63.54],
+ [-86.35, 64.04],
+ [-86.22, 64.82],
+ [-85.88, 65.74],
+ [-85.16, 65.66]
+ ],
+ [
+ [-75.87, 67.15],
+ [-76.99, 67.1],
+ [-77.24, 67.59],
+ [-76.81, 68.15],
+ [-75.9, 68.29],
+ [-75.11, 68.01],
+ [-75.1, 67.58],
+ [-75.22, 67.44],
+ [-75.87, 67.15]
+ ],
+ [
+ [-95.65, 69.11],
+ [-96.27, 68.76],
+ [-97.62, 69.06],
+ [-98.43, 68.95],
+ [-99.8, 69.4],
+ [-98.92, 69.71],
+ [-98.22, 70.14],
+ [-97.16, 69.86],
+ [-96.56, 69.68],
+ [-96.26, 69.49],
+ [-95.65, 69.11]
+ ],
+ [
+ [-67.14, 45.14],
+ [-67.79, 45.7],
+ [-67.79, 47.07],
+ [-68.23, 47.35],
+ [-68.9, 47.19],
+ [-69.24, 47.45],
+ [-70, 46.69],
+ [-70.31, 45.92],
+ [-70.66, 45.46],
+ [-71.08, 45.31],
+ [-71.4, 45.26],
+ [-71.51, 45.01],
+ [-73.35, 45.01],
+ [-74.87, 45],
+ [-75.32, 44.82],
+ [-76.37, 44.1],
+ [-76.5, 44.02],
+ [-76.82, 43.63],
+ [-77.74, 43.63],
+ [-78.72, 43.63],
+ [-79.17, 43.47],
+ [-79.01, 43.27],
+ [-78.92, 42.97],
+ [-78.94, 42.86],
+ [-80.25, 42.37],
+ [-81.28, 42.21],
+ [-82.44, 41.68],
+ [-82.69, 41.68],
+ [-83.03, 41.83],
+ [-83.14, 41.98],
+ [-83.12, 42.08],
+ [-82.9, 42.43],
+ [-82.43, 42.98],
+ [-82.14, 43.57],
+ [-82.34, 44.44],
+ [-82.55, 45.35],
+ [-83.59, 45.82],
+ [-83.47, 45.99],
+ [-83.62, 46.12],
+ [-83.89, 46.12],
+ [-84.09, 46.28],
+ [-84.14, 46.51],
+ [-84.34, 46.41],
+ [-84.6, 46.44],
+ [-84.54, 46.54],
+ [-84.78, 46.64],
+ [-84.88, 46.9],
+ [-85.65, 47.22],
+ [-86.46, 47.55],
+ [-87.44, 47.94],
+ [-88.38, 48.3],
+ [-89.27, 48.02],
+ [-89.6, 48.01],
+ [-90.83, 48.27],
+ [-91.64, 48.14],
+ [-92.61, 48.45],
+ [-93.63, 48.61],
+ [-94.33, 48.67],
+ [-94.64, 48.84],
+ [-94.82, 49.39],
+ [-95.16, 49.38],
+ [-95.16, 49],
+ [-97.23, 49],
+ [-100.65, 49],
+ [-104.05, 49],
+ [-107.05, 49],
+ [-110.05, 49],
+ [-113, 49],
+ [-116.05, 49],
+ [-117.03, 49],
+ [-120, 49],
+ [-122.84, 49]
+ ],
+ [
+ [-122.84, 49],
+ [-122.97, 49],
+ [-124.91, 49.98],
+ [-125.62, 50.42],
+ [-127.44, 50.83],
+ [-127.99, 51.72],
+ [-127.85, 52.33],
+ [-129.13, 52.76],
+ [-129.31, 53.56],
+ [-130.51, 54.29],
+ [-130.54, 54.8],
+ [-129.98, 55.29],
+ [-130.01, 55.92]
+ ],
+ [
+ [-130.01, 55.92],
+ [-131.71, 56.55],
+ [-132.73, 57.69]
+ ],
+ [
+ [-132.73, 57.69],
+ [-133.36, 58.41],
+ [-134.27, 58.86]
+ ],
+ [
+ [-134.27, 58.86],
+ [-134.94, 59.27],
+ [-135.48, 59.79],
+ [-136.48, 59.46],
+ [-137.45, 58.91],
+ [-138.34, 59.56]
+ ],
+ [
+ [-138.34, 59.56],
+ [-139.04, 60],
+ [-140.01, 60.28],
+ [-141, 60.31],
+ [-140.99, 66],
+ [-140.99, 69.71],
+ [-139.12, 69.47],
+ [-137.55, 68.99],
+ [-136.5, 68.9],
+ [-135.63, 69.32],
+ [-134.41, 69.63],
+ [-132.93, 69.51],
+ [-131.43, 69.94],
+ [-129.79, 70.19],
+ [-129.11, 69.78],
+ [-128.36, 70.01],
+ [-128.14, 70.48],
+ [-127.45, 70.38],
+ [-125.76, 69.48],
+ [-124.42, 70.16],
+ [-124.29, 69.4],
+ [-123.06, 69.56],
+ [-122.68, 69.86],
+ [-121.47, 69.8],
+ [-119.94, 69.38],
+ [-117.6, 69.01],
+ [-116.23, 68.84],
+ [-115.25, 68.91],
+ [-113.9, 68.4],
+ [-115.3, 67.9],
+ [-113.5, 67.69],
+ [-110.8, 67.81],
+ [-109.95, 67.98],
+ [-108.88, 67.38],
+ [-107.79, 67.89],
+ [-108.81, 68.31],
+ [-108.17, 68.65],
+ [-106.95, 68.7],
+ [-106.15, 68.8],
+ [-105.34, 68.56],
+ [-104.34, 68.02],
+ [-103.22, 68.1],
+ [-101.45, 67.65],
+ [-99.9, 67.81],
+ [-98.44, 67.78],
+ [-98.56, 68.4],
+ [-97.67, 68.58],
+ [-96.12, 68.24],
+ [-96.13, 67.29],
+ [-95.49, 68.09],
+ [-94.68, 68.06],
+ [-94.23, 69.07],
+ [-95.3, 69.69],
+ [-96.47, 70.09],
+ [-96.39, 71.19],
+ [-95.21, 71.92],
+ [-93.89, 71.76],
+ [-92.88, 71.32],
+ [-91.52, 70.19],
+ [-92.41, 69.7],
+ [-90.55, 69.5],
+ [-90.55, 68.47],
+ [-89.22, 69.26],
+ [-88.02, 68.62],
+ [-88.32, 67.87],
+ [-87.35, 67.2],
+ [-86.31, 67.92],
+ [-85.58, 68.78],
+ [-85.52, 69.88],
+ [-84.1, 69.81],
+ [-82.62, 69.66],
+ [-81.28, 69.16],
+ [-81.22, 68.67],
+ [-81.96, 68.13],
+ [-81.26, 67.6],
+ [-81.39, 67.11],
+ [-83.34, 66.41],
+ [-84.74, 66.26],
+ [-85.77, 66.56],
+ [-86.07, 66.06],
+ [-87.03, 65.21],
+ [-87.32, 64.78],
+ [-88.48, 64.1],
+ [-89.91, 64.03],
+ [-90.7, 63.61],
+ [-90.77, 62.96],
+ [-91.93, 62.84],
+ [-93.16, 62.02],
+ [-94.24, 60.9],
+ [-94.63, 60.11],
+ [-94.68, 58.95],
+ [-93.22, 58.78],
+ [-92.76, 57.85],
+ [-92.3, 57.09],
+ [-90.9, 57.28],
+ [-89.04, 56.85],
+ [-88.04, 56.47],
+ [-87.32, 56],
+ [-86.07, 55.72],
+ [-85.01, 55.3],
+ [-83.36, 55.24],
+ [-82.27, 55.15],
+ [-82.44, 54.28],
+ [-82.13, 53.28],
+ [-81.4, 52.16],
+ [-79.91, 51.21],
+ [-79.14, 51.53],
+ [-78.6, 52.56],
+ [-79.12, 54.14],
+ [-79.83, 54.67],
+ [-78.23, 55.14],
+ [-77.1, 55.84],
+ [-76.54, 56.53],
+ [-76.62, 57.2],
+ [-77.3, 58.05],
+ [-78.52, 58.8],
+ [-77.34, 59.85],
+ [-77.77, 60.76],
+ [-78.11, 62.32],
+ [-77.41, 62.55],
+ [-75.7, 62.28],
+ [-74.67, 62.18],
+ [-73.84, 62.44],
+ [-72.91, 62.11],
+ [-71.68, 61.53],
+ [-71.37, 61.14],
+ [-69.59, 61.06],
+ [-69.62, 60.22],
+ [-69.29, 58.96],
+ [-68.37, 58.8],
+ [-67.65, 58.21],
+ [-66.2, 58.77],
+ [-65.25, 59.87],
+ [-64.58, 60.34],
+ [-63.8, 59.44],
+ [-62.5, 58.17],
+ [-61.4, 56.97],
+ [-61.8, 56.34],
+ [-60.47, 55.78],
+ [-59.57, 55.2],
+ [-57.98, 54.95],
+ [-57.33, 54.63],
+ [-56.94, 53.78],
+ [-56.16, 53.65],
+ [-55.76, 53.27],
+ [-55.68, 52.15],
+ [-56.41, 51.77],
+ [-57.13, 51.42],
+ [-58.77, 51.06],
+ [-60.03, 50.24],
+ [-61.72, 50.08],
+ [-63.86, 50.29],
+ [-65.36, 50.3],
+ [-66.4, 50.23],
+ [-67.24, 49.51],
+ [-68.51, 49.07],
+ [-69.95, 47.74],
+ [-71.1, 46.82],
+ [-70.26, 46.99],
+ [-68.65, 48.3],
+ [-66.55, 49.13],
+ [-65.06, 49.23],
+ [-64.17, 48.74],
+ [-65.12, 48.07],
+ [-64.8, 46.99],
+ [-64.47, 46.24],
+ [-63.17, 45.74],
+ [-61.52, 45.88],
+ [-60.52, 47.01],
+ [-60.45, 46.28],
+ [-59.8, 45.92],
+ [-61.04, 45.27],
+ [-63.25, 44.67],
+ [-64.25, 44.27],
+ [-65.36, 43.55],
+ [-66.12, 43.62],
+ [-66.16, 44.47],
+ [-64.43, 45.29],
+ [-66.03, 45.26],
+ [-67.14, 45.14]
+ ],
+ [
+ [-114.17, 73.12],
+ [-114.67, 72.65],
+ [-112.44, 72.96],
+ [-111.05, 72.45],
+ [-109.92, 72.96],
+ [-109.01, 72.63],
+ [-108.19, 71.65],
+ [-107.69, 72.07],
+ [-108.4, 73.09],
+ [-107.52, 73.24],
+ [-106.52, 73.08],
+ [-105.4, 72.67],
+ [-104.77, 71.7],
+ [-104.46, 70.99],
+ [-102.79, 70.5],
+ [-100.98, 70.02],
+ [-101.09, 69.58],
+ [-102.73, 69.5],
+ [-102.09, 69.12],
+ [-102.43, 68.75],
+ [-104.24, 68.91],
+ [-105.96, 69.18],
+ [-107.12, 69.12],
+ [-109, 68.78],
+ [-111.53, 68.63],
+ [-113.31, 68.54],
+ [-113.85, 69.01],
+ [-115.22, 69.28],
+ [-116.11, 69.17],
+ [-117.34, 69.96],
+ [-116.67, 70.07],
+ [-115.13, 70.24],
+ [-113.72, 70.19],
+ [-112.42, 70.37],
+ [-114.35, 70.6],
+ [-116.49, 70.52],
+ [-117.9, 70.54],
+ [-118.43, 70.91],
+ [-116.11, 71.31],
+ [-117.66, 71.3],
+ [-119.4, 71.56],
+ [-118.56, 72.31],
+ [-117.87, 72.71],
+ [-115.19, 73.31],
+ [-114.17, 73.12]
+ ],
+ [
+ [-104.5, 73.42],
+ [-105.38, 72.76],
+ [-106.94, 73.46],
+ [-106.6, 73.6],
+ [-105.26, 73.64],
+ [-104.5, 73.42]
+ ],
+ [
+ [-76.34, 73.1],
+ [-76.25, 72.83],
+ [-77.31, 72.86],
+ [-78.39, 72.88],
+ [-79.49, 72.74],
+ [-79.78, 72.8],
+ [-80.88, 73.33],
+ [-80.83, 73.69],
+ [-80.35, 73.76],
+ [-78.06, 73.65],
+ [-76.34, 73.1]
+ ],
+ [
+ [-86.56, 73.16],
+ [-85.77, 72.53],
+ [-84.85, 73.34],
+ [-82.32, 73.75],
+ [-80.6, 72.72],
+ [-80.75, 72.06],
+ [-78.77, 72.35],
+ [-77.82, 72.75],
+ [-75.61, 72.24],
+ [-74.23, 71.77],
+ [-74.1, 71.33],
+ [-72.24, 71.56],
+ [-71.2, 70.92],
+ [-68.79, 70.53],
+ [-67.91, 70.12],
+ [-66.97, 69.19],
+ [-68.81, 68.72],
+ [-66.45, 68.07],
+ [-64.86, 67.85],
+ [-63.42, 66.93],
+ [-61.85, 66.86],
+ [-62.16, 66.16],
+ [-63.92, 65],
+ [-65.15, 65.43],
+ [-66.72, 66.39],
+ [-68.02, 66.26],
+ [-68.14, 65.69],
+ [-67.09, 65.11],
+ [-65.73, 64.65],
+ [-65.32, 64.38],
+ [-64.67, 63.39],
+ [-65.01, 62.67],
+ [-66.28, 62.95],
+ [-68.78, 63.75],
+ [-67.37, 62.88],
+ [-66.33, 62.28],
+ [-66.17, 61.93],
+ [-68.88, 62.33],
+ [-71.02, 62.91],
+ [-72.24, 63.4],
+ [-71.89, 63.68],
+ [-73.38, 64.19],
+ [-74.83, 64.68],
+ [-74.82, 64.39],
+ [-77.71, 64.23],
+ [-78.56, 64.57],
+ [-77.9, 65.31],
+ [-76.02, 65.33],
+ [-73.96, 65.45],
+ [-74.29, 65.81],
+ [-73.94, 66.31],
+ [-72.65, 67.28],
+ [-72.93, 67.73],
+ [-73.31, 68.07],
+ [-74.84, 68.55],
+ [-76.87, 68.89],
+ [-76.23, 69.15],
+ [-77.29, 69.77],
+ [-78.17, 69.83],
+ [-78.96, 70.17],
+ [-79.49, 69.87],
+ [-81.31, 69.74],
+ [-84.94, 69.97],
+ [-87.06, 70.26],
+ [-88.68, 70.41],
+ [-89.51, 70.76],
+ [-88.47, 71.22],
+ [-89.89, 71.22],
+ [-90.21, 72.24],
+ [-89.44, 73.13],
+ [-88.41, 73.54],
+ [-85.83, 73.8],
+ [-86.56, 73.16]
+ ],
+ [
+ [-100.36, 73.84],
+ [-99.16, 73.63],
+ [-97.38, 73.76],
+ [-97.12, 73.47],
+ [-98.05, 72.99],
+ [-96.54, 72.56],
+ [-96.72, 71.66],
+ [-98.36, 71.27],
+ [-99.32, 71.36],
+ [-100.01, 71.74],
+ [-102.5, 72.51],
+ [-102.48, 72.83],
+ [-100.44, 72.71],
+ [-101.54, 73.36],
+ [-100.36, 73.84]
+ ],
+ [
+ [-93.2, 72.77],
+ [-94.27, 72.02],
+ [-95.41, 72.06],
+ [-96.03, 72.94],
+ [-96.02, 73.44],
+ [-95.5, 73.86],
+ [-94.5, 74.13],
+ [-92.42, 74.1],
+ [-90.51, 73.86],
+ [-92, 72.97],
+ [-93.2, 72.77]
+ ],
+ [
+ [-120.46, 71.38],
+ [-123.09, 70.9],
+ [-123.62, 71.34],
+ [-125.93, 71.87],
+ [-125.5, 72.29],
+ [-124.81, 73.02],
+ [-123.94, 73.68],
+ [-124.92, 74.29],
+ [-121.54, 74.45],
+ [-120.11, 74.24],
+ [-117.56, 74.19],
+ [-116.58, 73.9],
+ [-115.51, 73.48],
+ [-116.77, 73.22],
+ [-119.22, 72.52],
+ [-120.46, 71.82],
+ [-120.46, 71.38]
+ ],
+ [
+ [-93.61, 74.98],
+ [-94.16, 74.59],
+ [-95.61, 74.67],
+ [-96.82, 74.93],
+ [-96.29, 75.38],
+ [-94.85, 75.65],
+ [-93.98, 75.3],
+ [-93.61, 74.98]
+ ],
+ [
+ [-98.5, 76.72],
+ [-97.74, 76.26],
+ [-97.7, 75.74],
+ [-98.16, 75],
+ [-99.81, 74.9],
+ [-100.88, 75.06],
+ [-100.86, 75.64],
+ [-102.5, 75.56],
+ [-102.57, 76.34],
+ [-101.49, 76.31],
+ [-99.98, 76.65],
+ [-98.58, 76.59],
+ [-98.5, 76.72]
+ ],
+ [
+ [-108.21, 76.2],
+ [-107.82, 75.85],
+ [-106.93, 76.01],
+ [-105.88, 75.97],
+ [-105.7, 75.48],
+ [-106.31, 75.01],
+ [-109.7, 74.85],
+ [-112.22, 74.42],
+ [-113.74, 74.39],
+ [-113.87, 74.72],
+ [-111.79, 75.16],
+ [-116.31, 75.04],
+ [-117.71, 75.22],
+ [-116.35, 76.2],
+ [-115.4, 76.48],
+ [-112.59, 76.14],
+ [-110.81, 75.55],
+ [-109.07, 75.47],
+ [-110.5, 76.43],
+ [-109.58, 76.79],
+ [-108.55, 76.68],
+ [-108.21, 76.2]
+ ],
+ [
+ [-94.68, 77.1],
+ [-93.57, 76.78],
+ [-91.61, 76.78],
+ [-90.74, 76.45],
+ [-90.97, 76.07],
+ [-89.82, 75.85],
+ [-89.19, 75.61],
+ [-87.84, 75.57],
+ [-86.38, 75.48],
+ [-84.79, 75.7],
+ [-82.75, 75.78],
+ [-81.13, 75.71],
+ [-80.06, 75.34],
+ [-79.83, 74.92],
+ [-80.46, 74.66],
+ [-81.95, 74.44],
+ [-83.23, 74.56],
+ [-86.1, 74.41],
+ [-88.15, 74.39],
+ [-89.76, 74.52],
+ [-92.42, 74.84],
+ [-92.77, 75.39],
+ [-92.89, 75.88],
+ [-93.89, 76.32],
+ [-95.96, 76.44],
+ [-97.12, 76.75],
+ [-96.75, 77.16],
+ [-94.68, 77.1]
+ ],
+ [
+ [-116.2, 77.65],
+ [-116.34, 76.88],
+ [-117.11, 76.53],
+ [-118.04, 76.48],
+ [-119.9, 76.05],
+ [-121.5, 75.9],
+ [-122.85, 76.12],
+ [-122.85, 76.12],
+ [-121.16, 76.86],
+ [-119.1, 77.51],
+ [-117.57, 77.5],
+ [-116.2, 77.65]
+ ],
+ [
+ [-93.84, 77.52],
+ [-94.3, 77.49],
+ [-96.17, 77.56],
+ [-96.44, 77.83],
+ [-94.42, 77.82],
+ [-93.72, 77.63],
+ [-93.84, 77.52]
+ ],
+ [
+ [-110.19, 77.7],
+ [-112.05, 77.41],
+ [-113.53, 77.73],
+ [-112.72, 78.05],
+ [-111.26, 78.15],
+ [-109.85, 78],
+ [-110.19, 77.7]
+ ],
+ [
+ [-109.66, 78.6],
+ [-110.88, 78.41],
+ [-112.54, 78.41],
+ [-112.53, 78.55],
+ [-111.5, 78.85],
+ [-110.96, 78.8],
+ [-109.66, 78.6]
+ ],
+ [
+ [-95.83, 78.06],
+ [-97.31, 77.85],
+ [-98.12, 78.08],
+ [-98.55, 78.46],
+ [-98.63, 78.87],
+ [-97.34, 78.83],
+ [-96.75, 78.77],
+ [-95.56, 78.42],
+ [-95.83, 78.06]
+ ],
+ [
+ [-100.06, 78.32],
+ [-99.67, 77.91],
+ [-101.3, 78.02],
+ [-102.95, 78.34],
+ [-105.18, 78.38],
+ [-104.21, 78.68],
+ [-105.42, 78.92],
+ [-105.49, 79.3],
+ [-103.53, 79.17],
+ [-100.83, 78.8],
+ [-100.06, 78.32]
+ ],
+ [
+ [-87.02, 79.66],
+ [-85.81, 79.34],
+ [-87.19, 79.04],
+ [-89.04, 78.29],
+ [-90.8, 78.22],
+ [-92.88, 78.34],
+ [-93.95, 78.75],
+ [-93.94, 79.11],
+ [-93.15, 79.38],
+ [-94.97, 79.37],
+ [-96.08, 79.71],
+ [-96.71, 80.16],
+ [-96.02, 80.6],
+ [-95.32, 80.91],
+ [-94.3, 80.98],
+ [-94.74, 81.21],
+ [-92.41, 81.26],
+ [-91.13, 80.72],
+ [-89.45, 80.51],
+ [-87.81, 80.32],
+ [-87.02, 79.66]
+ ],
+ [
+ [-68.5, 83.11],
+ [-65.83, 83.03],
+ [-63.68, 82.9],
+ [-61.85, 82.63],
+ [-61.89, 82.36],
+ [-64.33, 81.93],
+ [-66.75, 81.73],
+ [-67.66, 81.5],
+ [-65.48, 81.51],
+ [-67.84, 80.9],
+ [-69.47, 80.62],
+ [-71.18, 79.8],
+ [-73.24, 79.63],
+ [-73.88, 79.43],
+ [-76.91, 79.32],
+ [-75.53, 79.2],
+ [-76.22, 79.02],
+ [-75.39, 78.53],
+ [-76.34, 78.18],
+ [-77.89, 77.9],
+ [-78.36, 77.51],
+ [-79.76, 77.21],
+ [-79.62, 76.98],
+ [-77.91, 77.02],
+ [-77.89, 76.78],
+ [-80.56, 76.18],
+ [-83.17, 76.45],
+ [-86.11, 76.3],
+ [-87.6, 76.42],
+ [-89.49, 76.47],
+ [-89.62, 76.95],
+ [-87.77, 77.18],
+ [-88.26, 77.9],
+ [-87.65, 77.97],
+ [-84.98, 77.54],
+ [-86.34, 78.18],
+ [-87.96, 78.37],
+ [-87.15, 78.76],
+ [-85.38, 79],
+ [-85.09, 79.35],
+ [-86.51, 79.74],
+ [-86.93, 80.25],
+ [-84.2, 80.21],
+ [-83.41, 80.1],
+ [-81.85, 80.46],
+ [-84.1, 80.58],
+ [-87.6, 80.52],
+ [-89.37, 80.86],
+ [-90.2, 81.26],
+ [-91.37, 81.55],
+ [-91.59, 81.89],
+ [-90.1, 82.09],
+ [-88.93, 82.12],
+ [-86.97, 82.28],
+ [-85.5, 82.65],
+ [-84.26, 82.6],
+ [-83.18, 82.32],
+ [-82.42, 82.86],
+ [-81.1, 83.02],
+ [-79.31, 83.13],
+ [-76.25, 83.17],
+ [-75.72, 83.06],
+ [-72.83, 83.23],
+ [-70.67, 83.17],
+ [-68.5, 83.11]
+ ],
+ [
+ [9.63, 47.36],
+ [9.53, 47.27],
+ [9.49, 47.18]
+ ],
+ [
+ [9.49, 47.18],
+ [9.51, 47.09],
+ [9.47, 47.06],
+ [9.56, 47.05]
+ ],
+ [
+ [9.56, 47.05],
+ [9.61, 47.06],
+ [9.87, 47.02]
+ ],
+ [
+ [10.44, 46.89],
+ [10.36, 46.48],
+ [9.92, 46.31],
+ [9.18, 46.44],
+ [8.97, 46.04],
+ [8.49, 46.01],
+ [8.32, 46.16],
+ [7.76, 45.82],
+ [7.27, 45.78],
+ [6.84, 45.99]
+ ],
+ [
+ [6.84, 45.99],
+ [6.5, 46.43],
+ [6.02, 46.27],
+ [6.04, 46.73],
+ [6.77, 47.29],
+ [6.74, 47.54],
+ [7.19, 47.45],
+ [7.47, 47.62]
+ ],
+ [
+ [7.47, 47.62],
+ [8.32, 47.61],
+ [8.52, 47.83],
+ [9.59, 47.53]
+ ],
+ [
+ [-66.96, -54.9],
+ [-67.29, -55.3],
+ [-68.15, -55.61],
+ [-68.64, -55.58],
+ [-69.23, -55.5],
+ [-69.96, -55.2],
+ [-71.01, -55.05],
+ [-72.26, -54.5],
+ [-73.29, -53.96],
+ [-74.66, -52.84],
+ [-73.84, -53.05],
+ [-72.43, -53.72],
+ [-71.11, -54.07],
+ [-70.59, -53.62],
+ [-70.27, -52.93],
+ [-69.35, -52.52],
+ [-68.63, -52.64]
+ ],
+ [
+ [-68.57, -52.3],
+ [-69.46, -52.29],
+ [-69.94, -52.54],
+ [-70.85, -52.9],
+ [-71.01, -53.83],
+ [-71.43, -53.86],
+ [-72.56, -53.53],
+ [-73.7, -52.84],
+ [-73.7, -52.84],
+ [-74.95, -52.26],
+ [-75.26, -51.63],
+ [-74.98, -51.04],
+ [-75.48, -50.38],
+ [-75.61, -48.67],
+ [-75.18, -47.71],
+ [-74.13, -46.94],
+ [-75.64, -46.65],
+ [-74.69, -45.76],
+ [-74.35, -44.1],
+ [-73.24, -44.45],
+ [-72.72, -42.38],
+ [-73.39, -42.12],
+ [-73.7, -43.37],
+ [-74.33, -43.22],
+ [-74.02, -41.79],
+ [-73.68, -39.94],
+ [-73.22, -39.26],
+ [-73.51, -38.28],
+ [-73.59, -37.16],
+ [-73.17, -37.12],
+ [-72.55, -35.51],
+ [-71.86, -33.91],
+ [-71.44, -32.42],
+ [-71.67, -30.92],
+ [-71.37, -30.1],
+ [-71.49, -28.86],
+ [-70.91, -27.64],
+ [-70.72, -25.71],
+ [-70.4, -23.63],
+ [-70.09, -21.39],
+ [-70.16, -19.76],
+ [-70.37, -18.35]
+ ],
+ [
+ [-70.37, -18.35],
+ [-69.86, -18.09],
+ [-69.59, -17.58]
+ ],
+ [
+ [110.34, 18.68],
+ [109.48, 18.2],
+ [108.66, 18.51],
+ [108.63, 19.37],
+ [109.12, 19.82],
+ [110.21, 20.1],
+ [110.79, 20.08],
+ [111.01, 19.7],
+ [110.57, 19.26],
+ [110.34, 18.68]
+ ],
+ [
+ [129.4, 49.44],
+ [130.58, 48.73]
+ ],
+ [
+ [130.58, 48.73],
+ [130.99, 47.79],
+ [132.51, 47.79],
+ [133.37, 48.18]
+ ],
+ [
+ [133.37, 48.18],
+ [135.03, 48.48]
+ ],
+ [
+ [135.03, 48.48],
+ [134.5, 47.58],
+ [134.11, 47.21],
+ [133.77, 46.12]
+ ],
+ [
+ [133.77, 46.12],
+ [133.1, 45.14],
+ [131.88, 45.32]
+ ],
+ [
+ [131.88, 45.32],
+ [131.03, 44.97],
+ [131.29, 44.11]
+ ],
+ [
+ [131.29, 44.11],
+ [131.14, 42.93],
+ [130.63, 42.9]
+ ],
+ [
+ [130.63, 42.9],
+ [130.64, 42.4]
+ ],
+ [
+ [130.64, 42.4],
+ [129.99, 42.99],
+ [129.6, 42.42],
+ [128.05, 41.99],
+ [128.21, 41.47],
+ [127.34, 41.5],
+ [126.87, 41.82],
+ [126.18, 41.11],
+ [125.08, 40.57],
+ [124.27, 39.93]
+ ],
+ [
+ [124.27, 39.93],
+ [122.87, 39.64],
+ [122.13, 39.17],
+ [121.05, 38.9],
+ [121.59, 39.36],
+ [121.38, 39.75],
+ [122.17, 40.42],
+ [121.64, 40.95],
+ [120.77, 40.59],
+ [119.64, 39.9],
+ [119.02, 39.25],
+ [118.04, 39.2],
+ [117.53, 38.74],
+ [118.06, 38.06],
+ [118.88, 37.9],
+ [118.91, 37.45],
+ [119.7, 37.16],
+ [120.82, 37.87],
+ [121.71, 37.48],
+ [122.36, 37.45],
+ [122.52, 36.93],
+ [121.1, 36.65],
+ [120.64, 36.11],
+ [119.66, 35.61],
+ [119.15, 34.91],
+ [120.23, 34.36],
+ [120.62, 33.38],
+ [121.23, 32.46],
+ [121.91, 31.69],
+ [121.89, 30.95],
+ [121.26, 30.68],
+ [121.5, 30.14],
+ [122.09, 29.83],
+ [121.94, 29.02],
+ [121.68, 28.23],
+ [121.13, 28.14],
+ [120.4, 27.05],
+ [119.59, 25.74],
+ [118.66, 24.55],
+ [117.28, 23.62],
+ [115.89, 22.78],
+ [114.76, 22.67],
+ [114.15, 22.22],
+ [113.81, 22.55],
+ [113.24, 22.05],
+ [111.84, 21.55],
+ [110.79, 21.4],
+ [110.44, 20.34],
+ [109.89, 20.28],
+ [109.63, 21.01],
+ [109.86, 21.4],
+ [108.52, 21.72],
+ [108.05, 21.55]
+ ],
+ [
+ [108.05, 21.55],
+ [107.04, 21.81],
+ [106.57, 22.22],
+ [106.73, 22.79],
+ [105.81, 22.98],
+ [105.33, 23.35],
+ [104.48, 22.82],
+ [103.5, 22.7],
+ [102.71, 22.71],
+ [102.17, 22.46]
+ ],
+ [
+ [102.17, 22.46],
+ [101.65, 22.32],
+ [101.8, 21.17],
+ [101.27, 21.2],
+ [101.18, 21.44]
+ ],
+ [
+ [101.18, 21.44],
+ [101.15, 21.85],
+ [100.42, 21.56],
+ [99.98, 21.74],
+ [99.24, 22.12],
+ [99.53, 22.95],
+ [98.9, 23.14],
+ [98.66, 24.06],
+ [97.6, 23.9],
+ [97.72, 25.08],
+ [98.67, 25.92],
+ [98.71, 26.74],
+ [98.68, 27.51],
+ [98.25, 27.75],
+ [97.91, 28.34],
+ [97.33, 28.26]
+ ],
+ [
+ [97.33, 28.26],
+ [96.25, 28.41],
+ [96.59, 28.83],
+ [96.12, 29.45],
+ [95.4, 29.03],
+ [94.57, 29.28],
+ [93.41, 28.64],
+ [92.5, 27.9],
+ [91.7, 27.77]
+ ],
+ [
+ [88.81, 27.3],
+ [88.73, 28.09],
+ [88.12, 27.88]
+ ],
+ [
+ [88.12, 27.88],
+ [86.95, 27.97],
+ [85.82, 28.2],
+ [85.01, 28.64],
+ [84.23, 28.84],
+ [83.9, 29.32],
+ [83.34, 29.46],
+ [82.33, 30.12],
+ [81.53, 30.42],
+ [81.11, 30.18]
+ ],
+ [
+ [81.11, 30.18],
+ [79.72, 30.88],
+ [78.74, 31.52],
+ [78.46, 32.62],
+ [79.18, 32.48],
+ [79.21, 32.99],
+ [78.81, 33.51],
+ [78.91, 34.32],
+ [77.84, 35.49]
+ ],
+ [
+ [77.84, 35.49],
+ [76.19, 35.9],
+ [75.9, 36.67],
+ [75.16, 37.13]
+ ],
+ [
+ [74.98, 37.42],
+ [74.83, 37.99],
+ [74.86, 38.38],
+ [74.26, 38.61],
+ [73.93, 38.51],
+ [73.68, 39.43]
+ ],
+ [
+ [73.68, 39.43],
+ [73.96, 39.66],
+ [73.82, 39.89],
+ [74.78, 40.37],
+ [75.47, 40.56],
+ [76.53, 40.43],
+ [76.9, 41.07],
+ [78.19, 41.19],
+ [78.54, 41.58],
+ [80.12, 42.12],
+ [80.26, 42.35]
+ ],
+ [
+ [80.26, 42.35],
+ [80.18, 42.92],
+ [80.87, 43.18],
+ [79.97, 44.92],
+ [81.95, 45.32],
+ [82.46, 45.54],
+ [83.18, 47.33],
+ [85.16, 47],
+ [85.72, 47.45],
+ [85.77, 48.46],
+ [86.6, 48.55],
+ [87.36, 49.21]
+ ],
+ [
+ [87.36, 49.21],
+ [87.75, 49.3]
+ ],
+ [
+ [87.75, 49.3],
+ [88.01, 48.6],
+ [88.85, 48.07],
+ [90.28, 47.69],
+ [90.97, 46.89],
+ [90.59, 45.72],
+ [90.95, 45.29],
+ [92.13, 45.12],
+ [93.48, 44.98],
+ [94.69, 44.35],
+ [95.31, 44.24],
+ [95.76, 43.32],
+ [96.35, 42.73],
+ [97.45, 42.75],
+ [99.52, 42.52],
+ [100.85, 42.66],
+ [101.83, 42.51],
+ [103.31, 41.91],
+ [104.52, 41.91],
+ [104.96, 41.6],
+ [106.13, 42.13],
+ [107.74, 42.48],
+ [109.24, 42.52],
+ [110.41, 42.87],
+ [111.13, 43.41],
+ [111.83, 43.74],
+ [111.67, 44.07],
+ [111.35, 44.46],
+ [111.87, 45.1],
+ [112.44, 45.01],
+ [113.46, 44.81],
+ [114.46, 45.34],
+ [115.99, 45.73],
+ [116.72, 46.39],
+ [117.42, 46.67],
+ [118.87, 46.81],
+ [119.66, 46.69],
+ [119.77, 47.05],
+ [118.87, 47.75],
+ [118.06, 48.07],
+ [117.3, 47.7],
+ [116.31, 47.85],
+ [115.74, 47.73],
+ [115.49, 48.14],
+ [116.19, 49.13],
+ [116.68, 49.89]
+ ],
+ [
+ [116.68, 49.89],
+ [117.88, 49.51],
+ [119.29, 50.14]
+ ],
+ [
+ [119.29, 50.14],
+ [119.28, 50.58],
+ [120.18, 51.64],
+ [120.74, 51.96],
+ [120.73, 52.52]
+ ],
+ [
+ [120.73, 52.52],
+ [120.18, 52.75],
+ [121, 53.25],
+ [122.25, 53.43]
+ ],
+ [
+ [122.25, 53.43],
+ [123.57, 53.46],
+ [125.07, 53.16]
+ ],
+ [
+ [125.07, 53.16],
+ [125.95, 52.79],
+ [126.56, 51.78],
+ [126.94, 51.35],
+ [127.29, 50.74]
+ ],
+ [
+ [127.29, 50.74],
+ [127.66, 49.76],
+ [129.4, 49.44]
+ ],
+ [
+ [-2.86, 4.99],
+ [-3.31, 4.98],
+ [-4.01, 5.18],
+ [-4.65, 5.17],
+ [-5.83, 4.99],
+ [-6.53, 4.71],
+ [-7.52, 4.34],
+ [-7.71, 4.36]
+ ],
+ [
+ [-7.71, 4.36],
+ [-7.64, 5.19],
+ [-7.54, 5.31],
+ [-7.57, 5.71],
+ [-7.99, 6.13],
+ [-8.31, 6.19],
+ [-8.6, 6.47],
+ [-8.39, 6.91],
+ [-8.49, 7.4],
+ [-8.44, 7.69]
+ ],
+ [
+ [-8.44, 7.69],
+ [-8.28, 7.69],
+ [-8.22, 8.12],
+ [-8.3, 8.32],
+ [-8.2, 8.46],
+ [-7.83, 8.58],
+ [-8.08, 9.38],
+ [-8.31, 9.79],
+ [-8.23, 10.13],
+ [-8.03, 10.21]
+ ],
+ [
+ [-8.03, 10.21],
+ [-7.9, 10.3],
+ [-7.62, 10.15],
+ [-6.85, 10.14],
+ [-6.67, 10.43],
+ [-6.49, 10.41],
+ [-6.21, 10.52],
+ [-6.05, 10.1],
+ [-5.82, 10.22],
+ [-5.4, 10.37]
+ ],
+ [
+ [-2.83, 9.64],
+ [-2.56, 8.22],
+ [-2.98, 7.38],
+ [-3.24, 6.25],
+ [-2.81, 5.39],
+ [-2.86, 4.99]
+ ],
+ [
+ [13.08, 2.27],
+ [12.95, 2.32],
+ [12.36, 2.19],
+ [11.75, 2.33],
+ [11.28, 2.26]
+ ],
+ [
+ [11.28, 2.26],
+ [9.65, 2.28]
+ ],
+ [
+ [9.65, 2.28],
+ [9.8, 3.07],
+ [9.4, 3.73],
+ [8.95, 3.9],
+ [8.74, 4.35],
+ [8.49, 4.5],
+ [8.5, 4.77]
+ ],
+ [
+ [8.5, 4.77],
+ [8.76, 5.48],
+ [9.23, 6.44],
+ [9.52, 6.45],
+ [10.12, 7.04],
+ [10.5, 7.06],
+ [11.06, 6.64],
+ [11.75, 6.98],
+ [11.84, 7.4],
+ [12.06, 7.8],
+ [12.22, 8.31],
+ [12.75, 8.72],
+ [12.96, 9.42],
+ [13.17, 9.64],
+ [13.31, 10.16],
+ [13.57, 10.8],
+ [14.42, 11.57],
+ [14.47, 11.9],
+ [14.58, 12.09],
+ [14.18, 12.48]
+ ],
+ [
+ [14.18, 12.48],
+ [14.21, 12.8],
+ [14.5, 12.86]
+ ],
+ [
+ [14.5, 12.86],
+ [14.89, 12.22],
+ [14.96, 11.56]
+ ],
+ [
+ [14.96, 11.56],
+ [14.92, 10.89],
+ [15.47, 9.98],
+ [14.91, 9.99],
+ [14.63, 9.92],
+ [14.17, 10.02],
+ [13.95, 9.55],
+ [14.54, 8.97],
+ [14.98, 8.8],
+ [15.12, 8.38],
+ [15.44, 7.69],
+ [15.28, 7.42]
+ ],
+ [
+ [16.01, 2.27],
+ [15.94, 1.73],
+ [15.15, 1.96],
+ [14.34, 2.23],
+ [13.08, 2.27]
+ ],
+ [
+ [31.17, 2.2],
+ [30.85, 1.85],
+ [30.47, 1.58],
+ [30.09, 1.06],
+ [29.88, 0.6]
+ ],
+ [
+ [29.88, 0.6],
+ [29.82, -0.21],
+ [29.59, -0.59]
+ ],
+ [
+ [29.59, -0.59],
+ [29.58, -1.34]
+ ],
+ [
+ [29.58, -1.34],
+ [29.29, -1.62],
+ [29.25, -2.22],
+ [29.12, -2.29],
+ [29.02, -2.84]
+ ],
+ [
+ [29.34, -4.5],
+ [29.52, -5.42],
+ [29.42, -5.94]
+ ],
+ [
+ [29.42, -5.94],
+ [29.62, -6.52],
+ [30.2, -7.08],
+ [30.74, -8.34]
+ ],
+ [
+ [30.74, -8.34],
+ [30.35, -8.24],
+ [29, -8.41],
+ [28.73, -8.53],
+ [28.45, -9.16],
+ [28.67, -9.61],
+ [28.5, -10.79],
+ [28.37, -11.79],
+ [28.64, -11.97],
+ [29.34, -12.36],
+ [29.62, -12.18],
+ [29.7, -13.26],
+ [28.93, -13.25],
+ [28.52, -12.7],
+ [28.16, -12.27],
+ [27.39, -12.13],
+ [27.16, -11.61],
+ [26.55, -11.92],
+ [25.75, -11.78],
+ [25.42, -11.33],
+ [24.78, -11.24],
+ [24.31, -11.26],
+ [24.26, -10.95],
+ [23.91, -10.93]
+ ],
+ [
+ [12.32, -6.1],
+ [12.18, -5.79]
+ ],
+ [
+ [13, -4.78],
+ [13.26, -4.88],
+ [13.6, -4.5],
+ [14.14, -4.51],
+ [14.21, -4.79],
+ [14.58, -4.97],
+ [15.17, -4.34],
+ [15.75, -3.86],
+ [16.01, -3.54],
+ [15.97, -2.71],
+ [16.41, -1.74],
+ [16.87, -1.23],
+ [17.52, -0.74],
+ [17.64, -0.42],
+ [17.66, -0.06],
+ [17.83, 0.29],
+ [17.77, 0.86],
+ [17.9, 1.74],
+ [18.09, 2.37],
+ [18.39, 2.9],
+ [18.45, 3.5]
+ ],
+ [
+ [27.37, 5.23],
+ [27.98, 4.41],
+ [28.43, 4.29],
+ [28.7, 4.46],
+ [29.16, 4.39],
+ [29.72, 4.6]
+ ],
+ [
+ [29.72, 4.6],
+ [29.95, 4.17],
+ [30.83, 3.51],
+ [30.77, 2.34],
+ [31.17, 2.2]
+ ],
+ [
+ [11.91, -5.04],
+ [11.09, -3.98]
+ ],
+ [
+ [11.09, -3.98],
+ [11.86, -3.43],
+ [11.48, -2.77],
+ [11.82, -2.51],
+ [12.5, -2.39],
+ [12.58, -1.95],
+ [13.11, -2.43],
+ [13.99, -2.47],
+ [14.3, -2],
+ [14.43, -1.33],
+ [14.32, -0.55],
+ [13.84, 0.04],
+ [14.28, 1.2],
+ [14.03, 1.4],
+ [13.28, 1.31],
+ [13, 1.83],
+ [13.08, 2.27]
+ ],
+ [
+ [-75.37, -0.15],
+ [-75.8, 0.08],
+ [-76.29, 0.42],
+ [-76.58, 0.26],
+ [-77.42, 0.4],
+ [-77.67, 0.83],
+ [-77.86, 0.81],
+ [-78.86, 1.38]
+ ],
+ [
+ [-78.86, 1.38],
+ [-78.99, 1.69],
+ [-78.62, 1.77],
+ [-78.66, 2.27],
+ [-78.43, 2.63],
+ [-77.93, 2.7],
+ [-77.51, 3.33],
+ [-77.13, 3.85],
+ [-77.5, 4.09],
+ [-77.31, 4.67],
+ [-77.53, 5.58],
+ [-77.32, 5.85],
+ [-77.48, 6.69],
+ [-77.88, 7.22]
+ ],
+ [
+ [-77.88, 7.22],
+ [-77.75, 7.71],
+ [-77.43, 7.64],
+ [-77.24, 7.94],
+ [-77.47, 8.52],
+ [-77.35, 8.67]
+ ],
+ [
+ [-77.35, 8.67],
+ [-76.84, 8.64],
+ [-76.09, 9.34],
+ [-75.67, 9.44],
+ [-75.66, 9.77],
+ [-75.48, 10.62],
+ [-74.91, 11.08],
+ [-74.28, 11.1],
+ [-74.2, 11.31],
+ [-73.41, 11.23],
+ [-72.63, 11.73],
+ [-72.24, 11.96],
+ [-71.75, 12.44],
+ [-71.4, 12.38],
+ [-71.14, 12.11],
+ [-71.33, 11.78]
+ ],
+ [
+ [-71.33, 11.78],
+ [-71.97, 11.61],
+ [-72.23, 11.11],
+ [-72.61, 10.82],
+ [-72.91, 10.45],
+ [-73.03, 9.74],
+ [-73.3, 9.15],
+ [-72.79, 9.09],
+ [-72.66, 8.63],
+ [-72.44, 8.41],
+ [-72.36, 8],
+ [-72.48, 7.63],
+ [-72.44, 7.42],
+ [-72.2, 7.34],
+ [-71.96, 6.99],
+ [-70.67, 7.09],
+ [-70.09, 6.96],
+ [-69.39, 6.1],
+ [-68.99, 6.21],
+ [-68.27, 6.15],
+ [-67.7, 6.27],
+ [-67.34, 6.1],
+ [-67.52, 5.56],
+ [-67.74, 5.22],
+ [-67.82, 4.5],
+ [-67.62, 3.84],
+ [-67.34, 3.54],
+ [-67.3, 3.32],
+ [-67.81, 2.82],
+ [-67.45, 2.6],
+ [-67.18, 2.25],
+ [-66.88, 1.25]
+ ],
+ [
+ [-69.89, -4.3],
+ [-70.39, -3.77],
+ [-70.69, -3.74],
+ [-70.05, -2.73],
+ [-70.81, -2.26],
+ [-71.41, -2.34],
+ [-71.77, -2.17],
+ [-72.33, -2.43],
+ [-73.07, -2.31],
+ [-73.66, -1.26],
+ [-74.12, -1],
+ [-74.44, -0.53],
+ [-75.11, -0.06],
+ [-75.37, -0.15]
+ ],
+ [
+ [-82.97, 8.23],
+ [-83.51, 8.45],
+ [-83.71, 8.66],
+ [-83.6, 8.83],
+ [-83.63, 9.05],
+ [-83.91, 9.29],
+ [-84.3, 9.49],
+ [-84.65, 9.62],
+ [-84.71, 9.91],
+ [-84.98, 10.09],
+ [-84.91, 9.8],
+ [-85.11, 9.56],
+ [-85.34, 9.83],
+ [-85.66, 9.93],
+ [-85.8, 10.13],
+ [-85.79, 10.44],
+ [-85.66, 10.75],
+ [-85.94, 10.9],
+ [-85.71, 11.09]
+ ],
+ [
+ [-85.71, 11.09],
+ [-85.56, 11.22],
+ [-84.9, 10.95],
+ [-84.67, 11.08],
+ [-84.36, 11],
+ [-84.19, 10.79],
+ [-83.9, 10.73],
+ [-83.66, 10.94]
+ ],
+ [
+ [-83.66, 10.94],
+ [-83.4, 10.4],
+ [-83.02, 9.99],
+ [-82.55, 9.57]
+ ],
+ [
+ [-82.55, 9.57],
+ [-82.93, 9.48],
+ [-82.93, 9.07],
+ [-82.72, 8.93],
+ [-82.87, 8.81],
+ [-82.83, 8.63],
+ [-82.91, 8.42],
+ [-82.97, 8.23]
+ ],
+ [
+ [-82.27, 23.19],
+ [-81.4, 23.12],
+ [-80.62, 23.11],
+ [-79.68, 22.77],
+ [-79.28, 22.4],
+ [-78.35, 22.51],
+ [-77.99, 22.28],
+ [-77.15, 21.66],
+ [-76.52, 21.21],
+ [-76.19, 21.22],
+ [-75.6, 21.02],
+ [-75.67, 20.74],
+ [-74.93, 20.69],
+ [-74.18, 20.28],
+ [-74.3, 20.05],
+ [-74.96, 19.92],
+ [-75.63, 19.87],
+ [-76.32, 19.95],
+ [-77.76, 19.86],
+ [-77.09, 20.41],
+ [-77.49, 20.67],
+ [-78.14, 20.74],
+ [-78.48, 21.03],
+ [-78.72, 21.6],
+ [-79.28, 21.56],
+ [-80.22, 21.83],
+ [-80.52, 22.04],
+ [-81.82, 22.19],
+ [-82.17, 22.39],
+ [-81.8, 22.64],
+ [-82.78, 22.69],
+ [-83.49, 22.17],
+ [-83.91, 22.15],
+ [-84.05, 21.91],
+ [-84.55, 21.8],
+ [-84.97, 21.9],
+ [-84.45, 22.2],
+ [-84.23, 22.57],
+ [-83.78, 22.79],
+ [-83.27, 22.98],
+ [-82.51, 23.08],
+ [-82.27, 23.19]
+ ],
+ [
+ [32.73, 35.14],
+ [32.8, 35.15],
+ [32.95, 35.39],
+ [33.67, 35.37],
+ [34.58, 35.67],
+ [33.9, 35.25],
+ [33.97, 35.06],
+ [34, 34.98],
+ [32.98, 34.57],
+ [32.49, 34.7],
+ [32.26, 35.1],
+ [32.73, 35.14]
+ ],
+ [
+ [13.6, 48.88],
+ [13.03, 49.31],
+ [12.52, 49.55],
+ [12.42, 49.97],
+ [12.24, 50.27],
+ [12.97, 50.48],
+ [13.34, 50.73],
+ [14.06, 50.93],
+ [14.31, 51.12],
+ [14.57, 51],
+ [15.02, 51.11]
+ ],
+ [
+ [15.02, 51.11],
+ [15.49, 50.78],
+ [16.24, 50.7],
+ [16.18, 50.42],
+ [16.72, 50.22],
+ [16.87, 50.47],
+ [17.55, 50.36],
+ [17.65, 50.05],
+ [18.39, 49.99],
+ [18.85, 49.5]
+ ],
+ [
+ [18.85, 49.5],
+ [18.55, 49.5],
+ [18.4, 49.32],
+ [18.17, 49.27],
+ [18.1, 49.04],
+ [17.91, 49],
+ [17.89, 48.9],
+ [17.55, 48.8],
+ [17.1, 48.82],
+ [16.96, 48.6]
+ ],
+ [
+ [9.92, 54.98],
+ [9.94, 54.6],
+ [10.95, 54.36],
+ [10.94, 54.01],
+ [11.96, 54.2],
+ [12.52, 54.47],
+ [13.65, 54.08],
+ [14.12, 53.76]
+ ],
+ [
+ [14.12, 53.76],
+ [14.35, 53.25],
+ [14.07, 52.98],
+ [14.44, 52.62],
+ [14.69, 52.09],
+ [14.61, 51.75],
+ [15.02, 51.11]
+ ],
+ [
+ [7.47, 47.62],
+ [7.59, 48.33],
+ [8.1, 49.02],
+ [6.66, 49.2],
+ [6.19, 49.46]
+ ],
+ [
+ [6.19, 49.46],
+ [6.24, 49.9],
+ [6.04, 50.13]
+ ],
+ [
+ [6.16, 50.8],
+ [5.99, 51.85],
+ [6.59, 51.85],
+ [6.84, 52.23],
+ [7.09, 53.14],
+ [6.91, 53.48]
+ ],
+ [
+ [6.91, 53.48],
+ [7.1, 53.69],
+ [7.94, 53.75],
+ [8.12, 53.53],
+ [8.8, 54.02],
+ [8.57, 54.4],
+ [8.53, 54.96]
+ ],
+ [
+ [8.53, 54.96],
+ [9.28, 54.83],
+ [9.92, 54.98]
+ ],
+ [
+ [43.08, 12.7],
+ [43.32, 12.39],
+ [43.29, 11.97],
+ [42.72, 11.74],
+ [43.15, 11.46]
+ ],
+ [
+ [43.15, 11.46],
+ [42.78, 10.93]
+ ],
+ [
+ [42.78, 10.93],
+ [42.55, 11.11],
+ [42.31, 11.03],
+ [41.76, 11.05],
+ [41.74, 11.36],
+ [41.66, 11.63],
+ [42, 12.1],
+ [42.35, 12.54]
+ ],
+ [
+ [42.35, 12.54],
+ [42.78, 12.46],
+ [43.08, 12.7]
+ ],
+ [
+ [12.69, 55.61],
+ [12.09, 54.8],
+ [11.04, 55.36],
+ [10.9, 55.78],
+ [12.37, 56.11],
+ [12.69, 55.61]
+ ],
+ [
+ [8.53, 54.96],
+ [8.12, 55.52],
+ [8.09, 56.54],
+ [8.26, 56.81],
+ [8.54, 57.11],
+ [9.42, 57.17],
+ [9.78, 57.45],
+ [10.58, 57.73],
+ [10.55, 57.22],
+ [10.25, 56.89],
+ [10.37, 56.61],
+ [10.91, 56.46],
+ [10.67, 56.08],
+ [10.37, 56.19],
+ [9.65, 55.47],
+ [9.92, 54.98]
+ ],
+ [
+ [-46.76, 82.63],
+ [-43.41, 83.23],
+ [-39.9, 83.18],
+ [-38.62, 83.55],
+ [-35.09, 83.65],
+ [-27.1, 83.52],
+ [-20.85, 82.73],
+ [-22.69, 82.34],
+ [-26.52, 82.3],
+ [-31.9, 82.2],
+ [-31.4, 82.02],
+ [-27.86, 82.13],
+ [-24.84, 81.79],
+ [-22.9, 82.09],
+ [-22.07, 81.73],
+ [-23.17, 81.15],
+ [-20.62, 81.52],
+ [-15.77, 81.91],
+ [-12.77, 81.72],
+ [-12.21, 81.29],
+ [-16.29, 80.58],
+ [-16.85, 80.35],
+ [-20.05, 80.18],
+ [-17.73, 80.13],
+ [-18.9, 79.4],
+ [-19.7, 78.75],
+ [-19.67, 77.64],
+ [-18.47, 76.99],
+ [-20.04, 76.94],
+ [-21.68, 76.63],
+ [-19.83, 76.1],
+ [-19.6, 75.25],
+ [-20.67, 75.16],
+ [-19.37, 74.3],
+ [-21.59, 74.22],
+ [-20.43, 73.82],
+ [-20.76, 73.46],
+ [-22.17, 73.31],
+ [-23.57, 73.31],
+ [-22.31, 72.63],
+ [-22.3, 72.18],
+ [-24.28, 72.6],
+ [-24.79, 72.33],
+ [-23.44, 72.08],
+ [-22.13, 71.47],
+ [-21.75, 70.66],
+ [-23.54, 70.47],
+ [-24.31, 70.86],
+ [-25.54, 71.43],
+ [-25.2, 70.75],
+ [-26.36, 70.23],
+ [-23.73, 70.18],
+ [-22.35, 70.13],
+ [-25.03, 69.26],
+ [-27.75, 68.47],
+ [-30.67, 68.13],
+ [-31.78, 68.12],
+ [-32.81, 67.74],
+ [-34.2, 66.68],
+ [-36.35, 65.98],
+ [-37.04, 65.94],
+ [-38.38, 65.69],
+ [-39.81, 65.46],
+ [-40.67, 64.84],
+ [-40.68, 64.14],
+ [-41.19, 63.48],
+ [-42.82, 62.68],
+ [-42.42, 61.9],
+ [-42.87, 61.07],
+ [-43.38, 60.1],
+ [-44.79, 60.04],
+ [-46.26, 60.85],
+ [-48.26, 60.86],
+ [-49.23, 61.41],
+ [-49.9, 62.38],
+ [-51.63, 63.63],
+ [-52.14, 64.28],
+ [-52.28, 65.18],
+ [-53.66, 66.1],
+ [-53.3, 66.84],
+ [-53.97, 67.19],
+ [-52.98, 68.36],
+ [-51.48, 68.73],
+ [-51.08, 69.15],
+ [-50.87, 69.93],
+ [-52.01, 69.57],
+ [-52.56, 69.43],
+ [-53.46, 69.28],
+ [-54.68, 69.61],
+ [-54.75, 70.29],
+ [-54.36, 70.82],
+ [-53.43, 70.84],
+ [-51.39, 70.57],
+ [-53.11, 71.2],
+ [-54, 71.55],
+ [-55, 71.41],
+ [-55.83, 71.65],
+ [-54.72, 72.59],
+ [-55.33, 72.96],
+ [-56.12, 73.65],
+ [-57.32, 74.71],
+ [-58.6, 75.1],
+ [-58.59, 75.52],
+ [-61.27, 76.1],
+ [-63.39, 76.18],
+ [-66.06, 76.13],
+ [-68.5, 76.06],
+ [-69.66, 76.38],
+ [-71.4, 77.01],
+ [-68.78, 77.32],
+ [-66.76, 77.38],
+ [-71.04, 77.64],
+ [-73.3, 78.04],
+ [-73.16, 78.43],
+ [-69.37, 78.91],
+ [-65.71, 79.39],
+ [-65.32, 79.76],
+ [-68.02, 80.12],
+ [-67.15, 80.52],
+ [-63.69, 81.21],
+ [-62.23, 81.32],
+ [-62.65, 81.77],
+ [-60.28, 82.03],
+ [-57.21, 82.19],
+ [-54.13, 82.2],
+ [-53.04, 81.89],
+ [-50.39, 82.44],
+ [-48, 82.06],
+ [-46.6, 81.99],
+ [-44.52, 81.66],
+ [-46.9, 82.2],
+ [-46.76, 82.63]
+ ],
+ [
+ [-71.71, 19.71],
+ [-71.59, 19.88],
+ [-70.81, 19.88],
+ [-70.21, 19.62],
+ [-69.95, 19.65],
+ [-69.77, 19.29],
+ [-69.22, 19.31],
+ [-69.25, 19.02],
+ [-68.81, 18.98],
+ [-68.32, 18.61],
+ [-68.69, 18.21],
+ [-69.16, 18.42],
+ [-69.62, 18.38],
+ [-69.95, 18.43],
+ [-70.13, 18.25],
+ [-70.52, 18.18],
+ [-70.67, 18.43],
+ [-71, 18.28],
+ [-71.4, 17.6],
+ [-71.66, 17.76],
+ [-71.71, 18.04]
+ ],
+ [
+ [-71.71, 18.04],
+ [-71.69, 18.32],
+ [-71.95, 18.62],
+ [-71.7, 18.79],
+ [-71.62, 19.17],
+ [-71.71, 19.71]
+ ],
+ [
+ [12, 23.47],
+ [8.57, 21.57],
+ [5.68, 19.6],
+ [4.27, 19.16]
+ ],
+ [
+ [4.27, 19.16],
+ [3.16, 19.06],
+ [3.15, 19.69],
+ [2.68, 19.86],
+ [2.06, 20.14],
+ [1.82, 20.61],
+ [-1.55, 22.79],
+ [-4.92, 24.97]
+ ],
+ [
+ [-4.92, 24.97],
+ [-8.68, 27.4]
+ ],
+ [
+ [-8.68, 27.4],
+ [-8.67, 27.59],
+ [-8.67, 27.6]
+ ],
+ [
+ [-8.67, 27.6],
+ [-8.67, 28.84],
+ [-7.06, 29.58],
+ [-6.06, 29.73],
+ [-5.24, 30],
+ [-4.86, 30.5],
+ [-3.69, 30.9],
+ [-3.65, 31.64],
+ [-3.07, 31.72],
+ [-2.62, 32.09],
+ [-1.31, 32.26],
+ [-1.12, 32.65],
+ [-1.39, 32.86],
+ [-1.73, 33.92],
+ [-1.79, 34.53],
+ [-2.17, 35.17]
+ ],
+ [
+ [-2.17, 35.17],
+ [-1.21, 35.71],
+ [-0.13, 35.89],
+ [0.5, 36.3],
+ [1.47, 36.61],
+ [3.16, 36.78],
+ [4.82, 36.87],
+ [5.32, 36.72],
+ [6.26, 37.11],
+ [7.33, 37.12],
+ [7.74, 36.89],
+ [8.42, 36.95]
+ ],
+ [
+ [8.42, 36.95],
+ [8.22, 36.43],
+ [8.38, 35.48],
+ [8.14, 34.66],
+ [7.52, 34.1],
+ [7.61, 33.34],
+ [8.43, 32.75],
+ [8.44, 32.51],
+ [9.06, 32.1],
+ [9.48, 30.31]
+ ],
+ [
+ [9.48, 30.31],
+ [9.81, 29.42],
+ [9.86, 28.96],
+ [9.68, 28.14],
+ [9.76, 27.69],
+ [9.63, 27.14],
+ [9.72, 26.51],
+ [9.32, 26.09],
+ [9.91, 25.37],
+ [9.95, 24.94],
+ [10.3, 24.38],
+ [10.77, 24.56],
+ [11.56, 24.1],
+ [12, 23.47]
+ ],
+ [
+ [-80.3, -3.4],
+ [-79.77, -2.66],
+ [-79.99, -2.22],
+ [-80.37, -2.69],
+ [-80.97, -2.25],
+ [-80.76, -1.97],
+ [-80.93, -1.06],
+ [-80.58, -0.91],
+ [-80.4, -0.28],
+ [-80.02, 0.36],
+ [-80.09, 0.77],
+ [-79.54, 0.98],
+ [-78.86, 1.38]
+ ],
+ [
+ [-75.37, -0.15],
+ [-75.23, -0.91],
+ [-75.54, -1.56],
+ [-76.64, -2.61],
+ [-77.84, -3],
+ [-78.45, -3.87],
+ [-78.64, -4.55],
+ [-79.21, -4.96],
+ [-79.62, -4.45],
+ [-80.03, -4.35],
+ [-80.44, -4.43],
+ [-80.47, -4.06],
+ [-80.18, -3.82],
+ [-80.3, -3.4]
+ ],
+ [
+ [36.87, 22],
+ [32.9, 22],
+ [29.02, 22],
+ [25, 22]
+ ],
+ [
+ [25, 22],
+ [25, 25.68],
+ [25, 29.24],
+ [24.7, 30.04],
+ [24.96, 30.66],
+ [24.8, 31.09],
+ [25.16, 31.57]
+ ],
+ [
+ [25.16, 31.57],
+ [26.5, 31.59],
+ [27.46, 31.32],
+ [28.45, 31.03],
+ [28.91, 30.87],
+ [29.68, 31.19],
+ [30.1, 31.47],
+ [30.98, 31.56],
+ [31.69, 31.43],
+ [31.96, 30.93],
+ [32.19, 31.26],
+ [32.99, 31.02],
+ [33.77, 30.97],
+ [34.27, 31.22],
+ [34.92, 29.5],
+ [34.64, 29.1],
+ [34.43, 28.34],
+ [34.15, 27.82],
+ [33.92, 27.65],
+ [33.59, 27.97],
+ [33.14, 28.42],
+ [32.42, 29.85],
+ [32.32, 29.76],
+ [32.73, 28.71],
+ [33.35, 27.7],
+ [34.1, 26.14],
+ [34.47, 25.6],
+ [34.8, 25.03],
+ [35.69, 23.93],
+ [35.49, 23.75],
+ [35.53, 23.1],
+ [36.69, 22.2],
+ [36.87, 22]
+ ],
+ [
+ [42.35, 12.54],
+ [42.01, 12.87],
+ [41.6, 13.45]
+ ],
+ [
+ [41.6, 13.45],
+ [41.16, 13.77],
+ [40.9, 14.12]
+ ],
+ [
+ [40.9, 14.12],
+ [40.03, 14.52],
+ [39.34, 14.53]
+ ],
+ [
+ [39.34, 14.53],
+ [39.1, 14.74],
+ [38.51, 14.51],
+ [37.91, 14.96],
+ [37.59, 14.21],
+ [36.43, 14.42]
+ ],
+ [
+ [36.43, 14.42],
+ [36.32, 14.82],
+ [36.75, 16.29],
+ [36.85, 16.96]
+ ],
+ [
+ [36.85, 16.96],
+ [37.17, 17.26],
+ [37.9, 17.43],
+ [38.41, 18]
+ ],
+ [
+ [38.41, 18],
+ [38.99, 16.84],
+ [39.27, 15.92],
+ [39.81, 15.44],
+ [41.18, 14.49],
+ [41.73, 13.92],
+ [42.28, 13.34],
+ [42.59, 13],
+ [43.08, 12.7]
+ ],
+ [
+ [-9.03, 41.88],
+ [-8.98, 42.59],
+ [-9.39, 43.03],
+ [-7.98, 43.75],
+ [-6.75, 43.57],
+ [-5.41, 43.57],
+ [-4.35, 43.4],
+ [-3.52, 43.46],
+ [-1.9, 43.42]
+ ],
+ [
+ [-1.9, 43.42],
+ [-1.5, 43.03],
+ [0.34, 42.58],
+ [0.7, 42.8],
+ [1.83, 42.34],
+ [2.99, 42.47]
+ ],
+ [
+ [2.99, 42.47],
+ [3.04, 41.89],
+ [2.09, 41.23],
+ [0.81, 41.01],
+ [0.72, 40.68],
+ [0.11, 40.12],
+ [-0.28, 39.31],
+ [0.11, 38.74],
+ [-0.47, 38.29],
+ [-0.68, 37.64],
+ [-1.44, 37.44],
+ [-2.15, 36.67],
+ [-3.42, 36.66],
+ [-4.37, 36.68],
+ [-5, 36.32],
+ [-5.38, 35.95],
+ [-5.87, 36.03],
+ [-6.24, 36.37],
+ [-6.52, 36.94],
+ [-7.45, 37.1]
+ ],
+ [
+ [-7.45, 37.1],
+ [-7.54, 37.43],
+ [-7.17, 37.8],
+ [-7.03, 38.08],
+ [-7.37, 38.37],
+ [-7.1, 39.03],
+ [-7.5, 39.63],
+ [-7.07, 39.71],
+ [-7.03, 40.18],
+ [-6.86, 40.33],
+ [-6.85, 41.11],
+ [-6.39, 41.38],
+ [-6.67, 41.88],
+ [-7.25, 41.92],
+ [-7.42, 41.79],
+ [-8.01, 41.79],
+ [-8.26, 42.28],
+ [-8.67, 42.13],
+ [-9.03, 41.88]
+ ],
+ [
+ [24.31, 57.79],
+ [24.43, 58.38],
+ [24.06, 58.26],
+ [23.43, 58.61],
+ [23.34, 59.19],
+ [24.6, 59.47],
+ [25.86, 59.61],
+ [26.95, 59.45],
+ [27.98, 59.48],
+ [28.13, 59.3]
+ ],
+ [
+ [28.13, 59.3],
+ [27.42, 58.72],
+ [27.72, 57.79]
+ ],
+ [
+ [27.72, 57.79],
+ [27.29, 57.47]
+ ],
+ [
+ [27.29, 57.47],
+ [26.46, 57.48],
+ [25.6, 57.85],
+ [25.16, 57.97],
+ [24.31, 57.79]
+ ],
+ [
+ [39.34, 14.53],
+ [40.03, 14.52],
+ [40.9, 14.12]
+ ],
+ [
+ [40.9, 14.12],
+ [41.16, 13.77],
+ [41.6, 13.45]
+ ],
+ [
+ [42.78, 10.93],
+ [42.56, 10.57],
+ [42.93, 10.02]
+ ],
+ [
+ [42.93, 10.02],
+ [43.3, 9.54],
+ [43.68, 9.18]
+ ],
+ [
+ [43.68, 9.18],
+ [46.95, 8],
+ [47.79, 8]
+ ],
+ [
+ [47.79, 8],
+ [44.96, 5],
+ [43.66, 4.96],
+ [42.77, 4.25],
+ [42.13, 4.23],
+ [41.86, 3.92]
+ ],
+ [
+ [41.86, 3.92],
+ [41.17, 3.92],
+ [40.77, 4.26],
+ [39.85, 3.84],
+ [39.56, 3.42],
+ [38.89, 3.5],
+ [38.67, 3.62],
+ [38.44, 3.59],
+ [38.12, 3.6],
+ [36.86, 4.45],
+ [36.16, 4.45],
+ [35.82, 4.78],
+ [35.82, 5.34],
+ [35.3, 5.51]
+ ],
+ [
+ [35.3, 5.51],
+ [34.71, 6.59],
+ [34.25, 6.83],
+ [34.08, 7.23],
+ [33.57, 7.71],
+ [32.95, 7.78],
+ [33.29, 8.35],
+ [33.83, 8.38],
+ [33.97, 8.68]
+ ],
+ [
+ [33.97, 8.68],
+ [33.96, 9.58]
+ ],
+ [
+ [33.96, 9.58],
+ [34.26, 10.63],
+ [34.73, 10.91],
+ [34.83, 11.32],
+ [35.26, 12.08],
+ [35.86, 12.58],
+ [36.27, 13.56],
+ [36.43, 14.42]
+ ],
+ [
+ [28.59, 69.06],
+ [28.45, 68.36],
+ [29.98, 67.7],
+ [29.05, 66.94],
+ [30.22, 65.81],
+ [29.54, 64.95],
+ [30.44, 64.2],
+ [30.04, 63.55],
+ [31.52, 62.87],
+ [31.14, 62.36],
+ [30.21, 61.78]
+ ],
+ [
+ [30.21, 61.78],
+ [28.07, 60.5],
+ [26.26, 60.42],
+ [24.5, 60.06],
+ [22.87, 59.85],
+ [22.29, 60.39],
+ [21.32, 60.72],
+ [21.54, 61.71],
+ [21.06, 62.61],
+ [21.54, 63.19],
+ [22.44, 63.82],
+ [24.73, 64.9],
+ [25.4, 65.11],
+ [25.29, 65.53],
+ [23.9, 66.01]
+ ],
+ [
+ [23.9, 66.01],
+ [23.57, 66.4],
+ [23.54, 67.94],
+ [21.98, 68.62],
+ [20.65, 69.11]
+ ],
+ [
+ [20.65, 69.11],
+ [21.24, 69.37],
+ [22.36, 68.84],
+ [23.66, 68.89],
+ [24.74, 68.65],
+ [25.69, 69.09],
+ [26.18, 69.83],
+ [27.73, 70.16],
+ [29.02, 69.77],
+ [28.59, 69.06]
+ ],
+ [
+ [178.37, -17.34],
+ [178.72, -17.63],
+ [178.55, -18.15],
+ [177.93, -18.29],
+ [177.38, -18.16],
+ [177.29, -17.72],
+ [177.67, -17.38],
+ [178.13, -17.5],
+ [178.37, -17.34]
+ ],
+ [
+ [179.36, -16.8],
+ [178.73, -17.01],
+ [178.6, -16.64],
+ [179.1, -16.43],
+ [179.41, -16.38],
+ [180, -16.07],
+ [180, -16.56],
+ [179.36, -16.8]
+ ],
+ [
+ [-179.92, -16.5],
+ [-180, -16.56],
+ [-180, -16.07],
+ [-179.79, -16.02],
+ [-179.92, -16.5]
+ ],
+ [
+ [9.56, 42.15],
+ [9.23, 41.38],
+ [8.78, 41.58],
+ [8.54, 42.26],
+ [8.75, 42.63],
+ [9.39, 43.01],
+ [9.56, 42.15]
+ ],
+ [
+ [5.67, 49.53],
+ [5.9, 49.44],
+ [6.19, 49.46]
+ ],
+ [
+ [6.84, 45.99],
+ [6.8, 45.71],
+ [7.1, 45.33],
+ [6.75, 45.03],
+ [7.01, 44.25],
+ [7.55, 44.13],
+ [7.44, 43.69]
+ ],
+ [
+ [7.44, 43.69],
+ [6.53, 43.13],
+ [4.56, 43.4],
+ [3.1, 43.08],
+ [2.99, 42.47]
+ ],
+ [
+ [-1.9, 43.42],
+ [-1.38, 44.02],
+ [-1.19, 46.01],
+ [-2.23, 47.06],
+ [-2.96, 47.57],
+ [-4.49, 47.95],
+ [-4.59, 48.68],
+ [-3.3, 48.9],
+ [-1.62, 48.64],
+ [-1.93, 49.78],
+ [-0.99, 49.35],
+ [1.34, 50.13],
+ [1.64, 50.95],
+ [2.51, 51.15]
+ ],
+ [
+ [-54.52, 2.31],
+ [-54.27, 2.74],
+ [-54.18, 3.19],
+ [-54.01, 3.62],
+ [-54.4, 4.21]
+ ],
+ [
+ [-54.4, 4.21],
+ [-54.48, 4.9],
+ [-53.96, 5.76]
+ ],
+ [
+ [-53.96, 5.76],
+ [-53.62, 5.65],
+ [-52.88, 5.41],
+ [-51.82, 4.57],
+ [-51.66, 4.16]
+ ],
+ [
+ [11.09, -3.98],
+ [10.07, -2.97],
+ [9.41, -2.14],
+ [8.8, -1.11],
+ [8.83, -0.78],
+ [9.05, -0.46],
+ [9.29, 0.27],
+ [9.49, 1.01]
+ ],
+ [
+ [9.49, 1.01],
+ [9.83, 1.07],
+ [11.29, 1.06],
+ [11.28, 2.26]
+ ],
+ [
+ [-6.2, 53.87],
+ [-6.95, 54.07],
+ [-7.57, 54.06],
+ [-7.37, 54.6],
+ [-7.57, 55.13]
+ ],
+ [
+ [-7.57, 55.13],
+ [-6.73, 55.17],
+ [-5.66, 54.55],
+ [-6.2, 53.87]
+ ],
+ [
+ [-3.01, 58.64],
+ [-4.07, 57.55],
+ [-3.06, 57.69],
+ [-1.96, 57.68],
+ [-2.22, 56.87],
+ [-3.12, 55.97],
+ [-2.09, 55.91],
+ [-2.01, 55.8],
+ [-1.11, 54.62],
+ [-0.43, 54.46],
+ [0.18, 53.33],
+ [0.47, 52.93],
+ [1.68, 52.74],
+ [1.56, 52.1],
+ [1.05, 51.81],
+ [1.45, 51.29],
+ [0.55, 50.77],
+ [-0.79, 50.77],
+ [-2.49, 50.5],
+ [-2.96, 50.7],
+ [-3.62, 50.23],
+ [-4.54, 50.34],
+ [-5.25, 49.96],
+ [-5.78, 50.16],
+ [-4.31, 51.21],
+ [-3.41, 51.43],
+ [-3.42, 51.43],
+ [-4.98, 51.59],
+ [-5.27, 51.99],
+ [-4.22, 52.3],
+ [-4.77, 52.84],
+ [-4.58, 53.5],
+ [-3.09, 53.4],
+ [-3.09, 53.4],
+ [-2.95, 53.99],
+ [-3.61, 54.6],
+ [-3.63, 54.62],
+ [-4.84, 54.79],
+ [-5.08, 55.06],
+ [-4.72, 55.51],
+ [-5.05, 55.78],
+ [-5.59, 55.31],
+ [-5.64, 56.28],
+ [-6.15, 56.79],
+ [-5.79, 57.82],
+ [-5.01, 58.63],
+ [-4.21, 58.55],
+ [-3.01, 58.64]
+ ],
+ [
+ [41.55, 41.54],
+ [41.7, 41.96],
+ [41.45, 42.65],
+ [40.88, 43.01],
+ [40.32, 43.13],
+ [39.96, 43.43]
+ ],
+ [
+ [39.96, 43.43],
+ [40.08, 43.55]
+ ],
+ [
+ [40.08, 43.55],
+ [40.92, 43.38],
+ [42.39, 43.22],
+ [43.76, 42.74],
+ [43.93, 42.55],
+ [44.54, 42.71]
+ ],
+ [
+ [44.54, 42.71],
+ [45.47, 42.5]
+ ],
+ [
+ [45.47, 42.5],
+ [45.78, 42.09],
+ [46.4, 41.86]
+ ],
+ [
+ [43.58, 41.09],
+ [42.62, 41.58],
+ [41.55, 41.54]
+ ],
+ [
+ [1.06, 5.93],
+ [-0.51, 5.34],
+ [-1.06, 5],
+ [-1.96, 4.71],
+ [-2.86, 4.99]
+ ],
+ [
+ [0.02, 11.02],
+ [-0.05, 10.71],
+ [0.37, 10.19],
+ [0.37, 9.47],
+ [0.46, 8.68],
+ [0.71, 8.31],
+ [0.49, 7.41],
+ [0.57, 6.91],
+ [0.84, 6.28],
+ [1.06, 5.93]
+ ],
+ [
+ [-8.44, 7.69],
+ [-8.72, 7.71],
+ [-8.93, 7.31],
+ [-9.21, 7.31],
+ [-9.4, 7.53],
+ [-9.34, 7.93],
+ [-9.76, 8.54],
+ [-10.02, 8.43],
+ [-10.23, 8.41]
+ ],
+ [
+ [-10.23, 8.41],
+ [-10.51, 8.35],
+ [-10.49, 8.72],
+ [-10.65, 8.98],
+ [-10.62, 9.27],
+ [-10.84, 9.69],
+ [-11.12, 10.05],
+ [-11.92, 10.05],
+ [-12.15, 9.86],
+ [-12.43, 9.84],
+ [-12.6, 9.62],
+ [-12.71, 9.34],
+ [-13.25, 8.9]
+ ],
+ [
+ [-13.25, 8.9],
+ [-13.69, 9.49],
+ [-14.07, 9.89],
+ [-14.33, 10.02],
+ [-14.58, 10.21],
+ [-14.69, 10.66],
+ [-14.84, 10.88],
+ [-15.13, 11.04]
+ ],
+ [
+ [-15.13, 11.04],
+ [-14.69, 11.53],
+ [-14.38, 11.51],
+ [-14.12, 11.68],
+ [-13.9, 11.68],
+ [-13.74, 11.81],
+ [-13.83, 12.14],
+ [-13.72, 12.25],
+ [-13.7, 12.59]
+ ],
+ [
+ [-13.7, 12.59],
+ [-13.22, 12.58],
+ [-12.5, 12.33],
+ [-12.28, 12.35],
+ [-12.2, 12.47],
+ [-11.66, 12.39],
+ [-11.51, 12.44]
+ ],
+ [
+ [-11.51, 12.44],
+ [-11.46, 12.08],
+ [-11.3, 12.08],
+ [-11.04, 12.21],
+ [-10.87, 12.18],
+ [-10.59, 11.92],
+ [-10.17, 11.84],
+ [-9.89, 12.06],
+ [-9.57, 12.19],
+ [-9.33, 12.33],
+ [-9.13, 12.31],
+ [-8.91, 12.09],
+ [-8.79, 11.81],
+ [-8.38, 11.39],
+ [-8.58, 11.14],
+ [-8.62, 10.81],
+ [-8.41, 10.91],
+ [-8.28, 10.79],
+ [-8.34, 10.49],
+ [-8.03, 10.21]
+ ],
+ [
+ [-16.84, 13.15],
+ [-16.71, 13.59]
+ ],
+ [
+ [-16.71, 13.59],
+ [-15.62, 13.62],
+ [-15.4, 13.86],
+ [-15.08, 13.88],
+ [-14.69, 13.63],
+ [-14.38, 13.63],
+ [-14.05, 13.79],
+ [-13.84, 13.51],
+ [-14.28, 13.28],
+ [-14.71, 13.3],
+ [-15.14, 13.51],
+ [-15.51, 13.28],
+ [-15.69, 13.27],
+ [-15.93, 13.13],
+ [-16.84, 13.15]
+ ],
+ [
+ [-15.13, 11.04],
+ [-15.66, 11.46],
+ [-16.09, 11.52],
+ [-16.31, 11.81],
+ [-16.31, 11.96],
+ [-16.61, 12.17],
+ [-16.68, 12.38]
+ ],
+ [
+ [-16.68, 12.38],
+ [-16.15, 12.55],
+ [-15.82, 12.52],
+ [-15.55, 12.63],
+ [-13.7, 12.59]
+ ],
+ [
+ [9.49, 1.01],
+ [9.31, 1.16],
+ [9.65, 2.28]
+ ],
+ [
+ [23.7, 35.71],
+ [24.25, 35.37],
+ [25.03, 35.42],
+ [25.77, 35.35],
+ [25.75, 35.18],
+ [26.29, 35.3],
+ [26.16, 35],
+ [24.72, 34.92],
+ [24.74, 35.08],
+ [23.51, 35.28],
+ [23.7, 35.71]
+ ],
+ [
+ [26.06, 40.82],
+ [25.45, 40.85],
+ [24.93, 40.95],
+ [23.71, 40.69],
+ [24.41, 40.12],
+ [23.9, 39.96],
+ [23.34, 39.96],
+ [22.81, 40.48],
+ [22.63, 40.26],
+ [22.85, 39.66],
+ [23.35, 39.19],
+ [22.97, 38.97],
+ [23.53, 38.51],
+ [24.03, 38.22],
+ [24.04, 37.66],
+ [23.12, 37.92],
+ [23.41, 37.41],
+ [22.77, 37.31],
+ [23.15, 36.42],
+ [22.49, 36.41],
+ [21.67, 36.84],
+ [21.3, 37.64],
+ [21.12, 38.31],
+ [20.73, 38.77],
+ [20.22, 39.34],
+ [20.15, 39.62]
+ ],
+ [
+ [21.02, 40.84],
+ [21.67, 40.93],
+ [22.06, 41.15],
+ [22.6, 41.13],
+ [22.76, 41.3],
+ [22.95, 41.34]
+ ],
+ [
+ [26.12, 41.83],
+ [26.6, 41.56],
+ [26.29, 40.94],
+ [26.06, 40.82]
+ ],
+ [
+ [-90.1, 13.74],
+ [-90.61, 13.91],
+ [-91.23, 13.93],
+ [-91.69, 14.13],
+ [-92.23, 14.54]
+ ],
+ [
+ [-92.23, 14.54],
+ [-92.2, 14.83],
+ [-92.09, 15.06],
+ [-92.23, 15.25],
+ [-91.75, 16.07],
+ [-90.46, 16.07],
+ [-90.44, 16.41],
+ [-90.6, 16.47],
+ [-90.71, 16.69],
+ [-91.08, 16.92],
+ [-91.45, 17.25],
+ [-91, 17.25],
+ [-91, 17.82],
+ [-90.07, 17.82],
+ [-89.14, 17.81]
+ ],
+ [
+ [-88.93, 15.89],
+ [-88.6, 15.71],
+ [-88.52, 15.86],
+ [-88.23, 15.73]
+ ],
+ [
+ [-88.23, 15.73],
+ [-88.68, 15.35],
+ [-89.15, 15.07],
+ [-89.23, 14.87],
+ [-89.15, 14.68],
+ [-89.35, 14.42]
+ ],
+ [
+ [-89.35, 14.42],
+ [-89.59, 14.36],
+ [-89.53, 14.24],
+ [-89.72, 14.13],
+ [-90.06, 13.88],
+ [-90.1, 13.74]
+ ],
+ [
+ [-59.76, 8.37],
+ [-59.1, 8],
+ [-58.48, 7.35],
+ [-58.45, 6.83],
+ [-58.08, 6.81],
+ [-57.54, 6.32],
+ [-57.15, 5.97]
+ ],
+ [
+ [-57.15, 5.97],
+ [-57.31, 5.07],
+ [-57.91, 4.81],
+ [-57.86, 4.58],
+ [-58.04, 4.06],
+ [-57.6, 3.33],
+ [-57.28, 3.33],
+ [-57.15, 2.77],
+ [-56.54, 1.9]
+ ],
+ [
+ [-60.73, 5.2],
+ [-61.41, 5.96],
+ [-61.14, 6.23],
+ [-61.16, 6.7],
+ [-60.54, 6.86],
+ [-60.3, 7.04],
+ [-60.64, 7.42],
+ [-60.55, 7.78],
+ [-59.76, 8.37]
+ ],
+ [
+ [-87.32, 12.98],
+ [-87.49, 13.3],
+ [-87.79, 13.38]
+ ],
+ [
+ [-87.79, 13.38],
+ [-87.72, 13.79],
+ [-87.86, 13.89],
+ [-88.07, 13.96],
+ [-88.5, 13.85],
+ [-88.54, 13.98],
+ [-88.84, 14.14],
+ [-89.06, 14.34],
+ [-89.35, 14.42]
+ ],
+ [
+ [-88.23, 15.73],
+ [-88.12, 15.69],
+ [-87.9, 15.86],
+ [-87.62, 15.88],
+ [-87.52, 15.8],
+ [-87.37, 15.85],
+ [-86.9, 15.76],
+ [-86.44, 15.78],
+ [-86.12, 15.89],
+ [-86, 16.01],
+ [-85.68, 15.95],
+ [-85.44, 15.89],
+ [-85.18, 15.91],
+ [-84.98, 16],
+ [-84.53, 15.86],
+ [-84.37, 15.84],
+ [-84.06, 15.65],
+ [-83.77, 15.42],
+ [-83.41, 15.27],
+ [-83.15, 15]
+ ],
+ [
+ [-83.15, 15],
+ [-83.49, 15.02],
+ [-83.63, 14.88],
+ [-83.98, 14.75],
+ [-84.23, 14.75],
+ [-84.45, 14.62],
+ [-84.65, 14.67],
+ [-84.82, 14.82],
+ [-84.92, 14.79],
+ [-85.05, 14.55],
+ [-85.15, 14.56],
+ [-85.17, 14.35],
+ [-85.51, 14.08],
+ [-85.7, 13.96],
+ [-85.8, 13.84],
+ [-86.1, 14.04],
+ [-86.31, 13.77],
+ [-86.52, 13.78],
+ [-86.76, 13.75],
+ [-86.73, 13.26],
+ [-86.88, 13.25],
+ [-87.01, 13.03],
+ [-87.32, 12.98]
+ ],
+ [
+ [18.83, 45.91],
+ [19.07, 45.52]
+ ],
+ [
+ [19.07, 45.52],
+ [19.39, 45.24]
+ ],
+ [
+ [19.39, 45.24],
+ [19.01, 44.86]
+ ],
+ [
+ [18.56, 42.65],
+ [18.45, 42.48],
+ [17.51, 42.85],
+ [16.93, 43.21],
+ [16.02, 43.51],
+ [15.17, 44.24],
+ [15.38, 44.32],
+ [14.92, 44.74],
+ [14.9, 45.08],
+ [14.26, 45.23],
+ [13.95, 44.8],
+ [13.66, 45.14],
+ [13.68, 45.48],
+ [13.72, 45.5]
+ ],
+ [
+ [13.72, 45.5],
+ [14.41, 45.47],
+ [14.6, 45.63],
+ [14.94, 45.47],
+ [15.33, 45.45],
+ [15.32, 45.73],
+ [15.67, 45.83],
+ [15.77, 46.24],
+ [16.56, 46.5]
+ ],
+ [
+ [16.56, 46.5],
+ [16.88, 46.38],
+ [17.63, 45.95],
+ [18.46, 45.76],
+ [18.83, 45.91]
+ ],
+ [
+ [-71.71, 18.04],
+ [-72.37, 18.21],
+ [-72.84, 18.15],
+ [-73.45, 18.22],
+ [-73.92, 18.03],
+ [-74.46, 18.34],
+ [-74.37, 18.66],
+ [-73.45, 18.53],
+ [-72.69, 18.45],
+ [-72.33, 18.67],
+ [-72.79, 19.1],
+ [-72.78, 19.48],
+ [-73.42, 19.64],
+ [-73.19, 19.92],
+ [-72.58, 19.87],
+ [-71.71, 19.71]
+ ],
+ [
+ [16.98, 48.12],
+ [17.49, 47.87],
+ [17.86, 47.76],
+ [18.7, 47.88],
+ [18.78, 48.08],
+ [19.17, 48.11],
+ [19.66, 48.27],
+ [19.77, 48.2],
+ [20.24, 48.33],
+ [20.47, 48.56],
+ [20.8, 48.62],
+ [21.87, 48.32],
+ [22.09, 48.42]
+ ],
+ [
+ [22.09, 48.42],
+ [22.64, 48.15],
+ [22.71, 47.88]
+ ],
+ [
+ [22.71, 47.88],
+ [22.1, 47.67],
+ [21.63, 46.99],
+ [21.02, 46.32],
+ [20.22, 46.13]
+ ],
+ [
+ [20.22, 46.13],
+ [19.6, 46.17]
+ ],
+ [
+ [19.6, 46.17],
+ [18.83, 45.91]
+ ],
+ [
+ [16.56, 46.5],
+ [16.37, 46.84],
+ [16.2, 46.85]
+ ],
+ [
+ [120.72, -10.24],
+ [120.3, -10.26],
+ [118.97, -9.56],
+ [119.9, -9.36],
+ [120.43, -9.67],
+ [120.78, -9.97],
+ [120.72, -10.24]
+ ],
+ [
+ [124.97, -8.89],
+ [125.07, -9.09],
+ [125.09, -9.39]
+ ],
+ [
+ [125.09, -9.39],
+ [124.44, -10.14],
+ [123.58, -10.36],
+ [123.46, -10.24],
+ [123.55, -9.9],
+ [123.98, -9.29],
+ [124.97, -8.89]
+ ],
+ [
+ [117.9, -8.1],
+ [118.26, -8.36],
+ [118.88, -8.28],
+ [119.13, -8.71],
+ [117.97, -8.91],
+ [117.28, -9.04],
+ [116.74, -9.03],
+ [117.08, -8.46],
+ [117.63, -8.45],
+ [117.9, -8.1]
+ ],
+ [
+ [122.9, -8.09],
+ [122.76, -8.65],
+ [121.25, -8.93],
+ [119.92, -8.81],
+ [119.92, -8.44],
+ [120.72, -8.24],
+ [121.34, -8.54],
+ [122.01, -8.46],
+ [122.9, -8.09]
+ ],
+ [
+ [108.62, -6.78],
+ [110.54, -6.88],
+ [110.76, -6.47],
+ [112.61, -6.95],
+ [112.98, -7.59],
+ [114.48, -7.78],
+ [115.71, -8.37],
+ [114.56, -8.75],
+ [113.46, -8.35],
+ [112.56, -8.38],
+ [111.52, -8.3],
+ [110.59, -8.12],
+ [109.43, -7.74],
+ [108.69, -7.64],
+ [108.28, -7.77],
+ [106.45, -7.35],
+ [106.28, -6.92],
+ [105.37, -6.85],
+ [106.05, -5.9],
+ [107.27, -5.95],
+ [108.07, -6.35],
+ [108.49, -6.42],
+ [108.62, -6.78]
+ ],
+ [
+ [134.72, -6.21],
+ [134.21, -6.9],
+ [134.11, -6.14],
+ [134.29, -5.78],
+ [134.5, -5.45],
+ [134.73, -5.74],
+ [134.72, -6.21]
+ ],
+ [
+ [127.25, -3.46],
+ [126.87, -3.79],
+ [126.18, -3.61],
+ [125.99, -3.18],
+ [127, -3.13],
+ [127.25, -3.46]
+ ],
+ [
+ [130.47, -3.09],
+ [130.83, -3.86],
+ [129.99, -3.45],
+ [129.16, -3.36],
+ [128.59, -3.43],
+ [127.9, -3.39],
+ [128.14, -2.84],
+ [129.37, -2.8],
+ [130.47, -3.09]
+ ],
+ [
+ [141, -2.6],
+ [141.02, -5.86],
+ [141.03, -9.12]
+ ],
+ [
+ [141.03, -9.12],
+ [140.14, -8.3],
+ [139.13, -8.1],
+ [138.88, -8.38],
+ [137.61, -8.41],
+ [138.04, -7.6],
+ [138.67, -7.32],
+ [138.41, -6.23],
+ [137.93, -5.39],
+ [135.99, -4.55],
+ [135.16, -4.46],
+ [133.66, -3.54],
+ [133.37, -4.02],
+ [132.98, -4.11],
+ [132.76, -3.75],
+ [132.75, -3.31],
+ [131.99, -2.82],
+ [133.07, -2.46],
+ [133.78, -2.48],
+ [133.7, -2.21],
+ [132.23, -2.21],
+ [131.84, -1.62],
+ [130.94, -1.43],
+ [130.52, -0.94],
+ [131.87, -0.7],
+ [132.38, -0.37],
+ [133.99, -0.78],
+ [134.14, -1.15],
+ [134.42, -2.77],
+ [135.46, -3.37],
+ [136.29, -2.31],
+ [137.44, -1.7],
+ [138.33, -1.7],
+ [139.18, -2.05],
+ [139.93, -2.41],
+ [141, -2.6]
+ ],
+ [
+ [125.24, 1.42],
+ [124.44, 0.43],
+ [123.69, 0.24],
+ [122.72, 0.43],
+ [121.06, 0.38],
+ [120.18, 0.24],
+ [120.04, -0.52],
+ [120.94, -1.41],
+ [121.48, -0.96],
+ [123.34, -0.62],
+ [123.26, -1.08],
+ [122.82, -0.93],
+ [122.39, -1.52],
+ [121.51, -1.9],
+ [122.45, -3.19],
+ [122.27, -3.53],
+ [123.17, -4.68],
+ [123.16, -5.34],
+ [122.63, -5.63],
+ [122.24, -5.28],
+ [122.72, -4.46],
+ [121.74, -4.85],
+ [121.49, -4.57],
+ [121.62, -4.19],
+ [120.9, -3.6],
+ [120.97, -2.63],
+ [120.31, -2.93],
+ [120.39, -4.1],
+ [120.43, -5.53],
+ [119.8, -5.67],
+ [119.37, -5.38],
+ [119.65, -4.46],
+ [119.5, -3.49],
+ [119.08, -3.49],
+ [118.77, -2.8],
+ [119.18, -2.15],
+ [119.32, -1.35],
+ [119.83, 0.15],
+ [120.04, 0.57],
+ [120.89, 1.31],
+ [121.67, 1.01],
+ [122.93, 0.88],
+ [124.08, 0.92],
+ [125.07, 1.64],
+ [125.24, 1.42]
+ ],
+ [
+ [128.69, 1.13],
+ [128.64, 0.26],
+ [128.12, 0.36],
+ [127.97, -0.25],
+ [128.38, -0.78],
+ [128.1, -0.9],
+ [127.7, -0.27],
+ [127.4, 1.01],
+ [127.6, 1.81],
+ [127.93, 2.17],
+ [128, 1.63],
+ [128.59, 1.54],
+ [128.69, 1.13]
+ ],
+ [
+ [109.66, 2.01],
+ [109.83, 1.34],
+ [110.51, 0.77],
+ [111.16, 0.98],
+ [111.8, 0.9],
+ [112.38, 1.41],
+ [112.86, 1.5],
+ [113.81, 1.22],
+ [114.62, 1.43],
+ [115.13, 2.82],
+ [115.52, 3.17],
+ [115.87, 4.31],
+ [117.02, 4.31],
+ [117.88, 4.14]
+ ],
+ [
+ [117.88, 4.14],
+ [117.31, 3.23],
+ [118.05, 2.29],
+ [117.88, 1.83],
+ [119, 0.9],
+ [117.81, 0.78],
+ [117.48, 0.1],
+ [117.52, -0.8],
+ [116.56, -1.49],
+ [116.53, -2.48],
+ [116.15, -4.01],
+ [116, -3.66],
+ [114.86, -4.11],
+ [114.47, -3.5],
+ [113.76, -3.44],
+ [113.26, -3.12],
+ [112.07, -3.48],
+ [111.7, -2.99],
+ [111.05, -3.05],
+ [110.22, -2.93],
+ [110.07, -1.59],
+ [109.57, -1.31],
+ [109.09, -0.46],
+ [108.95, 0.42],
+ [109.07, 1.34],
+ [109.66, 2.01]
+ ],
+ [
+ [105.82, -5.85],
+ [104.71, -5.87],
+ [103.87, -5.04],
+ [102.58, -4.22],
+ [102.16, -3.61],
+ [101.4, -2.8],
+ [100.9, -2.05],
+ [100.14, -0.65],
+ [99.26, 0.18],
+ [98.97, 1.04],
+ [98.6, 1.82],
+ [97.7, 2.45],
+ [97.18, 3.31],
+ [96.42, 3.87],
+ [95.38, 4.97],
+ [95.29, 5.48],
+ [95.94, 5.44],
+ [97.48, 5.25],
+ [98.37, 4.27],
+ [99.14, 3.59],
+ [99.69, 3.17],
+ [100.64, 2.1],
+ [101.66, 2.08],
+ [102.5, 1.4],
+ [103.08, 0.56],
+ [103.84, 0.1],
+ [103.44, -0.71],
+ [104.01, -1.06],
+ [104.37, -1.08],
+ [104.54, -1.78],
+ [104.89, -2.34],
+ [105.62, -2.43],
+ [106.11, -3.06],
+ [105.86, -4.31],
+ [105.82, -5.85]
+ ],
+ [
+ [81.11, 30.18],
+ [80.48, 29.73],
+ [80.09, 28.79],
+ [81.06, 28.42],
+ [82, 27.93],
+ [83.3, 27.36],
+ [84.68, 27.23],
+ [85.25, 26.73],
+ [86.02, 26.63],
+ [87.23, 26.4],
+ [88.06, 26.41],
+ [88.17, 26.81],
+ [88.04, 27.45],
+ [88.12, 27.88]
+ ],
+ [
+ [97.33, 28.26],
+ [97.4, 27.88],
+ [97.05, 27.7],
+ [97.13, 27.08],
+ [96.42, 27.26],
+ [95.12, 26.57],
+ [95.16, 26],
+ [94.6, 25.16],
+ [94.55, 24.68],
+ [94.11, 23.85],
+ [93.33, 24.08],
+ [93.29, 23.04],
+ [93.06, 22.7],
+ [93.17, 22.28],
+ [92.67, 22.04]
+ ],
+ [
+ [89.03, 22.06],
+ [88.89, 21.69],
+ [88.21, 21.7],
+ [86.98, 21.5],
+ [87.03, 20.74],
+ [86.5, 20.15],
+ [85.06, 19.48],
+ [83.94, 18.3],
+ [83.19, 17.67],
+ [82.19, 17.02],
+ [82.19, 16.56],
+ [81.69, 16.31],
+ [80.79, 15.95],
+ [80.32, 15.9],
+ [80.03, 15.14],
+ [80.23, 13.84],
+ [80.29, 13.01],
+ [79.86, 12.06],
+ [79.86, 10.36],
+ [79.34, 10.31],
+ [78.89, 9.55],
+ [79.19, 9.22],
+ [78.28, 8.93],
+ [77.94, 8.25],
+ [77.54, 7.97],
+ [76.59, 8.9],
+ [76.13, 10.3],
+ [75.75, 11.31],
+ [75.4, 11.78],
+ [74.86, 12.74],
+ [74.62, 13.99],
+ [74.44, 14.62],
+ [73.53, 15.99],
+ [73.12, 17.93],
+ [72.82, 19.21],
+ [72.82, 20.42],
+ [72.63, 21.36],
+ [71.18, 20.76],
+ [70.47, 20.88],
+ [69.16, 22.09],
+ [69.64, 22.45],
+ [69.35, 22.84],
+ [68.18, 23.69]
+ ],
+ [
+ [68.18, 23.69],
+ [68.84, 24.36],
+ [71.04, 24.36],
+ [70.84, 25.22],
+ [70.28, 25.72],
+ [70.17, 26.49],
+ [69.51, 26.94],
+ [70.62, 27.99],
+ [71.78, 27.91],
+ [72.82, 28.96],
+ [73.45, 29.98],
+ [74.42, 30.98],
+ [74.41, 31.69],
+ [75.26, 32.27],
+ [74.45, 32.76],
+ [74.1, 33.44],
+ [73.75, 34.32],
+ [74.24, 34.75],
+ [75.76, 34.5],
+ [76.87, 34.65],
+ [77.84, 35.49]
+ ],
+ [
+ [-6.2, 53.87],
+ [-6.03, 53.15],
+ [-6.79, 52.26],
+ [-8.56, 51.67],
+ [-9.98, 51.82],
+ [-9.17, 52.86],
+ [-9.69, 53.88],
+ [-8.33, 54.66],
+ [-7.57, 55.13]
+ ],
+ [
+ [53.92, 37.2],
+ [54.8, 37.39],
+ [55.51, 37.96],
+ [56.18, 37.94],
+ [56.62, 38.12],
+ [57.33, 38.03],
+ [58.44, 37.52],
+ [59.23, 37.41],
+ [60.38, 36.53],
+ [61.12, 36.49],
+ [61.21, 35.65]
+ ],
+ [
+ [60.87, 29.83],
+ [61.37, 29.3],
+ [61.77, 28.7],
+ [62.73, 28.26],
+ [62.76, 27.38],
+ [63.23, 27.22],
+ [63.32, 26.76],
+ [61.87, 26.24],
+ [61.5, 25.08]
+ ],
+ [
+ [61.5, 25.08],
+ [59.62, 25.38],
+ [58.53, 25.61],
+ [57.4, 25.74],
+ [56.97, 26.97],
+ [56.49, 27.14],
+ [55.72, 26.96],
+ [54.72, 26.48],
+ [53.49, 26.81],
+ [52.48, 27.58],
+ [51.52, 27.87],
+ [50.85, 28.81],
+ [50.12, 30.15],
+ [49.58, 29.99],
+ [48.94, 30.32],
+ [48.57, 29.93]
+ ],
+ [
+ [48.57, 29.93],
+ [48.01, 30.45],
+ [48, 30.99],
+ [47.69, 30.98],
+ [47.85, 31.71],
+ [47.33, 32.47],
+ [46.11, 33.02],
+ [45.42, 33.97],
+ [45.65, 34.75],
+ [46.15, 35.09],
+ [46.08, 35.68],
+ [45.42, 35.98]
+ ],
+ [
+ [45.42, 35.98],
+ [44.77, 37.17],
+ [44.23, 37.97]
+ ],
+ [
+ [44.23, 37.97],
+ [44.42, 38.28],
+ [44.11, 39.43],
+ [44.79, 39.71]
+ ],
+ [
+ [48.88, 38.32],
+ [49.2, 37.58],
+ [50.15, 37.37],
+ [50.84, 36.87],
+ [52.26, 36.7],
+ [53.83, 36.97],
+ [53.92, 37.2]
+ ],
+ [
+ [48.57, 29.93],
+ [47.97, 29.98]
+ ],
+ [
+ [47.97, 29.98],
+ [47.3, 30.06],
+ [46.57, 29.1]
+ ],
+ [
+ [46.57, 29.1],
+ [44.71, 29.18],
+ [41.89, 31.19],
+ [40.4, 31.89],
+ [39.2, 32.16]
+ ],
+ [
+ [39.2, 32.16],
+ [38.79, 33.38]
+ ],
+ [
+ [38.79, 33.38],
+ [41.01, 34.42],
+ [41.38, 35.63],
+ [41.29, 36.36],
+ [41.84, 36.61],
+ [42.35, 37.23]
+ ],
+ [
+ [42.35, 37.23],
+ [42.78, 37.39],
+ [43.94, 37.26],
+ [44.29, 37],
+ [44.77, 37.17]
+ ],
+ [
+ [44.77, 37.17],
+ [45.42, 35.98]
+ ],
+ [
+ [-14.51, 66.46],
+ [-14.74, 65.81],
+ [-13.61, 65.13],
+ [-14.91, 64.36],
+ [-17.79, 63.68],
+ [-18.66, 63.5],
+ [-19.97, 63.64],
+ [-22.76, 63.96],
+ [-21.78, 64.4],
+ [-23.96, 64.89],
+ [-22.18, 65.08],
+ [-22.23, 65.38],
+ [-24.33, 65.61],
+ [-23.65, 66.26],
+ [-22.13, 66.41],
+ [-20.58, 65.73],
+ [-19.06, 66.28],
+ [-17.8, 65.99],
+ [-16.17, 66.53],
+ [-14.51, 66.46]
+ ],
+ [
+ [35.72, 32.71],
+ [35.55, 32.39]
+ ],
+ [
+ [35.55, 32.39],
+ [35.18, 32.53],
+ [34.97, 31.87],
+ [35.23, 31.75],
+ [34.97, 31.62],
+ [34.93, 31.35],
+ [35.4, 31.49]
+ ],
+ [
+ [35.4, 31.49],
+ [35.42, 31.1],
+ [34.92, 29.5]
+ ],
+ [
+ [34.92, 29.5],
+ [34.27, 31.22],
+ [34.56, 31.55],
+ [34.49, 31.61],
+ [34.75, 32.07],
+ [34.96, 32.83],
+ [35.1, 33.08],
+ [35.13, 33.09]
+ ],
+ [
+ [35.13, 33.09],
+ [35.46, 33.09],
+ [35.55, 33.26],
+ [35.82, 33.28]
+ ],
+ [
+ [35.82, 33.28],
+ [35.84, 32.87],
+ [35.7, 32.72],
+ [35.72, 32.71]
+ ],
+ [
+ [15.52, 38.23],
+ [15.16, 37.44],
+ [15.31, 37.13],
+ [15.1, 36.62],
+ [14.34, 37],
+ [13.83, 37.1],
+ [12.43, 37.61],
+ [12.57, 38.13],
+ [13.74, 38.03],
+ [14.76, 38.14],
+ [15.52, 38.23]
+ ],
+ [
+ [9.21, 41.21],
+ [9.81, 40.5],
+ [9.67, 39.18],
+ [9.21, 39.24],
+ [8.81, 38.91],
+ [8.43, 39.17],
+ [8.39, 40.38],
+ [8.16, 40.95],
+ [8.71, 40.9],
+ [9.21, 41.21]
+ ],
+ [
+ [13.81, 46.51],
+ [13.7, 46.02],
+ [13.94, 45.59]
+ ],
+ [
+ [13.94, 45.59],
+ [13.14, 45.74],
+ [12.33, 45.38],
+ [12.38, 44.89],
+ [12.26, 44.6],
+ [12.59, 44.09],
+ [13.53, 43.59],
+ [14.03, 42.76],
+ [15.14, 41.96],
+ [15.93, 41.96],
+ [16.17, 41.74],
+ [15.89, 41.54],
+ [16.79, 41.18],
+ [17.52, 40.88],
+ [18.38, 40.36],
+ [18.48, 40.17],
+ [18.29, 39.81],
+ [17.74, 40.28],
+ [16.87, 40.44],
+ [16.45, 39.8],
+ [17.17, 39.42],
+ [17.05, 38.9],
+ [16.64, 38.84],
+ [16.1, 37.99],
+ [15.68, 37.91],
+ [15.69, 38.21],
+ [15.89, 38.75],
+ [16.11, 38.96],
+ [15.72, 39.54],
+ [15.41, 40.05],
+ [15, 40.17],
+ [14.7, 40.6],
+ [14.06, 40.79],
+ [13.63, 41.19],
+ [12.89, 41.25],
+ [12.11, 41.7],
+ [11.19, 42.36],
+ [10.51, 42.93],
+ [10.2, 43.92],
+ [9.7, 44.04],
+ [8.89, 44.37],
+ [8.43, 44.23],
+ [7.85, 43.77],
+ [7.44, 43.69]
+ ],
+ [
+ [-77.57, 18.49],
+ [-76.9, 18.4],
+ [-76.37, 18.16],
+ [-76.2, 17.89],
+ [-76.9, 17.87],
+ [-77.21, 17.7],
+ [-77.77, 17.86],
+ [-78.34, 18.23],
+ [-78.22, 18.45],
+ [-77.8, 18.52],
+ [-77.57, 18.49]
+ ],
+ [
+ [35.72, 32.71],
+ [36.83, 32.31],
+ [38.79, 33.38]
+ ],
+ [
+ [39.2, 32.16],
+ [39, 32.01],
+ [37, 31.51],
+ [38, 30.51],
+ [37.67, 30.34],
+ [37.5, 30],
+ [36.74, 29.87],
+ [36.5, 29.51],
+ [36.07, 29.2],
+ [34.96, 29.36]
+ ],
+ [
+ [34.96, 29.36],
+ [34.92, 29.5]
+ ],
+ [
+ [35.4, 31.49],
+ [35.55, 31.78],
+ [35.55, 32.39]
+ ],
+ [
+ [134.64, 34.15],
+ [134.77, 33.81],
+ [134.2, 33.2],
+ [133.79, 33.52],
+ [133.28, 33.29],
+ [133.01, 32.7],
+ [132.36, 32.99],
+ [132.37, 33.46],
+ [132.92, 34.06],
+ [133.49, 33.94],
+ [133.9, 34.36],
+ [134.64, 34.15]
+ ],
+ [
+ [140.98, 37.14],
+ [140.6, 36.34],
+ [140.77, 35.84],
+ [140.25, 35.14],
+ [138.98, 34.67],
+ [137.22, 34.61],
+ [135.79, 33.46],
+ [135.12, 33.85],
+ [135.08, 34.6],
+ [133.34, 34.38],
+ [132.16, 33.9],
+ [130.99, 33.89],
+ [132, 33.15],
+ [131.33, 31.45],
+ [130.69, 31.03],
+ [130.2, 31.42],
+ [130.45, 32.32],
+ [129.81, 32.61],
+ [129.41, 33.3],
+ [130.35, 33.6],
+ [130.88, 34.23],
+ [131.88, 34.75],
+ [132.62, 35.43],
+ [134.61, 35.73],
+ [135.68, 35.53],
+ [136.72, 37.3],
+ [137.39, 36.83],
+ [138.86, 37.83],
+ [139.43, 38.22],
+ [140.05, 39.44],
+ [139.88, 40.56],
+ [140.31, 41.2],
+ [141.37, 41.38],
+ [141.91, 39.99],
+ [141.88, 39.18],
+ [140.96, 38.17],
+ [140.98, 37.14]
+ ],
+ [
+ [143.91, 44.17],
+ [144.61, 43.96],
+ [145.32, 44.38],
+ [145.54, 43.26],
+ [144.06, 42.99],
+ [143.18, 42],
+ [141.61, 42.68],
+ [141.07, 41.58],
+ [139.96, 41.57],
+ [139.82, 42.56],
+ [140.31, 43.33],
+ [141.38, 43.39],
+ [141.67, 44.77],
+ [141.97, 45.55],
+ [143.14, 44.51],
+ [143.91, 44.17]
+ ],
+ [
+ [70.96, 42.27],
+ [70.39, 42.08],
+ [69.07, 41.38],
+ [68.63, 40.67],
+ [68.26, 40.66],
+ [67.99, 41.14],
+ [66.71, 41.17],
+ [66.51, 41.99],
+ [66.02, 41.99],
+ [66.1, 43],
+ [64.9, 43.73],
+ [63.19, 43.65],
+ [62.01, 43.5],
+ [61.06, 44.41],
+ [60.24, 44.78],
+ [58.69, 45.5],
+ [58.5, 45.59],
+ [55.93, 45],
+ [55.97, 41.31]
+ ],
+ [
+ [55.97, 41.31],
+ [55.46, 41.26],
+ [54.76, 42.04],
+ [54.08, 42.32],
+ [52.94, 42.12],
+ [52.5, 41.78]
+ ],
+ [
+ [52.5, 41.78],
+ [52.45, 42.03],
+ [52.69, 42.44],
+ [52.5, 42.79],
+ [51.34, 43.13],
+ [50.89, 44.03],
+ [50.34, 44.28],
+ [50.31, 44.61],
+ [51.28, 44.51],
+ [51.32, 45.25],
+ [52.17, 45.41],
+ [53.04, 45.26],
+ [53.22, 46.23],
+ [53.04, 46.85],
+ [52.04, 46.8],
+ [51.19, 47.05],
+ [50.03, 46.61],
+ [49.1, 46.4]
+ ],
+ [
+ [49.1, 46.4],
+ [48.59, 46.56],
+ [48.69, 47.08]
+ ],
+ [
+ [48.69, 47.08],
+ [48.06, 47.74],
+ [47.32, 47.72],
+ [46.47, 48.39]
+ ],
+ [
+ [46.47, 48.39],
+ [47.04, 49.15],
+ [46.75, 49.36],
+ [47.55, 50.45],
+ [48.58, 49.87],
+ [48.7, 50.61],
+ [50.77, 51.69],
+ [52.33, 51.72],
+ [54.53, 51.03]
+ ],
+ [
+ [54.53, 51.03],
+ [55.72, 50.62],
+ [56.78, 51.04],
+ [58.36, 51.06],
+ [59.64, 50.55]
+ ],
+ [
+ [59.64, 50.55],
+ [59.93, 50.84],
+ [61.34, 50.8],
+ [61.59, 51.27],
+ [59.97, 51.96],
+ [60.93, 52.45],
+ [60.74, 52.72],
+ [61.7, 52.98],
+ [60.98, 53.66]
+ ],
+ [
+ [60.98, 53.66],
+ [61.44, 54.01],
+ [65.18, 54.35]
+ ],
+ [
+ [65.18, 54.35],
+ [65.67, 54.6],
+ [68.17, 54.97]
+ ],
+ [
+ [68.17, 54.97],
+ [69.07, 55.39],
+ [70.87, 55.17],
+ [71.18, 54.13],
+ [72.22, 54.38],
+ [73.51, 54.04],
+ [73.43, 53.49]
+ ],
+ [
+ [73.43, 53.49],
+ [74.38, 53.55],
+ [76.89, 54.49]
+ ],
+ [
+ [76.89, 54.49],
+ [76.53, 54.18],
+ [77.8, 53.4],
+ [80.04, 50.86],
+ [80.57, 51.39],
+ [81.95, 50.81],
+ [83.38, 51.07],
+ [83.94, 50.89],
+ [84.42, 50.31],
+ [85.12, 50.12],
+ [85.54, 49.69],
+ [86.83, 49.83],
+ [87.36, 49.21]
+ ],
+ [
+ [80.26, 42.35],
+ [79.64, 42.5],
+ [79.14, 42.86],
+ [77.66, 42.96],
+ [76, 42.99],
+ [75.64, 42.88],
+ [74.21, 43.3],
+ [73.65, 43.09],
+ [73.49, 42.5],
+ [71.84, 42.85],
+ [71.19, 42.7],
+ [70.96, 42.27]
+ ],
+ [
+ [41.59, -1.68],
+ [40.88, -2.08],
+ [40.64, -2.5],
+ [40.26, -2.57],
+ [40.12, -3.28],
+ [39.8, -3.68],
+ [39.6, -4.35],
+ [39.2, -4.68]
+ ],
+ [
+ [39.2, -4.68],
+ [37.77, -3.68],
+ [37.7, -3.1],
+ [34.07, -1.06],
+ [33.9, -0.95]
+ ],
+ [
+ [33.9, -0.95],
+ [33.89, 0.11],
+ [34.18, 0.52],
+ [34.67, 1.18],
+ [35.04, 1.91],
+ [34.6, 3.05],
+ [34.48, 3.56],
+ [34.01, 4.25]
+ ],
+ [
+ [34.01, 4.25],
+ [34.62, 4.85],
+ [35.3, 5.51]
+ ],
+ [
+ [41.86, 3.92],
+ [40.98, 2.78],
+ [40.99, -0.86],
+ [41.59, -1.68]
+ ],
+ [
+ [73.68, 39.43],
+ [71.78, 39.28],
+ [70.55, 39.6],
+ [69.46, 39.53],
+ [69.56, 40.1],
+ [70.65, 39.94],
+ [71.01, 40.24]
+ ],
+ [
+ [71.01, 40.24],
+ [71.77, 40.15],
+ [73.06, 40.87],
+ [71.87, 41.39],
+ [71.16, 41.14],
+ [70.42, 41.52],
+ [71.26, 42.17],
+ [70.96, 42.27]
+ ],
+ [
+ [102.58, 12.19],
+ [102.35, 13.39],
+ [102.99, 14.23],
+ [104.28, 14.42],
+ [105.22, 14.27]
+ ],
+ [
+ [105.22, 14.27],
+ [106.04, 13.88],
+ [106.5, 14.57],
+ [107.38, 14.2]
+ ],
+ [
+ [107.38, 14.2],
+ [107.61, 13.54],
+ [107.49, 12.34],
+ [105.81, 11.57],
+ [106.25, 10.96],
+ [105.2, 10.89],
+ [104.33, 10.49]
+ ],
+ [
+ [104.33, 10.49],
+ [103.5, 10.63],
+ [103.09, 11.15],
+ [102.58, 12.19]
+ ],
+ [
+ [128.35, 38.61],
+ [129.21, 37.43],
+ [129.46, 36.78],
+ [129.47, 35.63],
+ [129.09, 35.08],
+ [128.19, 34.89],
+ [127.39, 34.48],
+ [126.49, 34.39],
+ [126.37, 34.93],
+ [126.56, 35.68],
+ [126.12, 36.73],
+ [126.86, 36.89],
+ [126.17, 37.75]
+ ],
+ [
+ [126.17, 37.75],
+ [126.24, 37.84],
+ [126.68, 37.8],
+ [127.07, 38.26],
+ [127.78, 38.3],
+ [128.21, 38.37],
+ [128.35, 38.61]
+ ],
+ [
+ [20.59, 41.86],
+ [20.52, 42.22]
+ ],
+ [
+ [20.52, 42.22],
+ [20.28, 42.32],
+ [20.07, 42.59]
+ ],
+ [
+ [20.07, 42.59],
+ [20.26, 42.81]
+ ],
+ [
+ [20.26, 42.81],
+ [20.5, 42.88],
+ [20.64, 43.22],
+ [20.81, 43.27],
+ [20.96, 43.13],
+ [21.14, 43.07],
+ [21.27, 42.91],
+ [21.44, 42.86],
+ [21.63, 42.68],
+ [21.78, 42.68],
+ [21.66, 42.44],
+ [21.54, 42.32],
+ [21.58, 42.25]
+ ],
+ [
+ [21.58, 42.25],
+ [21.35, 42.21],
+ [20.76, 42.05],
+ [20.72, 41.85],
+ [20.59, 41.86]
+ ],
+ [
+ [47.97, 29.98],
+ [48.18, 29.53],
+ [48.09, 29.31],
+ [48.42, 28.55]
+ ],
+ [
+ [48.42, 28.55],
+ [47.71, 28.53],
+ [47.46, 29],
+ [46.57, 29.1]
+ ],
+ [
+ [105.22, 14.27],
+ [105.54, 14.72],
+ [105.59, 15.57],
+ [104.78, 16.44],
+ [104.72, 17.43],
+ [103.96, 18.24],
+ [103.2, 18.31],
+ [103, 17.96],
+ [102.41, 17.93],
+ [102.11, 18.11],
+ [101.06, 17.51],
+ [101.04, 18.41],
+ [101.28, 19.46],
+ [100.61, 19.51],
+ [100.55, 20.11],
+ [100.12, 20.42]
+ ],
+ [
+ [100.12, 20.42],
+ [100.33, 20.79],
+ [101.18, 21.44]
+ ],
+ [
+ [102.17, 22.46],
+ [102.75, 21.68],
+ [103.2, 20.77],
+ [104.44, 20.76],
+ [104.82, 19.89],
+ [104.18, 19.62],
+ [103.9, 19.27],
+ [105.09, 18.67],
+ [105.93, 17.49],
+ [106.56, 16.6],
+ [107.31, 15.91],
+ [107.56, 15.2],
+ [107.38, 14.2]
+ ],
+ [
+ [35.13, 33.09],
+ [35.48, 33.91],
+ [35.98, 34.61],
+ [36, 34.64]
+ ],
+ [
+ [36, 34.64],
+ [36.45, 34.59],
+ [36.61, 34.2],
+ [36.07, 33.82],
+ [35.82, 33.28]
+ ],
+ [
+ [-7.71, 4.36],
+ [-7.97, 4.36],
+ [-9, 4.83],
+ [-9.91, 5.59],
+ [-10.77, 6.14],
+ [-11.44, 6.79]
+ ],
+ [
+ [-11.44, 6.79],
+ [-11.2, 7.11],
+ [-11.15, 7.4],
+ [-10.7, 7.94],
+ [-10.23, 8.41]
+ ],
+ [
+ [14.85, 22.86],
+ [14.14, 22.49],
+ [13.58, 23.04],
+ [12, 23.47]
+ ],
+ [
+ [9.48, 30.31],
+ [9.97, 30.54],
+ [10.06, 30.96],
+ [9.95, 31.38],
+ [10.64, 31.76],
+ [10.94, 32.08],
+ [11.43, 32.37],
+ [11.49, 33.14]
+ ],
+ [
+ [11.49, 33.14],
+ [12.66, 32.79],
+ [13.08, 32.88],
+ [13.92, 32.71],
+ [15.25, 32.27],
+ [15.71, 31.38],
+ [16.61, 31.18],
+ [18.02, 30.76],
+ [19.09, 30.27],
+ [19.57, 30.53],
+ [20.05, 30.99],
+ [19.82, 31.75],
+ [20.13, 32.24],
+ [20.85, 32.71],
+ [21.54, 32.84],
+ [22.9, 32.64],
+ [23.24, 32.19],
+ [23.61, 32.19],
+ [23.93, 32.02],
+ [24.92, 31.9],
+ [25.16, 31.57]
+ ],
+ [
+ [25, 22],
+ [25, 20],
+ [23.85, 20],
+ [23.84, 19.58]
+ ],
+ [
+ [23.84, 19.58],
+ [19.85, 21.5],
+ [15.86, 23.41],
+ [14.85, 22.86]
+ ],
+ [
+ [81.79, 7.52],
+ [81.64, 6.48],
+ [81.22, 6.2],
+ [80.35, 5.97],
+ [79.87, 6.76],
+ [79.7, 8.2],
+ [80.15, 9.82],
+ [80.84, 9.27],
+ [81.3, 8.56],
+ [81.79, 7.52]
+ ],
+ [
+ [28.98, -28.96],
+ [29.33, -29.26],
+ [29.02, -29.74],
+ [28.85, -30.07],
+ [28.29, -30.23],
+ [28.11, -30.55],
+ [27.75, -30.65],
+ [27, -29.88],
+ [27.53, -29.24],
+ [28.07, -28.85],
+ [28.54, -28.65],
+ [28.98, -28.96]
+ ],
+ [
+ [22.73, 54.33],
+ [22.65, 54.58],
+ [22.76, 54.86],
+ [22.32, 55.02],
+ [21.27, 55.19]
+ ],
+ [
+ [21.27, 55.19],
+ [21.06, 56.03]
+ ],
+ [
+ [21.06, 56.03],
+ [22.2, 56.34],
+ [23.88, 56.27],
+ [24.86, 56.37],
+ [25, 56.16],
+ [25.53, 56.1],
+ [26.49, 55.62]
+ ],
+ [
+ [23.48, 53.91],
+ [23.24, 54.22],
+ [22.73, 54.33]
+ ],
+ [
+ [21.06, 56.03],
+ [21.09, 56.78],
+ [21.58, 57.41],
+ [22.52, 57.75],
+ [23.32, 57.01],
+ [24.12, 57.03],
+ [24.31, 57.79]
+ ],
+ [
+ [27.29, 57.47],
+ [27.77, 57.24],
+ [27.86, 56.76],
+ [28.18, 56.17]
+ ],
+ [
+ [-8.67, 27.6],
+ [-13.1, 27.6]
+ ],
+ [
+ [-13.1, 27.6],
+ [-12.36, 28.16],
+ [-10.9, 28.83],
+ [-10.4, 29.1],
+ [-9.56, 29.93],
+ [-9.81, 31.18],
+ [-9.43, 32.04],
+ [-9.3, 32.56],
+ [-8.66, 33.24],
+ [-7.65, 33.7],
+ [-6.91, 34.11],
+ [-6.24, 35.15],
+ [-5.93, 35.76],
+ [-5.19, 35.76],
+ [-4.59, 35.33],
+ [-3.64, 35.4],
+ [-2.6, 35.18],
+ [-2.17, 35.17]
+ ],
+ [
+ [26.62, 48.22],
+ [26.86, 48.37],
+ [27.52, 48.47],
+ [28.26, 48.16],
+ [28.67, 48.12],
+ [29.12, 47.85],
+ [29.05, 47.51],
+ [29.42, 47.35],
+ [29.56, 46.93],
+ [29.91, 46.67],
+ [29.84, 46.53],
+ [30.02, 46.42],
+ [29.76, 46.35],
+ [29.17, 46.38],
+ [29.07, 46.52],
+ [28.86, 46.44],
+ [28.93, 46.26],
+ [28.66, 45.94],
+ [28.49, 45.6],
+ [28.23, 45.49]
+ ],
+ [
+ [28.23, 45.49],
+ [28.05, 45.94],
+ [28.16, 46.37],
+ [28.13, 46.81],
+ [27.55, 47.41],
+ [27.23, 47.83],
+ [26.92, 48.12],
+ [26.62, 48.22]
+ ],
+ [
+ [49.54, -12.47],
+ [49.81, -12.9],
+ [50.06, -13.56],
+ [50.22, -14.76],
+ [50.48, -15.23],
+ [50.38, -15.71],
+ [50.2, -16],
+ [49.86, -15.41],
+ [49.67, -15.71],
+ [49.86, -16.45],
+ [49.77, -16.88],
+ [49.5, -17.11],
+ [49.44, -17.95],
+ [49.04, -19.12],
+ [48.55, -20.5],
+ [47.93, -22.39],
+ [47.55, -23.78],
+ [47.1, -24.94],
+ [46.28, -25.18],
+ [45.41, -25.6],
+ [44.83, -25.35],
+ [44.04, -24.99],
+ [43.76, -24.46],
+ [43.7, -23.57],
+ [43.35, -22.78],
+ [43.25, -22.06],
+ [43.43, -21.34],
+ [43.89, -21.16],
+ [43.9, -20.83],
+ [44.37, -20.07],
+ [44.46, -19.44],
+ [44.23, -18.96],
+ [44.04, -18.33],
+ [43.96, -17.41],
+ [44.31, -16.85],
+ [44.45, -16.22],
+ [44.94, -16.18],
+ [45.5, -15.97],
+ [45.87, -15.79],
+ [46.31, -15.78],
+ [46.88, -15.21],
+ [47.71, -14.59],
+ [48.01, -14.09],
+ [47.87, -13.66],
+ [48.29, -13.78],
+ [48.85, -13.09],
+ [48.86, -12.49],
+ [49.19, -12.04],
+ [49.54, -12.47]
+ ],
+ [
+ [-92.23, 14.54],
+ [-93.36, 15.62],
+ [-93.88, 15.94],
+ [-94.69, 16.2],
+ [-95.25, 16.13],
+ [-96.05, 15.75],
+ [-96.56, 15.65],
+ [-97.26, 15.92],
+ [-98.01, 16.11],
+ [-98.95, 16.57],
+ [-99.7, 16.71],
+ [-100.83, 17.17],
+ [-101.67, 17.65],
+ [-101.92, 17.92],
+ [-102.48, 17.98],
+ [-103.5, 18.29],
+ [-103.92, 18.75],
+ [-104.99, 19.32],
+ [-105.49, 19.95],
+ [-105.73, 20.43],
+ [-105.4, 20.53],
+ [-105.5, 20.82],
+ [-105.27, 21.08],
+ [-105.27, 21.42],
+ [-105.6, 21.87],
+ [-105.69, 22.27],
+ [-106.03, 22.77],
+ [-106.91, 23.77],
+ [-107.92, 24.55],
+ [-108.4, 25.17],
+ [-109.26, 25.58],
+ [-109.44, 25.82],
+ [-109.29, 26.44],
+ [-109.8, 26.68],
+ [-110.39, 27.16],
+ [-110.64, 27.86],
+ [-111.18, 27.94],
+ [-111.76, 28.47],
+ [-112.23, 28.95],
+ [-112.27, 29.27],
+ [-112.81, 30.02],
+ [-113.16, 30.79],
+ [-113.15, 31.17],
+ [-113.87, 31.57],
+ [-114.21, 31.52],
+ [-114.78, 31.8],
+ [-114.94, 31.39],
+ [-114.77, 30.91],
+ [-114.67, 30.16],
+ [-114.33, 29.75],
+ [-113.59, 29.06],
+ [-113.42, 28.83],
+ [-113.27, 28.75],
+ [-113.14, 28.41],
+ [-112.96, 28.43],
+ [-112.76, 27.78],
+ [-112.46, 27.53],
+ [-112.24, 27.17],
+ [-111.62, 26.66],
+ [-111.28, 25.73],
+ [-110.99, 25.29],
+ [-110.71, 24.83],
+ [-110.66, 24.3],
+ [-110.17, 24.27],
+ [-109.77, 23.81],
+ [-109.41, 23.36],
+ [-109.43, 23.19],
+ [-109.85, 22.82],
+ [-110.03, 22.82],
+ [-110.3, 23.43],
+ [-110.95, 24],
+ [-111.67, 24.48],
+ [-112.18, 24.74],
+ [-112.15, 25.47],
+ [-112.3, 26.01],
+ [-112.78, 26.32],
+ [-113.46, 26.77],
+ [-113.6, 26.64],
+ [-113.85, 26.9],
+ [-114.47, 27.14],
+ [-115.06, 27.72],
+ [-114.98, 27.8],
+ [-114.57, 27.74],
+ [-114.2, 28.12],
+ [-114.16, 28.57],
+ [-114.93, 29.28],
+ [-115.52, 29.56],
+ [-115.89, 30.18],
+ [-116.26, 30.84],
+ [-116.72, 31.64],
+ [-117.13, 32.54]
+ ],
+ [
+ [-117.13, 32.54],
+ [-115.99, 32.61],
+ [-114.72, 32.72],
+ [-114.81, 32.53],
+ [-113.3, 32.04],
+ [-111.02, 31.33],
+ [-109.03, 31.34],
+ [-108.24, 31.34],
+ [-108.24, 31.75],
+ [-106.51, 31.75],
+ [-106.14, 31.4],
+ [-105.63, 31.08],
+ [-105.04, 30.64],
+ [-104.71, 30.12],
+ [-104.46, 29.57],
+ [-103.94, 29.27],
+ [-103.11, 28.97],
+ [-102.48, 29.76],
+ [-101.66, 29.78],
+ [-100.96, 29.38],
+ [-100.46, 28.7],
+ [-100.11, 28.11],
+ [-99.52, 27.54],
+ [-99.3, 26.84],
+ [-99.02, 26.37],
+ [-98.24, 26.06],
+ [-97.53, 25.84]
+ ],
+ [
+ [-97.53, 25.84],
+ [-97.14, 25.87],
+ [-97.53, 24.99],
+ [-97.7, 24.27],
+ [-97.78, 22.93],
+ [-97.87, 22.44],
+ [-97.7, 21.9],
+ [-97.39, 21.41],
+ [-97.19, 20.64],
+ [-96.53, 19.89],
+ [-96.29, 19.32],
+ [-95.9, 18.83],
+ [-94.84, 18.56],
+ [-94.43, 18.14],
+ [-93.55, 18.42],
+ [-92.79, 18.52],
+ [-92.04, 18.7],
+ [-91.41, 18.88],
+ [-90.77, 19.28],
+ [-90.53, 19.87],
+ [-90.45, 20.71],
+ [-90.28, 21],
+ [-89.6, 21.26],
+ [-88.54, 21.49],
+ [-87.66, 21.46],
+ [-87.05, 21.54],
+ [-86.81, 21.33],
+ [-86.85, 20.85],
+ [-87.38, 20.26],
+ [-87.62, 19.65],
+ [-87.44, 19.47],
+ [-87.59, 19.04],
+ [-87.84, 18.26],
+ [-88.09, 18.52],
+ [-88.3, 18.5]
+ ],
+ [
+ [21.58, 42.25],
+ [21.92, 42.3],
+ [22.38, 42.32]
+ ],
+ [
+ [21.02, 40.84],
+ [20.61, 41.09],
+ [20.46, 41.52],
+ [20.59, 41.86]
+ ],
+ [
+ [-12.17, 14.62],
+ [-11.83, 14.8],
+ [-11.67, 15.39],
+ [-11.35, 15.41],
+ [-10.65, 15.13],
+ [-10.09, 15.33],
+ [-9.7, 15.26],
+ [-9.55, 15.49],
+ [-5.54, 15.5],
+ [-5.32, 16.2],
+ [-5.49, 16.33],
+ [-5.97, 20.64],
+ [-6.45, 24.96],
+ [-4.92, 24.97]
+ ],
+ [
+ [4.27, 19.16],
+ [4.27, 16.85],
+ [3.72, 16.18],
+ [3.64, 15.57],
+ [2.75, 15.41],
+ [1.39, 15.32],
+ [1.02, 14.97],
+ [0.37, 14.93]
+ ],
+ [
+ [-11.51, 12.44],
+ [-11.47, 12.75],
+ [-11.55, 13.14],
+ [-11.93, 13.42],
+ [-12.12, 13.99],
+ [-12.17, 14.62]
+ ],
+ [
+ [98.55, 9.93],
+ [98.46, 10.68],
+ [98.76, 11.44],
+ [98.43, 12.03],
+ [98.51, 13.12],
+ [98.1, 13.64],
+ [97.78, 14.84],
+ [97.6, 16.1],
+ [97.16, 16.93],
+ [96.51, 16.43],
+ [95.37, 15.71],
+ [94.81, 15.8],
+ [94.19, 16.04],
+ [94.53, 17.28],
+ [94.32, 18.21],
+ [93.54, 19.37],
+ [93.66, 19.73],
+ [93.08, 19.86],
+ [92.37, 20.67]
+ ],
+ [
+ [100.12, 20.42],
+ [99.54, 20.19],
+ [98.96, 19.75],
+ [98.25, 19.71],
+ [97.8, 18.63],
+ [97.38, 18.45],
+ [97.86, 17.57],
+ [98.49, 16.84],
+ [98.9, 16.18],
+ [98.54, 15.31],
+ [98.19, 15.12],
+ [98.43, 14.62],
+ [99.1, 13.83],
+ [99.21, 13.27],
+ [99.2, 12.8],
+ [99.59, 11.89],
+ [99.04, 10.96],
+ [98.55, 9.93]
+ ],
+ [
+ [19.74, 42.69],
+ [19.3, 42.2],
+ [19.37, 41.88],
+ [19.16, 41.96],
+ [18.88, 42.28],
+ [18.45, 42.48],
+ [18.56, 42.65]
+ ],
+ [
+ [19.22, 43.52],
+ [19.48, 43.35],
+ [19.63, 43.21],
+ [19.96, 43.11],
+ [20.34, 42.9],
+ [20.26, 42.81]
+ ],
+ [
+ [87.75, 49.3],
+ [88.81, 49.47],
+ [90.71, 50.33],
+ [92.23, 50.8]
+ ],
+ [
+ [92.23, 50.8],
+ [93.1, 50.5],
+ [94.15, 50.48]
+ ],
+ [
+ [94.15, 50.48],
+ [94.82, 50.01]
+ ],
+ [
+ [94.82, 50.01],
+ [95.81, 49.98],
+ [97.26, 49.73],
+ [98.23, 50.42]
+ ],
+ [
+ [98.23, 50.42],
+ [97.83, 51.01],
+ [98.86, 52.05],
+ [99.98, 51.63],
+ [100.89, 51.52]
+ ],
+ [
+ [100.89, 51.52],
+ [102.07, 51.26],
+ [102.26, 50.51],
+ [103.68, 50.09]
+ ],
+ [
+ [103.68, 50.09],
+ [104.62, 50.28],
+ [105.89, 50.41]
+ ],
+ [
+ [105.89, 50.41],
+ [106.89, 50.27],
+ [107.87, 49.79],
+ [108.48, 49.28],
+ [109.4, 49.29],
+ [110.66, 49.13],
+ [111.58, 49.38],
+ [112.9, 49.54],
+ [114.36, 50.25],
+ [114.96, 50.14],
+ [115.49, 49.81],
+ [116.68, 49.89]
+ ],
+ [
+ [34.56, -11.52],
+ [35.31, -11.44],
+ [36.51, -11.72],
+ [36.78, -11.59]
+ ],
+ [
+ [36.78, -11.59],
+ [37.47, -11.57],
+ [37.83, -11.27],
+ [38.43, -11.29]
+ ],
+ [
+ [38.43, -11.29],
+ [39.52, -10.9],
+ [40.32, -10.32],
+ [40.48, -10.77],
+ [40.44, -11.76],
+ [40.56, -12.64],
+ [40.6, -14.2],
+ [40.78, -14.69],
+ [40.48, -15.41],
+ [40.09, -16.1],
+ [39.45, -16.72],
+ [38.54, -17.1],
+ [37.41, -17.59],
+ [36.28, -18.66],
+ [35.9, -18.84],
+ [35.2, -19.55],
+ [34.79, -19.78],
+ [34.7, -20.5],
+ [35.18, -21.25],
+ [35.37, -21.84],
+ [35.39, -22.14],
+ [35.56, -22.09],
+ [35.53, -23.07],
+ [35.37, -23.54],
+ [35.61, -23.71],
+ [35.46, -24.12],
+ [35.04, -24.48],
+ [34.22, -24.82],
+ [33.01, -25.36],
+ [32.57, -25.73],
+ [32.66, -26.15],
+ [32.92, -26.22],
+ [32.83, -26.74]
+ ],
+ [
+ [32.83, -26.74],
+ [32.07, -26.73]
+ ],
+ [
+ [32.07, -26.73],
+ [31.99, -26.29],
+ [31.84, -25.84]
+ ],
+ [
+ [31.84, -25.84],
+ [31.75, -25.48],
+ [31.93, -24.37],
+ [31.67, -23.66],
+ [31.19, -22.25]
+ ],
+ [
+ [31.19, -22.25],
+ [32.24, -21.12],
+ [32.51, -20.4],
+ [32.66, -20.3],
+ [32.77, -19.72],
+ [32.61, -19.42],
+ [32.65, -18.67],
+ [32.85, -17.98],
+ [32.85, -16.71],
+ [32.33, -16.39],
+ [31.85, -16.32],
+ [31.64, -16.07],
+ [31.17, -15.86],
+ [30.34, -15.88],
+ [30.27, -15.51]
+ ],
+ [
+ [30.27, -15.51],
+ [30.18, -14.8],
+ [33.21, -13.97]
+ ],
+ [
+ [33.21, -13.97],
+ [33.79, -14.45],
+ [34.06, -14.36],
+ [34.46, -14.61],
+ [34.52, -15.01],
+ [34.31, -15.48],
+ [34.38, -16.18],
+ [35.03, -16.8],
+ [35.34, -16.11],
+ [35.77, -15.9],
+ [35.69, -14.61],
+ [35.27, -13.89],
+ [34.91, -13.57],
+ [34.56, -13.58],
+ [34.28, -12.28],
+ [34.56, -11.52]
+ ],
+ [
+ [-12.17, 14.62],
+ [-12.83, 15.3],
+ [-13.44, 16.04],
+ [-14.1, 16.3],
+ [-14.58, 16.6],
+ [-15.14, 16.59],
+ [-15.62, 16.37],
+ [-16.12, 16.46],
+ [-16.46, 16.14]
+ ],
+ [
+ [-16.46, 16.14],
+ [-16.55, 16.67],
+ [-16.27, 17.17],
+ [-16.15, 18.11],
+ [-16.26, 19.1],
+ [-16.38, 19.59],
+ [-16.28, 20.09],
+ [-16.54, 20.57],
+ [-17.06, 21]
+ ],
+ [
+ [-17.06, 21],
+ [-16.85, 21.33],
+ [-12.93, 21.33],
+ [-13.12, 22.77],
+ [-12.87, 23.28],
+ [-11.94, 23.37],
+ [-11.97, 25.93],
+ [-8.69, 25.88],
+ [-8.68, 27.4]
+ ],
+ [
+ [33.21, -13.97],
+ [32.69, -13.71],
+ [32.99, -12.78],
+ [33.31, -12.44],
+ [33.11, -11.61],
+ [33.32, -10.8],
+ [33.49, -10.53],
+ [33.23, -9.68],
+ [32.76, -9.23]
+ ],
+ [
+ [32.76, -9.23],
+ [33.74, -9.42],
+ [33.94, -9.69]
+ ],
+ [
+ [33.94, -9.69],
+ [34.28, -10.16],
+ [34.56, -11.52]
+ ],
+ [
+ [102.14, 6.22],
+ [102.37, 6.13],
+ [102.96, 5.52],
+ [103.38, 4.86],
+ [103.44, 4.18],
+ [103.33, 3.73],
+ [103.43, 3.38],
+ [103.5, 2.79],
+ [103.85, 2.52],
+ [104.25, 1.63],
+ [104.29, 1.37],
+ [104.13, 1.27]
+ ],
+ [
+ [104.13, 1.27],
+ [103.74, 1.13],
+ [103.56, 1.19]
+ ],
+ [
+ [103.56, 1.19],
+ [102.57, 1.97],
+ [101.39, 2.76],
+ [101.27, 3.27],
+ [100.7, 3.94],
+ [100.56, 4.77],
+ [100.2, 5.31],
+ [100.31, 6.04],
+ [100.09, 6.46]
+ ],
+ [
+ [100.09, 6.46],
+ [100.26, 6.64],
+ [101.08, 6.2],
+ [101.15, 5.69],
+ [101.81, 5.81],
+ [102.14, 6.22]
+ ],
+ [
+ [109.66, 2.01],
+ [110.4, 1.66],
+ [111.17, 1.85],
+ [111.37, 2.7],
+ [111.8, 2.89],
+ [113, 3.1],
+ [113.71, 3.89],
+ [114.2, 4.53]
+ ],
+ [
+ [115.45, 5.45],
+ [116.22, 6.14],
+ [116.73, 6.92],
+ [117.13, 6.93],
+ [117.64, 6.42],
+ [117.69, 5.99],
+ [118.35, 5.71],
+ [119.18, 5.41],
+ [119.11, 5.02],
+ [118.44, 4.97],
+ [118.62, 4.48],
+ [117.88, 4.14]
+ ],
+ [
+ [16.34, -28.58],
+ [15.6, -27.82],
+ [15.21, -27.09],
+ [14.99, -26.12],
+ [14.74, -25.39],
+ [14.41, -23.85],
+ [14.39, -22.66],
+ [14.26, -22.11],
+ [13.87, -21.7],
+ [13.35, -20.87],
+ [12.83, -19.67],
+ [12.61, -19.05],
+ [11.79, -18.07],
+ [11.73, -17.3]
+ ],
+ [
+ [23.22, -17.52],
+ [24.03, -17.3],
+ [24.68, -17.35],
+ [25.08, -17.58],
+ [25.08, -17.66]
+ ],
+ [
+ [19.9, -24.77],
+ [19.89, -28.46],
+ [19, -28.97],
+ [18.46, -29.05],
+ [17.84, -28.86],
+ [17.39, -28.78],
+ [17.22, -28.36],
+ [16.82, -28.08],
+ [16.34, -28.58]
+ ],
+ [
+ [165.78, -21.08],
+ [166.6, -21.7],
+ [167.12, -22.16],
+ [166.74, -22.4],
+ [166.19, -22.13],
+ [165.47, -21.68],
+ [164.83, -21.15],
+ [164.17, -20.44],
+ [164.03, -20.11],
+ [164.46, -20.12],
+ [165.02, -20.46],
+ [165.46, -20.8],
+ [165.78, -21.08]
+ ],
+ [
+ [14.85, 22.86],
+ [15.1, 21.31]
+ ],
+ [
+ [15.1, 21.31],
+ [15.47, 21.05],
+ [15.49, 20.73]
+ ],
+ [
+ [15.49, 20.73],
+ [15.9, 20.39],
+ [15.69, 19.96],
+ [15.3, 17.93],
+ [15.25, 16.63]
+ ],
+ [
+ [15.25, 16.63],
+ [13.97, 15.68],
+ [13.54, 14.37]
+ ],
+ [
+ [13.54, 14.37],
+ [13.96, 14],
+ [13.95, 13.35],
+ [14.6, 13.33],
+ [14.5, 12.86]
+ ],
+ [
+ [14.18, 12.48],
+ [14, 12.46],
+ [13.32, 13.56],
+ [13.08, 13.6],
+ [12.3, 13.04],
+ [11.53, 13.33],
+ [10.99, 13.39],
+ [10.7, 13.25],
+ [10.11, 13.28],
+ [9.52, 12.85],
+ [9.01, 12.83],
+ [7.8, 13.34],
+ [7.33, 13.1],
+ [6.82, 13.12],
+ [6.45, 13.49],
+ [5.44, 13.87],
+ [4.37, 13.75],
+ [4.11, 13.53],
+ [3.97, 12.96],
+ [3.68, 12.55],
+ [3.61, 11.66]
+ ],
+ [
+ [8.5, 4.77],
+ [7.46, 4.41],
+ [7.08, 4.46],
+ [6.7, 4.24],
+ [5.9, 4.26],
+ [5.36, 4.89],
+ [5.03, 5.61],
+ [4.33, 6.27],
+ [3.57, 6.26],
+ [2.69, 6.26]
+ ],
+ [
+ [-85.71, 11.09],
+ [-86.06, 11.4],
+ [-86.53, 11.81],
+ [-86.75, 12.14],
+ [-87.17, 12.46],
+ [-87.67, 12.91],
+ [-87.56, 13.06],
+ [-87.39, 12.91],
+ [-87.32, 12.98]
+ ],
+ [
+ [-83.15, 15],
+ [-83.23, 14.9],
+ [-83.28, 14.68],
+ [-83.18, 14.31],
+ [-83.41, 13.97],
+ [-83.52, 13.57],
+ [-83.55, 13.13],
+ [-83.5, 12.87],
+ [-83.47, 12.42],
+ [-83.63, 12.32],
+ [-83.72, 11.89],
+ [-83.65, 11.63],
+ [-83.86, 11.37],
+ [-83.81, 11.1],
+ [-83.66, 10.94]
+ ],
+ [
+ [4.05, 51.27],
+ [3.31, 51.35],
+ [3.83, 51.62],
+ [4.71, 53.09],
+ [6.07, 53.51],
+ [6.91, 53.48]
+ ],
+ [
+ [20.65, 69.11],
+ [20.03, 69.07],
+ [19.88, 68.41],
+ [17.99, 68.57],
+ [17.73, 68.01],
+ [16.77, 68.01],
+ [16.11, 67.3],
+ [15.11, 66.19],
+ [13.56, 64.79],
+ [13.92, 64.45],
+ [13.57, 64.05],
+ [12.58, 64.07],
+ [11.93, 63.13],
+ [11.99, 61.8],
+ [12.63, 61.29],
+ [12.3, 60.12],
+ [11.47, 59.43],
+ [11.03, 58.86]
+ ],
+ [
+ [11.03, 58.86],
+ [10.36, 59.47],
+ [8.38, 58.31],
+ [7.05, 58.08],
+ [5.67, 58.59],
+ [5.31, 59.66],
+ [4.99, 61.97],
+ [5.91, 62.61],
+ [8.55, 63.45],
+ [10.53, 64.49],
+ [12.36, 65.88],
+ [14.76, 67.81],
+ [16.44, 68.56],
+ [19.18, 69.82],
+ [21.38, 70.26],
+ [23.02, 70.2],
+ [24.55, 71.03],
+ [26.37, 70.99],
+ [28.17, 71.19],
+ [31.29, 70.45],
+ [30.01, 70.19],
+ [31.1, 69.56],
+ [29.4, 69.16],
+ [28.59, 69.06]
+ ],
+ [
+ [24.72, 77.85],
+ [22.49, 77.44],
+ [20.73, 77.68],
+ [21.42, 77.94],
+ [20.81, 78.25],
+ [22.88, 78.45],
+ [23.28, 78.08],
+ [24.72, 77.85]
+ ],
+ [
+ [18.25, 79.7],
+ [21.54, 78.96],
+ [19.03, 78.56],
+ [18.47, 77.83],
+ [17.59, 77.64],
+ [17.12, 76.81],
+ [15.91, 76.77],
+ [13.76, 77.38],
+ [14.67, 77.74],
+ [13.17, 78.02],
+ [11.22, 78.87],
+ [10.44, 79.65],
+ [13.17, 80.01],
+ [13.72, 79.66],
+ [15.14, 79.67],
+ [15.52, 80.02],
+ [16.99, 80.05],
+ [18.25, 79.7]
+ ],
+ [
+ [25.45, 80.41],
+ [27.41, 80.06],
+ [25.92, 79.52],
+ [23.02, 79.4],
+ [20.08, 79.57],
+ [19.9, 79.84],
+ [18.46, 79.86],
+ [17.37, 80.32],
+ [20.46, 80.6],
+ [21.91, 80.36],
+ [22.92, 80.66],
+ [25.45, 80.41]
+ ],
+ [
+ [173.02, -40.92],
+ [173.25, -41.33],
+ [173.96, -40.93],
+ [174.25, -41.35],
+ [174.25, -41.77],
+ [173.88, -42.23],
+ [173.22, -42.97],
+ [172.71, -43.37],
+ [173.08, -43.85],
+ [172.31, -43.87],
+ [171.45, -44.24],
+ [171.19, -44.9],
+ [170.62, -45.91],
+ [169.83, -46.36],
+ [169.33, -46.64],
+ [168.41, -46.62],
+ [167.76, -46.29],
+ [166.68, -46.22],
+ [166.51, -45.85],
+ [167.05, -45.11],
+ [168.3, -44.12],
+ [168.95, -43.94],
+ [169.67, -43.56],
+ [170.52, -43.03],
+ [171.13, -42.51],
+ [171.57, -41.77],
+ [171.95, -41.51],
+ [172.1, -40.96],
+ [172.8, -40.49],
+ [173.02, -40.92]
+ ],
+ [
+ [174.61, -36.16],
+ [175.34, -37.21],
+ [175.36, -36.53],
+ [175.81, -36.8],
+ [175.96, -37.56],
+ [176.76, -37.88],
+ [177.44, -37.96],
+ [178.01, -37.58],
+ [178.52, -37.7],
+ [178.27, -38.58],
+ [177.97, -39.17],
+ [177.21, -39.15],
+ [176.94, -39.45],
+ [177.03, -39.88],
+ [176.89, -40.07],
+ [176.51, -40.6],
+ [176.01, -41.29],
+ [175.24, -41.69],
+ [175.07, -41.43],
+ [174.65, -41.28],
+ [175.23, -40.46],
+ [174.9, -39.91],
+ [173.82, -39.51],
+ [173.85, -39.15],
+ [174.57, -38.8],
+ [174.74, -38.03],
+ [174.7, -37.38],
+ [174.29, -36.71],
+ [174.32, -36.53],
+ [173.84, -36.12],
+ [173.05, -35.24],
+ [172.64, -34.53],
+ [173.01, -34.45],
+ [173.55, -35.01],
+ [174.33, -35.27],
+ [174.61, -36.16]
+ ],
+ [
+ [53.11, 16.65],
+ [52.78, 17.35],
+ [52, 19]
+ ],
+ [
+ [52, 19],
+ [55, 20],
+ [55.67, 22],
+ [55.21, 22.71]
+ ],
+ [
+ [56.4, 24.92],
+ [56.85, 24.24],
+ [57.4, 23.88],
+ [58.14, 23.75],
+ [58.73, 23.57],
+ [59.18, 22.99],
+ [59.45, 22.66],
+ [59.81, 22.53],
+ [59.81, 22.31],
+ [59.44, 21.71],
+ [59.28, 21.43],
+ [58.86, 21.11],
+ [58.49, 20.43],
+ [58.03, 20.48],
+ [57.83, 20.24],
+ [57.67, 19.74],
+ [57.79, 19.07],
+ [57.69, 18.94],
+ [57.23, 18.95],
+ [56.61, 18.57],
+ [56.51, 18.09],
+ [56.28, 17.88],
+ [55.66, 17.88],
+ [55.27, 17.63],
+ [55.27, 17.23],
+ [54.79, 16.95],
+ [54.24, 17.04],
+ [53.57, 16.71],
+ [53.11, 16.65]
+ ],
+ [
+ [56.07, 26.06],
+ [56.36, 26.4],
+ [56.49, 26.31],
+ [56.39, 25.9],
+ [56.26, 25.71]
+ ],
+ [
+ [68.18, 23.69],
+ [67.44, 23.94],
+ [67.15, 24.66],
+ [66.37, 25.43],
+ [64.53, 25.24],
+ [62.91, 25.22],
+ [61.5, 25.08]
+ ],
+ [
+ [-77.88, 7.22],
+ [-78.21, 7.51],
+ [-78.43, 8.05],
+ [-78.18, 8.32],
+ [-78.44, 8.39],
+ [-78.62, 8.72],
+ [-79.12, 9],
+ [-79.56, 8.93],
+ [-79.76, 8.58],
+ [-80.16, 8.33],
+ [-80.38, 8.3],
+ [-80.48, 8.09],
+ [-80, 7.55],
+ [-80.28, 7.42],
+ [-80.42, 7.27],
+ [-80.89, 7.22],
+ [-81.06, 7.82],
+ [-81.19, 7.65],
+ [-81.52, 7.71],
+ [-81.72, 8.11],
+ [-82.13, 8.18],
+ [-82.39, 8.29],
+ [-82.82, 8.29],
+ [-82.85, 8.07],
+ [-82.97, 8.23]
+ ],
+ [
+ [-82.55, 9.57],
+ [-82.19, 9.21],
+ [-82.21, 9],
+ [-81.81, 8.95],
+ [-81.71, 9.03],
+ [-81.44, 8.79],
+ [-80.95, 8.86],
+ [-80.52, 9.11],
+ [-79.91, 9.31],
+ [-79.57, 9.61],
+ [-79.02, 9.55],
+ [-79.06, 9.45],
+ [-78.5, 9.42],
+ [-78.06, 9.25],
+ [-77.73, 8.95],
+ [-77.35, 8.67]
+ ],
+ [
+ [-70.37, -18.35],
+ [-71.38, -17.77],
+ [-71.46, -17.36],
+ [-73.44, -16.36],
+ [-75.24, -15.27],
+ [-76.01, -14.65],
+ [-76.42, -13.82],
+ [-76.26, -13.54],
+ [-77.11, -12.22],
+ [-78.09, -10.38],
+ [-79.04, -8.39],
+ [-79.45, -7.93],
+ [-79.76, -7.19],
+ [-80.54, -6.54],
+ [-81.25, -6.14],
+ [-80.93, -5.69],
+ [-81.41, -4.74],
+ [-81.1, -4.04],
+ [-80.3, -3.4]
+ ],
+ [
+ [126.38, 8.41],
+ [126.48, 7.75],
+ [126.54, 7.19],
+ [126.2, 6.27],
+ [125.83, 7.29],
+ [125.36, 6.79],
+ [125.68, 6.05],
+ [125.4, 5.58],
+ [124.22, 6.16],
+ [123.94, 6.89],
+ [124.24, 7.36],
+ [123.61, 7.83],
+ [123.3, 7.42],
+ [122.83, 7.46],
+ [122.09, 6.9],
+ [121.92, 7.19],
+ [122.31, 8.03],
+ [122.94, 8.32],
+ [123.49, 8.69],
+ [123.84, 8.24],
+ [124.6, 8.51],
+ [124.76, 8.96],
+ [125.47, 8.99],
+ [125.41, 9.76],
+ [126.22, 9.29],
+ [126.31, 8.78],
+ [126.38, 8.41]
+ ],
+ [
+ [123.98, 10.28],
+ [123.62, 9.95],
+ [123.31, 9.32],
+ [123, 9.02],
+ [122.38, 9.71],
+ [122.59, 9.98],
+ [122.84, 10.26],
+ [122.95, 10.88],
+ [123.5, 10.94],
+ [123.34, 10.27],
+ [124.08, 11.23],
+ [123.98, 10.28]
+ ],
+ [
+ [118.5, 9.32],
+ [117.17, 8.37],
+ [117.66, 9.07],
+ [118.39, 9.68],
+ [118.99, 10.38],
+ [119.51, 11.37],
+ [119.69, 10.55],
+ [119.03, 10],
+ [118.5, 9.32]
+ ],
+ [
+ [121.88, 11.89],
+ [122.48, 11.58],
+ [123.12, 11.58],
+ [123.1, 11.17],
+ [122.64, 10.74],
+ [122, 10.44],
+ [121.97, 10.91],
+ [122.04, 11.42],
+ [121.88, 11.89]
+ ],
+ [
+ [125.5, 12.16],
+ [125.78, 11.05],
+ [125.01, 11.31],
+ [125.03, 10.98],
+ [125.28, 10.36],
+ [124.8, 10.13],
+ [124.76, 10.84],
+ [124.46, 10.89],
+ [124.3, 11.5],
+ [124.89, 11.42],
+ [124.88, 11.79],
+ [124.27, 12.56],
+ [125.23, 12.54],
+ [125.5, 12.16]
+ ],
+ [
+ [121.53, 13.07],
+ [121.26, 12.21],
+ [120.83, 12.7],
+ [120.32, 13.47],
+ [121.18, 13.43],
+ [121.53, 13.07]
+ ],
+ [
+ [121.32, 18.5],
+ [121.94, 18.22],
+ [122.25, 18.48],
+ [122.34, 18.22],
+ [122.17, 17.81],
+ [122.52, 17.09],
+ [122.25, 16.26],
+ [121.66, 15.93],
+ [121.51, 15.12],
+ [121.73, 14.33],
+ [122.26, 14.22],
+ [122.7, 14.34],
+ [123.95, 13.78],
+ [123.86, 13.24],
+ [124.18, 13],
+ [124.08, 12.54],
+ [123.3, 13.03],
+ [122.93, 13.55],
+ [122.67, 13.19],
+ [122.03, 13.78],
+ [121.13, 13.64],
+ [120.63, 13.86],
+ [120.68, 14.27],
+ [120.99, 14.53],
+ [120.69, 14.76],
+ [120.56, 14.4],
+ [120.07, 14.97],
+ [119.92, 15.41],
+ [119.88, 16.36],
+ [120.29, 16.03],
+ [120.39, 17.6],
+ [120.72, 18.51],
+ [121.32, 18.5]
+ ],
+ [
+ [155.88, -6.82],
+ [155.6, -6.92],
+ [155.17, -6.54],
+ [154.73, -5.9],
+ [154.51, -5.14],
+ [154.65, -5.04],
+ [154.76, -5.34],
+ [155.06, -5.57],
+ [155.55, -6.2],
+ [156.02, -6.54],
+ [155.88, -6.82]
+ ],
+ [
+ [151.98, -5.48],
+ [151.46, -5.56],
+ [151.3, -5.84],
+ [150.75, -6.08],
+ [150.24, -6.32],
+ [149.71, -6.32],
+ [148.89, -6.03],
+ [148.32, -5.75],
+ [148.4, -5.44],
+ [149.3, -5.58],
+ [149.85, -5.51],
+ [150, -5.03],
+ [150.14, -5],
+ [150.24, -5.53],
+ [150.81, -5.46],
+ [151.09, -5.11],
+ [151.65, -4.76],
+ [151.54, -4.17],
+ [152.14, -4.15],
+ [152.34, -4.31],
+ [152.32, -4.87],
+ [151.98, -5.48]
+ ],
+ [
+ [141, -2.6],
+ [142.74, -3.29],
+ [144.58, -3.86],
+ [145.27, -4.37],
+ [145.83, -4.88],
+ [145.98, -5.47],
+ [147.65, -6.08],
+ [147.89, -6.61],
+ [146.97, -6.72],
+ [147.19, -7.39],
+ [148.08, -8.04],
+ [148.73, -9.1],
+ [149.31, -9.07],
+ [149.27, -9.51],
+ [150.04, -9.68],
+ [149.74, -9.87],
+ [150.8, -10.29],
+ [150.69, -10.58],
+ [150.03, -10.65],
+ [149.78, -10.39],
+ [148.92, -10.28],
+ [147.91, -10.13],
+ [147.14, -9.49],
+ [146.57, -8.94],
+ [146.05, -8.07],
+ [144.74, -7.63],
+ [143.9, -7.92],
+ [143.29, -8.25],
+ [143.41, -8.98],
+ [142.63, -9.33],
+ [142.07, -9.16],
+ [141.03, -9.12]
+ ],
+ [
+ [153.14, -4.5],
+ [152.83, -4.77],
+ [152.64, -4.18],
+ [152.41, -3.79],
+ [151.95, -3.46],
+ [151.38, -3.04],
+ [150.66, -2.74],
+ [150.94, -2.5],
+ [151.48, -2.78],
+ [151.82, -3],
+ [152.24, -3.24],
+ [152.64, -3.66],
+ [153.02, -3.98],
+ [153.14, -4.5]
+ ],
+ [
+ [14.12, 53.76],
+ [14.8, 54.05],
+ [16.36, 54.51],
+ [17.62, 54.85],
+ [18.62, 54.68],
+ [18.7, 54.44],
+ [19.66, 54.43]
+ ],
+ [
+ [19.66, 54.43],
+ [20.89, 54.31],
+ [22.73, 54.33]
+ ],
+ [
+ [23.53, 51.58],
+ [24.03, 50.71],
+ [23.92, 50.42],
+ [23.43, 50.31],
+ [22.52, 49.48],
+ [22.78, 49.03],
+ [22.56, 49.09]
+ ],
+ [
+ [22.56, 49.09],
+ [21.61, 49.47],
+ [20.89, 49.33],
+ [20.42, 49.43],
+ [19.83, 49.22],
+ [19.32, 49.57],
+ [18.91, 49.44],
+ [18.85, 49.5]
+ ],
+ [
+ [-66.28, 18.51],
+ [-65.77, 18.43],
+ [-65.59, 18.23],
+ [-65.85, 17.98],
+ [-66.6, 17.98],
+ [-67.18, 17.95],
+ [-67.24, 18.37],
+ [-67.1, 18.52],
+ [-66.28, 18.51]
+ ],
+ [
+ [130.64, 42.4],
+ [130.78, 42.22],
+ [130.4, 42.28],
+ [129.97, 41.94],
+ [129.67, 41.6],
+ [129.71, 40.88],
+ [129.19, 40.66],
+ [129.01, 40.49],
+ [128.63, 40.19],
+ [127.97, 40.03],
+ [127.53, 39.76],
+ [127.5, 39.32],
+ [127.39, 39.21],
+ [127.78, 39.05],
+ [128.35, 38.61]
+ ],
+ [
+ [126.17, 37.75],
+ [125.69, 37.94],
+ [125.57, 37.75],
+ [125.28, 37.67],
+ [125.24, 37.86],
+ [124.98, 37.95],
+ [124.71, 38.11],
+ [124.99, 38.55],
+ [125.22, 38.67],
+ [125.13, 38.85],
+ [125.39, 39.39],
+ [125.32, 39.55],
+ [124.74, 39.66],
+ [124.27, 39.93]
+ ],
+ [
+ [-7.45, 37.1],
+ [-7.86, 36.84],
+ [-8.38, 36.98],
+ [-8.9, 36.87],
+ [-8.75, 37.65],
+ [-8.84, 38.27],
+ [-9.29, 38.36],
+ [-9.53, 38.74],
+ [-9.45, 39.39],
+ [-9.05, 39.76],
+ [-8.98, 40.16],
+ [-8.77, 40.76],
+ [-8.79, 41.18],
+ [-8.99, 41.54],
+ [-9.03, 41.88]
+ ],
+ [
+ [50.81, 24.75],
+ [50.74, 25.48],
+ [51.01, 26.01],
+ [51.29, 26.11],
+ [51.59, 25.8],
+ [51.61, 25.22],
+ [51.39, 24.63]
+ ],
+ [
+ [51.39, 24.63],
+ [51.11, 24.56],
+ [50.81, 24.75]
+ ],
+ [
+ [22.71, 47.88],
+ [23.14, 48.1],
+ [23.76, 47.99],
+ [24.4, 47.98],
+ [24.87, 47.74],
+ [25.21, 47.89],
+ [25.95, 47.99],
+ [26.2, 48.22],
+ [26.62, 48.22]
+ ],
+ [
+ [28.23, 45.49],
+ [28.68, 45.3],
+ [29.15, 45.46],
+ [29.6, 45.29]
+ ],
+ [
+ [29.6, 45.29],
+ [29.63, 45.04],
+ [29.14, 44.82],
+ [28.84, 44.91],
+ [28.56, 43.71]
+ ],
+ [
+ [22.66, 44.23],
+ [22.47, 44.41],
+ [22.71, 44.58],
+ [22.46, 44.7],
+ [22.15, 44.48],
+ [21.56, 44.77],
+ [21.48, 45.18],
+ [20.87, 45.42],
+ [20.76, 45.73],
+ [20.22, 46.13]
+ ],
+ [
+ [143.65, 50.75],
+ [144.65, 48.98],
+ [143.17, 49.31],
+ [142.56, 47.86],
+ [143.53, 46.84],
+ [143.51, 46.14],
+ [142.75, 46.74],
+ [142.09, 45.97],
+ [141.91, 46.81],
+ [142.02, 47.78],
+ [141.9, 48.86],
+ [142.14, 49.62],
+ [142.18, 50.95],
+ [141.59, 51.94],
+ [141.68, 53.3],
+ [142.61, 53.76],
+ [142.21, 54.23],
+ [142.65, 54.37],
+ [142.91, 53.7],
+ [143.26, 52.74],
+ [143.24, 51.76],
+ [143.65, 50.75]
+ ],
+ [
+ [19.66, 54.43],
+ [19.89, 54.87],
+ [21.27, 55.19]
+ ],
+ [
+ [-175.01, 66.58],
+ [-174.34, 66.34],
+ [-174.57, 67.06],
+ [-171.86, 66.91],
+ [-169.9, 65.98],
+ [-170.89, 65.54],
+ [-172.53, 65.44],
+ [-172.55, 64.46],
+ [-172.96, 64.25],
+ [-173.89, 64.28],
+ [-174.65, 64.63],
+ [-175.98, 64.92],
+ [-176.21, 65.36],
+ [-177.22, 65.52],
+ [-178.36, 65.39],
+ [-178.9, 65.74],
+ [-178.69, 66.11],
+ [-179.88, 65.87],
+ [-179.43, 65.4],
+ [-180, 64.98],
+ [-180, 68.96],
+ [-177.55, 68.2],
+ [-174.93, 67.21],
+ [-175.01, 66.58]
+ ],
+ [
+ [180, 70.83],
+ [178.9, 70.78],
+ [178.73, 71.1],
+ [180, 71.52],
+ [180, 70.83]
+ ],
+ [
+ [-178.69, 70.89],
+ [-180, 70.83],
+ [-180, 71.52],
+ [-179.87, 71.56],
+ [-179.02, 71.56],
+ [-177.58, 71.27],
+ [-177.66, 71.13],
+ [-178.69, 70.89]
+ ],
+ [
+ [143.6, 73.21],
+ [142.09, 73.21],
+ [140.04, 73.32],
+ [139.86, 73.37],
+ [140.81, 73.77],
+ [142.06, 73.86],
+ [143.48, 73.48],
+ [143.6, 73.21]
+ ],
+ [
+ [150.73, 75.08],
+ [149.58, 74.69],
+ [147.98, 74.78],
+ [146.12, 75.17],
+ [146.36, 75.5],
+ [148.22, 75.35],
+ [150.73, 75.08]
+ ],
+ [
+ [145.09, 75.56],
+ [144.3, 74.82],
+ [140.61, 74.85],
+ [138.96, 74.61],
+ [136.97, 75.26],
+ [137.51, 75.95],
+ [138.83, 76.14],
+ [141.47, 76.09],
+ [145.09, 75.56]
+ ],
+ [
+ [57.54, 70.72],
+ [56.94, 70.63],
+ [53.68, 70.76],
+ [53.41, 71.21],
+ [51.6, 71.47],
+ [51.46, 72.01],
+ [52.48, 72.23],
+ [52.44, 72.77],
+ [54.43, 73.63],
+ [53.51, 73.75],
+ [55.9, 74.63],
+ [55.63, 75.08],
+ [57.87, 75.61],
+ [61.17, 76.25],
+ [64.5, 76.44],
+ [66.21, 76.81],
+ [68.16, 76.94],
+ [68.85, 76.54],
+ [68.18, 76.23],
+ [64.64, 75.74],
+ [61.58, 75.26],
+ [58.48, 74.31],
+ [56.99, 73.33],
+ [55.42, 72.37],
+ [55.62, 71.54],
+ [57.54, 70.72]
+ ],
+ [
+ [131.29, 44.11],
+ [131.03, 44.97],
+ [131.88, 45.32]
+ ],
+ [
+ [131.88, 45.32],
+ [133.1, 45.14],
+ [133.77, 46.12]
+ ],
+ [
+ [133.77, 46.12],
+ [134.11, 47.21],
+ [134.5, 47.58],
+ [135.03, 48.48]
+ ],
+ [
+ [133.37, 48.18],
+ [132.51, 47.79],
+ [130.99, 47.79],
+ [130.58, 48.73]
+ ],
+ [
+ [129.4, 49.44],
+ [127.66, 49.76],
+ [127.29, 50.74]
+ ],
+ [
+ [125.07, 53.16],
+ [123.57, 53.46],
+ [122.25, 53.43]
+ ],
+ [
+ [120.73, 52.52],
+ [120.74, 51.96],
+ [120.18, 51.64],
+ [119.28, 50.58],
+ [119.29, 50.14]
+ ],
+ [
+ [105.89, 50.41],
+ [104.62, 50.28],
+ [103.68, 50.09]
+ ],
+ [
+ [103.68, 50.09],
+ [102.26, 50.51],
+ [102.07, 51.26],
+ [100.89, 51.52]
+ ],
+ [
+ [98.23, 50.42],
+ [97.26, 49.73],
+ [95.81, 49.98],
+ [94.82, 50.01]
+ ],
+ [
+ [94.15, 50.48],
+ [93.1, 50.5],
+ [92.23, 50.8]
+ ],
+ [
+ [76.89, 54.49],
+ [74.38, 53.55],
+ [73.43, 53.49]
+ ],
+ [
+ [68.17, 54.97],
+ [65.67, 54.6],
+ [65.18, 54.35]
+ ],
+ [
+ [65.18, 54.35],
+ [61.44, 54.01],
+ [60.98, 53.66]
+ ],
+ [
+ [59.64, 50.55],
+ [58.36, 51.06],
+ [56.78, 51.04],
+ [55.72, 50.62],
+ [54.53, 51.03]
+ ],
+ [
+ [46.47, 48.39],
+ [47.32, 47.72],
+ [48.06, 47.74],
+ [48.69, 47.08]
+ ],
+ [
+ [48.69, 47.08],
+ [48.59, 46.56],
+ [49.1, 46.4]
+ ],
+ [
+ [49.1, 46.4],
+ [48.65, 45.81],
+ [47.68, 45.64],
+ [46.68, 44.61],
+ [47.59, 43.66],
+ [47.49, 42.99],
+ [48.58, 41.81],
+ [47.99, 41.41]
+ ],
+ [
+ [46.4, 41.86],
+ [45.78, 42.09],
+ [45.47, 42.5]
+ ],
+ [
+ [44.54, 42.71],
+ [43.93, 42.55],
+ [43.76, 42.74],
+ [42.39, 43.22],
+ [40.92, 43.38],
+ [40.08, 43.55]
+ ],
+ [
+ [39.96, 43.43],
+ [38.68, 44.28],
+ [37.54, 44.66],
+ [36.68, 45.24],
+ [37.4, 45.4],
+ [38.23, 46.24],
+ [37.67, 46.64],
+ [39.15, 47.04],
+ [39.12, 47.26],
+ [38.22, 47.1]
+ ],
+ [
+ [38.22, 47.1],
+ [38.26, 47.55]
+ ],
+ [
+ [38.26, 47.55],
+ [38.77, 47.83],
+ [39.74, 47.9]
+ ],
+ [
+ [39.74, 47.9],
+ [39.9, 48.23],
+ [39.67, 48.78],
+ [40.08, 49.31]
+ ],
+ [
+ [40.08, 49.31],
+ [40.07, 49.6],
+ [38.59, 49.93]
+ ],
+ [
+ [38.59, 49.93],
+ [38.01, 49.92],
+ [37.39, 50.38],
+ [36.63, 50.23],
+ [35.36, 50.58]
+ ],
+ [
+ [35.36, 50.58],
+ [35.38, 50.77],
+ [35.02, 51.21]
+ ],
+ [
+ [35.02, 51.21],
+ [34.22, 51.26],
+ [34.14, 51.57],
+ [34.39, 51.77],
+ [33.75, 52.34],
+ [32.72, 52.24],
+ [32.41, 52.29]
+ ],
+ [
+ [32.41, 52.29],
+ [32.16, 52.06],
+ [31.79, 52.1],
+ [31.54, 52.74]
+ ],
+ [
+ [31.31, 53.07],
+ [31.5, 53.17],
+ [32.3, 53.13]
+ ],
+ [
+ [27.72, 57.79],
+ [27.42, 58.72],
+ [28.13, 59.3]
+ ],
+ [
+ [28.13, 59.3],
+ [27.98, 59.48],
+ [29.12, 60.03],
+ [28.07, 60.5],
+ [30.21, 61.78]
+ ],
+ [
+ [28.59, 69.06],
+ [29.4, 69.16],
+ [31.1, 69.56],
+ [32.13, 69.91],
+ [33.78, 69.3],
+ [36.51, 69.06],
+ [40.29, 67.93],
+ [41.06, 67.46],
+ [41.13, 66.79],
+ [40.02, 66.27],
+ [38.38, 66],
+ [33.92, 66.76],
+ [33.18, 66.63],
+ [34.81, 65.9],
+ [34.88, 65.44],
+ [34.94, 64.41],
+ [36.23, 64.11],
+ [37.01, 63.85],
+ [37.14, 64.33],
+ [36.54, 64.76],
+ [37.18, 65.14],
+ [39.59, 64.52],
+ [40.44, 64.76],
+ [39.76, 65.5],
+ [42.09, 66.48],
+ [43.02, 66.42],
+ [43.95, 66.07],
+ [44.53, 66.76],
+ [43.7, 67.35],
+ [44.19, 67.95],
+ [43.45, 68.57],
+ [46.25, 68.25],
+ [46.82, 67.69],
+ [45.56, 67.57],
+ [45.56, 67.01],
+ [46.35, 66.67],
+ [47.89, 66.88],
+ [48.14, 67.52],
+ [50.23, 68],
+ [53.72, 68.86],
+ [54.47, 68.81],
+ [53.49, 68.2],
+ [54.73, 68.1],
+ [55.44, 68.44],
+ [57.32, 68.47],
+ [58.8, 68.88],
+ [59.94, 68.28],
+ [61.08, 68.94],
+ [60.03, 69.52],
+ [60.55, 69.85],
+ [63.5, 69.55],
+ [64.89, 69.23],
+ [68.51, 68.09],
+ [69.18, 68.62],
+ [68.16, 69.14],
+ [68.14, 69.36],
+ [66.93, 69.45],
+ [67.26, 69.93],
+ [66.72, 70.71],
+ [66.69, 71.03],
+ [68.54, 71.93],
+ [69.2, 72.84],
+ [69.94, 73.04],
+ [72.59, 72.78],
+ [72.8, 72.22],
+ [71.85, 71.41],
+ [72.47, 71.09],
+ [72.79, 70.39],
+ [72.56, 69.02],
+ [73.67, 68.41],
+ [73.24, 67.74],
+ [71.28, 66.32],
+ [72.42, 66.17],
+ [72.82, 66.53],
+ [73.92, 66.79],
+ [74.19, 67.28],
+ [75.05, 67.76],
+ [74.47, 68.33],
+ [74.94, 68.99],
+ [73.84, 69.07],
+ [73.6, 69.63],
+ [74.4, 70.63],
+ [73.1, 71.45],
+ [74.89, 72.12],
+ [74.66, 72.83],
+ [75.16, 72.85],
+ [75.68, 72.3],
+ [75.29, 71.34],
+ [76.36, 71.15],
+ [75.9, 71.87],
+ [77.58, 72.27],
+ [79.65, 72.32],
+ [81.5, 71.75],
+ [80.61, 72.58],
+ [80.51, 73.65],
+ [82.25, 73.85],
+ [84.66, 73.81],
+ [86.82, 73.94],
+ [86.01, 74.46],
+ [87.17, 75.12],
+ [88.32, 75.14],
+ [90.26, 75.64],
+ [92.9, 75.77],
+ [93.23, 76.05],
+ [95.86, 76.14],
+ [96.68, 75.92],
+ [98.92, 76.45],
+ [100.76, 76.43],
+ [101.04, 76.86],
+ [101.99, 77.29],
+ [104.35, 77.7],
+ [106.07, 77.37],
+ [104.71, 77.13],
+ [106.97, 76.97],
+ [107.24, 76.48],
+ [108.15, 76.72],
+ [111.08, 76.71],
+ [113.33, 76.22],
+ [114.13, 75.85],
+ [113.89, 75.33],
+ [112.78, 75.03],
+ [110.15, 74.48],
+ [109.4, 74.18],
+ [110.64, 74.04],
+ [112.12, 73.79],
+ [113.02, 73.98],
+ [113.53, 73.34],
+ [113.97, 73.59],
+ [115.57, 73.75],
+ [118.78, 73.59],
+ [119.02, 73.12],
+ [123.2, 72.97],
+ [123.26, 73.74],
+ [125.38, 73.56],
+ [126.98, 73.57],
+ [128.59, 73.04],
+ [129.05, 72.4],
+ [128.46, 71.98],
+ [129.72, 71.19],
+ [131.29, 70.79],
+ [132.25, 71.84],
+ [133.86, 71.39],
+ [135.56, 71.66],
+ [137.5, 71.35],
+ [138.23, 71.63],
+ [139.87, 71.49],
+ [139.15, 72.42],
+ [140.47, 72.85],
+ [149.5, 72.2],
+ [150.35, 71.61],
+ [152.97, 70.84],
+ [157.01, 71.03],
+ [159, 70.87],
+ [159.83, 70.45],
+ [159.71, 69.72],
+ [160.94, 69.44],
+ [162.28, 69.64],
+ [164.05, 69.67],
+ [165.94, 69.47],
+ [167.84, 69.58],
+ [169.58, 68.69],
+ [170.82, 69.01],
+ [170.01, 69.65],
+ [170.45, 70.1],
+ [173.64, 69.82],
+ [175.72, 69.88],
+ [178.6, 69.4],
+ [180, 68.96],
+ [180, 64.98],
+ [179.99, 64.97],
+ [178.71, 64.53],
+ [177.41, 64.61],
+ [178.31, 64.08],
+ [178.91, 63.25],
+ [179.37, 62.98],
+ [179.49, 62.57],
+ [179.23, 62.3],
+ [177.36, 62.52],
+ [174.57, 61.77],
+ [173.68, 61.65],
+ [172.15, 60.95],
+ [170.7, 60.34],
+ [170.33, 59.88],
+ [168.9, 60.57],
+ [166.29, 59.79],
+ [165.84, 60.16],
+ [164.88, 59.73],
+ [163.54, 59.87],
+ [163.22, 59.21],
+ [162.02, 58.24],
+ [162.05, 57.84],
+ [163.19, 57.62],
+ [163.06, 56.16],
+ [162.13, 56.12],
+ [161.7, 55.29],
+ [162.12, 54.86],
+ [160.37, 54.34],
+ [160.02, 53.2],
+ [158.53, 52.96],
+ [158.23, 51.94],
+ [156.79, 51.01],
+ [156.42, 51.7],
+ [155.99, 53.16],
+ [155.43, 55.38],
+ [155.91, 56.77],
+ [156.76, 57.36],
+ [156.81, 57.83],
+ [158.36, 58.06],
+ [160.15, 59.31],
+ [161.87, 60.34],
+ [163.67, 61.14],
+ [164.47, 62.55],
+ [163.26, 62.47],
+ [162.66, 61.64],
+ [160.12, 60.54],
+ [159.3, 61.77],
+ [156.72, 61.43],
+ [154.22, 59.76],
+ [155.04, 59.14],
+ [152.81, 58.88],
+ [151.27, 58.78],
+ [151.34, 59.5],
+ [149.78, 59.66],
+ [148.54, 59.16],
+ [145.49, 59.34],
+ [142.2, 59.04],
+ [138.96, 57.09],
+ [135.13, 54.73],
+ [136.7, 54.6],
+ [137.19, 53.98],
+ [138.16, 53.76],
+ [138.8, 54.25],
+ [139.9, 54.19],
+ [141.35, 53.09],
+ [141.38, 52.24],
+ [140.6, 51.24],
+ [140.51, 50.05],
+ [140.06, 48.45],
+ [138.55, 47],
+ [138.22, 46.31],
+ [136.86, 45.14],
+ [135.52, 43.99],
+ [134.87, 43.4],
+ [133.54, 42.81],
+ [132.91, 42.8],
+ [132.28, 43.28],
+ [130.94, 42.55],
+ [130.78, 42.22],
+ [130.64, 42.4],
+ [130.63, 42.9]
+ ],
+ [
+ [105.08, 78.31],
+ [99.44, 77.92],
+ [101.26, 79.23],
+ [102.09, 79.35],
+ [102.84, 79.28],
+ [105.37, 78.71],
+ [105.08, 78.31]
+ ],
+ [
+ [51.14, 80.55],
+ [49.79, 80.42],
+ [48.89, 80.34],
+ [48.75, 80.18],
+ [47.59, 80.01],
+ [46.5, 80.25],
+ [47.07, 80.56],
+ [44.85, 80.59],
+ [46.8, 80.77],
+ [48.32, 80.78],
+ [48.52, 80.51],
+ [49.1, 80.75],
+ [50.04, 80.92],
+ [51.52, 80.7],
+ [51.14, 80.55]
+ ],
+ [
+ [99.94, 78.88],
+ [97.76, 78.76],
+ [94.97, 79.04],
+ [93.31, 79.43],
+ [92.55, 80.14],
+ [91.18, 80.34],
+ [93.78, 81.02],
+ [95.94, 81.25],
+ [97.88, 80.75],
+ [100.19, 79.78],
+ [99.94, 78.88]
+ ],
+ [
+ [30.42, -1.13],
+ [30.82, -1.7],
+ [30.76, -2.29]
+ ],
+ [
+ [30.76, -2.29],
+ [30.47, -2.41]
+ ],
+ [
+ [29.58, -1.34],
+ [29.82, -1.44],
+ [30.42, -1.13]
+ ],
+ [
+ [-17.06, 21],
+ [-17.02, 21.41],
+ [-16.98, 21.89],
+ [-16.58, 22.16],
+ [-16.26, 22.68],
+ [-16.33, 23.02],
+ [-15.99, 23.73],
+ [-15.43, 24.36],
+ [-15.09, 24.52],
+ [-14.83, 25.1],
+ [-14.8, 25.64],
+ [-14.44, 26.25],
+ [-13.78, 26.62],
+ [-13.41, 27.2],
+ [-13.26, 27.44],
+ [-13.1, 27.6]
+ ],
+ [
+ [42.78, 16.35],
+ [42.65, 16.77],
+ [42.35, 17.08],
+ [42.27, 17.47],
+ [41.75, 17.83],
+ [41.22, 18.67],
+ [40.94, 19.49],
+ [40.25, 20.17],
+ [39.8, 20.34],
+ [39.14, 21.29],
+ [39.02, 21.99],
+ [39.07, 22.58],
+ [38.49, 23.69],
+ [38.02, 24.08],
+ [37.48, 24.29],
+ [37.15, 24.86],
+ [37.21, 25.08],
+ [36.93, 25.6],
+ [36.64, 25.83],
+ [36.25, 26.57],
+ [35.64, 27.38],
+ [35.13, 28.06],
+ [34.63, 28.06],
+ [34.79, 28.61],
+ [34.83, 28.96],
+ [34.96, 29.36]
+ ],
+ [
+ [48.42, 28.55],
+ [48.81, 27.69],
+ [49.3, 27.46],
+ [49.47, 27.11],
+ [50.15, 26.69],
+ [50.21, 26.28],
+ [50.11, 25.94],
+ [50.24, 25.61],
+ [50.53, 25.33],
+ [50.66, 25],
+ [50.81, 24.75]
+ ],
+ [
+ [51.39, 24.63],
+ [51.58, 24.25]
+ ],
+ [
+ [52, 19],
+ [49.12, 18.62],
+ [48.18, 18.17],
+ [47.47, 17.12],
+ [47, 16.95],
+ [46.75, 17.28],
+ [46.37, 17.23],
+ [45.4, 17.33],
+ [45.22, 17.43],
+ [44.06, 17.41],
+ [43.79, 17.32],
+ [43.38, 17.58],
+ [43.12, 17.09],
+ [43.22, 16.67],
+ [42.78, 16.35]
+ ],
+ [
+ [33.96, 9.46],
+ [33.82, 9.48],
+ [33.84, 9.98],
+ [33.72, 10.33],
+ [33.21, 10.72],
+ [33.09, 11.44],
+ [33.21, 12.18],
+ [32.74, 12.25],
+ [32.67, 12.02],
+ [32.07, 11.97],
+ [32.31, 11.68],
+ [32.4, 11.08],
+ [31.85, 10.53],
+ [31.35, 9.81],
+ [30.84, 9.71],
+ [30, 10.29],
+ [29.62, 10.08],
+ [29.52, 9.79],
+ [29, 9.6],
+ [28.97, 9.4],
+ [27.97, 9.4],
+ [27.83, 9.6],
+ [27.11, 9.64],
+ [26.75, 9.47],
+ [26.48, 9.55],
+ [25.96, 10.14],
+ [25.79, 10.41],
+ [25.07, 10.27],
+ [24.79, 9.81],
+ [24.54, 8.92],
+ [24.19, 8.73],
+ [23.89, 8.62]
+ ],
+ [
+ [23.89, 8.62],
+ [23.81, 8.67]
+ ],
+ [
+ [22.86, 11.14],
+ [22.88, 11.38],
+ [22.51, 11.68],
+ [22.5, 12.26],
+ [22.29, 12.65],
+ [21.94, 12.59],
+ [22.04, 12.96],
+ [22.3, 13.37],
+ [22.18, 13.79],
+ [22.51, 14.09],
+ [22.3, 14.33],
+ [22.57, 14.94],
+ [23.02, 15.68],
+ [23.89, 15.61],
+ [23.84, 19.58]
+ ],
+ [
+ [36.87, 22],
+ [37.19, 21.02],
+ [36.97, 20.84],
+ [37.11, 19.81],
+ [37.48, 18.61],
+ [37.86, 18.37],
+ [38.41, 18]
+ ],
+ [
+ [36.85, 16.96],
+ [36.75, 16.29],
+ [36.32, 14.82],
+ [36.43, 14.42]
+ ],
+ [
+ [33.96, 9.58],
+ [33.96, 9.46]
+ ],
+ [
+ [33.96, 9.46],
+ [33.97, 8.68]
+ ],
+ [
+ [34.01, 4.25],
+ [33.39, 3.79],
+ [32.69, 3.79],
+ [31.88, 3.56],
+ [31.25, 3.78],
+ [30.83, 3.51]
+ ],
+ [
+ [30.83, 3.51],
+ [29.95, 4.17],
+ [29.72, 4.6]
+ ],
+ [
+ [24.57, 8.23],
+ [23.89, 8.62]
+ ],
+ [
+ [-16.71, 13.59],
+ [-17.13, 14.37],
+ [-17.63, 14.73],
+ [-17.19, 14.92],
+ [-16.7, 15.62],
+ [-16.46, 16.14]
+ ],
+ [
+ [-16.68, 12.38],
+ [-16.84, 13.15]
+ ],
+ [
+ [162.12, -10.48],
+ [162.4, -10.83],
+ [161.7, -10.82],
+ [161.32, -10.2],
+ [161.92, -10.45],
+ [162.12, -10.48]
+ ],
+ [
+ [160.85, -9.87],
+ [160.46, -9.9],
+ [159.85, -9.79],
+ [159.64, -9.64],
+ [159.7, -9.24],
+ [160.36, -9.4],
+ [160.69, -9.61],
+ [160.85, -9.87]
+ ],
+ [
+ [161.68, -9.6],
+ [161.53, -9.78],
+ [160.79, -8.92],
+ [160.58, -8.32],
+ [160.92, -8.32],
+ [161.28, -9.12],
+ [161.68, -9.6]
+ ],
+ [
+ [159.88, -8.34],
+ [159.92, -8.54],
+ [159.13, -8.11],
+ [158.59, -7.75],
+ [158.21, -7.42],
+ [158.36, -7.32],
+ [158.82, -7.56],
+ [159.64, -8.02],
+ [159.88, -8.34]
+ ],
+ [
+ [157.54, -7.35],
+ [157.34, -7.4],
+ [156.9, -7.18],
+ [156.49, -6.77],
+ [156.54, -6.6],
+ [157.14, -7.02],
+ [157.54, -7.35]
+ ],
+ [
+ [-11.44, 6.79],
+ [-11.71, 6.86],
+ [-12.43, 7.26],
+ [-12.95, 7.8],
+ [-13.12, 8.16],
+ [-13.25, 8.9]
+ ],
+ [
+ [-87.79, 13.38],
+ [-87.9, 13.15],
+ [-88.48, 13.16],
+ [-88.84, 13.26],
+ [-89.26, 13.46],
+ [-89.81, 13.52],
+ [-90.1, 13.74]
+ ],
+ [
+ [47.79, 8],
+ [46.95, 8],
+ [43.68, 9.18]
+ ],
+ [
+ [43.68, 9.18],
+ [43.3, 9.54],
+ [42.93, 10.02]
+ ],
+ [
+ [43.15, 11.46],
+ [43.47, 11.28],
+ [43.67, 10.86],
+ [44.12, 10.45],
+ [44.61, 10.44],
+ [45.56, 10.7],
+ [46.65, 10.82],
+ [47.53, 11.13],
+ [48.02, 11.19],
+ [48.38, 11.38],
+ [48.95, 11.41],
+ [48.94, 11.39],
+ [48.95, 11.41],
+ [49.27, 11.43],
+ [49.73, 11.58],
+ [50.26, 11.68],
+ [50.73, 12.02],
+ [51.11, 12.02],
+ [51.13, 11.75],
+ [51.04, 11.17],
+ [51.05, 10.64],
+ [50.83, 10.28],
+ [50.55, 9.2],
+ [50.07, 8.08],
+ [49.45, 6.8],
+ [48.59, 5.34],
+ [47.74, 4.22],
+ [46.56, 2.86],
+ [45.56, 2.05],
+ [44.07, 1.05],
+ [43.14, 0.29],
+ [42.04, -0.92],
+ [41.81, -1.45],
+ [41.59, -1.68]
+ ],
+ [
+ [19.37, 44.86],
+ [19.01, 44.86],
+ [19.39, 45.24]
+ ],
+ [
+ [19.07, 45.52],
+ [18.83, 45.91],
+ [19.6, 46.17]
+ ],
+ [
+ [-57.15, 5.97],
+ [-55.95, 5.77],
+ [-55.84, 5.95],
+ [-55.03, 6.03],
+ [-53.96, 5.76]
+ ],
+ [
+ [-54.4, 4.21],
+ [-54.01, 3.62],
+ [-54.18, 3.19],
+ [-54.27, 2.73],
+ [-54.52, 2.31]
+ ],
+ [
+ [22.56, 49.09],
+ [22.28, 48.83],
+ [22.09, 48.42]
+ ],
+ [
+ [13.72, 45.5],
+ [13.94, 45.59]
+ ],
+ [
+ [23.9, 66.01],
+ [22.18, 65.72],
+ [21.21, 65.03],
+ [21.37, 64.41],
+ [19.78, 63.61],
+ [17.85, 62.75],
+ [17.12, 61.34],
+ [17.83, 60.64],
+ [18.79, 60.08],
+ [17.87, 58.95],
+ [16.83, 58.72],
+ [16.45, 57.04],
+ [15.88, 56.1],
+ [14.67, 56.2],
+ [14.1, 55.41],
+ [12.94, 55.36],
+ [12.63, 56.31],
+ [11.79, 57.44],
+ [11.03, 58.86]
+ ],
+ [
+ [32.07, -26.73],
+ [31.87, -27.18],
+ [31.28, -27.29],
+ [30.69, -26.74],
+ [30.68, -26.4],
+ [30.95, -26.02],
+ [31.04, -25.73],
+ [31.33, -25.66],
+ [31.84, -25.84]
+ ],
+ [
+ [36, 34.64],
+ [35.91, 35.41],
+ [36.15, 35.82]
+ ],
+ [
+ [36.15, 35.82],
+ [36.42, 36.04],
+ [36.69, 36.26],
+ [36.74, 36.82],
+ [37.07, 36.62],
+ [38.17, 36.9],
+ [38.7, 36.71],
+ [39.52, 36.72],
+ [40.67, 37.09],
+ [41.21, 37.07],
+ [42.35, 37.23]
+ ],
+ [
+ [13.54, 14.37],
+ [13.97, 15.68],
+ [15.25, 16.63]
+ ],
+ [
+ [15.49, 20.73],
+ [15.47, 21.05],
+ [15.1, 21.31]
+ ],
+ [
+ [14.96, 11.56],
+ [14.89, 12.22],
+ [14.5, 12.86]
+ ],
+ [
+ [1.87, 6.14],
+ [1.06, 5.93]
+ ],
+ [
+ [102.58, 12.19],
+ [101.69, 12.65],
+ [100.83, 12.63],
+ [100.98, 13.41],
+ [100.1, 13.41],
+ [100.02, 12.31],
+ [99.48, 10.85],
+ [99.15, 9.96],
+ [99.22, 9.24],
+ [99.87, 9.21],
+ [100.28, 8.3],
+ [100.46, 7.43],
+ [101.02, 6.86],
+ [101.62, 6.74],
+ [102.14, 6.22]
+ ],
+ [
+ [100.09, 6.46],
+ [99.69, 6.85],
+ [99.52, 7.34],
+ [98.99, 7.91],
+ [98.5, 8.38],
+ [98.34, 7.79],
+ [98.15, 8.35],
+ [98.26, 8.97],
+ [98.55, 9.93]
+ ],
+ [
+ [67.83, 37.14],
+ [68.39, 38.16],
+ [68.18, 38.9],
+ [67.44, 39.14],
+ [67.7, 39.58],
+ [68.54, 39.53],
+ [69.01, 40.09],
+ [69.33, 40.73],
+ [70.67, 40.96],
+ [70.46, 40.5],
+ [70.6, 40.22],
+ [71.01, 40.24]
+ ],
+ [
+ [53.92, 37.2],
+ [53.74, 37.91],
+ [53.88, 38.95],
+ [53.1, 39.29],
+ [53.36, 39.98],
+ [52.69, 40.03],
+ [52.92, 40.88],
+ [53.86, 40.63],
+ [54.74, 40.95],
+ [54.01, 41.55],
+ [53.72, 42.12],
+ [52.92, 41.87],
+ [52.81, 41.14],
+ [52.5, 41.78]
+ ],
+ [
+ [55.97, 41.31],
+ [57.1, 41.32],
+ [56.93, 41.83],
+ [57.79, 42.17],
+ [58.63, 42.75],
+ [59.98, 42.22],
+ [60.08, 41.43],
+ [60.47, 41.22],
+ [61.55, 41.27],
+ [61.88, 41.08],
+ [62.37, 40.05],
+ [63.52, 39.36],
+ [64.17, 38.89],
+ [65.22, 38.4],
+ [66.55, 37.97],
+ [66.52, 37.36]
+ ],
+ [
+ [124.97, -8.89],
+ [125.09, -8.66],
+ [125.95, -8.43],
+ [126.64, -8.4],
+ [126.96, -8.27],
+ [127.34, -8.4],
+ [126.97, -8.67],
+ [125.93, -9.11],
+ [125.09, -9.39]
+ ],
+ [
+ [-61.68, 10.76],
+ [-61.1, 10.89],
+ [-60.89, 10.86],
+ [-60.93, 10.11],
+ [-61.77, 10],
+ [-61.95, 10.09],
+ [-61.66, 10.37],
+ [-61.68, 10.76]
+ ],
+ [
+ [8.42, 36.95],
+ [9.51, 37.35],
+ [10.21, 37.23],
+ [10.18, 36.72],
+ [11.03, 37.09],
+ [11.1, 36.9],
+ [10.6, 36.41],
+ [10.59, 35.95],
+ [10.94, 35.7],
+ [10.81, 34.83],
+ [10.15, 34.33],
+ [10.34, 33.79],
+ [10.86, 33.77],
+ [11.11, 33.29],
+ [11.49, 33.14]
+ ],
+ [
+ [44.23, 37.97],
+ [44.77, 37.17]
+ ],
+ [
+ [36.15, 35.82],
+ [35.78, 36.27],
+ [36.16, 36.65],
+ [35.55, 36.57],
+ [34.71, 36.8],
+ [34.03, 36.22],
+ [32.51, 36.11],
+ [31.7, 36.64],
+ [30.62, 36.68],
+ [30.39, 36.26],
+ [29.7, 36.14],
+ [28.73, 36.68],
+ [27.64, 36.66],
+ [27.05, 37.65],
+ [26.32, 38.21],
+ [26.8, 38.99],
+ [26.17, 39.46],
+ [27.28, 40.42],
+ [28.82, 40.46],
+ [29.24, 41.22],
+ [31.15, 41.09],
+ [32.35, 41.74],
+ [33.51, 42.02],
+ [35.17, 42.04],
+ [36.91, 41.34],
+ [38.35, 40.95],
+ [39.51, 41.1],
+ [40.37, 41.01],
+ [41.55, 41.54]
+ ],
+ [
+ [28, 42.01],
+ [28.12, 41.62],
+ [28.99, 41.3],
+ [28.81, 41.05],
+ [27.62, 41],
+ [27.19, 40.69],
+ [26.36, 40.15],
+ [26.04, 40.62],
+ [26.06, 40.82]
+ ],
+ [
+ [121.78, 24.39],
+ [121.18, 22.79],
+ [120.75, 21.97],
+ [120.22, 22.81],
+ [120.11, 23.56],
+ [120.69, 24.54],
+ [121.5, 25.3],
+ [121.95, 25],
+ [121.78, 24.39]
+ ],
+ [
+ [39.2, -4.68],
+ [38.74, -5.91],
+ [38.8, -6.48],
+ [39.44, -6.84],
+ [39.47, -7.1],
+ [39.19, -7.7],
+ [39.25, -8.01],
+ [39.19, -8.49],
+ [39.54, -9.11],
+ [39.95, -10.1],
+ [40.32, -10.32],
+ [39.52, -10.9],
+ [38.43, -11.29]
+ ],
+ [
+ [38.43, -11.29],
+ [37.83, -11.27],
+ [37.47, -11.57],
+ [36.78, -11.59]
+ ],
+ [
+ [34.56, -11.52],
+ [34.28, -10.16],
+ [33.94, -9.69]
+ ],
+ [
+ [33.94, -9.69],
+ [33.74, -9.42],
+ [32.76, -9.23]
+ ],
+ [
+ [32.76, -9.23],
+ [32.19, -8.93],
+ [31.56, -8.76],
+ [31.16, -8.59]
+ ],
+ [
+ [31.16, -8.59],
+ [30.74, -8.34],
+ [30.2, -7.08],
+ [29.62, -6.52],
+ [29.42, -5.94]
+ ],
+ [
+ [29.75, -4.45],
+ [30.12, -4.09],
+ [30.51, -3.57],
+ [30.75, -3.36],
+ [30.74, -3.03],
+ [30.53, -2.81],
+ [30.47, -2.41],
+ [30.76, -2.29]
+ ],
+ [
+ [30.42, -1.13],
+ [30.77, -1.01],
+ [31.87, -1.03],
+ [33.9, -0.95]
+ ],
+ [
+ [29.59, -0.59],
+ [29.82, -0.21],
+ [29.88, 0.6]
+ ],
+ [
+ [31.17, 2.2],
+ [30.77, 2.34],
+ [30.83, 3.51]
+ ],
+ [
+ [31.79, 52.1],
+ [32.16, 52.06],
+ [32.41, 52.29]
+ ],
+ [
+ [35.02, 51.21],
+ [35.38, 50.77],
+ [35.36, 50.58]
+ ],
+ [
+ [38.59, 49.93],
+ [40.07, 49.6],
+ [40.08, 49.31]
+ ],
+ [
+ [40.08, 49.31],
+ [39.67, 48.78],
+ [39.9, 48.23],
+ [39.74, 47.9]
+ ],
+ [
+ [39.74, 47.9],
+ [38.77, 47.83],
+ [38.26, 47.55]
+ ],
+ [
+ [38.22, 47.1],
+ [37.43, 47.02],
+ [36.76, 46.7],
+ [35.82, 46.65],
+ [34.96, 46.27],
+ [35.02, 45.65],
+ [35.51, 45.41],
+ [36.53, 45.47],
+ [36.33, 45.11],
+ [35.24, 44.94],
+ [33.88, 44.36],
+ [33.33, 44.56],
+ [33.55, 45.03],
+ [32.45, 45.33],
+ [32.63, 45.52],
+ [33.59, 45.85],
+ [33.3, 46.08],
+ [31.74, 46.33],
+ [31.68, 46.71],
+ [30.75, 46.58],
+ [30.38, 46.03],
+ [29.6, 45.29]
+ ],
+ [
+ [-53.37, -33.77],
+ [-53.81, -34.4],
+ [-54.94, -34.95],
+ [-55.67, -34.75],
+ [-56.22, -34.86],
+ [-57.14, -34.43],
+ [-57.82, -34.46],
+ [-58.43, -33.91]
+ ],
+ [
+ [-155.54, 19.08],
+ [-155.69, 18.92],
+ [-155.94, 19.06],
+ [-155.91, 19.34],
+ [-156.07, 19.7],
+ [-156.02, 19.81],
+ [-155.85, 19.98],
+ [-155.92, 20.17],
+ [-155.86, 20.27],
+ [-155.79, 20.25],
+ [-155.4, 20.08],
+ [-155.22, 19.99],
+ [-155.06, 19.86],
+ [-154.81, 19.51],
+ [-154.83, 19.45],
+ [-155.22, 19.24],
+ [-155.54, 19.08]
+ ],
+ [
+ [-156.08, 20.64],
+ [-156.41, 20.57],
+ [-156.59, 20.78],
+ [-156.7, 20.86],
+ [-156.71, 20.93],
+ [-156.61, 21.01],
+ [-156.26, 20.92],
+ [-156, 20.76],
+ [-156.08, 20.64]
+ ],
+ [
+ [-156.76, 21.18],
+ [-156.79, 21.07],
+ [-157.33, 21.1],
+ [-157.25, 21.22],
+ [-156.76, 21.18]
+ ],
+ [
+ [-157.65, 21.32],
+ [-157.71, 21.26],
+ [-157.78, 21.28],
+ [-158.13, 21.31],
+ [-158.25, 21.54],
+ [-158.29, 21.58],
+ [-158.03, 21.72],
+ [-157.94, 21.65],
+ [-157.65, 21.32]
+ ],
+ [
+ [-159.35, 21.98],
+ [-159.46, 21.88],
+ [-159.8, 22.07],
+ [-159.75, 22.14],
+ [-159.6, 22.24],
+ [-159.37, 22.21],
+ [-159.35, 21.98]
+ ],
+ [
+ [-67.14, 45.14],
+ [-66.96, 44.81],
+ [-68.03, 44.33],
+ [-69.06, 43.98],
+ [-70.12, 43.68],
+ [-70.65, 43.09],
+ [-70.81, 42.87],
+ [-70.82, 42.34],
+ [-70.49, 41.81],
+ [-70.08, 41.78],
+ [-70.18, 42.15],
+ [-69.88, 41.92],
+ [-69.97, 41.64],
+ [-70.64, 41.48],
+ [-71.12, 41.49],
+ [-71.86, 41.32],
+ [-72.29, 41.27],
+ [-72.88, 41.22],
+ [-73.71, 40.93],
+ [-72.24, 41.12],
+ [-71.94, 40.93],
+ [-73.34, 40.63],
+ [-73.98, 40.63],
+ [-73.95, 40.75],
+ [-74.26, 40.47],
+ [-73.96, 40.43],
+ [-74.18, 39.71],
+ [-74.91, 38.94],
+ [-74.98, 39.2],
+ [-75.2, 39.25],
+ [-75.53, 39.5],
+ [-75.32, 38.96],
+ [-75.07, 38.78],
+ [-75.06, 38.4],
+ [-75.38, 38.02],
+ [-75.94, 37.22],
+ [-76.03, 37.26],
+ [-75.72, 37.94],
+ [-76.23, 38.32],
+ [-76.35, 39.15],
+ [-76.54, 38.72],
+ [-76.33, 38.08],
+ [-76.99, 38.24],
+ [-76.3, 37.92],
+ [-76.26, 36.97],
+ [-75.97, 36.9],
+ [-75.87, 36.55],
+ [-75.73, 35.55],
+ [-76.36, 34.81],
+ [-77.4, 34.51],
+ [-78.05, 33.93],
+ [-78.55, 33.86],
+ [-79.06, 33.49],
+ [-79.2, 33.16],
+ [-80.3, 32.51],
+ [-80.86, 32.03],
+ [-81.34, 31.44],
+ [-81.49, 30.73],
+ [-81.31, 30.04],
+ [-80.98, 29.18],
+ [-80.54, 28.47],
+ [-80.53, 28.04],
+ [-80.06, 26.88],
+ [-80.09, 26.21],
+ [-80.13, 25.82],
+ [-80.38, 25.21],
+ [-80.68, 25.08],
+ [-81.17, 25.2],
+ [-81.33, 25.64],
+ [-81.71, 25.87],
+ [-82.24, 26.73],
+ [-82.71, 27.5],
+ [-82.86, 27.89],
+ [-82.65, 28.55],
+ [-82.93, 29.1],
+ [-83.71, 29.94],
+ [-84.1, 30.09],
+ [-85.11, 29.64],
+ [-85.29, 29.69],
+ [-85.77, 30.15],
+ [-86.4, 30.4],
+ [-87.53, 30.27],
+ [-88.42, 30.38],
+ [-89.18, 30.32],
+ [-89.59, 30.16],
+ [-89.41, 29.89],
+ [-89.43, 29.49],
+ [-89.22, 29.29],
+ [-89.41, 29.16],
+ [-89.78, 29.31],
+ [-90.15, 29.12],
+ [-90.88, 29.15],
+ [-91.63, 29.68],
+ [-92.5, 29.55],
+ [-93.23, 29.78],
+ [-93.85, 29.71],
+ [-94.69, 29.48],
+ [-95.6, 28.74],
+ [-96.59, 28.31],
+ [-97.14, 27.83],
+ [-97.37, 27.38],
+ [-97.38, 26.69],
+ [-97.33, 26.21],
+ [-97.14, 25.87],
+ [-97.53, 25.84]
+ ],
+ [
+ [-117.13, 32.54],
+ [-117.3, 33.05],
+ [-117.94, 33.62],
+ [-118.41, 33.74],
+ [-118.52, 34.03],
+ [-119.08, 34.08],
+ [-119.44, 34.35],
+ [-120.37, 34.45],
+ [-120.62, 34.61],
+ [-120.74, 35.16],
+ [-121.71, 36.16],
+ [-122.55, 37.55],
+ [-122.51, 37.78],
+ [-122.95, 38.11],
+ [-123.73, 38.95],
+ [-123.87, 39.77],
+ [-124.4, 40.31],
+ [-124.18, 41.14],
+ [-124.21, 42],
+ [-124.53, 42.77],
+ [-124.14, 43.71],
+ [-124.02, 44.62],
+ [-123.9, 45.52],
+ [-124.08, 46.86],
+ [-124.4, 47.72],
+ [-124.69, 48.18],
+ [-124.57, 48.38],
+ [-123.12, 48.04],
+ [-122.59, 47.1],
+ [-122.34, 47.36],
+ [-122.5, 48.18],
+ [-122.84, 49]
+ ],
+ [
+ [-153.01, 57.12],
+ [-154.01, 56.73],
+ [-154.52, 56.99],
+ [-154.67, 57.46],
+ [-153.76, 57.82],
+ [-153.23, 57.97],
+ [-152.56, 57.9],
+ [-152.14, 57.59],
+ [-153.01, 57.12]
+ ],
+ [
+ [-165.58, 59.91],
+ [-166.19, 59.75],
+ [-166.85, 59.94],
+ [-167.46, 60.21],
+ [-166.47, 60.38],
+ [-165.67, 60.29],
+ [-165.58, 59.91]
+ ],
+ [
+ [-171.73, 63.78],
+ [-171.11, 63.59],
+ [-170.49, 63.69],
+ [-169.68, 63.43],
+ [-168.69, 63.3],
+ [-168.77, 63.19],
+ [-169.53, 62.98],
+ [-170.29, 63.19],
+ [-170.67, 63.38],
+ [-171.55, 63.32],
+ [-171.79, 63.41],
+ [-171.73, 63.78]
+ ],
+ [
+ [-134.27, 58.86],
+ [-133.36, 58.41],
+ [-132.73, 57.69]
+ ],
+ [
+ [-130.01, 55.92],
+ [-129.98, 55.28],
+ [-130.54, 54.8],
+ [-131.09, 55.18],
+ [-131.97, 55.5],
+ [-132.25, 56.37],
+ [-133.54, 57.18],
+ [-134.08, 58.12],
+ [-135.04, 58.19],
+ [-136.63, 58.21],
+ [-137.8, 58.5],
+ [-139.87, 59.54],
+ [-140.83, 59.73],
+ [-142.57, 60.08],
+ [-143.96, 60],
+ [-145.93, 60.46],
+ [-147.11, 60.88],
+ [-148.22, 60.67],
+ [-148.02, 59.98],
+ [-148.57, 59.91],
+ [-149.73, 59.71],
+ [-150.61, 59.37],
+ [-151.72, 59.16],
+ [-151.86, 59.74],
+ [-151.41, 60.73],
+ [-150.35, 61.03],
+ [-150.62, 61.28],
+ [-151.9, 60.73],
+ [-152.58, 60.06],
+ [-154.02, 59.35],
+ [-153.29, 58.86],
+ [-154.23, 58.15],
+ [-155.31, 57.73],
+ [-156.31, 57.42],
+ [-156.56, 56.98],
+ [-158.12, 56.46],
+ [-158.43, 55.99],
+ [-159.6, 55.57],
+ [-160.29, 55.64],
+ [-161.22, 55.36],
+ [-162.24, 55.02],
+ [-163.07, 54.69],
+ [-164.79, 54.4],
+ [-164.94, 54.57],
+ [-163.85, 55.04],
+ [-162.87, 55.35],
+ [-161.8, 55.89],
+ [-160.56, 56.01],
+ [-160.07, 56.42],
+ [-158.68, 57.02],
+ [-158.46, 57.22],
+ [-157.72, 57.57],
+ [-157.55, 58.33],
+ [-157.04, 58.92],
+ [-158.19, 58.62],
+ [-158.52, 58.79],
+ [-159.06, 58.42],
+ [-159.71, 58.93],
+ [-159.98, 58.57],
+ [-160.36, 59.07],
+ [-161.36, 58.67],
+ [-161.97, 58.67],
+ [-162.05, 59.27],
+ [-161.87, 59.63],
+ [-162.52, 59.99],
+ [-163.82, 59.8],
+ [-164.66, 60.27],
+ [-165.35, 60.51],
+ [-165.35, 61.07],
+ [-166.12, 61.5],
+ [-165.73, 62.07],
+ [-164.92, 62.63],
+ [-164.56, 63.15],
+ [-163.75, 63.22],
+ [-163.07, 63.06],
+ [-162.26, 63.54],
+ [-161.53, 63.46],
+ [-160.77, 63.77],
+ [-160.96, 64.22],
+ [-161.52, 64.4],
+ [-160.78, 64.79],
+ [-161.39, 64.78],
+ [-162.45, 64.56],
+ [-162.76, 64.34],
+ [-163.55, 64.56],
+ [-164.96, 64.45],
+ [-166.43, 64.69],
+ [-166.85, 65.09],
+ [-168.11, 65.67],
+ [-166.71, 66.09],
+ [-164.47, 66.58],
+ [-163.65, 66.58],
+ [-163.79, 66.08],
+ [-161.68, 66.12],
+ [-162.49, 66.74],
+ [-163.72, 67.12],
+ [-164.43, 67.62],
+ [-165.39, 68.04],
+ [-166.76, 68.36],
+ [-166.2, 68.88],
+ [-164.43, 68.92],
+ [-163.17, 69.37],
+ [-162.93, 69.86],
+ [-161.91, 70.33],
+ [-160.93, 70.45],
+ [-159.04, 70.89],
+ [-158.12, 70.82],
+ [-156.58, 71.36],
+ [-155.07, 71.15],
+ [-154.34, 70.7],
+ [-153.9, 70.89],
+ [-152.21, 70.83],
+ [-152.27, 70.6],
+ [-150.74, 70.43],
+ [-149.72, 70.53],
+ [-147.61, 70.21],
+ [-145.69, 70.12],
+ [-144.92, 69.99],
+ [-143.59, 70.15],
+ [-142.07, 69.85],
+ [-140.99, 69.71],
+ [-140.99, 69.71],
+ [-140.99, 66],
+ [-141, 60.31],
+ [-140.01, 60.28],
+ [-139.04, 60],
+ [-138.34, 59.56]
+ ],
+ [
+ [-71.33, 11.78],
+ [-71.36, 11.54],
+ [-71.95, 11.42],
+ [-71.62, 10.97],
+ [-71.63, 10.45],
+ [-72.07, 9.87],
+ [-71.7, 9.07],
+ [-71.26, 9.14],
+ [-71.04, 9.86],
+ [-71.35, 10.21],
+ [-71.4, 10.97],
+ [-70.16, 11.38],
+ [-70.29, 11.85],
+ [-69.94, 12.16],
+ [-69.58, 11.46],
+ [-68.88, 11.44],
+ [-68.23, 10.89],
+ [-68.19, 10.55],
+ [-67.3, 10.55],
+ [-66.23, 10.65],
+ [-65.66, 10.2],
+ [-64.89, 10.08],
+ [-64.33, 10.39],
+ [-64.32, 10.64],
+ [-63.08, 10.7],
+ [-61.88, 10.72],
+ [-62.73, 10.42],
+ [-62.39, 9.95],
+ [-61.59, 9.87],
+ [-60.83, 9.38],
+ [-60.67, 8.58],
+ [-60.15, 8.6],
+ [-59.76, 8.37]
+ ],
+ [
+ [108.05, 21.55],
+ [106.72, 20.7],
+ [105.88, 19.75],
+ [105.66, 19.06],
+ [106.43, 18],
+ [107.36, 16.7],
+ [108.27, 16.08],
+ [108.88, 15.28],
+ [109.34, 13.43],
+ [109.2, 11.67],
+ [108.37, 11.01],
+ [107.22, 10.36],
+ [106.41, 9.53],
+ [105.16, 8.6],
+ [104.8, 9.24],
+ [105.08, 9.92],
+ [104.33, 10.49]
+ ],
+ [
+ [167.84, -16.47],
+ [167.52, -16.6],
+ [167.18, -16.16],
+ [167.22, -15.89],
+ [167.84, -16.47]
+ ],
+ [
+ [167.11, -14.93],
+ [167.27, -15.74],
+ [167, -15.61],
+ [166.79, -15.67],
+ [166.65, -15.39],
+ [166.63, -14.63],
+ [167.11, -14.93]
+ ],
+ [
+ [53.11, 16.65],
+ [52.39, 16.38],
+ [52.19, 15.94],
+ [52.17, 15.6],
+ [51.17, 15.18],
+ [49.57, 14.71],
+ [48.68, 14],
+ [48.24, 13.95],
+ [47.94, 14.01],
+ [47.35, 13.59],
+ [46.72, 13.4],
+ [45.88, 13.35],
+ [45.63, 13.29],
+ [45.41, 13.03],
+ [45.14, 12.95],
+ [44.99, 12.7],
+ [44.49, 12.72],
+ [44.18, 12.59],
+ [43.48, 12.64],
+ [43.22, 13.22],
+ [43.25, 13.77],
+ [43.09, 14.06],
+ [42.89, 14.8],
+ [42.6, 15.21],
+ [42.81, 15.26],
+ [42.7, 15.72],
+ [42.82, 15.91],
+ [42.78, 16.35]
+ ],
+ [
+ [29.43, -22.09],
+ [29.84, -22.1],
+ [30.32, -22.27],
+ [30.66, -22.15],
+ [31.19, -22.25]
+ ],
+ [
+ [32.83, -26.74],
+ [32.58, -27.47],
+ [32.46, -28.3],
+ [32.2, -28.75],
+ [31.52, -29.26],
+ [31.33, -29.4],
+ [30.9, -29.91],
+ [30.62, -30.42],
+ [30.06, -31.14],
+ [28.93, -32.17],
+ [28.22, -32.77],
+ [27.46, -33.23],
+ [26.42, -33.61],
+ [25.91, -33.67],
+ [25.78, -33.94],
+ [25.17, -33.8],
+ [24.68, -33.99],
+ [23.59, -33.79],
+ [22.99, -33.92],
+ [22.57, -33.86],
+ [21.54, -34.26],
+ [20.69, -34.42],
+ [20.07, -34.8],
+ [19.62, -34.82],
+ [19.19, -34.46],
+ [18.86, -34.44],
+ [18.42, -34],
+ [18.38, -34.14],
+ [18.24, -33.87],
+ [18.25, -33.28],
+ [17.93, -32.61],
+ [18.25, -32.43],
+ [18.22, -31.66],
+ [17.57, -30.73],
+ [17.06, -29.88],
+ [17.06, -29.88],
+ [16.34, -28.58]
+ ],
+ [
+ [30.27, -15.51],
+ [29.52, -15.64],
+ [28.95, -16.04],
+ [28.83, -16.39],
+ [28.47, -16.47],
+ [27.6, -17.29],
+ [27.04, -17.94],
+ [26.71, -17.96],
+ [26.38, -17.85],
+ [25.26, -17.74]
+ ],
+ [
+ [30.74, -8.34],
+ [31.16, -8.59]
+ ],
+ [
+ [-24.32, 14.85],
+ [-24.39, 14.81],
+ [-24.46, 14.83],
+ [-24.54, 14.92],
+ [-24.5, 14.97],
+ [-24.39, 15.02],
+ [-24.36, 15],
+ [-24.32, 14.92],
+ [-24.32, 14.85]
+ ],
+ [
+ [-23.2, 15.13],
+ [-23.24, 15.13],
+ [-23.28, 15.18],
+ [-23.28, 15.25],
+ [-23.24, 15.32],
+ [-23.17, 15.32],
+ [-23.13, 15.26],
+ [-23.13, 15.16],
+ [-23.2, 15.13]
+ ],
+ [
+ [-23.46, 15],
+ [-23.53, 14.9],
+ [-23.67, 14.92],
+ [-23.82, 15.07],
+ [-23.82, 15.16],
+ [-23.78, 15.23],
+ [-23.78, 15.32],
+ [-23.71, 15.32],
+ [-23.71, 15.26],
+ [-23.56, 15.13],
+ [-23.46, 15]
+ ],
+ [
+ [-22.95, 16.24],
+ [-22.84, 16.2],
+ [-22.77, 16.22],
+ [-22.7, 16.17],
+ [-22.7, 16.1],
+ [-22.74, 16.03],
+ [-22.84, 15.98],
+ [-22.92, 15.98],
+ [-22.99, 16.03],
+ [-22.92, 16.13],
+ [-22.95, 16.24]
+ ],
+ [
+ [-24.1, 16.62],
+ [-24.07, 16.57],
+ [-24.1, 16.55],
+ [-24.25, 16.58],
+ [-24.28, 16.57],
+ [-24.36, 16.48],
+ [-24.43, 16.6],
+ [-24.43, 16.65],
+ [-24.39, 16.67],
+ [-24.28, 16.64],
+ [-24.1, 16.62]
+ ],
+ [
+ [-22.92, 16.65],
+ [-22.95, 16.6],
+ [-22.99, 16.67],
+ [-23.02, 16.79],
+ [-22.95, 16.83],
+ [-22.92, 16.83],
+ [-22.92, 16.65]
+ ],
+ [
+ [-24.9, 16.81],
+ [-25.04, 16.79],
+ [-25.11, 16.83],
+ [-25.08, 16.86],
+ [-24.97, 16.91],
+ [-24.9, 16.84],
+ [-24.9, 16.81]
+ ],
+ [
+ [-25.18, 16.93],
+ [-25.29, 16.91],
+ [-25.33, 16.93],
+ [-25.33, 17],
+ [-25.36, 17.05],
+ [-25.36, 17.09],
+ [-25.15, 17.19],
+ [-25.04, 17.17],
+ [-25, 17.09],
+ [-25.04, 17.04],
+ [-25.18, 16.93]
+ ],
+ [
+ [43.76, -12.32],
+ [43.65, -12.36],
+ [43.62, -12.29],
+ [43.62, -12.25],
+ [43.69, -12.27],
+ [43.76, -12.32]
+ ],
+ [
+ [44.45, -12.1],
+ [44.52, -12.24],
+ [44.52, -12.34],
+ [44.48, -12.36],
+ [44.45, -12.34],
+ [44.37, -12.25],
+ [44.19, -12.18],
+ [44.27, -12.17],
+ [44.3, -12.18],
+ [44.37, -12.17],
+ [44.37, -12.13],
+ [44.41, -12.1],
+ [44.45, -12.1]
+ ],
+ [
+ [43.44, -11.91],
+ [43.44, -11.92],
+ [43.29, -11.85],
+ [43.22, -11.77],
+ [43.22, -11.44],
+ [43.26, -11.4],
+ [43.33, -11.39],
+ [43.37, -11.42],
+ [43.37, -11.63],
+ [43.44, -11.77],
+ [43.47, -11.87],
+ [43.44, -11.91]
+ ],
+ [
+ [57.62, -20.5],
+ [57.52, -20.52],
+ [57.37, -20.52],
+ [57.3, -20.47],
+ [57.34, -20.41],
+ [57.34, -20.34],
+ [57.37, -20.24],
+ [57.41, -20.19],
+ [57.48, -20.15],
+ [57.48, -20.07],
+ [57.55, -20.01],
+ [57.62, -20],
+ [57.73, -20.1],
+ [57.77, -20.22],
+ [57.77, -20.34],
+ [57.7, -20.38],
+ [57.7, -20.45],
+ [57.62, -20.5]
+ ],
+ [
+ [55.54, -4.7],
+ [55.54, -4.79],
+ [55.46, -4.77],
+ [55.46, -4.7],
+ [55.39, -4.67],
+ [55.36, -4.61],
+ [55.43, -4.56],
+ [55.54, -4.7]
+ ],
+ [
+ [50.6, 25.87],
+ [50.57, 25.8],
+ [50.53, 25.82],
+ [50.46, 25.96],
+ [50.46, 26.05],
+ [50.42, 26.18],
+ [50.46, 26.22],
+ [50.57, 26.24],
+ [50.53, 26.18],
+ [50.6, 26.12],
+ [50.6, 25.87]
+ ],
+ [
+ [73.39, 3.22],
+ [73.36, 3.23],
+ [73.36, 3.27],
+ [73.39, 3.28],
+ [73.43, 3.27],
+ [73.43, 3.23],
+ [73.39, 3.22]
+ ],
+ [
+ [73.5, 4.15],
+ [73.47, 4.15],
+ [73.47, 4.21],
+ [73.5, 4.22],
+ [73.5, 4.15]
+ ],
+ [
+ [169.63, 5.82],
+ [169.59, 5.85],
+ [169.63, 5.94],
+ [169.67, 5.92],
+ [169.63, 5.82]
+ ],
+ [
+ [171.07, 7.12],
+ [171.22, 7.07],
+ [171.36, 7.1],
+ [171.36, 7.09],
+ [171.25, 7.05],
+ [171.22, 7.05],
+ [171.07, 7.1],
+ [171.07, 7.12]
+ ],
+ [
+ [162.97, 5.32],
+ [162.97, 5.26],
+ [162.9, 5.3],
+ [162.93, 5.33],
+ [162.97, 5.32]
+ ],
+ [
+ [158.29, 6.81],
+ [158.25, 6.77],
+ [158.18, 6.79],
+ [158.15, 6.88],
+ [158.11, 6.9],
+ [158.11, 6.93],
+ [158.18, 6.97],
+ [158.29, 6.95],
+ [158.33, 6.88],
+ [158.29, 6.84],
+ [158.29, 6.81]
+ ],
+ [
+ [151.63, 7.33],
+ [151.59, 7.35],
+ [151.59, 7.38],
+ [151.63, 7.38],
+ [151.63, 7.33]
+ ],
+ [
+ [151.88, 7.42],
+ [151.85, 7.42],
+ [151.85, 7.45],
+ [151.88, 7.45],
+ [151.88, 7.42]
+ ],
+ [
+ [138.13, 9.5],
+ [138.13, 9.57],
+ [138.16, 9.59],
+ [138.2, 9.54],
+ [138.16, 9.5],
+ [138.13, 9.5]
+ ],
+ [
+ [166.89, -0.52],
+ [166.93, -0.5],
+ [166.97, -0.52],
+ [166.97, -0.55],
+ [166.93, -0.55],
+ [166.89, -0.55],
+ [166.89, -0.53],
+ [166.89, -0.52]
+ ],
+ [
+ [134.56, 7.36],
+ [134.53, 7.35],
+ [134.49, 7.43],
+ [134.49, 7.52],
+ [134.53, 7.59],
+ [134.6, 7.61],
+ [134.6, 7.49],
+ [134.56, 7.43],
+ [134.56, 7.36]
+ ],
+ [
+ [-171.47, -14.06],
+ [-171.76, -14.06],
+ [-171.9, -14.01],
+ [-171.94, -14.01],
+ [-172.04, -13.92],
+ [-172.08, -13.87],
+ [-172.01, -13.83],
+ [-171.86, -13.82],
+ [-171.61, -13.89],
+ [-171.58, -13.96],
+ [-171.54, -13.96],
+ [-171.47, -13.99],
+ [-171.47, -14.06]
+ ],
+ [
+ [-172.37, -13.47],
+ [-172.22, -13.57],
+ [-172.19, -13.69],
+ [-172.26, -13.82],
+ [-172.33, -13.78],
+ [-172.51, -13.82],
+ [-172.55, -13.8],
+ [-172.76, -13.59],
+ [-172.8, -13.52],
+ [-172.69, -13.54],
+ [-172.51, -13.49],
+ [-172.37, -13.47]
+ ],
+ [
+ [103.56, 1.19],
+ [103.67, 1.42],
+ [103.72, 1.46],
+ [103.86, 1.47],
+ [104, 1.42],
+ [104.08, 1.43],
+ [104.08, 1.36],
+ [104.13, 1.27]
+ ],
+ [
+ [-174.92, -21.32],
+ [-174.92, -21.46],
+ [-175, -21.39],
+ [-175, -21.35],
+ [-174.92, -21.32]
+ ],
+ [
+ [-175.18, -21.18],
+ [-175.14, -21.14],
+ [-175.1, -21.18],
+ [-175.18, -21.26],
+ [-175.21, -21.23],
+ [-175.36, -21.16],
+ [-175.32, -21.13],
+ [-175.25, -21.13],
+ [-175.18, -21.18]
+ ],
+ [
+ [-173.99, -18.64],
+ [-174.02, -18.71],
+ [-174.1, -18.64],
+ [-174.02, -18.57],
+ [-173.95, -18.59],
+ [-173.95, -18.63],
+ [-173.99, -18.64]
+ ],
+ [
+ [178.31, -8.03],
+ [178.38, -7.93],
+ [178.45, -7.97],
+ [178.38, -8.09],
+ [178.31, -8.03]
+ ],
+ [
+ [178.67, -7.46],
+ [178.7, -7.48],
+ [178.67, -7.5],
+ [178.67, -7.46]
+ ],
+ [
+ [179.14, -8.42],
+ [179.17, -8.43],
+ [179.21, -8.52],
+ [179.1, -8.59],
+ [179.1, -8.63],
+ [179.06, -8.64],
+ [179.06, -8.59],
+ [179.03, -8.52],
+ [179.1, -8.43],
+ [179.14, -8.42]
+ ],
+ [
+ [179.86, -9.35],
+ [179.86, -9.37],
+ [179.89, -9.39],
+ [179.86, -9.44],
+ [179.86, -9.42],
+ [179.82, -9.37],
+ [179.82, -9.35],
+ [179.86, -9.35]
+ ],
+ [
+ [176.98, -12.46],
+ [177.16, -12.48],
+ [177.16, -12.53],
+ [177.01, -12.51],
+ [176.98, -12.46]
+ ],
+ [
+ [177.16, -7.25],
+ [177.12, -7.18],
+ [177.16, -7.2],
+ [177.16, -7.25]
+ ],
+ [
+ [176.08, -5.64],
+ [176.11, -5.66],
+ [176.15, -5.71],
+ [176.11, -5.67],
+ [176.04, -5.64],
+ [176.08, -5.64]
+ ],
+ [
+ [-61.73, 17.04],
+ [-61.76, 16.98],
+ [-61.87, 17],
+ [-61.91, 17.05],
+ [-61.91, 17.09],
+ [-61.84, 17.16],
+ [-61.69, 17.09],
+ [-61.69, 17.05],
+ [-61.73, 17.04]
+ ],
+ [
+ [-61.76, 17.57],
+ [-61.76, 17.54],
+ [-61.87, 17.59],
+ [-61.87, 17.71],
+ [-61.76, 17.66],
+ [-61.76, 17.57]
+ ],
+ [
+ [-59.5, 13.08],
+ [-59.53, 13.06],
+ [-59.64, 13.09],
+ [-59.68, 13.15],
+ [-59.68, 13.3],
+ [-59.6, 13.3],
+ [-59.46, 13.15],
+ [-59.5, 13.08]
+ ],
+ [
+ [-61.3, 15.25],
+ [-61.4, 15.21],
+ [-61.44, 15.39],
+ [-61.51, 15.52],
+ [-61.48, 15.59],
+ [-61.48, 15.63],
+ [-61.33, 15.58],
+ [-61.3, 15.52],
+ [-61.26, 15.37],
+ [-61.3, 15.25]
+ ],
+ [
+ [-61.73, 12],
+ [-61.76, 12.04],
+ [-61.76, 12.1],
+ [-61.69, 12.23],
+ [-61.62, 12.21],
+ [-61.66, 12.05],
+ [-61.73, 12]
+ ],
+ [
+ [-62.56, 17.1],
+ [-62.59, 17.09],
+ [-62.63, 17.12],
+ [-62.63, 17.19],
+ [-62.59, 17.19],
+ [-62.56, 17.16],
+ [-62.56, 17.1]
+ ],
+ [
+ [-62.66, 17.23],
+ [-62.74, 17.28],
+ [-62.81, 17.3],
+ [-62.84, 17.33],
+ [-62.84, 17.38],
+ [-62.81, 17.4],
+ [-62.74, 17.35],
+ [-62.7, 17.28],
+ [-62.66, 17.26],
+ [-62.66, 17.23]
+ ],
+ [
+ [-60.9, 13.81],
+ [-60.97, 13.7],
+ [-61.08, 13.77],
+ [-61.08, 13.91],
+ [-61.01, 14],
+ [-60.97, 14.07],
+ [-60.94, 14.08],
+ [-60.9, 14],
+ [-60.9, 13.81]
+ ],
+ [
+ [-61.19, 13.15],
+ [-61.22, 13.13],
+ [-61.3, 13.2],
+ [-61.3, 13.29],
+ [-61.22, 13.32],
+ [-61.19, 13.35],
+ [-61.15, 13.35],
+ [-61.15, 13.2],
+ [-61.19, 13.15]
+ ],
+ [
+ [1.42, 42.59],
+ [1.49, 42.63],
+ [1.57, 42.63],
+ [1.71, 42.57],
+ [1.71, 42.52],
+ [1.67, 42.49],
+ [1.46, 42.42],
+ [1.42, 42.44],
+ [1.42, 42.49],
+ [1.39, 42.52],
+ [1.42, 42.59]
+ ],
+ [
+ [9.61, 47.06],
+ [9.56, 47.05]
+ ],
+ [
+ [9.49, 47.18],
+ [9.53, 47.27],
+ [9.58, 47.21]
+ ],
+ [
+ [14.56, 35.84],
+ [14.53, 35.8],
+ [14.42, 35.82],
+ [14.35, 35.86],
+ [14.35, 35.98],
+ [14.53, 35.87],
+ [14.56, 35.84]
+ ],
+ [
+ [7.43, 43.74],
+ [7.36, 43.72],
+ [7.36, 43.75],
+ [7.43, 43.74]
+ ],
+ [
+ [12.37, 43.93],
+ [12.44, 43.98],
+ [12.51, 43.95],
+ [12.48, 43.89],
+ [12.4, 43.89],
+ [12.37, 43.93]
+ ],
+ [
+ [-157.42, 2.02],
+ [-157.39, 1.99],
+ [-157.32, 1.97],
+ [-157.31, 1.97],
+ [-157.31, 1.96],
+ [-157.32, 1.95],
+ [-157.32, 1.95],
+ [-157.34, 1.94],
+ [-157.34, 1.93],
+ [-157.34, 1.91],
+ [-157.35, 1.86],
+ [-157.33, 1.84],
+ [-157.29, 1.82],
+ [-157.25, 1.8],
+ [-157.24, 1.78],
+ [-157.22, 1.77],
+ [-157.21, 1.77],
+ [-157.19, 1.76],
+ [-157.18, 1.75],
+ [-157.17, 1.73],
+ [-157.18, 1.72],
+ [-157.19, 1.72],
+ [-157.2, 1.72],
+ [-157.21, 1.71],
+ [-157.22, 1.71],
+ [-157.24, 1.71],
+ [-157.25, 1.71],
+ [-157.25, 1.72],
+ [-157.26, 1.73],
+ [-157.27, 1.73],
+ [-157.28, 1.75],
+ [-157.29, 1.75],
+ [-157.31, 1.75],
+ [-157.41, 1.78],
+ [-157.45, 1.8],
+ [-157.48, 1.83],
+ [-157.51, 1.85],
+ [-157.53, 1.86],
+ [-157.54, 1.86],
+ [-157.55, 1.86],
+ [-157.57, 1.86],
+ [-157.58, 1.88],
+ [-157.58, 1.91],
+ [-157.57, 1.91],
+ [-157.54, 1.93],
+ [-157.53, 1.93],
+ [-157.52, 1.93],
+ [-157.52, 1.92],
+ [-157.52, 1.92],
+ [-157.53, 1.92],
+ [-157.55, 1.9],
+ [-157.55, 1.88],
+ [-157.54, 1.87],
+ [-157.53, 1.86],
+ [-157.51, 1.87],
+ [-157.5, 1.87],
+ [-157.5, 1.86],
+ [-157.49, 1.85],
+ [-157.48, 1.85],
+ [-157.45, 1.85],
+ [-157.43, 1.84],
+ [-157.43, 1.84],
+ [-157.44, 1.85],
+ [-157.45, 1.86],
+ [-157.46, 1.86],
+ [-157.47, 1.87],
+ [-157.48, 1.88],
+ [-157.46, 1.88],
+ [-157.45, 1.88],
+ [-157.45, 1.88],
+ [-157.45, 1.9],
+ [-157.44, 1.89],
+ [-157.43, 1.88],
+ [-157.43, 1.87],
+ [-157.42, 1.86],
+ [-157.41, 1.86],
+ [-157.41, 1.87],
+ [-157.41, 1.88],
+ [-157.42, 1.9],
+ [-157.43, 1.91],
+ [-157.42, 1.92],
+ [-157.42, 1.93],
+ [-157.41, 1.92],
+ [-157.4, 1.92],
+ [-157.41, 1.94],
+ [-157.39, 1.94],
+ [-157.39, 1.93],
+ [-157.4, 1.92],
+ [-157.39, 1.91],
+ [-157.38, 1.9],
+ [-157.38, 1.91],
+ [-157.37, 1.91],
+ [-157.36, 1.92],
+ [-157.36, 1.92],
+ [-157.35, 1.93],
+ [-157.35, 1.94],
+ [-157.36, 1.95],
+ [-157.36, 1.95],
+ [-157.38, 1.96],
+ [-157.41, 1.97],
+ [-157.45, 1.99],
+ [-157.45, 2],
+ [-157.46, 2],
+ [-157.47, 2.01],
+ [-157.48, 2.02],
+ [-157.48, 2.01],
+ [-157.49, 2],
+ [-157.5, 2],
+ [-157.51, 2.01],
+ [-157.51, 2.02],
+ [-157.5, 2.03],
+ [-157.47, 2.03],
+ [-157.42, 2.02]
+ ],
+ [
+ [6.66, 0.42],
+ [6.77, 0.28],
+ [6.66, 0.12],
+ [6.53, 0.02],
+ [6.46, 0.21],
+ [6.49, 0.31],
+ [6.66, 0.42]
+ ]
+ ],
+ "bbox": [-180, -55.61183, 180, 83.64513]
+}
diff --git a/src/client/components/Form/Button.tsx b/src/client/components/Form/Button.tsx
new file mode 100644
index 000000000..984619480
--- /dev/null
+++ b/src/client/components/Form/Button.tsx
@@ -0,0 +1,83 @@
+import { type ReactNode, type MouseEventHandler } from 'react';
+
+import styled from '@emotion/styled';
+import { keyframes } from '@emotion/react';
+import colors from 'client/styles/colors';
+import { type InputSize, applySize } from 'client/styles/dimensions';
+
+type LoadState = 'loading' | 'success' | 'error';
+
+interface ButtonProps {
+ children: ReactNode;
+ onClick?: MouseEventHandler;
+ size?: InputSize;
+ bgColor?: string;
+ fgColor?: string;
+ styles?: string;
+ title?: string;
+ type?: 'button' | 'submit' | 'reset' | undefined;
+ loadState?: LoadState;
+}
+
+const StyledButton = styled.button`
+ cursor: pointer;
+ border: none;
+ border-radius: 0.25rem;
+ font-family: var(--font-mono);
+ box-sizing: border-box;
+ width: -moz-available;
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ box-shadow: 3px 3px 0px ${colors.fgShadowColor};
+ &:hover {
+ box-shadow: 5px 5px 0px ${colors.fgShadowColor};
+ }
+ &:active {
+ box-shadow: -3px -3px 0px ${colors.fgShadowColor};
+ }
+ ${(props) => applySize(props.size)};
+ ${(props) => (props.bgColor ? `background: ${props.bgColor};` : `background: ${colors.primary};`)}
+ ${(props) => (props.fgColor ? `color: ${props.fgColor};` : `color: ${colors.background};`)}
+ ${(props) => props.styles}
+`;
+
+const spinAnimation = keyframes`
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+`;
+const SimpleLoader = styled.div`
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top: 4px solid ${colors.background};
+ width: 1rem;
+ height: 1rem;
+ animation: ${spinAnimation} 1s linear infinite;
+`;
+
+const Loader = (props: { loadState: LoadState }) => {
+ if (props.loadState === 'loading') return ;
+ if (props.loadState === 'success') return ✔ ;
+ if (props.loadState === 'error') return ✗ ;
+ return ;
+};
+
+const Button = (props: ButtonProps): JSX.Element => {
+ const { children, size, bgColor, fgColor, onClick, styles, title, loadState, type } = props;
+ return (
+ null)}
+ size={size}
+ bgColor={bgColor}
+ fgColor={fgColor}
+ styles={styles}
+ title={title?.toString()}
+ type={type || 'button'}
+ >
+ {loadState && }
+ {children}
+
+ );
+};
+
+export default Button;
diff --git a/src/client/components/Form/Card.tsx b/src/client/components/Form/Card.tsx
new file mode 100644
index 000000000..53dea32a3
--- /dev/null
+++ b/src/client/components/Form/Card.tsx
@@ -0,0 +1,44 @@
+import styled from '@emotion/styled';
+
+import { type ReactNode } from 'react';
+import ErrorBoundary from 'client/components/misc/ErrorBoundary';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+export const StyledCard = styled.section<{ styles?: string }>`
+ background: ${colors.backgroundLighter};
+ color: ${colors.textColor};
+ box-shadow: 4px 4px 0px ${colors.bgShadowColor};
+ border-radius: 8px;
+ padding: 1rem;
+ position: relative;
+ max-height: 54rem;
+ overflow: auto;
+ ${(props) => props.styles}
+`;
+
+interface CardProps {
+ children: ReactNode;
+ heading?: string;
+ styles?: string;
+ actionButtons?: ReactNode | undefined;
+}
+
+export const Card = (props: CardProps): JSX.Element => {
+ const { children, heading, styles, actionButtons } = props;
+ return (
+
+
+ {actionButtons && actionButtons}
+ {heading && (
+
+ {heading}
+
+ )}
+ {children}
+
+
+ );
+};
+
+export default StyledCard;
diff --git a/src/client/components/Form/Heading.tsx b/src/client/components/Form/Heading.tsx
new file mode 100644
index 000000000..d543cc232
--- /dev/null
+++ b/src/client/components/Form/Heading.tsx
@@ -0,0 +1,88 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { TextSizes } from 'client/styles/typography';
+import type { ReactNode } from 'react';
+
+interface HeadingProps {
+ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'p';
+ align?: 'left' | 'center' | 'right';
+ color?: string;
+ size?: 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge';
+ inline?: boolean;
+ children: ReactNode;
+ id?: string;
+ className?: string;
+}
+
+const StyledHeading = styled.h1`
+ margin: 0.5rem 0;
+ text-shadow: 2px 2px 0px ${colors.bgShadowColor};
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ font-size: ${TextSizes.medium};
+ img {
+ // Some titles have an icon
+ width: 2.5rem;
+ border-radius: 4px;
+ }
+ a {
+ // If a title is a link, keep title styles
+ color: inherit;
+ text-decoration: none;
+ display: flex;
+ }
+ ${(props) => {
+ switch (props.size) {
+ case 'xSmall':
+ return `font-size: ${TextSizes.xSmall};`;
+ case 'small':
+ return `font-size: ${TextSizes.small};`;
+ case 'medium':
+ return `font-size: ${TextSizes.large};`;
+ case 'large':
+ return `font-size: ${TextSizes.xLarge};`;
+ case 'xLarge':
+ return `font-size: ${TextSizes.xLarge};`;
+ }
+ }};
+ ${(props) => {
+ switch (props.align) {
+ case 'left':
+ return 'text-align: left;';
+ case 'right':
+ return 'text-align: right;';
+ case 'center':
+ return 'text-align: center; justify-content: center;';
+ }
+ }};
+ ${(props) => (props.color ? `color: ${props.color};` : '')}
+ ${(props) => (props.inline ? 'display: inline;' : '')}
+`;
+
+const makeAnchor = (title: string): string => {
+ return title
+ .toLowerCase()
+ .replace(/[^\w\s]|_/g, '')
+ .replace(/\s+/g, '-');
+};
+
+const Heading = (props: HeadingProps): JSX.Element => {
+ const { children, as, size, align, color, inline, id, className } = props;
+ return (
+
+ {children}
+
+ );
+};
+
+export default Heading;
diff --git a/src/client/components/Form/Input.tsx b/src/client/components/Form/Input.tsx
new file mode 100644
index 000000000..e8a7ce840
--- /dev/null
+++ b/src/client/components/Form/Input.tsx
@@ -0,0 +1,88 @@
+import { type InputHTMLAttributes } from 'react';
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { type InputSize, applySize } from 'client/styles/dimensions';
+
+type Orientation = 'horizontal' | 'vertical';
+
+interface Props {
+ id: string;
+ value: string;
+ name?: string;
+ label?: string;
+ placeholder?: string;
+ disabled?: boolean;
+ size?: InputSize;
+ orientation?: Orientation;
+ handleChange: (nweVal: React.ChangeEvent) => void;
+ handleKeyDown?: (keyEvent: React.KeyboardEvent) => void;
+}
+
+type SupportedElements = HTMLInputElement | HTMLLabelElement | HTMLDivElement;
+interface StyledInputTypes extends InputHTMLAttributes {
+ inputSize?: InputSize;
+ orientation?: Orientation;
+}
+
+const InputContainer = styled.div`
+ display: flex;
+ ${(props) => (props.orientation === 'vertical' ? 'flex-direction: column;' : '')};
+`;
+
+const StyledInput = styled.input`
+ background: ${colors.background};
+ color: ${colors.textColor};
+ border: none;
+ border-radius: 0.25rem;
+ font-family: var(--font-mono);
+ box-shadow: 3px 3px 0px ${colors.backgroundDarker};
+ &:focus {
+ outline: 1px solid ${colors.primary};
+ }
+
+ ${(props) => applySize(props.inputSize)};
+`;
+
+const StyledLabel = styled.label`
+ color: ${colors.textColor};
+ ${(props) => applySize(props.inputSize)};
+ padding: 0;
+ font-size: 1.6rem;
+`;
+
+const Input = (inputProps: Props): JSX.Element => {
+ const {
+ id,
+ value,
+ label,
+ placeholder,
+ name,
+ disabled,
+ size,
+ orientation,
+ handleChange,
+ handleKeyDown,
+ } = inputProps;
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ {})}
+ />
+
+ );
+};
+
+export default Input;
diff --git a/src/client/components/Form/Modal.tsx b/src/client/components/Form/Modal.tsx
new file mode 100644
index 000000000..87ba0fbc2
--- /dev/null
+++ b/src/client/components/Form/Modal.tsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import type { ReactNode } from 'react';
+import ReactDOM from 'react-dom';
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import Button from 'client/components/Form/Button';
+
+interface ModalProps {
+ children: ReactNode;
+ isOpen: boolean;
+ closeModal: () => void;
+}
+
+const Overlay = styled.div`
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.5);
+ animation: fadeIn 0.5s;
+
+ @keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+`;
+
+const ModalWindow = styled.div`
+ width: 80%;
+ max-width: 500px;
+ background: ${colors.backgroundLighter};
+ padding: 2rem;
+ border-radius: 12px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ position: relative;
+ animation: appear 0.5s;
+ color: ${colors.textColor};
+ box-shadow: 4px 4px 0px ${colors.bgShadowColor};
+ max-height: 80%;
+ overflow-y: auto;
+ @keyframes appear {
+ 0% {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+ pre {
+ white-space: break-spaces;
+ }
+`;
+
+const Modal: React.FC = ({ children, isOpen, closeModal }) => {
+ const handleOverlayClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ closeModal();
+ }
+ };
+
+ React.useEffect(() => {
+ const handleEscPress = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ closeModal();
+ }
+ };
+
+ if (isOpen) {
+ window.addEventListener('keydown', handleEscPress);
+ }
+
+ return () => {
+ window.removeEventListener('keydown', handleEscPress);
+ };
+ }, [isOpen, closeModal]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return ReactDOM.createPortal(
+
+
+ {children}
+
+ Close
+
+
+ ,
+ document.body,
+ );
+};
+
+export default Modal;
diff --git a/src/client/components/Form/Nav.tsx b/src/client/components/Form/Nav.tsx
new file mode 100644
index 000000000..ae967293b
--- /dev/null
+++ b/src/client/components/Form/Nav.tsx
@@ -0,0 +1,33 @@
+import styled from '@emotion/styled';
+import type { ReactNode } from 'react';
+
+import { StyledCard } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+const Header = styled(StyledCard)`
+ margin: 0 auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ align-items: center;
+ width: 95vw;
+`;
+
+const Nav = (props: { children?: ReactNode }) => {
+ return (
+
+ );
+};
+
+export default Nav;
diff --git a/src/client/components/Form/Row.tsx b/src/client/components/Form/Row.tsx
new file mode 100644
index 000000000..6b030e25a
--- /dev/null
+++ b/src/client/components/Form/Row.tsx
@@ -0,0 +1,239 @@
+import type { ReactNode } from 'react';
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import Heading from 'client/components/Form/Heading';
+
+export interface RowProps {
+ lbl: string;
+ val: string;
+ key?: string | number;
+ children?: ReactNode;
+ rowList?: RowProps[];
+ title?: string;
+ open?: boolean;
+ plaintext?: string;
+ listResults?: string[];
+}
+
+export const StyledRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ padding: 0.25rem;
+ &li {
+ border-bottom: 1px dashed ${colors.primaryTransparent} !important;
+ }
+ &:not(:last-child) {
+ border-bottom: 1px solid ${colors.primaryTransparent};
+ }
+ span.lbl {
+ font-weight: bold;
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ span.val {
+ max-width: 16rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ a {
+ color: ${colors.primary};
+ }
+ }
+`;
+
+export const Details = styled.details`
+ transition: all 0.2s ease-in-out;
+ summary {
+ padding-left: 1rem;
+ cursor: pointer;
+ }
+ summary:before {
+ content: '►';
+ position: absolute;
+ margin-left: -1rem;
+ color: ${colors.primary};
+ cursor: pointer;
+ }
+ &[open] summary:before {
+ content: '▼';
+ }
+`;
+
+const SubRowList = styled.ul`
+ margin: 0;
+ padding: 0.25rem;
+ background: ${colors.primaryTransparent};
+`;
+
+const PlainText = styled.pre`
+ background: ${colors.background};
+ width: 95%;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ border-radius: 4px;
+ padding: 0.25rem;
+`;
+
+const List = styled.ul`
+ // background: ${colors.background};
+ width: 95%;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ border-radius: 4px;
+ margin: 0;
+ padding: 0.25rem 0.25rem 0.25rem 1rem;
+ li {
+ // white-space: nowrap;
+ // overflow: hidden;
+ text-overflow: ellipsis;
+ list-style: circle;
+ &:first-letter {
+ text-transform: capitalize;
+ }
+ &::marker {
+ color: ${colors.primary};
+ }
+ }
+`;
+
+const isValidDate = (date: any): boolean => {
+ // Checks if a date is within reasonable range
+ const isInRange = (date: Date): boolean => {
+ return date >= new Date('1995-01-01') && date <= new Date('2030-12-31');
+ };
+
+ // Check if input is a timestamp
+ if (typeof date === 'number') {
+ const timestampDate = new Date(date);
+ return !isNaN(timestampDate.getTime()) && isInRange(timestampDate);
+ }
+
+ // Check if input is a date string
+ if (typeof date === 'string') {
+ const dateStringDate = new Date(date);
+ return !isNaN(dateStringDate.getTime()) && isInRange(dateStringDate);
+ }
+
+ // Check if input is a Date object
+ if (date instanceof Date) {
+ return !isNaN(date.getTime()) && isInRange(date);
+ }
+
+ return false;
+};
+
+const formatDate = (dateString: string): string => {
+ return new Intl.DateTimeFormat('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }).format(new Date(dateString));
+};
+const formatValue = (value: any): string => {
+ if (isValidDate(new Date(value))) return formatDate(value);
+ if (typeof value === 'boolean') return value ? '✅' : '❌';
+ return value;
+};
+
+const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+};
+
+const snip = (text: string, length: number = 80) => {
+ if (text.length < length) return text;
+ return `${text.substring(0, length)}...`;
+};
+
+export const ExpandableRow = (props: RowProps) => {
+ const { lbl, val, title, rowList, open } = props;
+ return (
+
+
+
+ {lbl}
+
+
+ {val.toString()}
+
+
+ {rowList && (
+
+ {rowList?.map((row: RowProps, index: number) => {
+ return (
+
+
+ {row.lbl}
+
+ copyToClipboard(row.val)}
+ >
+ {formatValue(row.val)}
+
+ {row.plaintext && {row.plaintext} }
+ {row.listResults && (
+
+ {row.listResults.map((listItem: string) => (
+ {snip(listItem)}
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export const ListRow = (props: { list: string[]; title: string }) => {
+ const { list, title } = props;
+ return (
+ <>
+
+ {title}
+
+ {list.map((entry: string, index: number) => {
+ return (
+
+ {entry}
+
+ );
+ })}
+ >
+ );
+};
+
+const Row = (props: RowProps) => {
+ const { lbl, val, title, plaintext, listResults, children } = props;
+ if (children) return {children} ;
+ return (
+
+ {lbl && (
+
+ {lbl}
+
+ )}
+ copyToClipboard(val)}>
+ {formatValue(val)}
+
+ {plaintext && {plaintext} }
+ {listResults && (
+
+ {listResults.map((listItem: string, listIndex: number) => (
+
+ {snip(listItem)}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default Row;
diff --git a/src/client/components/Results/Archives.tsx b/src/client/components/Results/Archives.tsx
new file mode 100644
index 000000000..1530acb0f
--- /dev/null
+++ b/src/client/components/Results/Archives.tsx
@@ -0,0 +1,37 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const Note = styled.small`
+ opacity: 0.5;
+ display: block;
+ margin-top: 0.5rem;
+ a {
+ color: ${colors.primary};
+ }
+`;
+
+const ArchivesCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const data = props.data;
+ return (
+
+
+
+
+
+
+
+
+
+ View historical versions of this page{' '}
+
+ here
+
+ , via the Internet Archive's Wayback Machine.
+
+
+ );
+};
+
+export default ArchivesCard;
diff --git a/src/client/components/Results/BlockLists.tsx b/src/client/components/Results/BlockLists.tsx
new file mode 100644
index 000000000..23e5fecf4
--- /dev/null
+++ b/src/client/components/Results/BlockLists.tsx
@@ -0,0 +1,20 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const BlockListsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const blockLists = props.data.blocklists;
+ return (
+
+ {blockLists.map((blocklist: any, blockIndex: number) => (
+
+ ))}
+
+ );
+};
+
+export default BlockListsCard;
diff --git a/src/client/components/Results/BuiltWith.tsx b/src/client/components/Results/BuiltWith.tsx
new file mode 100644
index 000000000..08213c485
--- /dev/null
+++ b/src/client/components/Results/BuiltWith.tsx
@@ -0,0 +1,65 @@
+import styled from '@emotion/styled';
+import type { TechnologyGroup, Technology } from 'client/utils/result-processor';
+import colors from 'client/styles/colors';
+import Card from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+
+const Outer = styled(Card)`
+ grid-row: span 2;
+`;
+
+const Row = styled.div`
+ display: flex;
+ justify-content: space-between;
+ padding: 0.25rem;
+ &:not(:last-child) {
+ border-bottom: 1px solid ${colors.primaryTransparent};
+ }
+ span.lbl {
+ font-weight: bold;
+ }
+ span.val {
+ max-width: 200px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+`;
+
+const ListRow = (props: { list: Technology[]; title: string }) => {
+ const { list, title } = props;
+ return (
+ <>
+
+ {title}
+
+ {list.map((entry: Technology, index: number) => {
+ return (
+
+ {entry.Name}
+
+ );
+ })}
+ >
+ );
+};
+
+const BuiltWithCard = (props: { data: TechnologyGroup[] }): JSX.Element => {
+ // const { created, updated, expires, nameservers } = whois;
+ return (
+
+
+ Technologies
+
+ {props.data.map((group: TechnologyGroup) => {
+ return ;
+ })}
+ {/* { created && }
+ { updated && }
+ { expires && }
+ { nameservers && } */}
+
+ );
+};
+
+export default BuiltWithCard;
diff --git a/src/client/components/Results/CarbonFootprint.tsx b/src/client/components/Results/CarbonFootprint.tsx
new file mode 100644
index 000000000..545b26c05
--- /dev/null
+++ b/src/client/components/Results/CarbonFootprint.tsx
@@ -0,0 +1,65 @@
+import styled from '@emotion/styled';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const LearnMoreInfo = styled.p`
+ font-size: 0.8rem;
+ margin-top: 0.5rem;
+ opacity: 0.75;
+ a {
+ color: ${colors.primary};
+ }
+`;
+
+const formatBytes = (n: number): string => {
+ if (n >= 1048576) return `${(n / 1048576).toFixed(2)} MB`;
+ if (n >= 1024) return `${(n / 1024).toFixed(2)} KB`;
+ return `${Math.round(n)} bytes`;
+};
+
+const formatGrams = (g: number): string => {
+ if (g >= 1000) return `${(g / 1000).toFixed(2)} kg`;
+ if (g >= 1) return `${g.toFixed(2)} g`;
+ return `${(g * 1000).toFixed(2)} mg`;
+};
+
+const formatKwh = (kwh: number): string => {
+ if (kwh >= 1) return `${kwh.toFixed(3)} kWh`;
+ if (kwh >= 0.001) return `${(kwh * 1000).toFixed(3)} Wh`;
+ return `${(kwh * 1_000_000).toFixed(2)} mWh`;
+};
+
+const CarbonCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const carbons = props.data.statistics;
+ const cleanerThan = props.data.cleanerThan;
+
+ return (
+
+ {!carbons?.adjustedBytes && Unable to calculate carbon footprint for host
}
+ {carbons?.adjustedBytes > 0 && (
+ <>
+
+
+
+ {cleanerThan > 0 && (
+
+ )}
+ >
+ )}
+
+
+ Calculated using the{' '}
+
+ Sustainable Web Model v4
+
+
+
+ );
+};
+
+export default CarbonCard;
diff --git a/src/client/components/Results/ContentLinks.tsx b/src/client/components/Results/ContentLinks.tsx
new file mode 100644
index 000000000..d56d71946
--- /dev/null
+++ b/src/client/components/Results/ContentLinks.tsx
@@ -0,0 +1,91 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+const cardStyles = `
+ small { margin-top: 1rem; opacity: 0.5; }
+ a {
+ color: ${colors.textColor};
+ }
+ details {
+ // display: inline;
+ display: flex;
+ transition: all 0.2s ease-in-out;
+ h3 {
+ display: inline;
+ }
+ summary {
+ padding: 0;
+ margin: 1rem 0 0 0;
+ cursor: pointer;
+ }
+ summary:before {
+ content: "►";
+ position: absolute;
+ margin-left: -1rem;
+ color: ${colors.primary};
+ cursor: pointer;
+ }
+ &[open] summary:before {
+ content: "▼";
+ }
+ }
+`;
+
+const getPathName = (link: string) => {
+ try {
+ const url = new URL(link);
+ return url.pathname;
+ } catch (e) {
+ return link;
+ }
+};
+
+const ContentLinksCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const internal = props.data.internal || [];
+ const external = props.data.external || [];
+ return (
+
+
+ Summary
+
+
+
+ {internal && internal.length > 0 && (
+
+
+
+ Internal Links
+
+
+ {internal.map((link: string) => (
+
+
+ {getPathName(link)}
+
+
+ ))}
+
+ )}
+ {external && external.length > 0 && (
+
+
+
+ External Links
+
+
+ {external.map((link: string) => (
+
+
+ {link}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default ContentLinksCard;
diff --git a/src/client/components/Results/Cookies.tsx b/src/client/components/Results/Cookies.tsx
new file mode 100644
index 000000000..6d269c475
--- /dev/null
+++ b/src/client/components/Results/Cookies.tsx
@@ -0,0 +1,61 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { ExpandableRow } from 'client/components/Form/Row';
+import type { Cookie } from 'client/utils/result-processor';
+
+export const parseHeaderCookies = (cookiesHeader: string[]): Cookie[] => {
+ if (!cookiesHeader || !cookiesHeader.length) return [];
+ const cookies = cookiesHeader.flatMap((cookieHeader) => {
+ return cookieHeader.split(/,(?=\s[A-Za-z0-9]+=)/).map((cookieString) => {
+ const [nameValuePair, ...attributePairs] = cookieString
+ .split('; ')
+ .map((part) => part.trim());
+ const [name, value] = nameValuePair.split('=');
+ const attributes: Record = {};
+ attributePairs.forEach((pair) => {
+ const [attributeName, attributeValue = ''] = pair.split('=');
+ attributes[attributeName] = attributeValue;
+ });
+ return { name, value, attributes };
+ });
+ });
+ return cookies;
+};
+
+const CookiesCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const headerCookies = parseHeaderCookies(props.data.headerCookies) || [];
+ const clientCookies = props.data.clientCookies || [];
+ const noCookies = !headerCookies.length && !clientCookies.length;
+ return (
+
+ {noCookies &&
}
+ {headerCookies.map((cookie: any, index: number) => {
+ const attributes = Object.keys(cookie.attributes).map((key: string) => {
+ return { lbl: key, val: cookie.attributes[key] };
+ });
+ return (
+
+ );
+ })}
+ {clientCookies.map((cookie: any, index: number) => {
+ const nameValPairs = Object.keys(cookie).map((key: string) => {
+ return { lbl: key, val: cookie[key] };
+ });
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default CookiesCard;
diff --git a/src/client/components/Results/DnsRecords.tsx b/src/client/components/Results/DnsRecords.tsx
new file mode 100644
index 000000000..e4deed61d
--- /dev/null
+++ b/src/client/components/Results/DnsRecords.tsx
@@ -0,0 +1,42 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { ListRow } from 'client/components/Form/Row';
+
+const styles = `
+ grid-row: span 2;
+ .content {
+ max-height: 50rem;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overflow-wrap: anywhere;
+ }
+`;
+
+const DnsRecordsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const dns = props.data;
+ const mx = (dns.MX || []).map((r: any) =>
+ typeof r === 'string' ? r : `${r.exchange} (priority ${r.priority})`,
+ );
+ const soa = dns.SOA ? [`${dns.SOA.nsname} (${dns.SOA.hostmaster})`] : [];
+ return (
+
+
+ {dns.A?.length > 0 && }
+ {dns.AAAA?.length > 0 && }
+ {mx.length > 0 && }
+ {dns.CNAME?.length > 0 && }
+ {dns.NS?.length > 0 && }
+ {dns.PTR?.length > 0 && }
+ {soa.length > 0 && }
+ {dns.SRV?.length > 0 && (
+ `${r.name}:${r.port} (priority ${r.priority})`)}
+ />
+ )}
+ {dns.TXT?.length > 0 &&
}
+
+
+ );
+};
+
+export default DnsRecordsCard;
diff --git a/src/client/components/Results/DnsSec.tsx b/src/client/components/Results/DnsSec.tsx
new file mode 100644
index 000000000..0c020b9af
--- /dev/null
+++ b/src/client/components/Results/DnsSec.tsx
@@ -0,0 +1,273 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { ExpandableRow, type RowProps } from 'client/components/Form/Row';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+const parseDNSKeyData = (data: string) => {
+ const dnsKey = data.split(' ');
+
+ const flags = parseInt(dnsKey[0]);
+ const protocol = parseInt(dnsKey[1]);
+ const algorithm = parseInt(dnsKey[2]);
+
+ let flagMeaning = '';
+ let protocolMeaning = '';
+ let algorithmMeaning = '';
+
+ // Flags
+ if (flags === 256) {
+ flagMeaning = 'Zone Signing Key (ZSK)';
+ } else if (flags === 257) {
+ flagMeaning = 'Key Signing Key (KSK)';
+ } else {
+ flagMeaning = 'Unknown';
+ }
+
+ // Protocol
+ protocolMeaning = protocol === 3 ? 'DNSSEC' : 'Unknown';
+
+ // Algorithm
+ switch (algorithm) {
+ case 5:
+ algorithmMeaning = 'RSA/SHA-1';
+ break;
+ case 7:
+ algorithmMeaning = 'RSASHA1-NSEC3-SHA1';
+ break;
+ case 8:
+ algorithmMeaning = 'RSA/SHA-256';
+ break;
+ case 10:
+ algorithmMeaning = 'RSA/SHA-512';
+ break;
+ case 13:
+ algorithmMeaning = 'ECDSA Curve P-256 with SHA-256';
+ break;
+ case 14:
+ algorithmMeaning = 'ECDSA Curve P-384 with SHA-384';
+ break;
+ case 15:
+ algorithmMeaning = 'Ed25519';
+ break;
+ case 16:
+ algorithmMeaning = 'Ed448';
+ break;
+ default:
+ algorithmMeaning = 'Unknown';
+ break;
+ }
+
+ return {
+ flags: flagMeaning,
+ protocol: protocolMeaning,
+ algorithm: algorithmMeaning,
+ publicKey: dnsKey[3],
+ };
+};
+
+const getRecordTypeName = (typeCode: number): string => {
+ switch (typeCode) {
+ case 1:
+ return 'A';
+ case 2:
+ return 'NS';
+ case 5:
+ return 'CNAME';
+ case 6:
+ return 'SOA';
+ case 12:
+ return 'PTR';
+ case 13:
+ return 'HINFO';
+ case 15:
+ return 'MX';
+ case 16:
+ return 'TXT';
+ case 28:
+ return 'AAAA';
+ case 33:
+ return 'SRV';
+ case 35:
+ return 'NAPTR';
+ case 39:
+ return 'DNAME';
+ case 41:
+ return 'OPT';
+ case 43:
+ return 'DS';
+ case 46:
+ return 'RRSIG';
+ case 47:
+ return 'NSEC';
+ case 48:
+ return 'DNSKEY';
+ case 50:
+ return 'NSEC3';
+ case 51:
+ return 'NSEC3PARAM';
+ case 52:
+ return 'TLSA';
+ case 53:
+ return 'SMIMEA';
+ case 55:
+ return 'HIP';
+ case 56:
+ return 'NINFO';
+ case 57:
+ return 'RKEY';
+ case 58:
+ return 'TALINK';
+ case 59:
+ return 'CDS';
+ case 60:
+ return 'CDNSKEY';
+ case 61:
+ return 'OPENPGPKEY';
+ case 62:
+ return 'CSYNC';
+ case 63:
+ return 'ZONEMD';
+ default:
+ return 'Unknown';
+ }
+};
+
+const parseDSData = (dsData: string) => {
+ const parts = dsData.split(' ');
+
+ const keyTag = parts[0];
+ const algorithm = getAlgorithmName(parseInt(parts[1], 10));
+ const digestType = getDigestTypeName(parseInt(parts[2], 10));
+ const digest = parts[3];
+
+ return {
+ keyTag,
+ algorithm,
+ digestType,
+ digest,
+ };
+};
+
+const getAlgorithmName = (code: number) => {
+ switch (code) {
+ case 1:
+ return 'RSA/MD5';
+ case 2:
+ return 'Diffie-Hellman';
+ case 3:
+ return 'DSA/SHA1';
+ case 5:
+ return 'RSA/SHA1';
+ case 6:
+ return 'DSA/NSEC3/SHA1';
+ case 7:
+ return 'RSASHA1/NSEC3/SHA1';
+ case 8:
+ return 'RSA/SHA256';
+ case 10:
+ return 'RSA/SHA512';
+ case 12:
+ return 'ECC/GOST';
+ case 13:
+ return 'ECDSA/CurveP256/SHA256';
+ case 14:
+ return 'ECDSA/CurveP384/SHA384';
+ case 15:
+ return 'Ed25519';
+ case 16:
+ return 'Ed448';
+ default:
+ return 'Unknown';
+ }
+};
+
+const getDigestTypeName = (code: number) => {
+ switch (code) {
+ case 1:
+ return 'SHA1';
+ case 2:
+ return 'SHA256';
+ case 3:
+ return 'GOST R 34.11-94';
+ case 4:
+ return 'SHA384';
+ default:
+ return 'Unknown';
+ }
+};
+
+const makeResponseList = (response: any): RowProps[] => {
+ const result = [] as RowProps[];
+ if (!response) return result;
+ if (typeof response.RD === 'boolean')
+ result.push({ lbl: 'Recursion Desired (RD)', val: response.RD });
+ if (typeof response.RA === 'boolean')
+ result.push({ lbl: 'Recursion Available (RA)', val: response.RA });
+ if (typeof response.TC === 'boolean') result.push({ lbl: 'TrunCation (TC)', val: response.TC });
+ if (typeof response.AD === 'boolean')
+ result.push({ lbl: 'Authentic Data (AD)', val: response.AD });
+ if (typeof response.CD === 'boolean')
+ result.push({ lbl: 'Checking Disabled (CD)', val: response.CD });
+ return result;
+};
+
+const makeAnswerList = (recordData: any): RowProps[] => {
+ return [
+ { lbl: 'Domain', val: recordData.name },
+ { lbl: 'Type', val: `${getRecordTypeName(recordData.type)} (${recordData.type})` },
+ { lbl: 'TTL', val: recordData.TTL },
+ { lbl: 'Algorithm', val: recordData.algorithm },
+ { lbl: 'Flags', val: recordData.flags },
+ { lbl: 'Protocol', val: recordData.protocol },
+ { lbl: 'Public Key', val: recordData.publicKey },
+ { lbl: 'Key Tag', val: recordData.keyTag },
+ { lbl: 'Digest Type', val: recordData.digestType },
+ { lbl: 'Digest', val: recordData.digest },
+ ].filter((rowData) => rowData.val && rowData.val !== 'Unknown');
+};
+
+const DnsSecCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const dnsSec = props.data;
+ return (
+
+ {['DNSKEY', 'DS', 'RRSIG'].map((key: string, index: number) => {
+ const record = dnsSec[key];
+ return (
+
+
+ {key}
+
+ {record.isFound && record.answer && (
+ <>
+
+ {record.answer.map((answer: any, index: number) => {
+ const keyData = parseDNSKeyData(answer.data);
+ const dsData = parseDSData(answer.data);
+ const label = keyData.flags && keyData.flags !== 'Unknown' ? keyData.flags : key;
+ return (
+
+ );
+ })}
+ >
+ )}
+
+ {!(record.isFound && record.answer) && record.response && (
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default DnsSecCard;
diff --git a/src/client/components/Results/DnsServer.tsx b/src/client/components/Results/DnsServer.tsx
new file mode 100644
index 000000000..8c219e8e3
--- /dev/null
+++ b/src/client/components/Results/DnsServer.tsx
@@ -0,0 +1,27 @@
+import { Card } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import Row from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const DnsServerCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const dnsSecurity = props.data;
+ return (
+
+ {dnsSecurity.dns.map((dns: any, index: number) => {
+ return (
+
+ {dnsSecurity.dns.length > 1 && (
+
+ DNS Server #{index + 1}
+
+ )}
+
+ {dns.address &&
}
+
+ );
+ })}
+
+ );
+};
+
+export default DnsServerCard;
diff --git a/src/client/components/Results/DomainLookup.tsx b/src/client/components/Results/DomainLookup.tsx
new file mode 100644
index 000000000..6adf76621
--- /dev/null
+++ b/src/client/components/Results/DomainLookup.tsx
@@ -0,0 +1,36 @@
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const cardStyles = `
+span.val {
+ &.up { color: ${colors.success}; }
+ &.down { color: ${colors.danger}; }
+}
+`;
+
+const DomainLookupCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const d = props.data || {};
+ return (
+
+ {d.domain &&
}
+ {d.created &&
}
+ {d.updated &&
}
+ {d.expires &&
}
+ {d.registryDomainId &&
}
+ {d.registrarWhoisServer &&
}
+ {d.registrar && (
+
+ Registrar
+
+ {d.registrar}
+
+
+ )}
+ {d.registrarIanaId &&
}
+ {d.dnssec &&
}
+
+ );
+};
+
+export default DomainLookupCard;
diff --git a/src/client/components/Results/Firewall.tsx b/src/client/components/Results/Firewall.tsx
new file mode 100644
index 000000000..b595daee3
--- /dev/null
+++ b/src/client/components/Results/Firewall.tsx
@@ -0,0 +1,27 @@
+import styled from '@emotion/styled';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const Note = styled.small`
+ opacity: 0.5;
+ display: block;
+ margin-top: 0.5rem;
+`;
+
+const FirewallCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const data = props.data;
+ return (
+
+
+ {data.waf &&
}
+ {!data.hasWaf && (
+
+ *The domain may be protected with a proprietary or custom WAF which we were unable to
+ identify automatically
+
+ )}
+
+ );
+};
+
+export default FirewallCard;
diff --git a/src/client/components/Results/Headers.tsx b/src/client/components/Results/Headers.tsx
new file mode 100644
index 000000000..fd9d4d043
--- /dev/null
+++ b/src/client/components/Results/Headers.tsx
@@ -0,0 +1,20 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import type { ReactNode } from 'react';
+
+const HeadersCard = (props: {
+ data: any;
+ title: string;
+ actionButtons: ReactNode;
+}): JSX.Element => {
+ const headers = props.data;
+ return (
+
+ {Object.keys(headers).map((header: string, index: number) => {
+ return
;
+ })}
+
+ );
+};
+
+export default HeadersCard;
diff --git a/src/client/components/Results/HostNames.tsx b/src/client/components/Results/HostNames.tsx
new file mode 100644
index 000000000..a575cf57c
--- /dev/null
+++ b/src/client/components/Results/HostNames.tsx
@@ -0,0 +1,56 @@
+import styled from '@emotion/styled';
+import type { HostNames } from 'client/utils/result-processor';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+
+const Row = styled.div`
+ display: flex;
+ justify-content: space-between;
+ padding: 0.25rem;
+ &:not(:last-child) {
+ border-bottom: 1px solid ${colors.primaryTransparent};
+ }
+ span:first-child {
+ font-weight: bold;
+ }
+`;
+
+const HostListSection = (props: { list: string[]; title: string }) => {
+ const { list, title } = props;
+ return (
+ <>
+
+ {title}
+
+ {list.map((entry: string, index: number) => {
+ return (
+
+ {entry}
+
+ );
+ })}
+ >
+ );
+};
+
+const cardStyles = `
+ max-height: 50rem;
+ overflow: auto;
+`;
+
+const HostNamesCard = (props: {
+ data: HostNames;
+ title: string;
+ actionButtons: any;
+}): JSX.Element => {
+ const hosts = props.data;
+ return (
+
+ {hosts.domains.length > 0 && }
+ {hosts.hostnames.length > 0 && }
+
+ );
+};
+
+export default HostNamesCard;
diff --git a/src/client/components/Results/Hsts.tsx b/src/client/components/Results/Hsts.tsx
new file mode 100644
index 000000000..842962e05
--- /dev/null
+++ b/src/client/components/Results/Hsts.tsx
@@ -0,0 +1,39 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { type RowProps } from 'client/components/Form/Row';
+
+const cardStyles = '';
+
+const parseHeader = (headerString: string): RowProps[] => {
+ return headerString.split(';').map((part) => {
+ const trimmedPart = part.trim();
+ const equalsIndex = trimmedPart.indexOf('=');
+
+ if (equalsIndex >= 0) {
+ return {
+ lbl: trimmedPart.substring(0, equalsIndex).trim(),
+ val: trimmedPart.substring(equalsIndex + 1).trim(),
+ };
+ } else {
+ return { lbl: trimmedPart, val: 'true' };
+ }
+ });
+};
+
+const HstsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const hstsResults = props.data;
+ const hstsHeaders = hstsResults?.hstsHeader ? parseHeader(hstsResults.hstsHeader) : [];
+ return (
+
+ {typeof hstsResults.compatible === 'boolean' && (
+
+ )}
+ {hstsHeaders.length > 0 &&
+ hstsHeaders.map((header: RowProps, index: number) => {
+ return
;
+ })}
+ {hstsResults.message && {hstsResults.message}
}
+
+ );
+};
+
+export default HstsCard;
diff --git a/src/client/components/Results/HttpSecurity.tsx b/src/client/components/Results/HttpSecurity.tsx
new file mode 100644
index 000000000..536f9f344
--- /dev/null
+++ b/src/client/components/Results/HttpSecurity.tsx
@@ -0,0 +1,25 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const yes = '✅ Yes';
+const no = '❌ No';
+const check = (v: any) => (v ? yes : no);
+
+const HttpSecurityCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const d = props.data;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default HttpSecurityCard;
diff --git a/src/client/components/Results/Lighthouse.tsx b/src/client/components/Results/Lighthouse.tsx
new file mode 100644
index 000000000..1568752bf
--- /dev/null
+++ b/src/client/components/Results/Lighthouse.tsx
@@ -0,0 +1,57 @@
+import { Card } from 'client/components/Form/Card';
+import { ExpandableRow } from 'client/components/Form/Row';
+
+const processScore = (percentile: number) => {
+ return `${Math.round(percentile * 100)}%`;
+};
+
+interface Audit {
+ id: string;
+ score?: number | string;
+ scoreDisplayMode?: string;
+ title?: string;
+ description?: string;
+ displayValue?: string;
+}
+
+const makeValue = (audit: Audit) => {
+ let score = audit.score;
+ if (audit.displayValue) {
+ score = audit.displayValue;
+ } else if (audit.scoreDisplayMode) {
+ score = audit.score === 1 ? '✅ Pass' : '❌ Fail';
+ }
+ return score;
+};
+
+const LighthouseCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const lighthouse = props.data;
+ const categories = lighthouse?.categories || {};
+ const audits = lighthouse?.audits || [];
+
+ return (
+
+ {Object.keys(categories).map((title: string, index: number) => {
+ const scoreIds = categories[title].auditRefs.map((ref: { id: string }) => ref.id);
+ const scoreList = scoreIds.map((id: string) => {
+ return {
+ lbl: audits[id].title,
+ val: makeValue(audits[id]),
+ title: audits[id].description,
+ key: id,
+ };
+ });
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default LighthouseCard;
diff --git a/src/client/components/Results/MailConfig.tsx b/src/client/components/Results/MailConfig.tsx
new file mode 100644
index 000000000..0eafcf642
--- /dev/null
+++ b/src/client/components/Results/MailConfig.tsx
@@ -0,0 +1,58 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+const cardStyles = ``;
+
+const MailConfigCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const mailServer = props.data;
+ const txtRecords = (mailServer.txtRecords || []).join('').toLowerCase() || '';
+ return (
+
+
+ Mail Security Checklist
+
+
+
+
+
+
+ {mailServer.mxRecords && (
+
+ MX Records
+
+ )}
+ {mailServer.mxRecords &&
+ mailServer.mxRecords.map((record: any, index: number) => (
+
+ {record.exchange}
+ {record.priority ? `Priority: ${record.priority}` : ''}
+
+ ))}
+ {mailServer.mailServices.length > 0 && (
+
+ External Mail Services
+
+ )}
+ {mailServer.mailServices &&
+ mailServer.mailServices.map((service: any, index: number) => (
+
+ ))}
+
+ {mailServer.txtRecords && (
+
+ Mail-related TXT Records
+
+ )}
+ {mailServer.txtRecords &&
+ mailServer.txtRecords.map((record: any, index: number) => (
+
+ {record}
+
+ ))}
+
+ );
+};
+
+export default MailConfigCard;
diff --git a/src/client/components/Results/OpenPorts.tsx b/src/client/components/Results/OpenPorts.tsx
new file mode 100644
index 000000000..03f820110
--- /dev/null
+++ b/src/client/components/Results/OpenPorts.tsx
@@ -0,0 +1,27 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const cardStyles = `
+ small { margin-top: 1rem; opacity: 0.5; }
+`;
+
+const OpenPortsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const portData = props.data;
+ return (
+
+ {portData.openPorts.map((port: any) => (
+
+ {port}
+
+ ))}
+
+
+ Unable to establish connections to:
+
+ {portData.failedPorts.join(', ')}
+
+
+ );
+};
+
+export default OpenPortsCard;
diff --git a/src/client/components/Results/Rank.tsx b/src/client/components/Results/Rank.tsx
new file mode 100644
index 000000000..8b9b70715
--- /dev/null
+++ b/src/client/components/Results/Rank.tsx
@@ -0,0 +1,91 @@
+import { AreaChart, Area, Tooltip, CartesianGrid, ResponsiveContainer } from 'recharts';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const cardStyles = `
+span.val {
+ &.up { color: ${colors.success}; }
+ &.down { color: ${colors.danger}; }
+}
+.rank-average {
+ text-align: center;
+ font-size: 1.8rem;
+ font-weight: bold;
+}
+.chart-container {
+ margin-top: 1rem;
+}
+`;
+
+const makeRankStats = (data: { date: string; rank: number }[]) => {
+ const average = Math.round(data.reduce((acc, cur) => acc + cur.rank, 0) / data.length);
+ const today = data[0].rank;
+ const yesterday = data[1].rank;
+ const percentageChange = ((today - yesterday) / yesterday) * 100;
+ return {
+ average,
+ percentageChange,
+ };
+};
+
+const makeChartData = (data: { date: string; rank: number }[]) => {
+ return data.map((d) => {
+ return {
+ date: d.date,
+ uv: d.rank,
+ };
+ });
+};
+
+function Chart(chartData: { date: string; uv: number }[], data: any) {
+ return (
+
+
+
+
+
+
+
+
+
+ ['Date : ', data[value].date]}
+ />
+
+
+
+ );
+}
+
+const RankCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const data = props.data.ranks || [];
+ const { average, percentageChange } = makeRankStats(data);
+ const chartData = makeChartData(data);
+ return (
+
+ {data[0].rank.toLocaleString()}
+ 0 ? '+' : ''} ${percentageChange.toFixed(2)}%`}
+ />
+
+ {Chart(chartData, data)}
+
+ );
+};
+
+export default RankCard;
diff --git a/src/client/components/Results/Redirects.tsx b/src/client/components/Results/Redirects.tsx
new file mode 100644
index 000000000..35a7d1aa1
--- /dev/null
+++ b/src/client/components/Results/Redirects.tsx
@@ -0,0 +1,42 @@
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const cardStyles = `
+ div {
+ justify-content: flex-start;
+ align-items: baseline;
+ }
+ .arrow-thing {
+ color: ${colors.primary};
+ font-size: 1.8rem;
+ font-weight: bold;
+ margin-right: 0.5rem;
+ }
+ .redirect-count {
+ color: ${colors.textColorSecondary};
+ margin: 0;
+ }
+`;
+
+const RedirectsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const redirects = props.data;
+ return (
+
+ {!redirects?.redirects.length &&
}
+
+ Followed {redirects.redirects.length} redirect{redirects.redirects.length === 1 ? '' : 's'}{' '}
+ when contacting host
+
+ {redirects.redirects.map((redirect: any, index: number) => {
+ return (
+
+ ↳ {redirect}
+
+ );
+ })}
+
+ );
+};
+
+export default RedirectsCard;
diff --git a/src/client/components/Results/RobotsTxt.tsx b/src/client/components/Results/RobotsTxt.tsx
new file mode 100644
index 000000000..f60d16674
--- /dev/null
+++ b/src/client/components/Results/RobotsTxt.tsx
@@ -0,0 +1,32 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { type RowProps } from 'client/components/Form/Row';
+
+const cardStyles = `
+ grid-row: span 2;
+ .content {
+ max-height: 50rem;
+ overflow-y: auto;
+ }
+`;
+
+const RobotsTxtCard = (props: {
+ data: { robots: RowProps[] };
+ title: string;
+ actionButtons: any;
+}): JSX.Element => {
+ const { data } = props;
+ const robots = data?.robots || [];
+
+ return (
+
+
+ {robots.length === 0 &&
No crawl rules found.
}
+ {robots.map((row: RowProps, index: number) => {
+ return
|
;
+ })}
+
+
+ );
+};
+
+export default RobotsTxtCard;
diff --git a/src/client/components/Results/Screenshot.tsx b/src/client/components/Results/Screenshot.tsx
new file mode 100644
index 000000000..4274582a0
--- /dev/null
+++ b/src/client/components/Results/Screenshot.tsx
@@ -0,0 +1,32 @@
+import { Card } from 'client/components/Form/Card';
+
+const cardStyles = `
+ overflow: auto;
+ max-height: 50rem;
+ grid-row: span 2;
+ img {
+ border-radius: 6px;
+ width: 100%;
+ margin 0.5rem 0;;
+ }
+`;
+
+const ScreenshotCard = (props: {
+ data: { image?: string; data?: string };
+ title: string;
+ actionButtons: any;
+}): JSX.Element => {
+ const screenshot = props.data;
+ return (
+
+ {screenshot.image && (
+
+ )}
+ {!screenshot.image && screenshot.data && (
+
+ )}
+
+ );
+};
+
+export default ScreenshotCard;
diff --git a/src/client/components/Results/SecurityTxt.tsx b/src/client/components/Results/SecurityTxt.tsx
new file mode 100644
index 000000000..602ff950e
--- /dev/null
+++ b/src/client/components/Results/SecurityTxt.tsx
@@ -0,0 +1,75 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { Details } from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const cardStyles = `
+small {
+ margin-top: 1rem;
+ opacity: 0.5;
+ display: block;
+ a { color: ${colors.primary}; }
+}
+summary {
+ padding: 0.5rem 0 0 0.5rem !important;
+ cursor: pointer;
+ font-weight: bold;
+}
+pre {
+ background: ${colors.background};
+ padding: 0.5rem 0.25rem;
+ border-radius: 4px;
+ overflow: auto;
+}
+`;
+
+const getPagePath = (url: string): string => {
+ try {
+ return new URL(url).pathname;
+ } catch (error) {
+ return url;
+ }
+};
+
+const SecurityTxtCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const securityTxt = props.data;
+ return (
+
+
+ {securityTxt.isPresent && (
+ <>
+
+
+ {securityTxt.fields &&
+ Object.keys(securityTxt.fields).map((field: string, index: number) => {
+ if (securityTxt.fields[field].includes('http'))
+ return (
+
+ {field}
+
+
+ {getPagePath(securityTxt.fields[field])}
+
+
+
+ );
+ return (
+
+ );
+ })}
+
+ View Full Policy
+ {securityTxt.content}
+
+ >
+ )}
+ {!securityTxt.isPresent && (
+
+ Having a security.txt ensures security researchers know how and where to safely report
+ vulnerabilities.
+
+ )}
+
+ );
+};
+
+export default SecurityTxtCard;
diff --git a/src/client/components/Results/ServerInfo.tsx b/src/client/components/Results/ServerInfo.tsx
new file mode 100644
index 000000000..bf87dbc45
--- /dev/null
+++ b/src/client/components/Results/ServerInfo.tsx
@@ -0,0 +1,26 @@
+import type { ServerInfo } from 'client/utils/result-processor';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const ServerInfoCard = (props: {
+ data: ServerInfo;
+ title: string;
+ actionButtons: any;
+}): JSX.Element => {
+ const info = props.data;
+ const { org, asn, isp, os, ports, ip, loc, type } = info;
+ return (
+
+ {org &&
}
+ {isp && isp !== org &&
}
+ {os &&
}
+ {asn &&
}
+ {ports &&
}
+ {ip &&
}
+ {type &&
}
+ {loc &&
}
+
+ );
+};
+
+export default ServerInfoCard;
diff --git a/src/client/components/Results/ServerLocation.tsx b/src/client/components/Results/ServerLocation.tsx
new file mode 100644
index 000000000..d65e413c3
--- /dev/null
+++ b/src/client/components/Results/ServerLocation.tsx
@@ -0,0 +1,71 @@
+import styled from '@emotion/styled';
+import type { ServerLocation } from 'client/utils/result-processor';
+import { Card } from 'client/components/Form/Card';
+import LocationMap from 'client/components/misc/LocationMap';
+import Flag from 'client/components/misc/Flag';
+import { TextSizes } from 'client/styles/typography';
+import Row, { StyledRow } from 'client/components/Form/Row';
+
+const cardStyles = '';
+
+const SmallText = styled.span`
+ opacity: 0.5;
+ font-size: ${TextSizes.xSmall};
+ text-align: right;
+ display: block;
+`;
+
+const MapRow = styled(StyledRow)`
+ padding-top: 1rem;
+ flex-direction: column;
+`;
+
+const CountryValue = styled.span`
+ display: flex;
+ gap: 0.5rem;
+`;
+
+const ServerLocationCard = (props: {
+ data: ServerLocation;
+ title: string;
+ actionButtons: any;
+}): JSX.Element => {
+ const location = props.data;
+ const {
+ city,
+ region,
+ country,
+ postCode,
+ countryCode,
+ coords,
+ isp,
+ timezone,
+ languages,
+ currency,
+ currencyCode,
+ } = location;
+
+ return (
+
+
+
+ Country
+
+ {country}
+ {countryCode && }
+
+
+
+
+
+
+
+
+ Latitude: {coords.latitude}, Longitude: {coords.longitude}{' '}
+
+
+
+ );
+};
+
+export default ServerLocationCard;
diff --git a/src/client/components/Results/ServerStatus.tsx b/src/client/components/Results/ServerStatus.tsx
new file mode 100644
index 000000000..feabc7587
--- /dev/null
+++ b/src/client/components/Results/ServerStatus.tsx
@@ -0,0 +1,32 @@
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const cardStyles = `
+span.val {
+ &.up { color: ${colors.success}; }
+ &.down { color: ${colors.danger}; }
+}
+`;
+
+const ServerStatusCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const serverStatus = props.data;
+ return (
+
+
+ Is Up?
+ {serverStatus.isUp ? (
+ ✅ Online
+ ) : (
+ ❌ Offline
+ )}
+
+
+ {serverStatus.responseTime && (
+
+ )}
+
+ );
+};
+
+export default ServerStatusCard;
diff --git a/src/client/components/Results/Sitemap.tsx b/src/client/components/Results/Sitemap.tsx
new file mode 100644
index 000000000..e7c7ce0f0
--- /dev/null
+++ b/src/client/components/Results/Sitemap.tsx
@@ -0,0 +1,72 @@
+import { Card } from 'client/components/Form/Card';
+import Row, { ExpandableRow } from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const cardStyles = `
+ max-height: 50rem;
+ overflow-y: auto;
+ a {
+ color: ${colors.primary};
+ }
+ small {
+ margin-top: 1rem;
+ opacity: 0.5;
+ display: block;
+ a { color: ${colors.primary}; }
+ }
+`;
+
+const SitemapCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const normalSiteMap = props.data.url || props.data.urlset?.url || null;
+ const siteMapIndex = props.data.sitemapindex?.sitemap || null;
+
+ const makeExpandableRowData = (site: any) => {
+ const results = [];
+ if (site.lastmod) {
+ results.push({ lbl: 'Last Modified', val: site.lastmod[0] });
+ }
+ if (site.changefreq) {
+ results.push({ lbl: 'Change Frequency', val: site.changefreq[0] });
+ }
+ if (site.priority) {
+ results.push({ lbl: 'Priority', val: site.priority[0] });
+ }
+ return results;
+ };
+
+ const getPathFromUrl = (url: string) => {
+ try {
+ const urlObj = new URL(url);
+ return urlObj.pathname;
+ } catch (e) {
+ return url;
+ }
+ };
+
+ return (
+
+ {normalSiteMap &&
+ normalSiteMap.map((subpage: any, index: number) => {
+ return (
+
+ );
+ })}
+ {siteMapIndex && This site returns a sitemap index, which is a list of sitemaps.
}
+ {siteMapIndex &&
+ siteMapIndex.map((subpage: any, index: number) => {
+ return (
+
+ {getPathFromUrl(subpage.loc[0])}
+
+ );
+ })}
+
+ );
+};
+
+export default SitemapCard;
diff --git a/src/client/components/Results/SocialTags.tsx b/src/client/components/Results/SocialTags.tsx
new file mode 100644
index 000000000..538864539
--- /dev/null
+++ b/src/client/components/Results/SocialTags.tsx
@@ -0,0 +1,60 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const cardStyles = `
+ .banner-image img {
+ width: 100%;
+ border-radius: 4px;
+ margin: 0.5rem 0;
+ }
+ .color-field {
+ border-radius: 4px;
+ &:hover {
+ color: ${colors.primary};
+ }
+ }
+`;
+
+const OgBanner = ({ ogImage, ogUrl }: { ogImage: string; ogUrl?: string }): JSX.Element => {
+ const urlCover = ogImage.startsWith('/') && ogUrl ? `${ogUrl}${ogImage}` : ogImage;
+ return (
+
+
+
+ );
+};
+
+const SocialTagsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const tags = props.data;
+ return (
+
+ {tags.title &&
}
+ {tags.description &&
}
+ {tags.keywords &&
}
+ {tags.canonicalUrl &&
}
+ {tags.themeColor && (
+
+ Theme Color
+
+ {tags.themeColor}
+
+
+ )}
+ {tags.twitterSite && (
+
+ Twitter Site
+
+ {tags.twitterSite}
+
+
+ )}
+ {tags.author &&
}
+ {tags.publisher &&
}
+ {tags.generator &&
}
+ {tags.ogImage && }
+
+ );
+};
+
+export default SocialTagsCard;
diff --git a/src/client/components/Results/SslCert.tsx b/src/client/components/Results/SslCert.tsx
new file mode 100644
index 000000000..78c62f7be
--- /dev/null
+++ b/src/client/components/Results/SslCert.tsx
@@ -0,0 +1,120 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+
+const Row = styled.div`
+ display: flex;
+ justify-content: space-between;
+ padding: 0.25rem;
+ &:not(:last-child) {
+ border-bottom: 1px solid ${colors.primaryTransparent};
+ }
+ span.lbl {
+ font-weight: bold;
+ }
+ span.val {
+ max-width: 200px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+`;
+
+const formatDate = (dateString: string): string => {
+ const date = new Date(dateString);
+ const formatter = new Intl.DateTimeFormat('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+ return formatter.format(date);
+};
+
+const DataRow = (props: { lbl: string; val: string }) => {
+ const { lbl, val } = props;
+ return (
+
+ {lbl}
+
+ {val}
+
+
+ );
+};
+
+function getExtendedKeyUsage(oids: string[]) {
+ const oidMap: { [key: string]: string } = {
+ '1.3.6.1.5.5.7.3.1': 'TLS Web Server Authentication',
+ '1.3.6.1.5.5.7.3.2': 'TLS Web Client Authentication',
+ '1.3.6.1.5.5.7.3.3': 'Code Signing',
+ '1.3.6.1.5.5.7.3.4': 'Email Protection (SMIME)',
+ '1.3.6.1.5.5.7.3.8': 'Time Stamping',
+ '1.3.6.1.5.5.7.3.9': 'OCSP Signing',
+ '1.3.6.1.5.5.7.3.5': 'IPSec End System',
+ '1.3.6.1.5.5.7.3.6': 'IPSec Tunnel',
+ '1.3.6.1.5.5.7.3.7': 'IPSec User',
+ '1.3.6.1.5.5.8.2.2': 'IKE Intermediate',
+ '2.16.840.1.113730.4.1': 'Netscape Server Gated Crypto',
+ '1.3.6.1.4.1.311.10.3.3': 'Microsoft Server Gated Crypto',
+ '1.3.6.1.4.1.311.10.3.4': 'Microsoft Encrypted File System',
+ '1.3.6.1.4.1.311.20.2.2': 'Microsoft Smartcard Logon',
+ '1.3.6.1.4.1.311.10.3.12': 'Microsoft Document Signing',
+ '0.9.2342.19200300.100.1.3': 'Email Address (in Subject Alternative Name)',
+ };
+ const results = oids.map((oid) => oidMap[oid] || oid);
+ return results.filter((item, index) => results.indexOf(item) === index);
+}
+
+const ListRow = (props: { list: string[]; title: string }) => {
+ const { list, title } = props;
+ return (
+ <>
+
+ {title}
+
+ {list.map((entry: string, index: number) => {
+ return (
+
+ {entry}
+
+ );
+ })}
+ >
+ );
+};
+
+const SslCertCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const sslCert = props.data;
+ const {
+ subject,
+ issuer,
+ fingerprint,
+ serialNumber,
+ asn1Curve,
+ nistCurve,
+ valid_to,
+ valid_from,
+ ext_key_usage,
+ } = sslCert;
+ return (
+
+ {subject && }
+ {issuer?.O && }
+ {typeof sslCert.isValid === 'boolean' && (
+
+ )}
+ {asn1Curve && }
+ {nistCurve && }
+ {valid_to && }
+ {valid_from && }
+ {serialNumber && }
+ {fingerprint && }
+ {ext_key_usage && (
+
+ )}
+
+ );
+};
+
+export default SslCertCard;
diff --git a/src/client/components/Results/Subdomains.tsx b/src/client/components/Results/Subdomains.tsx
new file mode 100644
index 000000000..12874386c
--- /dev/null
+++ b/src/client/components/Results/Subdomains.tsx
@@ -0,0 +1,48 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const cardStyles = `
+ ul {
+ list-style: none;
+ padding: 0;
+ margin: 0.5rem 0 0 0;
+ li {
+ padding: 0.25rem;
+ border-bottom: 1px solid ${colors.primaryTransparent};
+ &:last-child { border-bottom: none }
+ }
+ a {
+ color: ${colors.textColor};
+ &:hover { color: ${colors.primary} }
+ }
+ }
+ small {
+ display: block;
+ margin-top: 0.75rem;
+ opacity: 0.5;
+ }
+`;
+
+const SubdomainsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const { domain, count, truncated, subdomains = [], source } = props.data;
+ return (
+
+
+
+ {truncated &&
}
+
+ {subdomains.map((sub: string) => (
+
+
+ {sub}
+
+
+ ))}
+
+ {source && Source: Certificate Transparency logs via {source} }
+
+ );
+};
+
+export default SubdomainsCard;
diff --git a/src/client/components/Results/TechStack.tsx b/src/client/components/Results/TechStack.tsx
new file mode 100644
index 000000000..d10bbdb1b
--- /dev/null
+++ b/src/client/components/Results/TechStack.tsx
@@ -0,0 +1,133 @@
+import styled from '@emotion/styled';
+import { Card } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+const cardStyles = `
+ grid-row: span 2;
+ small {
+ margin-top: 1rem;
+ opacity: 0.5;
+ display: block;
+ a { color: ${colors.primary}; }
+ }
+`;
+
+const TechStackRow = styled.div`
+ transition: all 0.2s ease-in-out;
+ .r1 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+ h4 {
+ margin: 0.5rem 0 0 0;
+ }
+ .r2 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+ .tech-version {
+ opacity: 0.5;
+ }
+ .tech-confidence,
+ .tech-categories {
+ font-size: 0.8rem;
+ opacity: 0.5;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ flex: 1;
+ text-align: right;
+ }
+ .tech-confidence {
+ display: none;
+ }
+ .tech-description,
+ .tech-website {
+ font-size: 0.8rem;
+ margin: 0.25rem 0;
+ font-style: italic;
+
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ &.tech-website {
+ -webkit-line-clamp: 1;
+ }
+ a {
+ color: ${colors.primary};
+ opacity: 0.75;
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+ .tech-icon {
+ min-width: 2.5rem;
+ border-radius: 4px;
+ margin: 0.5rem 0;
+ }
+ &:not(:last-child) {
+ border-bottom: 1px solid ${colors.primaryTransparent};
+ }
+ &:hover {
+ .tech-confidence {
+ display: block;
+ }
+ .tech-categories {
+ display: none;
+ }
+ }
+`;
+
+const TechStackCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const technologies = props.data.technologies;
+ const iconsCdn = 'https://www.wappalyzer.com/images/icons/';
+ return (
+
+ {technologies.map((tech: any, index: number) => {
+ return (
+
+
+
+ {tech.name}
+ {tech.version ? `(v${tech.version})` : ''}
+
+
+ Certainty: {tech.confidence}%
+
+
+ {tech.categories.map(
+ (cat: any, i: number) =>
+ `${cat.name}${i < tech.categories.length - 1 ? ', ' : ''}`,
+ )}
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default TechStackCard;
diff --git a/src/client/components/Results/Threats.tsx b/src/client/components/Results/Threats.tsx
new file mode 100644
index 000000000..46d5edb0c
--- /dev/null
+++ b/src/client/components/Results/Threats.tsx
@@ -0,0 +1,101 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Row, { ExpandableRow } from 'client/components/Form/Row';
+
+const Expandable = styled.details`
+ margin-top: 0.5rem;
+ cursor: pointer;
+ summary::marker {
+ color: ${colors.primary};
+ }
+`;
+
+const getExpandableTitle = (urlObj: any) => {
+ let pathName = '';
+ try {
+ pathName = new URL(urlObj.url).pathname;
+ } catch (e) {}
+ return `${pathName} (${urlObj.id})`;
+};
+
+const convertToDate = (dateString: string): string => {
+ const [date, time] = dateString.split(' ');
+ const [year, month, day] = date.split('-').map(Number);
+ const [hour, minute, second] = time.split(':').map(Number);
+ const dateObject = new Date(year, month - 1, day, hour, minute, second);
+ if (isNaN(dateObject.getTime())) {
+ return dateString;
+ }
+ return dateObject.toString();
+};
+
+const MalwareCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const urlHaus = props.data.urlHaus || {};
+ const phishTank = props.data.phishTank || {};
+ const cloudmersive = props.data.cloudmersive || {};
+ const safeBrowsing = props.data.safeBrowsing || {};
+ return (
+
+ {safeBrowsing && !safeBrowsing.error && (
+
+ )}
+ {((cloudmersive && !cloudmersive.error) || safeBrowsing?.details) && (
+
+ )}
+ {phishTank && !phishTank.error && (
+
+ )}
+ {phishTank.url0?.valid === 'true' && phishTank.url0.phish_detail_page && (
+
+ Phish Info
+
+ {phishTank.url0.phish_id}
+
+
+ )}
+ {urlHaus.query_status === 'no_results' && (
+
+ )}
+ {urlHaus.query_status === 'ok' && (
+ <>
+
+
+
+ >
+ )}
+ {urlHaus.urls && (
+
+ Expand Results
+ {urlHaus.urls.map((urlResult: any) => {
+ const rows = [
+ { lbl: 'ID', val: urlResult.id },
+ { lbl: 'Status', val: urlResult.url_status },
+ { lbl: 'Date Added', val: convertToDate(urlResult.date_added) },
+ { lbl: 'Threat Type', val: urlResult.threat },
+ { lbl: 'Reported By', val: urlResult.reporter },
+ { lbl: 'Takedown Time', val: urlResult.takedown_time_seconds },
+ { lbl: 'Larted', val: urlResult.larted },
+ { lbl: 'Tags', val: (urlResult.tags || []).join(', ') },
+ { lbl: 'Reference', val: urlResult.urlhaus_reference },
+ { lbl: 'File Path', val: urlResult.url },
+ ];
+ return ;
+ })}
+
+ )}
+
+ );
+};
+
+export default MalwareCard;
diff --git a/src/client/components/Results/TlsClientCompat.tsx b/src/client/components/Results/TlsClientCompat.tsx
new file mode 100644
index 000000000..f2f096e33
--- /dev/null
+++ b/src/client/components/Results/TlsClientCompat.tsx
@@ -0,0 +1,71 @@
+import { Card } from 'client/components/Form/Card';
+import { ExpandableRow } from 'client/components/Form/Row';
+
+const PROTOCOL_NAMES: Record = {
+ 512: 'SSL 2.0',
+ 768: 'SSL 3.0',
+ 769: 'TLS 1.0',
+ 770: 'TLS 1.1',
+ 771: 'TLS 1.2',
+ 772: 'TLS 1.3',
+};
+
+const buildTitle = (c: any): string => {
+ const platform = c.platform ? ` (${c.platform})` : '';
+ const version = c.version ? ` ${c.version}` : '';
+ const ref = c.isReference ? ' *' : '';
+ return `${c.name || 'Client'}${platform}${version}${ref}`;
+};
+
+const optionalRow = (lbl: string, val: any) =>
+ val == null || val === '' ? null : { lbl, val: String(val) };
+
+// Per-client fields shown when the simulated handshake succeeded
+const successFields = (sim: any) =>
+ [
+ optionalRow(
+ 'Protocol',
+ PROTOCOL_NAMES[sim.protocolId] || (sim.protocolId ? String(sim.protocolId) : null),
+ ),
+ optionalRow('Cipher', sim.suiteName),
+ optionalRow('Key Exchange', sim.kxType),
+ optionalRow('Key Exchange Strength', sim.kxStrength),
+ optionalRow('DH Strength', sim.dhStrength),
+ optionalRow('Key Algorithm', sim.keyAlg),
+ optionalRow('Key Size', sim.keySize),
+ optionalRow('Signature Algorithm', sim.sigAlg),
+ optionalRow('Attempts', sim.attempts > 1 ? sim.attempts : null),
+ ].filter(Boolean) as { lbl: string; val: string }[];
+
+// Per-client fields shown when the simulated handshake failed
+const failureFields = (sim: any) => {
+ const reason =
+ sim.alertDescription || sim.errorMessage || 'This client cannot complete the TLS handshake';
+ return [{ lbl: '', val: '', plaintext: reason }];
+};
+
+const TlsClientCompatCard = (props: {
+ data: any;
+ title: string;
+ actionButtons: any;
+}): JSX.Element | null => {
+ const sims = props.data?.endpoints?.[0]?.details?.sims?.results;
+ if (!sims?.length) return null;
+ return (
+
+ {sims.map((sim: any, i: number) => {
+ const ok = sim.errorCode === 0;
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default TlsClientCompatCard;
diff --git a/src/client/components/Results/TlsConnection.tsx b/src/client/components/Results/TlsConnection.tsx
new file mode 100644
index 000000000..903146741
--- /dev/null
+++ b/src/client/components/Results/TlsConnection.tsx
@@ -0,0 +1,48 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+import colors from 'client/styles/colors';
+
+const yesNo = (v: boolean) => (v ? '✅ Yes' : '❌ No');
+
+const ocspStatus = (v: boolean) => (v ? 'Yes' : 'Not enabled');
+
+const formatEphemeralKey = (k: any): string => {
+ if (!k?.type) return '';
+ const parts = [k.type];
+ if (k.name) parts.push(`(${k.name})`);
+ if (k.size) parts.push(`${k.size}-bit`);
+ return parts.join(' ');
+};
+
+const TlsConnectionCard = (props: {
+ data: any;
+ title: string;
+ actionButtons: any;
+}): JSX.Element => {
+ const d = props.data || {};
+ const cipherName = d.cipher?.standardName || d.cipher?.name || '';
+ const ephemeral = formatEphemeralKey(d.ephemeralKey);
+ return (
+
+ {d.protocol &&
}
+ {cipherName &&
}
+ {d.cipher?.version &&
}
+ {ephemeral &&
}
+ {d.alpnProtocol &&
}
+
+
+
+ OCSP Stapling
+
+ {d.ocspStapled ? 'ⓘ Present' : 'ⓘ Not Present'} — may impact visitor privacy
+
+
+
+
+ );
+};
+
+export default TlsConnectionCard;
diff --git a/src/client/components/Results/TlsSecurityAudit.tsx b/src/client/components/Results/TlsSecurityAudit.tsx
new file mode 100644
index 000000000..759bba09d
--- /dev/null
+++ b/src/client/components/Results/TlsSecurityAudit.tsx
@@ -0,0 +1,106 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+// Booleans are direct, ints follow SSL Labs scheme: 1 = safe, 2/3 = vulnerable
+const isVulnerable = (v: any): boolean => {
+ if (typeof v === 'boolean') return v;
+ if (typeof v === 'number') return v === 2 || v === 3;
+ return false;
+};
+
+const yesNo = (v: any) => (v ? '✅ Yes' : '❌ No');
+const yesNoVuln = (v: any) => (v ? '⚠️ Yes' : '✅ No');
+
+const fwdSecrecy = (n: number): string => {
+ if (!n) return '❌ No';
+ if (n & 4) return '✅ With all browsers';
+ if (n & 2) return '⚠️ With most modern browsers';
+ if (n & 1) return '⚠️ Limited';
+ return '❌ No';
+};
+
+const hsts = (policy?: any): string => {
+ if (!policy || policy.status !== 'present') return '❌ Not set';
+ const days = Math.round((policy.maxAge || 0) / 86400);
+ const sub = policy.includeSubDomains ? ' + includeSubDomains' : '';
+ return days >= 365 ? `✅ ${days} days${sub}` : `⚠️ ${days} days${sub} (recommend >= 365)`;
+};
+
+const reneg = (n?: number): string => {
+ if (!n) return '⚠️ Insecure';
+ const flags = [];
+ if (n & 1) flags.push('secure');
+ if (n & 2) flags.push('client-init');
+ if (n & 4) flags.push('server-required');
+ return `✅ ${flags.join(', ') || 'unknown'}`;
+};
+
+const sessionResumption = (n?: number): string =>
+ n === 1 ? '✅ Tickets' : n === 2 ? '✅ Tickets (no client cert)' : '❌ Disabled';
+
+const compression = (n?: number): string => (n ? '⚠️ Supported (CRIME risk)' : '✅ Disabled');
+
+// Each entry returns the value string, or null/undefined to hide the row entirely
+const POSTURE: Array<[string, (d: any) => string | null | undefined]> = [
+ ['Forward Secrecy', (d) => fwdSecrecy(d.forwardSecrecy)],
+ ['HSTS', (d) => hsts(d.hstsPolicy)],
+ ['Session Resumption', (d) => sessionResumption(d.sessionResumption)],
+ ['OCSP Stapling', (d) => yesNo(d.ocspStapling)],
+ ['Renegotiation', (d) => reneg(d.renegSupport)],
+ ['Fallback SCSV (downgrade protection)', (d) => yesNo(d.fallbackScsv)],
+ ['Certificate Transparency', (d) => yesNo(d.hasSct)],
+ ['HTTP -> HTTPS Forwarding', (d) => (d.httpForwarding == null ? null : yesNo(d.httpForwarding))],
+ ['ChaCha20 Preferred', (d) => yesNo(d.chaCha20Preference)],
+ ['AEAD Cipher Support', (d) => yesNo(d.supportsAead)],
+ ['Legacy CBC Cipher Support', (d) => yesNoVuln(d.supportsCBC)],
+ ['TLS Compression', (d) => compression(d.compressionMethods)],
+ ['Static DH Key Reuse', (d) => yesNoVuln(d.dhYsReuse)],
+ ['Weak DH Primes', (d) => yesNoVuln(d.dhUsesKnownPrimes)],
+ ['Static ECDH Parameter Reuse', (d) => yesNoVuln(d.ecdhParameterReuse)],
+];
+
+const VULNS: Array<[string, string]> = [
+ ['heartbleed', 'Heartbleed'],
+ ['poodle', 'POODLE (SSLv3)'],
+ ['poodleTls', 'POODLE (TLS)'],
+ ['zombiePoodle', 'Zombie POODLE'],
+ ['goldenDoodle', 'GOLDENDOODLE'],
+ ['sleepingPoodle', 'Sleeping POODLE'],
+ ['zeroLengthPaddingOracle', 'Zero-Length Padding Oracle'],
+ ['freak', 'FREAK'],
+ ['logjam', 'LOGJAM'],
+ ['drownVulnerable', 'DROWN'],
+ ['openSslCcs', 'OpenSSL CCS Injection'],
+ ['openSSLLuckyMinus20', 'Lucky13'],
+ ['bleichenbacher', 'ROBOT (Bleichenbacher)'],
+ ['ticketbleed', 'Ticketbleed'],
+];
+
+const TlsSecurityAuditCard = (props: {
+ data: any;
+ title: string;
+ actionButtons: any;
+}): JSX.Element | null => {
+ const ep = props.data?.endpoints?.[0];
+ if (!ep?.details) return null;
+ const d = ep.details;
+ const protocols = (d.protocols || []).map((p: any) => `${p.name} ${p.version}`).join(', ');
+ return (
+
+ {ep.grade &&
}
+ {ep.gradeTrustIgnored && ep.gradeTrustIgnored !== ep.grade && (
+
+ )}
+ {protocols &&
}
+ {POSTURE.map(([label, getter]) => {
+ const val = getter(d);
+ return val ?
: null;
+ })}
+ {VULNS.map(([key, label]) => (
+
+ ))}
+
+ );
+};
+
+export default TlsSecurityAuditCard;
diff --git a/src/client/components/Results/TraceRoute.tsx b/src/client/components/Results/TraceRoute.tsx
new file mode 100644
index 000000000..f5fadef59
--- /dev/null
+++ b/src/client/components/Results/TraceRoute.tsx
@@ -0,0 +1,63 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+
+const RouteRow = styled.div`
+ text-align: center;
+ width: fit-content;
+ margin: 0 auto;
+ .ipName {
+ font-size: 1rem;
+ }
+`;
+
+const RouteTimings = styled.div`
+ p {
+ margin: 0 auto;
+ }
+ .arrow {
+ font-size: 2.5rem;
+ color: ${colors.primary};
+ margin-top: -1rem;
+ }
+ .times {
+ font-size: 0.85rem;
+ color: ${colors.textColorSecondary};
+ }
+ .completed {
+ text-align: center;
+ font-weight: bold;
+ }
+`;
+
+const cardStyles = ``;
+
+const TraceRouteCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const traceRouteResponse = props.data;
+ const routes = traceRouteResponse.result;
+ return (
+
+ {routes
+ .filter((x: any) => x)
+ .map((route: any, index: number) => (
+
+ {Object.keys(route)[0]}
+
+ {route[Object.keys(route)[0]].map((time: any, packetIndex: number) => (
+
+ {route[Object.keys(route)[0]].length > 1 && <>Packet #{packetIndex + 1}:>}
+ Took {time} ms
+
+ ))}
+ ↓
+
+
+ ))}
+
+ Round trip completed in {traceRouteResponse.timeTaken} ms
+
+
+ );
+};
+
+export default TraceRouteCard;
diff --git a/src/client/components/Results/TxtRecords.tsx b/src/client/components/Results/TxtRecords.tsx
new file mode 100644
index 000000000..05ce27400
--- /dev/null
+++ b/src/client/components/Results/TxtRecords.tsx
@@ -0,0 +1,22 @@
+import { Card } from 'client/components/Form/Card';
+import Row from 'client/components/Form/Row';
+
+const cardStyles = `
+grid-column: span 2;
+span.val { max-width: 32rem !important; }
+span { overflow: hidden; }
+`;
+
+const TxtRecordCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
+ const records = props.data;
+ return (
+
+ {!records &&
}
+ {Object.keys(records).map((recordName: any, index: number) => {
+ return
;
+ })}
+
+ );
+};
+
+export default TxtRecordCard;
diff --git a/src/client/components/Results/WhoIs.tsx b/src/client/components/Results/WhoIs.tsx
new file mode 100644
index 000000000..34786a7f8
--- /dev/null
+++ b/src/client/components/Results/WhoIs.tsx
@@ -0,0 +1,17 @@
+import type { Whois } from 'client/utils/result-processor';
+import { Card } from 'client/components/Form/Card';
+import Row, { ListRow } from 'client/components/Form/Row';
+
+const WhoIsCard = (props: { data: Whois; title: string; actionButtons: any }): JSX.Element => {
+ const { created, updated, expires, nameservers } = props.data;
+ return (
+
+ {created &&
}
+ {updated &&
}
+ {expires &&
}
+ {nameservers && }
+
+ );
+};
+
+export default WhoIsCard;
diff --git a/src/client/components/boundaries/PageError.tsx b/src/client/components/boundaries/PageError.tsx
new file mode 100644
index 000000000..1ccc315d2
--- /dev/null
+++ b/src/client/components/boundaries/PageError.tsx
@@ -0,0 +1,145 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import colors from 'client/styles/colors';
+import Heading from 'client/components/Form/Heading';
+import Footer from 'client/components/misc/Footer';
+import Nav from 'client/components/Form/Nav';
+import Button from 'client/components/Form/Button';
+import { StyledCard } from 'client/components/Form/Card';
+import { Link } from 'react-router-dom';
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ errorCount: number;
+ errorMessage: string | null;
+}
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+}
+
+const ErrorPageContainer = styled.div`
+width: 95vw;
+max-width: 1000px;
+margin: 2rem auto;
+padding-bottom: 1rem;
+header {
+ margin 1rem 0;
+ width: auto;
+}
+section {
+ width: auto;
+ .inner-heading { display: none; }
+}
+`;
+
+const HeaderLinkContainer = styled.nav`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ a {
+ text-decoration: none;
+ }
+`;
+
+const ErrorInner = styled(StyledCard)`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ h3 {
+ font-size: 6rem;
+ }
+`;
+
+const ErrorDetails = styled.div`
+ background: ${colors.primaryTransparent};
+ padding: 1rem;
+ border-radius: 0.5rem;
+`;
+
+const ErrorMessageText = styled.p`
+ color: ${colors.danger};
+`;
+
+class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, errorCount: 0, errorMessage: null };
+ }
+
+ static getDerivedStateFromError(err: Error): ErrorBoundaryState {
+ return { hasError: true, errorCount: 0, errorMessage: err.message };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('Uncaught error:', error, errorInfo);
+ console.error(
+ `%cCritical Error%c\n\nRoute or component failed to mount%c:%c\n` +
+ `${this.state.errorCount < 1 ? 'Will attempt a page reload' : ''}. ` +
+ `Error Details:\n${error}\n\n${JSON.stringify(errorInfo || {})}`,
+ `background: ${colors.danger}; color:${colors.background}; padding: 4px 8px; font-size: 16px;`,
+ `font-weight: bold; color: ${colors.danger};`,
+ `color: ${colors.danger};`,
+ `color: ${colors.warning};`,
+ );
+ if (this.state.errorCount < 1) {
+ this.setState((prevState) => ({ errorCount: prevState.errorCount + 1 }));
+ window.location.reload();
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
+ Go back Home
+
+
+ View on GitHub
+
+
+
+
+
+ Something's gone wrong
+
+
+ An unexpected error occurred.
+
+
+ 🤯
+
+
+
+ We're sorry this happened. Usually reloading the page will resolve this, but if it
+ doesn't, please raise a bug report.
+
+ {this.state.errorMessage && (
+
+ Below is the error message we received:
+
+
+ {this.state.errorMessage}
+
+ )}
+
+ window.location.reload()}>Reload Page
+
+ Report Issue
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/src/client/components/misc/ActionButtons.tsx b/src/client/components/misc/ActionButtons.tsx
new file mode 100644
index 000000000..0af159104
--- /dev/null
+++ b/src/client/components/misc/ActionButtons.tsx
@@ -0,0 +1,60 @@
+import styled from '@emotion/styled';
+import Button from 'client/components/Form/Button';
+import colors from 'client/styles/colors';
+
+const ActionButtonContainer = styled.div`
+ position: absolute;
+ top: 0.25rem;
+ right: 0.25rem;
+ opacity: 0.75;
+ display: flex;
+ gap: 0.125rem;
+ align-items: baseline;
+`;
+
+interface Action {
+ label: string;
+ icon: string;
+ onClick: () => void;
+}
+
+const actionButtonStyles = `
+ padding: 0 0.25rem;
+ font-size: 1.25rem;
+ text-align: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ color: ${colors.textColor};
+ background: none;
+ box-shadow: none;
+ transition: all 0.2s ease-in-out;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ &:hover {
+ color: ${colors.primary};
+ background: ${colors.backgroundDarker};
+ box-shadow: none;
+ }
+`;
+
+const ActionButtons = (props: { actions: any }): JSX.Element => {
+ const actions = props.actions;
+ if (!actions) return <>>;
+ return (
+
+ {actions.map((action: Action, index: number) => (
+
+ {action.icon}
+
+ ))}
+
+ );
+};
+
+export default ActionButtons;
diff --git a/src/client/components/misc/AdditionalResources.tsx b/src/client/components/misc/AdditionalResources.tsx
new file mode 100644
index 000000000..b5c4ccc5d
--- /dev/null
+++ b/src/client/components/misc/AdditionalResources.tsx
@@ -0,0 +1,329 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+
+const ResourceListOuter = styled.ul`
+ list-style: none;
+ margin: 0;
+ padding: 1rem;
+ display: grid;
+ gap: 0.5rem;
+ grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
+ li a.resource-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ background: ${colors.background};
+ border-radius: 8px;
+ text-decoration: none;
+ color: ${colors.textColor};
+ height: 100%;
+
+ transition: all 0.2s ease-in-out;
+ cursor: pointer;
+ border: none;
+ border-radius: 0.25rem;
+ font-family: var(--font-mono);
+ box-sizing: border-box;
+ width: -moz-available;
+ box-shadow: 3px 3px 0px ${colors.backgroundDarker};
+ &:hover {
+ box-shadow: 5px 5px 0px ${colors.backgroundDarker};
+ a {
+ opacity: 1;
+ }
+ }
+ &:active {
+ box-shadow: -3px -3px 0px ${colors.fgShadowColor};
+ }
+ }
+ img {
+ width: 2.5rem;
+ border-radius: 4px;
+ margin: 0.25rem 0.1rem 0.1rem 0.1rem;
+ }
+ p,
+ a {
+ margin: 0;
+ }
+ .resource-link {
+ color: ${colors.primary};
+ opacity: 0.75;
+ font-size: 0.9rem;
+ transition: all 0.2s ease-in-out;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+ .resource-title {
+ font-weight: bold;
+ }
+ .resource-lower {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+ .resource-details {
+ max-width: 20rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ .resource-description {
+ color: ${colors.textColorSecondary};
+ font-size: 0.9rem;
+ }
+ }
+`;
+
+const Note = styled.small`
+ margin-top: 1rem;
+ opacity: 0.5;
+ display: block;
+ a {
+ color: ${colors.primary};
+ }
+`;
+
+const CardStyles = `
+ margin: 0 auto;
+ width: 95vw;
+ position: relative;
+ transition: all 0.2s ease-in-out;
+ max-height: 100%;
+`;
+
+const resources = [
+ {
+ title: 'Hudson Rock',
+ link: 'https://hudsonrock.com/free-tools/?=webcheck',
+ icon: 'https://pixelflare.cc/alicia/icons/hudson-rock.png/w128',
+ description: 'Identify Infostealer infection data related to domains and emails',
+ },
+ {
+ title: 'SSL Labs Test',
+ link: 'https://ssllabs.com/ssltest/analyze.html',
+ icon: 'https://pixelflare.cc/alicia/icons/qualys-ssl-labs.png/w128',
+ description: 'Analyzes the SSL configuration of a server and grades it',
+ searchLink: 'https://www.ssllabs.com/ssltest/analyze.html?d={DOMAIN}',
+ },
+ {
+ title: 'Virus Total',
+ link: 'https://virustotal.com',
+ icon: 'https://pixelflare.cc/alicia/icons/virustotal.png/w128',
+ description: 'Checks a URL against multiple antivirus engines',
+ searchLink: 'https://www.virustotal.com/gui/domain/{DOMAIN}',
+ },
+ {
+ title: 'Shodan',
+ link: 'https://shodan.io/',
+ icon: 'https://pixelflare.cc/alicia/icons/shodan.png/w128',
+ description: 'Search engine for Internet-connected devices',
+ searchLink: 'https://www.shodan.io/search/report?query={DOMAIN}',
+ },
+ {
+ title: 'Archive',
+ link: 'https://archive.org/',
+ icon: 'https://pixelflare.cc/alicia/icons/internet-archive.png/w128',
+ description: 'View previous versions of a site via the Internet Archive',
+ searchLink: 'https://web.archive.org/web/*/{URL}',
+ },
+ {
+ title: 'URLScan',
+ link: 'https://urlscan.io/',
+ icon: 'https://pixelflare.cc/alicia/icons/urlscan.png/w128',
+ description: 'Scans a URL and provides information about the page',
+ searchLink: 'https://urlscan.io/domain/{DOMAIN}',
+ },
+ {
+ title: 'Sucuri SiteCheck',
+ link: 'https://sitecheck.sucuri.net/',
+ icon: 'https://pixelflare.cc/alicia/icons/sucuri.png/w128',
+ description: 'Checks a URL against blacklists and known threats',
+ searchLink: 'https://sitecheck.sucuri.net/results/{DOMAIN}',
+ },
+ {
+ title: 'Domain Tools',
+ link: 'https://whois.domaintools.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/domaintools.png/w128',
+ description: 'Run a WhoIs lookup on a domain',
+ searchLink: 'https://whois.domaintools.com/{DOMAIN}',
+ },
+ {
+ title: 'NS Lookup',
+ link: 'https://nslookup.io/',
+ icon: 'https://pixelflare.cc/alicia/icons/nslookup.png/w128',
+ description: 'View DNS records for a domain',
+ searchLink: 'https://www.nslookup.io/domains/{DOMAIN}/dns-records/',
+ },
+ {
+ title: 'DNS Checker',
+ link: 'https://dnschecker.org/',
+ icon: 'https://pixelflare.cc/alicia/icons/dns-checker.png/w128',
+ description: 'Check global DNS propagation across multiple servers',
+ searchLink: 'https://dnschecker.org/#A/{DOMAIN}',
+ },
+ {
+ title: 'Censys',
+ link: 'https://search.censys.io/',
+ icon: 'https://pixelflare.cc/alicia/icons/censys.png/w128',
+ description: 'Lookup hosts associated with a domain',
+ searchLink: 'https://search.censys.io/search?resource=hosts&q={DOMAIN}',
+ },
+ {
+ title: 'Page Speed Insights',
+ link: 'https://developers.google.com/speed/pagespeed/insights/',
+ icon: 'https://pixelflare.cc/alicia/icons/page-speed-insights.png/w128',
+ description: 'Checks the performance, accessibility and SEO of a page on mobile + desktop',
+ searchLink: 'https://developers.google.com/speed/pagespeed/insights/?url={URL}',
+ },
+ {
+ title: 'Built With',
+ link: 'https://builtwith.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/built-with.png/w128',
+ description: 'View the tech stack of a website',
+ searchLink: 'https://builtwith.com/{DOMAIN}',
+ },
+ {
+ title: 'DNS Dumpster',
+ link: 'https://dnsdumpster.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/dnsdumpster.png/w128',
+ description: "DNS recon tool, to map out a domain from it's DNS records",
+ },
+ {
+ title: 'BGP Tools',
+ link: 'https://bgp.tools/',
+ icon: 'https://pixelflare.cc/alicia/icons/bgp-tools.png/w128',
+ description: 'View realtime BGP data for any ASN, Prefix or DNS',
+ searchLink: 'https://bgp.tools/dns/{DOMAIN}',
+ },
+ {
+ title: 'Similar Web',
+ link: 'https://similarweb.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/similar-web.png/w128',
+ description: 'View approx traffic and engagement stats for a website',
+ searchLink: 'https://similarweb.com/website/{DOMAIN}/',
+ },
+ {
+ title: 'Blacklist Checker',
+ link: 'https://blacklistchecker.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/black-list-checker.png/w128',
+ description: 'Check if a domain, IP or email is present on the top blacklists',
+ searchLink: 'https://blacklistchecker.com/check?input={DOMAIN}',
+ },
+ {
+ title: 'Cloudflare Radar',
+ link: 'https://radar.cloudflare.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/cloudflare.png/w128',
+ description: 'View traffic source locations for a domain through Cloudflare',
+ searchLink: 'https://radar.cloudflare.com/domains/domain/{DOMAIN}',
+ },
+ {
+ title: 'Mozilla HTTP Observatory',
+ link: 'https://developer.mozilla.org/en-US/observatory',
+ icon: 'https://pixelflare.cc/alicia/icons/mozilla.png/w128',
+ description:
+ 'Assesses website security posture by analyzing various security headers and practices',
+ searchLink: 'https://developer.mozilla.org/en-US/observatory/analyze?host={DOMAIN}',
+ },
+ {
+ title: 'AbuseIPDB',
+ link: 'https://abuseipdb.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/abuseipdb.png/w128',
+ description: "Checks a website against Zscaler's dynamic risk scoring engine",
+ searchLink: 'https://www.abuseipdb.com/check?query={DOMAIN}',
+ },
+ {
+ title: 'IBM X-Force Exchange',
+ link: 'https://exchange.xforce.ibmcloud.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/ibm-x-force-exchange.png/w128',
+ description: 'View shared human and machine generated threat intelligence',
+ searchLink: 'https://exchange.xforce.ibmcloud.com/url/{URL_ENCODED}',
+ },
+ {
+ title: 'URLVoid',
+ link: 'https://urlvoid.com/',
+ icon: 'https://pixelflare.cc/alicia/icons/urlvoid.png/w128',
+ description: 'Checks a website across 30+ blocklist engines and website reputation services',
+ searchLink: 'https://urlvoid.com/scan/{DOMAIN}',
+ },
+ {
+ title: 'URLhaus',
+ link: 'https://urlhaus.abuse.ch/',
+ icon: 'https://pixelflare.cc/alicia/icons/urlhaus.png/w128',
+ description: "Checks if the site is in URLhaus's malware URL exchange",
+ searchLink: 'https://urlhaus.abuse.ch/browse.php?search={URL_ENCODED}',
+ },
+ {
+ title: 'ANY.RUN',
+ link: 'https://any.run/',
+ icon: 'https://pixelflare.cc/alicia/icons/anyrun.png/w128',
+ description: 'An interactive malware and web sandbox',
+ },
+];
+
+const makeLink = (resource: any, scanUrl: string | undefined): string => {
+ return scanUrl && resource.searchLink
+ ? resource.searchLink
+ .replaceAll('{URL}', scanUrl.replace(/(https?:\/\/)?/i, ''))
+ .replaceAll(
+ '{URL_ENCODED}',
+ encodeURIComponent(scanUrl.replace(/(https?:\/\/)?/i, '')).replace(
+ /['\.*]/g,
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
+ ),
+ )
+ .replaceAll(
+ '{DOMAIN}',
+ scanUrl.replace(/(https?:\/\/)?(www.)?/i, '').replace(/(\/.*)/i, ''),
+ )
+ : resource.link;
+};
+
+const AdditionalResources = (props: { url?: string }): JSX.Element => {
+ return (
+
+
+ {resources.map((resource, index) => {
+ return (
+
+
+ {resource.title}
+ window.open(resource.link, '_blank')}
+ title={`Open: ${resource.link}`}
+ >
+ {new URL(resource.link).hostname}
+
+
+
+
+
{resource.description}
+
+
+
+
+ );
+ })}
+
+
+ These tools are not affiliated with Web-Check. Please use them at your own risk.
+
+ At the time of listing, all of the above were available and free to use - if this changes,
+ please report it via GitHub (
+
+ lissy93/web-check
+
+ ).
+
+
+ );
+};
+
+export default AdditionalResources;
diff --git a/src/client/components/misc/AdvisoryPanel.tsx b/src/client/components/misc/AdvisoryPanel.tsx
new file mode 100644
index 000000000..b0e8e6484
--- /dev/null
+++ b/src/client/components/misc/AdvisoryPanel.tsx
@@ -0,0 +1,166 @@
+import { useMemo, type ReactNode } from 'react';
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import Card from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import type { Finding, Severity } from 'client/analysis/types';
+
+const ORDER: Severity[] = ['critical', 'issue', 'warning', 'info', 'pass'];
+
+interface SevMeta {
+ label: string;
+ color: string;
+ glyph: string;
+ defaultOpen: boolean;
+}
+
+const META: Record = {
+ critical: { label: 'Critical', color: colors.danger, glyph: '✕', defaultOpen: true },
+ issue: { label: 'Issues', color: colors.error, glyph: '!', defaultOpen: true },
+ warning: { label: 'Warnings', color: colors.warning, glyph: '△', defaultOpen: false },
+ info: { label: 'Informational', color: colors.info, glyph: 'ⓘ', defaultOpen: false },
+ pass: { label: 'Passes', color: colors.success, glyph: '✓', defaultOpen: false },
+};
+
+const Wrapper = styled(Card)`
+ margin: 0 auto;
+ width: 95vw;
+ max-height: 100%;
+ h2 {
+ margin: 0 0 0.75rem 0;
+ }
+ details {
+ border-radius: 4px;
+ margin: 0.4rem 0;
+ padding: 0.25rem 0.5rem;
+ summary {
+ cursor: pointer;
+ font-weight: 600;
+ padding: 0.35rem 0;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ &::-webkit-details-marker {
+ display: none;
+ }
+ &:before {
+ content: '►';
+ color: currentColor;
+ font-size: 0.85rem;
+ }
+ .count {
+ color: ${colors.textColorSecondary};
+ font-weight: 400;
+ font-size: 0.9rem;
+ }
+ }
+ &[open] summary:before {
+ content: '▼';
+ }
+ }
+ ul.findings {
+ list-style: none;
+ margin: 0.25rem 0 0.5rem 0;
+ padding: 0;
+ li {
+ display: grid;
+ grid-template-columns: 1.25rem 1fr;
+ gap: 0.5rem;
+ align-items: baseline;
+ padding: 0.3rem 0;
+ .glyph {
+ font-weight: 700;
+ line-height: 1;
+ text-align: center;
+ align-self: center;
+ }
+ .body {
+ button.jump {
+ background: none;
+ border: none;
+ color: ${colors.textColor};
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+ text-align: left;
+ cursor: pointer;
+ &:hover,
+ &:focus-visible {
+ color: ${colors.primary};
+ outline: none;
+ }
+ }
+ .detail {
+ color: ${colors.textColorSecondary};
+ font-size: 0.85rem;
+ display: block;
+ }
+ }
+ }
+ }
+`;
+
+interface Props {
+ findings: Finding[];
+ onJumpTo: (cardId: string) => void;
+}
+
+// Group findings by severity, render summary + collapsible sections, hide when empty
+const AdvisoryPanel = ({ findings, onJumpTo }: Props): ReactNode => {
+ const { grouped, visible } = useMemo(() => {
+ const grouped: Record = {
+ critical: [],
+ issue: [],
+ warning: [],
+ info: [],
+ pass: [],
+ };
+ for (const f of findings) grouped[f.severity].push(f);
+ return { grouped, visible: ORDER.filter((sev) => grouped[sev].length) };
+ }, [findings]);
+
+ if (!findings.length) return null;
+
+ return (
+
+
+ Advisory
+
+ {visible.map((sev) => {
+ const meta = META[sev];
+ const items = grouped[sev];
+ return (
+
+
+ {meta.label}
+ ({items.length})
+
+
+ {items.map((f, i) => (
+
+
+ {meta.glyph}
+
+
+ onJumpTo(f.cardId)}>
+ {f.title}
+
+ {f.detail && {f.detail} }
+
+
+ ))}
+
+
+ );
+ })}
+
+ );
+};
+
+export default AdvisoryPanel;
diff --git a/src/client/components/misc/DocContent.tsx b/src/client/components/misc/DocContent.tsx
new file mode 100644
index 000000000..f33eae138
--- /dev/null
+++ b/src/client/components/misc/DocContent.tsx
@@ -0,0 +1,80 @@
+import styled from '@emotion/styled';
+import docs, { type Doc } from 'client/utils/docs';
+import colors from 'client/styles/colors';
+import Heading from 'client/components/Form/Heading';
+
+const JobDocsContainer = styled.div`
+ p.doc-desc,
+ p.doc-uses,
+ ul {
+ margin: 0.25rem auto 1.5rem auto;
+ }
+ ul {
+ padding: 0 0.5rem 0 1rem;
+ }
+ ul li a {
+ color: ${colors.primary};
+ }
+ summary {
+ color: ${colors.primary};
+ }
+ h4 {
+ border-top: 1px solid ${colors.primary};
+ color: ${colors.primary};
+ opacity: 0.75;
+ padding: 0.5rem 0;
+ }
+`;
+
+const DocContent = (id: string) => {
+ const doc = docs.filter((doc: Doc) => doc.id === id)[0] || null;
+ return doc ? (
+
+
+ {doc.title}
+
+
+ About
+
+ {doc.description}
+
+ Use Cases
+
+ {doc.use}
+
+ Links
+
+
+ {doc.resources.map((resource: string | { title: string; link: string }, index: number) =>
+ typeof resource === 'string' ? (
+
+
+ {resource}
+
+
+ ) : (
+
+
+ {resource.title}
+
+
+ ),
+ )}
+
+
+
+
+ Example
+
+
+
+
+
+ ) : (
+
+ No Docs provided for this widget yet
+
+ );
+};
+
+export default DocContent;
diff --git a/src/client/components/misc/ErrorBoundary.tsx b/src/client/components/misc/ErrorBoundary.tsx
new file mode 100644
index 000000000..c42869058
--- /dev/null
+++ b/src/client/components/misc/ErrorBoundary.tsx
@@ -0,0 +1,62 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import styled from '@emotion/styled';
+import Card from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+interface Props {
+ children: ReactNode;
+ title?: string;
+ key?: string;
+}
+
+interface State {
+ hasError: boolean;
+ errorMessage: string | null;
+}
+
+const ErrorText = styled.p`
+ color: ${colors.danger};
+`;
+
+class ErrorBoundary extends Component {
+ public state: State = {
+ hasError: false,
+ errorMessage: null,
+ };
+
+ // Catch errors in any components below and re-render with error message
+ public static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, errorMessage: error.message };
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('Uncaught error:', error, errorInfo);
+ }
+
+ public render() {
+ if (this.state.hasError) {
+ return (
+
+ {this.props.title && {this.props.title} }
+ This component errored unexpectedly
+
+ Usually this happens if the result from the server was not what was expected. Check the
+ logs for more info. If you continue to experience this issue, please raise a ticket on
+ the repository.
+
+ {this.state.errorMessage && (
+
+ Error Details
+ {this.state.errorMessage}
+
+ )}
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/src/components/misc/FancyBackground.tsx b/src/client/components/misc/FancyBackground.tsx
similarity index 80%
rename from src/components/misc/FancyBackground.tsx
rename to src/client/components/misc/FancyBackground.tsx
index c6f4a99ff..5415d6924 100644
--- a/src/components/misc/FancyBackground.tsx
+++ b/src/client/components/misc/FancyBackground.tsx
@@ -1,7 +1,6 @@
-
+import { useEffect, useMemo } from 'react';
const FancyBackground = (): JSX.Element => {
-
const makeAbsolute = (elem: HTMLElement) => {
elem.style.position = 'absolute';
elem.style.top = '0';
@@ -12,7 +11,9 @@ const FancyBackground = (): JSX.Element => {
const chaos = 30;
const iteratee = (e: any) => e.field + chaos * Math.random();
let result;
- if (array == null) { return result; }
+ if (array == null) {
+ return result;
+ }
let computed;
for (const value of array) {
const current = iteratee(value);
@@ -23,11 +24,10 @@ const FancyBackground = (): JSX.Element => {
}
return result;
};
-
- const App: any = {};
-
- App.setup = function () {
+ const App: any = useMemo(() => [], []);
+
+ App.setup = function () {
this.lifespan = 1000;
this.popPerBirth = 1;
this.maxPop = 300;
@@ -55,11 +55,10 @@ const FancyBackground = (): JSX.Element => {
this.ctx.msImageSmoothingEnabled = false;
this.xC = this.width / 2;
this.yC = this.height / 2;
-
+
this.stepCount = 0;
this.particles = [];
-
// Build grid
this.gridSize = 8; // Motion coords
this.gridSteps = Math.floor(1000 / this.gridSize);
@@ -71,10 +70,10 @@ const FancyBackground = (): JSX.Element => {
var r = Math.sqrt(xx * xx + yy * yy),
r0 = 100,
field;
-
+
if (r < r0) field = (255 / r0) * r;
else if (r > r0) field = 255 - Math.min(255, (r - r0) / 2);
-
+
this.grid.push({
x: xx,
y: yy,
@@ -84,35 +83,35 @@ const FancyBackground = (): JSX.Element => {
xx === -500
? 'left'
: xx === -500 + this.gridSize * (this.gridSteps - 1)
- ? 'right'
- : yy === -500
- ? 'top'
- : yy === -500 + this.gridSize * (this.gridSteps - 1)
- ? 'bottom'
- : false,
+ ? 'right'
+ : yy === -500
+ ? 'top'
+ : yy === -500 + this.gridSize * (this.gridSteps - 1)
+ ? 'bottom'
+ : false,
field: field,
});
i++;
}
}
this.gridMaxIndex = i;
-
+
// Counters for UI
this.drawnInLastFrame = 0;
this.deathCount = 0;
-
+
this.initDraw();
};
App.evolve = function () {
var time1 = performance.now();
-
+
this.stepCount++;
-
+
// Increment all grid ages
this.grid.forEach(function (e: any) {
if (e.busyAge > 0) e.busyAge++;
});
-
+
if (
this.stepCount % this.birthFreq === 0 &&
this.particles.length + this.popPerBirth < this.maxPop
@@ -121,18 +120,22 @@ const FancyBackground = (): JSX.Element => {
}
App.move();
App.draw();
-
+
var time2 = performance.now();
-
+
// Update UI
- document.getElementsByClassName('dead')[0].textContent = this.deathCount;
- document.getElementsByClassName('alive')[0].textContent =
- this.particles.length;
- document.getElementsByClassName('fps')[0].textContent = Math.floor(
- 1000 / (time2 - time1)
- ).toString();
- document.getElementsByClassName('drawn')[0].textContent =
- this.drawnInLastFrame;
+ const elemDead = document.getElementsByClassName('dead');
+ if (elemDead && elemDead.length > 0) elemDead[0].textContent = this.deathCount;
+
+ const elemAlive = document.getElementsByClassName('alive');
+ if (elemAlive && elemAlive.length > 0) elemAlive[0].textContent = this.particles.length;
+
+ const elemFPS = document.getElementsByClassName('fps');
+ if (elemFPS && elemFPS.length > 0)
+ elemFPS[0].textContent = Math.round(1000 / (time2 - time1)).toString();
+
+ const elemDrawn = document.getElementsByClassName('drawn');
+ if (elemDrawn && elemDrawn.length > 0) elemDrawn[0].textContent = this.drawnInLastFrame;
};
App.birth = function () {
var x, y;
@@ -140,7 +143,7 @@ const FancyBackground = (): JSX.Element => {
var gridSpot = this.grid[gridSpotIndex];
x = gridSpot.x;
y = gridSpot.y;
-
+
var particle = {
hue: 200, // + Math.floor(50*Math.random()),
sat: 95, //30 + Math.floor(70*Math.random()),
@@ -162,24 +165,22 @@ const FancyBackground = (): JSX.Element => {
this.particles.push(particle);
};
App.kill = function (particleName: any) {
- const newArray = this.particles.filter(
- (seed: any) => seed.name !== particleName
- );
+ const newArray = this.particles.filter((seed: any) => seed.name !== particleName);
this.particles = [...newArray];
};
App.move = function () {
for (var i = 0; i < this.particles.length; i++) {
// Get particle
var p = this.particles[i];
-
+
// Save last position
p.xLast = p.x;
p.yLast = p.y;
-
+
// Attractor and corresponding grid spot
var index = p.attractor.gridSpotIndex,
gridSpot = this.grid[index];
-
+
// Maybe move attractor and with certain constraints
if (Math.random() < 0.5) {
// Move attractor
@@ -193,16 +194,11 @@ const FancyBackground = (): JSX.Element => {
bottomSpot = this.grid[bottomIndex],
leftSpot = this.grid[leftIndex],
rightSpot = this.grid[rightIndex];
-
- var maxFieldSpot = maxBy(
- [topSpot, bottomSpot, leftSpot, rightSpot]
- );
-
+
+ var maxFieldSpot = maxBy([topSpot, bottomSpot, leftSpot, rightSpot]);
+
var potentialNewGridSpot = maxFieldSpot;
- if (
- potentialNewGridSpot.busyAge === 0 ||
- potentialNewGridSpot.busyAge > 15
- ) {
+ if (potentialNewGridSpot.busyAge === 0 || potentialNewGridSpot.busyAge > 15) {
p.ageSinceStuck = 0;
p.attractor.oldIndex = index;
p.attractor.gridSpotIndex = potentialNewGridSpot.spotIndex;
@@ -210,38 +206,39 @@ const FancyBackground = (): JSX.Element => {
gridSpot.busyAge = 1;
} else p.ageSinceStuck++;
} else p.ageSinceStuck++;
-
+
if (p.ageSinceStuck === 10) this.kill(p.name);
}
-
+
// Spring attractor to center with viscosity
- const k = 8, visc = 0.4;
+ const k = 8,
+ visc = 0.4;
var dx = p.x - gridSpot.x,
dy = p.y - gridSpot.y,
dist = Math.sqrt(dx * dx + dy * dy);
-
+
// Spring
var xAcc = -k * dx,
yAcc = -k * dy;
-
+
p.xSpeed += xAcc;
p.ySpeed += yAcc;
-
+
// Calm the f*ck down
p.xSpeed *= visc;
p.ySpeed *= visc;
-
+
// Store stuff in particle brain
p.speed = Math.sqrt(p.xSpeed * p.xSpeed + p.ySpeed * p.ySpeed);
p.dist = dist;
-
+
// Update position
p.x += 0.1 * p.xSpeed;
p.y += 0.1 * p.ySpeed;
-
+
// Get older
p.age++;
-
+
// Kill if too old
if (p.age > this.lifespan) {
this.kill(p.name);
@@ -259,57 +256,50 @@ const FancyBackground = (): JSX.Element => {
App.draw = function () {
this.drawnInLastFrame = 0;
if (!this.particles.length) return false;
-
+
this.ctx.beginPath();
this.ctx.rect(0, 0, this.width, this.height);
this.ctx.fillStyle = this.bgColor;
this.ctx.fill();
this.ctx.closePath();
-
+
for (var i = 0; i < this.particles.length; i++) {
var p = this.particles[i];
-
+
var last = this.dataXYtoCanvasXY(p.xLast, p.yLast),
now = this.dataXYtoCanvasXY(p.x, p.y);
var attracSpot = this.grid[p.attractor.gridSpotIndex],
attracXY = this.dataXYtoCanvasXY(attracSpot.x, attracSpot.y);
var oldAttracSpot = this.grid[p.attractor.oldIndex],
oldAttracXY = this.dataXYtoCanvasXY(oldAttracSpot.x, oldAttracSpot.y);
-
+
this.ctx.beginPath();
this.ctx.strokeStyle = '#9fef00';
this.ctx.fillStyle = '#9fef00';
-
+
// Particle trail
this.ctx.moveTo(last.x, last.y);
this.ctx.lineTo(now.x, now.y);
-
+
this.ctx.lineWidth = 1.5 * this.dataToImageRatio;
this.ctx.stroke();
this.ctx.closePath();
-
+
// Attractor positions
this.ctx.beginPath();
this.ctx.lineWidth = 1.5 * this.dataToImageRatio;
this.ctx.moveTo(oldAttracXY.x, oldAttracXY.y);
this.ctx.lineTo(attracXY.x, attracXY.y);
- this.ctx.arc(
- attracXY.x,
- attracXY.y,
- 1.5 * this.dataToImageRatio,
- 0,
- 2 * Math.PI,
- false
- );
+ this.ctx.arc(attracXY.x, attracXY.y, 1.5 * this.dataToImageRatio, 0, 2 * Math.PI, false);
this.ctx.strokeStyle = '#9fef00';
this.ctx.fillStyle = '#9fef00';
this.ctx.stroke();
this.ctx.fill();
-
+
this.ctx.closePath();
-
+
// UI counter
this.drawnInLastFrame++;
}
@@ -318,30 +308,41 @@ const FancyBackground = (): JSX.Element => {
var zoom = 1.6;
var xx = this.xC + x * zoom * this.dataToImageRatio,
yy = this.yC + y * zoom * this.dataToImageRatio;
-
+
return { x: xx, y: yy };
};
-
- document.addEventListener('DOMContentLoaded', function () {
+
+ useEffect(() => {
App.setup();
App.draw();
-
- var frame = function () {
- App.evolve();
+ const frameInterval = 25;
+ let lastTime = 0;
+ const frame = (now: number) => {
+ if (now - lastTime >= frameInterval) {
+ App.evolve();
+ lastTime = now;
+ }
requestAnimationFrame(frame);
};
- frame();
- });
-
+ requestAnimationFrame(frame);
+ }, [App]);
return (
-
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
);
-}
+};
export default FancyBackground;
diff --git a/src/client/components/misc/Flag.tsx b/src/client/components/misc/Flag.tsx
new file mode 100644
index 000000000..83e5253df
--- /dev/null
+++ b/src/client/components/misc/Flag.tsx
@@ -0,0 +1,13 @@
+interface Props {
+ countryCode: string;
+ width: number;
+}
+
+const Flag = ({ countryCode, width }: Props) => {
+ const dimensions = `${width}x${width * 0.75}`;
+ const country = countryCode.toLowerCase();
+ const src = `https://flagcdn.com/${dimensions}/${country}.png`;
+ return ;
+};
+
+export default Flag;
diff --git a/src/client/components/misc/Footer.tsx b/src/client/components/misc/Footer.tsx
new file mode 100644
index 000000000..811489061
--- /dev/null
+++ b/src/client/components/misc/Footer.tsx
@@ -0,0 +1,60 @@
+import styled from '@emotion/styled';
+import { Link } from 'react-router-dom';
+import colors from 'client/styles/colors';
+
+const StyledFooter = styled.footer`
+ bottom: 0;
+ width: 100%;
+ text-align: center;
+ padding: 0.5rem 0;
+ background: ${colors.backgroundDarker};
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ align-content: center;
+ flex-wrap: wrap;
+ opacity: 0.75;
+ transition: all 0.2s ease-in-out;
+ @media (min-width: 1024px) {
+ justify-content: space-between;
+ }
+ &:hover {
+ opacity: 1;
+ }
+ span {
+ margin: 0 0.5rem;
+ text-align: center;
+ }
+`;
+
+const ALink = styled.a`
+ color: ${colors.primary};
+ font-weight: bold;
+ border-radius: 4px;
+ padding: 0.1rem;
+ transition: all 0.2s ease-in-out;
+ &:hover {
+ background: ${colors.primary};
+ color: ${colors.backgroundDarker};
+ text-decoration: none;
+ }
+`;
+
+const Footer = (props: { isFixed?: boolean }): JSX.Element => {
+ const licenseUrl = 'https://github.com/lissy93/web-check/blob/master/LICENSE';
+ const authorUrl = 'https://aliciasykes.com';
+ const githubUrl = 'https://github.com/lissy93/web-check';
+ return (
+
+
+ View source at github.com/lissy93/web-check
+
+
+ Web-Check is licensed under MIT -
+ © Alicia Sykes 2026
+
+
+ );
+};
+
+export default Footer;
diff --git a/src/client/components/misc/Loader.tsx b/src/client/components/misc/Loader.tsx
new file mode 100644
index 000000000..67290b08c
--- /dev/null
+++ b/src/client/components/misc/Loader.tsx
@@ -0,0 +1,126 @@
+import styled from '@emotion/styled';
+
+import { StyledCard } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import colors from 'client/styles/colors';
+
+const LoaderContainer = styled(StyledCard)`
+ margin: 0 auto;
+ width: 95vw;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ gap: 2rem;
+ height: 50vh;
+ transition:
+ height 0.3s ease-in-out,
+ opacity 0.3s ease-in-out;
+ p.loadTimeInfo {
+ text-align: center;
+ margin: 0;
+ color: ${colors.textColorSecondary};
+ opacity: 0.5;
+ }
+ &.flex {
+ display: flex;
+ }
+ &.finished {
+ height: 0;
+ overflow: hidden;
+ opacity: 0;
+ margin-top: -1rem;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ &.hide {
+ display: none;
+ }
+`;
+
+const StyledSvg = styled.svg`
+ width: 200px;
+ margin: 0 auto;
+ path {
+ fill: ${colors.primary};
+ &:nth-of-type(2) {
+ opacity: 0.8;
+ }
+ &:nth-of-type(3) {
+ opacity: 0.5;
+ }
+ }
+`;
+
+const Loader = (props: { show: boolean }): JSX.Element => {
+ return (
+
+
+ Crunching data...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ It may take up-to a minute for all jobs to complete
+
+ You can view preliminary results as they come in below
+
+
+ );
+};
+
+export default Loader;
diff --git a/src/client/components/misc/LocationMap.tsx b/src/client/components/misc/LocationMap.tsx
new file mode 100644
index 000000000..ae0129d7c
--- /dev/null
+++ b/src/client/components/misc/LocationMap.tsx
@@ -0,0 +1,52 @@
+import { ComposableMap, Geographies, Geography, Annotation } from 'react-simple-maps';
+
+import colors from 'client/styles/colors';
+import MapFeatures from 'client/assets/data/map-features.json';
+
+interface Props {
+ lat: number;
+ lon: number;
+ label?: string;
+}
+
+const MapChart = (location: Props) => {
+ const { lat, lon, label } = location;
+
+ return (
+
+
+ {({ geographies }: any) =>
+ geographies.map((geo: any) => )
+ }
+
+
+
+ {label || 'Server'}
+
+
+
+ );
+};
+
+export default MapChart;
diff --git a/src/client/components/misc/NoResults.tsx b/src/client/components/misc/NoResults.tsx
new file mode 100644
index 000000000..c04d36a32
--- /dev/null
+++ b/src/client/components/misc/NoResults.tsx
@@ -0,0 +1,112 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { StyledCard } from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+
+const Wrapper = styled(StyledCard)`
+ margin: 0 auto;
+ width: 95vw;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ h2 {
+ margin: 0;
+ }
+ p {
+ margin: 0;
+ }
+ .target {
+ font-family: var(--font-mono);
+ background: ${colors.background};
+ padding: 0.4rem 0.6rem;
+ border-radius: 4px;
+ word-break: break-all;
+ align-self: flex-start;
+ max-width: 100%;
+ color: ${colors.textColor};
+ }
+ .reasons {
+ margin: 0;
+ padding-left: 1.25rem;
+ color: ${colors.textColorSecondary};
+ li {
+ padding: 0.15rem 0;
+ }
+ }
+ .detail {
+ color: ${colors.textColorSecondary};
+ font-size: 0.85rem;
+ word-break: break-word;
+ }
+`;
+
+type Kind = 'unreachable' | 'invalid' | 'api-down' | 'disabled';
+
+const VARIANT: Record = {
+ unreachable: {
+ title: 'Cannot Reach This Site',
+ description: 'We could not resolve an IP address for this host, so checks cannot run',
+ reasons: [
+ 'The domain might be misspelled or no longer registered',
+ 'The website may be offline or temporarily unreachable',
+ 'A DNS resolution issue may be affecting the lookup',
+ 'A firewall or geo-block may be preventing access',
+ ],
+ },
+ invalid: {
+ title: 'Invalid Input',
+ description: 'That does not look like a valid URL or IP address, so checks cannot run',
+ reasons: [
+ 'Enter a domain (example.com) or an IPv4 / IPv6 address',
+ 'Check for typos or stray characters in the input',
+ 'Avoid spaces and unsupported symbols in the address',
+ ],
+ },
+ 'api-down': {
+ title: 'Service Unavailable',
+ description: 'Most checks failed because the Web-Check API could not be reached',
+ reasons: [
+ 'The API may be down, restarting or rate-limited',
+ 'A self-hosted instance might be misconfigured or offline',
+ 'A network or firewall issue could be blocking the API',
+ ],
+ },
+ disabled: {
+ title: 'Web-Check is Paused',
+ description: 'This instance has been temporarily disabled, so checks cannot run',
+ reasons: [
+ 'The public instance may be paused to manage running costs',
+ 'A self-hosted instance may be in maintenance mode',
+ 'You can run your own copy from the open-source repo on GitHub',
+ ],
+ },
+};
+
+interface Props {
+ address: string;
+ error?: string;
+ kind?: Kind;
+}
+
+// Surface a friendly explanation when input is invalid or the host is unreachable
+const NoResults = ({ address, error, kind = 'unreachable' }: Props): JSX.Element => {
+ const { title, description, reasons } = VARIANT[kind];
+ return (
+
+
+ {title}
+
+ {description}
+ {address}
+ Possible reasons:
+
+ {reasons.map((r) => (
+ {r}
+ ))}
+
+ {error && Lookup error: {error} }
+
+ );
+};
+
+export default NoResults;
diff --git a/src/client/components/misc/ProgressBar.tsx b/src/client/components/misc/ProgressBar.tsx
new file mode 100644
index 000000000..e4c2659c4
--- /dev/null
+++ b/src/client/components/misc/ProgressBar.tsx
@@ -0,0 +1,518 @@
+import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import Card from 'client/components/Form/Card';
+import Heading from 'client/components/Form/Heading';
+import { allCardIds } from 'client/jobs/registry';
+
+export type LoadingState = 'success' | 'loading' | 'skipped' | 'error' | 'timed-out';
+
+export interface LoadingJob {
+ name: string;
+ state: LoadingState;
+ error?: string;
+ timeTaken?: number;
+ retry?: () => void;
+}
+
+const STATE_META: Record = {
+ success: { emoji: '✅', color: colors.success },
+ loading: { emoji: '🔄', color: colors.info },
+ error: { emoji: '❌', color: colors.danger },
+ 'timed-out': { emoji: '⏸️', color: colors.warning },
+ skipped: { emoji: '⏭️', color: colors.neutral },
+};
+
+// Tally jobs by their loading state in a single pass
+const countByState = (jobs: LoadingJob[]): Record => {
+ const counts: Record = {
+ success: 0,
+ loading: 0,
+ error: 0,
+ skipped: 0,
+ 'timed-out': 0,
+ };
+ for (const j of jobs) counts[j.state]++;
+ return counts;
+};
+
+// Convert per-state counts into percentages of the total
+const stateToPercent = (jobs: LoadingJob[]): Record => {
+ const counts = countByState(jobs);
+ const total = jobs.length || 1;
+ return Object.fromEntries(
+ Object.entries(counts).map(([k, v]) => [k, (v / total) * 100]),
+ ) as Record;
+};
+
+const LoadCard = styled(Card)`
+ margin: 0 auto;
+ width: 95vw;
+ max-height: 100%;
+ position: relative;
+`;
+
+// Animates height auto <-> 0 via the grid-template-rows 1fr/0fr trick, plus fade and slide
+const Collapsible = styled.div`
+ display: grid;
+ grid-template-rows: 1fr;
+ opacity: 1;
+ transform: translateY(0);
+ transition:
+ grid-template-rows 0.3s ease,
+ opacity 0.25s ease,
+ transform 0.3s ease;
+ > .inner {
+ overflow: hidden;
+ min-height: 0;
+ }
+ &.collapsed {
+ grid-template-rows: 0fr;
+ opacity: 0;
+ transform: translateY(-0.5rem);
+ pointer-events: none;
+ }
+`;
+
+const ProgressBarContainer = styled.div`
+ width: 100%;
+ height: 0.5rem;
+ background: ${colors.bgShadowColor};
+ border-radius: 4px;
+ overflow: hidden;
+`;
+
+const ProgressBarSegment = styled.div<{ color: string; width: number }>`
+ height: 1rem;
+ display: inline-block;
+ width: ${(p) => p.width}%;
+ background: ${(p) => `repeating-linear-gradient(
+ 315deg,
+ ${p.color},
+ ${p.color} 3px,
+ color-mix(in srgb, ${p.color} 92%, #000) 3px,
+ color-mix(in srgb, ${p.color} 92%, #000) 6px
+ )`};
+ transition: width 0.5s ease-in-out;
+`;
+
+const StateLabel = styled.span<{ color: string }>`
+ color: ${(p) => p.color};
+`;
+
+const Details = styled.details`
+ summary {
+ margin: 0.5rem 0;
+ font-weight: bold;
+ cursor: pointer;
+ &:before {
+ content: '►';
+ position: absolute;
+ margin-left: -1rem;
+ color: ${colors.primary};
+ }
+ }
+ &[open] summary:before {
+ content: '▼';
+ }
+ ul {
+ list-style: none;
+ padding: 0.25rem;
+ border-radius: 4px;
+ width: fit-content;
+ li {
+ button.docs {
+ background: none;
+ border: none;
+ color: inherit;
+ font: inherit;
+ font-weight: 700;
+ padding: 0;
+ cursor: pointer;
+ &:hover,
+ &:focus-visible {
+ color: ${colors.primary};
+ outline: none;
+ }
+ }
+ i {
+ color: ${colors.textColorSecondary};
+ }
+ }
+ }
+ p.error {
+ margin: 0.5rem 0;
+ opacity: 0.75;
+ color: ${colors.danger};
+ }
+`;
+
+const AboutPageLink = styled.a`
+ color: ${colors.primary};
+`;
+
+const SummaryContainer = styled.div`
+ display: flex;
+ align-items: center;
+ margin: 0.5rem 0;
+ &.error-info {
+ color: ${colors.danger};
+ }
+ &.success-info {
+ color: ${colors.success};
+ }
+ &.loading-info {
+ color: ${colors.info};
+ }
+ .skipped,
+ .success,
+ .error,
+ .timed-out {
+ margin-left: 0.75rem;
+ }
+ .skipped {
+ color: ${colors.warning};
+ }
+ .success {
+ color: ${colors.success};
+ }
+ .error {
+ color: ${colors.danger};
+ }
+ .timed-out {
+ color: ${colors.error};
+ }
+ .elapsed {
+ color: ${colors.textColorSecondary};
+ margin-left: auto;
+ }
+`;
+
+const ReShowRow = styled.div`
+ margin: 0 auto;
+ width: 95vw;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ .summary {
+ color: ${colors.textColorSecondary};
+ font-size: 0.9rem;
+ button.extras {
+ background: none;
+ border: none;
+ color: inherit;
+ font: inherit;
+ padding: 0;
+ cursor: pointer;
+ &:hover,
+ &:focus-visible {
+ text-decoration: underline;
+ color: ${colors.primary};
+ outline: none;
+ }
+ }
+ }
+`;
+
+// Re-open trigger styled to match the repo's filter buttons (shadow grows on hover)
+const ShowLoadStateButton = styled.button`
+ background: ${colors.backgroundLighter};
+ color: ${colors.textColor};
+ border: none;
+ padding: 0.3rem 0.7rem;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ cursor: pointer;
+ box-shadow: 2px 2px 0px ${colors.bgShadowColor};
+ transition:
+ box-shadow 0.2s ease-in-out,
+ color 0.2s ease-in-out;
+ &:hover,
+ &:focus-visible {
+ color: ${colors.primary};
+ box-shadow: 4px 4px 0px ${colors.bgShadowColor};
+ outline: none;
+ }
+`;
+
+const DismissButton = styled.button`
+ width: fit-content;
+ position: absolute;
+ right: 1rem;
+ bottom: 1rem;
+ background: ${colors.background};
+ color: ${colors.textColorSecondary};
+ border: none;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ &:hover {
+ color: ${colors.primary};
+ }
+`;
+
+const FailedJobActionButton = styled.button`
+ margin: 0.1rem 0.1rem 0.1rem 0.5rem;
+ background: ${colors.background};
+ color: ${colors.textColorSecondary};
+ border: 1px solid ${colors.textColorSecondary};
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ &:hover {
+ color: ${colors.primary};
+ border-color: ${colors.primary};
+ }
+`;
+
+const ErrorModalContent = styled.div`
+ p {
+ margin: 0;
+ }
+ pre {
+ color: ${colors.danger};
+ &.info {
+ color: ${colors.warning};
+ }
+ }
+`;
+
+interface JobListItemProps {
+ job: LoadingJob;
+ showJobDocs: (name: string) => void;
+ showErrorModal: (job: LoadingJob, isInfo?: boolean) => void;
+}
+
+const REASON_LABEL: Partial> = {
+ error: '■ Show Error',
+ 'timed-out': '■ Show Timeout Reason',
+ skipped: '■ Show Skip Reason',
+};
+
+// One row in the details list, showing job state, time and any actions
+const JobListItem = ({ job, showJobDocs, showErrorModal }: JobListItemProps): ReactNode => {
+ const { name, state, timeTaken, retry, error } = job;
+ const canRetry = retry && state !== 'success' && state !== 'loading';
+ const reasonLabel = error ? REASON_LABEL[state] : undefined;
+ return (
+
+ showJobDocs(name)}>
+ {STATE_META[state].emoji} {name}
+
+ ({state})
+ {timeTaken && state !== 'loading' ? ` Took ${timeTaken} ms` : ''}
+ {canRetry && (
+
+ ↻ Retry
+
+ )}
+ {reasonLabel && (
+ showErrorModal(job, state === 'skipped')}
+ >
+ {reasonLabel}
+
+ )}
+
+ );
+};
+
+interface LoadSummaryProps {
+ jobs: LoadingJob[];
+ elapsedMs: number;
+ onOpen: () => void;
+}
+
+// Compact one-liner shown alongside the "Show Load State" button when collapsed
+const LoadSummary = ({ jobs, elapsedMs, onOpen }: LoadSummaryProps): ReactNode => {
+ const total = allCardIds.length;
+ const c = countByState(jobs);
+ const issues = c.error + c['timed-out'] + c.skipped;
+ const sec = (elapsedMs / 1000).toFixed(1);
+ const text = c.loading
+ ? `Loading ${total - c.loading} of ${total}` + (elapsedMs < 15000 ? ` (${sec}s)` : '')
+ : `Finished ${total} lookups in ${sec}s`;
+ return (
+
+ {text}
+ {issues > 0 && (
+ <>
+ {' · '}
+
+ {issues} {issues === 1 ? 'issue' : 'issues'}
+
+ >
+ )}
+
+ );
+};
+
+type ChipKey = Exclude;
+
+const CHIP_LABEL: Record = {
+ success: 'successful',
+ skipped: 'skipped',
+ 'timed-out': 'timed out',
+ error: 'failed',
+};
+
+interface SummaryTextProps {
+ jobs: LoadingJob[];
+ elapsedMs: number;
+}
+
+// Heading-style summary that adapts to loading, all-success and partial-failure
+const SummaryText = ({ jobs, elapsedMs }: SummaryTextProps): ReactNode => {
+ const total = allCardIds.length;
+ const c = countByState(jobs);
+ const isDone = c.loading === 0;
+ const hasIssues = c.error > 0 || c['timed-out'] > 0;
+ const elapsed = elapsedMs >= 10_000 ? `${(elapsedMs / 1000).toFixed(1)} s` : `${elapsedMs} ms`;
+ const chip = (k: ChipKey) =>
+ c[k] > 0 && (
+
+ {c[k]} {c[k] === 1 ? 'job' : 'jobs'} {CHIP_LABEL[k]}{' '}
+
+ );
+ const cls = !isDone ? 'loading-info' : hasIssues ? 'error-info' : 'success-info';
+ const heading = !isDone
+ ? `Loading ${total - c.loading} / ${total} Jobs`
+ : !hasIssues
+ ? `${c.success} Jobs Completed Successfully`
+ : null;
+ const keys: ChipKey[] = !isDone
+ ? ['skipped', 'timed-out', 'error']
+ : hasIssues
+ ? ['success', 'skipped', 'timed-out', 'error']
+ : ['skipped'];
+ return (
+
+ {heading && {heading} }
+ {keys.map(chip)}
+ {isDone ? `Done in ${elapsed}` : elapsed}
+
+ );
+};
+
+interface ProgressLoaderProps {
+ loadStatus: LoadingJob[];
+ showModal: (err: ReactNode) => void;
+ showJobDocs: (job: string) => void;
+}
+
+// Top-of-results progress bar with collapsible per-job detail and error modals
+const ProgressLoader = ({ loadStatus, showModal, showJobDocs }: ProgressLoaderProps): ReactNode => {
+ const [hideLoader, setHideLoader] = useState(false);
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const percentages = stateToPercent(loadStatus);
+ const isDone = !loadStatus.some((j) => j.state === 'loading');
+
+ // Tick elapsed-time while loading, freeze on done so summary shows final duration
+ useEffect(() => {
+ if (isDone) return;
+ const id = setInterval(() => setElapsedMs((v) => v + 100), 100);
+ return () => clearInterval(id);
+ }, [isDone]);
+
+ // Auto-collapse once when 75% of jobs have settled
+ const autoCollapsedRef = useRef(false);
+ const autoCollapse = useCallback(() => {
+ if (autoCollapsedRef.current) return;
+ autoCollapsedRef.current = true;
+ setHideLoader(true);
+ }, []);
+
+ useEffect(() => {
+ const total = loadStatus.length || 1;
+ const settled = loadStatus.filter((j) => j.state !== 'loading').length;
+ if (settled / total >= 0.75) autoCollapse();
+ }, [loadStatus, autoCollapse]);
+
+ const colorFor = (state: LoadingState) =>
+ state === 'success' && isDone ? colors.primary : STATE_META[state].color;
+
+ const showErrorModal = (job: LoadingJob, isInfo?: boolean) => {
+ const detailsLabel = job.state === 'skipped' ? 'Reason:' : 'Server response:';
+ showModal(
+
+ Details for {job.name}
+
+ The {job.name} job ended with state '{job.state}'
+ {job.timeTaken !== undefined ? ` after ${job.timeTaken} ms` : ''}. {detailsLabel}
+
+ {job.error}
+ ,
+ );
+ };
+
+ return (
+
+
+
+
+ setHideLoader(false)}
+ />
+ setHideLoader(false)}>
+ Show Load State
+
+
+
+
+
+
+
+
+ {(Object.keys(percentages) as LoadingState[]).map((state) => (
+
+ ))}
+
+
+
+ Show Details
+
+ {loadStatus.map((job) => (
+
+ ))}
+
+ {loadStatus.some((j) => j.state === 'error') && (
+
+ Check the browser console for logs and more info
+
+ It's normal for some jobs to fail, either because the host doesn't return the
+ required info, or restrictions in the lambda function, or hitting an API limit.
+
+ )}
+
+ Learn More about Web-Check
+
+
+ setHideLoader(true)}>
+ Dismiss
+
+
+
+
+
+ );
+};
+
+export default ProgressLoader;
diff --git a/src/client/components/misc/ResultsMasonryGrid.tsx b/src/client/components/misc/ResultsMasonryGrid.tsx
new file mode 100644
index 000000000..b39a72aea
--- /dev/null
+++ b/src/client/components/misc/ResultsMasonryGrid.tsx
@@ -0,0 +1,58 @@
+import { Children, useEffect, useRef, useState, type ReactNode } from 'react';
+import styled from '@emotion/styled';
+
+interface Props {
+ minColWidth: number;
+ gap?: number;
+ className?: string;
+ children: ReactNode;
+}
+
+const Grid = styled.div<{ gap: number }>`
+ display: flex;
+ gap: ${(props) => props.gap}px;
+`;
+
+const Column = styled.div<{ gap: number }>`
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: ${(props) => props.gap}px;
+`;
+
+// Round-robin distribution so we keep each item's column position stable as new items append
+const ResultsMasonryGrid = ({ minColWidth, gap = 16, className, children }: Props): JSX.Element => {
+ const containerRef = useRef(null);
+ const [columnCount, setColumnCount] = useState(1);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const updateColumnCount = () => {
+ const width = container.clientWidth;
+ if (!width) return;
+ setColumnCount(Math.max(1, Math.floor((width + gap) / (minColWidth + gap))));
+ };
+ updateColumnCount();
+ const observer = new ResizeObserver(updateColumnCount);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [minColWidth, gap]);
+
+ const items = Children.toArray(children);
+ const columns: ReactNode[][] = Array.from({ length: columnCount }, () => []);
+ items.forEach((item, index) => columns[index % columnCount].push(item));
+
+ return (
+
+ {columns.map((column, index) => (
+
+ {column}
+
+ ))}
+
+ );
+};
+
+export default ResultsMasonryGrid;
diff --git a/src/client/components/misc/SelfScanMsg.tsx b/src/client/components/misc/SelfScanMsg.tsx
new file mode 100644
index 000000000..5f24637c1
--- /dev/null
+++ b/src/client/components/misc/SelfScanMsg.tsx
@@ -0,0 +1,58 @@
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { StyledCard } from 'client/components/Form/Card';
+
+const StyledSelfScanMsg = styled(StyledCard)`
+ margin: 0px auto 1rem;
+ width: 95vw;
+ a {
+ color: ${colors.primary};
+ }
+ b {
+ font-weight: extra-bold;
+ }
+ span,
+ i {
+ opacity: 0.85;
+ }
+ img {
+ width: 5rem;
+ float: right;
+ border-radius: 4px;
+ }
+`;
+
+const messages = [
+ "Nice try! But scanning this app is like trying to tickle yourself. It just doesn't work!",
+ "Recursive scanning detected. The universe might implode...or it might not. But let's not try to find out.",
+ "Hey, stop checking us out! We're blushing... 😉",
+ 'Hmmm, scanning us, are you? We feel so special!',
+ "Alert! Mirror scanning detected. Trust us, we're looking good 😉",
+ "We're flattered you're trying to scan us, but we can't tickle ourselves!",
+ "Oh, inspecting the inspector, aren't we? Inception much?",
+ "Just a second...wait a minute...you're scanning us?! Well, that's an interesting twist!",
+ "Scanning us? It's like asking a mirror to reflect on itself.",
+ 'Well, this is awkward... like a dog chasing its own tail!',
+ "Ah, I see you're scanning this site... But alas, this did not cause an infinite recursive loop (this time)",
+];
+
+const SelfScanMsg = () => {
+ return (
+
+
+ {messages[Math.floor(Math.random() * messages.length)]}
+
+
+ But if you want to see how this site is built, why not check out the{' '}
+
+ source code
+
+ ?
+
+
+ Do me a favour, and drop the repo a Star while you're there 😉
+
+ );
+};
+
+export default SelfScanMsg;
diff --git a/src/client/components/misc/ViewRaw.tsx b/src/client/components/misc/ViewRaw.tsx
new file mode 100644
index 000000000..9e77dc78b
--- /dev/null
+++ b/src/client/components/misc/ViewRaw.tsx
@@ -0,0 +1,108 @@
+import { useState } from 'react';
+import styled from '@emotion/styled';
+import colors from 'client/styles/colors';
+import { Card } from 'client/components/Form/Card';
+import Button from 'client/components/Form/Button';
+
+const CardStyles = `
+margin: 0 auto;
+width: 95vw;
+position: relative;
+transition: all 0.2s ease-in-out;
+display: flex;
+flex-direction: column;
+max-height: 100%;
+a {
+ color: ${colors.primary};
+}
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ button {
+ max-width: 300px;
+ }
+}
+small {
+ margin-top: 0.5rem;
+ font-size: 0.8rem;
+ opacity: 0.5;
+}
+`;
+
+const StyledIframe = styled.iframe`
+ width: calc(100% - 2rem);
+ outline: none;
+ border: none;
+ border-radius: 4px;
+ min-height: 50vh;
+ height: 100%;
+ margin: 1rem;
+ background: ${colors.background};
+`;
+
+const ViewRaw = (props: { everything: { id: string; result: any }[] }) => {
+ const [resultUrl, setResultUrl] = useState(null);
+ const [error, setError] = useState(null);
+
+ const makeResults = () => {
+ const result: { [key: string]: any } = {};
+ props.everything.forEach((item: { id: string; result: any }) => {
+ result[item.id] = item.result;
+ });
+ return result;
+ };
+
+ const fetchResultsUrl = async () => {
+ const resultContent = makeResults();
+ const response = await fetch('https://jsonhero.io/api/create.json', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: 'web-check results',
+ content: resultContent,
+ readOnly: true,
+ ttl: 3600,
+ }),
+ });
+ if (!response.ok) {
+ setError(`HTTP error! status: ${response.status}`);
+ } else {
+ setError(null);
+ }
+ await response.json().then((data) => setResultUrl(data.location));
+ };
+
+ const handleDownload = () => {
+ const blob = new Blob([JSON.stringify(makeResults(), null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'web-check-results.json';
+ link.click();
+ URL.revokeObjectURL(url);
+ };
+ return (
+
+
+ Download Results
+ {resultUrl ? 'Update Results' : 'View Results'}
+ {resultUrl && setResultUrl('')}>Hide Results }
+
+ {resultUrl && !error && (
+ <>
+
+
+ Your results are available to view here .
+
+ >
+ )}
+ {error && {error}
}
+
+ These are the raw results generated from your URL, and in JSON format. You can import these
+ into your own program, for further analysis.
+
+
+ );
+};
+
+export default ViewRaw;
diff --git a/src/client/hooks/useJobs.ts b/src/client/hooks/useJobs.ts
new file mode 100644
index 000000000..2bab73072
--- /dev/null
+++ b/src/client/hooks/useJobs.ts
@@ -0,0 +1,244 @@
+import { useReducer, useEffect, useRef, useCallback, useState } from 'react';
+import { logJobOutcome } from 'client/utils/logger';
+import keys from 'client/utils/get-keys';
+import type { AddressType } from 'client/utils/address-type-checker';
+import type { LoadingState } from 'client/components/misc/ProgressBar';
+import type { JobSpec, JobContext, JobsState } from 'client/jobs/types';
+import { allCardIds } from 'client/jobs/registry';
+
+type Action =
+ | { type: 'start'; cardIds: string[] }
+ | { type: 'success'; cardIds: string[]; raw: any; timeTaken: number }
+ | {
+ type: 'error';
+ cardIds: string[];
+ outcome: 'error' | 'timed-out';
+ error: string;
+ timeTaken: number;
+ }
+ | { type: 'skipped'; cardIds: string[]; reason?: string }
+ | { type: 'force'; cardId: string; state: LoadingState };
+
+const initialState: JobsState = Object.fromEntries(
+ allCardIds.map((id) => [id, { state: 'loading' as LoadingState }]),
+);
+
+const setMany = (s: JobsState, ids: string[], patch: Partial) =>
+ ids.reduce((acc, id) => ({ ...acc, [id]: { ...acc[id], ...patch } }), s);
+
+const reducer = (s: JobsState, a: Action): JobsState => {
+ switch (a.type) {
+ case 'start':
+ return setMany(s, a.cardIds, { state: 'loading', error: undefined });
+ case 'success':
+ return setMany(s, a.cardIds, {
+ state: 'success',
+ raw: a.raw,
+ timeTaken: a.timeTaken,
+ error: undefined,
+ });
+ case 'error':
+ return setMany(s, a.cardIds, {
+ state: a.outcome,
+ error: a.error,
+ timeTaken: a.timeTaken,
+ });
+ case 'skipped':
+ return setMany(s, a.cardIds, { state: 'skipped', error: a.reason });
+ case 'force':
+ return { ...s, [a.cardId]: { ...s[a.cardId], state: a.state } };
+ }
+};
+
+const isTimeout = (msg = '') => /timed[- ]?out/i.test(msg);
+
+const apiBase = (import.meta.env.PUBLIC_API_ENDPOINT || '/api') as string;
+
+// Drives every job's lifecycle: fetch, retry, abort, fallback promotion
+const useJobs = (address: string, addressType: AddressType, jobs: JobSpec[]) => {
+ const [state, dispatch] = useReducer(reducer, initialState);
+ const [ipAddress, setIpAddress] = useState();
+ const [ipLookupError, setIpLookupError] = useState();
+ const startTime = useRef(Date.now()).current;
+ const controllers = useRef>({});
+ const fired = useRef>(new Set());
+ const stateRef = useRef(state);
+ useEffect(() => {
+ stateRef.current = state;
+ }, [state]);
+
+ const runJob = useCallback(
+ (job: JobSpec, ip?: string) => {
+ const cardIds = job.cards.map((c) => c.id);
+ controllers.current[job.id]?.abort();
+ const controller = new AbortController();
+ controllers.current[job.id] = controller;
+ fired.current.add(job.id);
+
+ if (cardIds.length) dispatch({ type: 'start', cardIds });
+
+ const ctx: JobContext = { address, ipAddress: ip, api: apiBase, signal: controller.signal };
+ job
+ .fetcher(ctx)
+ .then((raw: any) => {
+ if (controller.signal.aborted) return;
+ const timeTaken = Date.now() - startTime;
+ if (job.id === 'get-ip') {
+ if (typeof raw === 'string') setIpAddress(raw);
+ else if (raw?.error) setIpLookupError(raw.error);
+ return;
+ }
+ if (raw?.skipped) {
+ dispatch({ type: 'skipped', cardIds, reason: raw.skipped });
+ cardIds.forEach((id) => logJobOutcome('error', id, timeTaken, raw.skipped));
+ return;
+ }
+ if (raw?.error) {
+ const outcome = isTimeout(raw.error) ? 'timed-out' : 'error';
+ dispatch({ type: 'error', cardIds, outcome, error: raw.error, timeTaken });
+ cardIds.forEach((id) => logJobOutcome(outcome, id, timeTaken, raw.error));
+ return;
+ }
+ dispatch({ type: 'success', cardIds, raw, timeTaken });
+ cardIds.forEach((id) => logJobOutcome('success', id, timeTaken));
+ })
+ .catch((err: any) => {
+ if (controller.signal.aborted || err?.name === 'AbortError') return;
+ const timeTaken = Date.now() - startTime;
+ const message = err?.message || 'Unknown error';
+ if (job.id === 'get-ip') return;
+ const outcome = isTimeout(message) ? 'timed-out' : 'error';
+ dispatch({ type: 'error', cardIds, outcome, error: message, timeTaken });
+ cardIds.forEach((id) => logJobOutcome(outcome, id, timeTaken, message));
+ });
+ },
+ [address, startTime],
+ );
+
+ const skipJob = useCallback((job: JobSpec, reason?: string) => {
+ const cardIds = job.cards.map((c) => c.id);
+ if (cardIds.length) dispatch({ type: 'skipped', cardIds, reason });
+ }, []);
+
+ // Decide which jobs are eligible for the current input
+ const eligible = useCallback(
+ (job: JobSpec) => {
+ if (job.needsIp) {
+ return addressType === 'url' || addressType === 'ipV4' || addressType === 'ipV6';
+ }
+ if (!job.expectedAddressTypes) return true;
+ return job.expectedAddressTypes.includes(addressType);
+ },
+ [addressType],
+ );
+
+ const skipReason = useCallback(
+ (job: JobSpec): string => {
+ const allowed = job.expectedAddressTypes;
+ if (allowed && !allowed.includes(addressType)) {
+ if (addressType === 'ipV4' || addressType === 'ipV6') {
+ return 'This check requires a domain name and cannot be run against an IP address';
+ }
+ return `This check is only available for ${allowed.join(', ')} input`;
+ }
+ return 'This check is not applicable for the current input';
+ },
+ [addressType],
+ );
+
+ // Initial fan-out: fire non-IP jobs immediately, mark unsupported as skipped
+ useEffect(() => {
+ if (keys.disableEverything) {
+ const reason = 'Web-Check has been temporarily disabled on this instance';
+ jobs.forEach((j) => skipJob(j, reason));
+ return;
+ }
+ if (!address || addressType === 'empt' || addressType === 'err') return;
+
+ fired.current.clear();
+ setIpLookupError(undefined);
+ if (addressType === 'ipV4' || addressType === 'ipV6') setIpAddress(address);
+ else setIpAddress(undefined);
+
+ jobs.forEach((job) => {
+ if (!eligible(job)) {
+ skipJob(job, skipReason(job));
+ return;
+ }
+ if (job.needsIp) return;
+ runJob(job);
+ });
+
+ return () => {
+ Object.values(controllers.current).forEach((c) => c.abort());
+ controllers.current = {};
+ };
+ }, [address, addressType, jobs, runJob, skipJob, eligible, skipReason]);
+
+ // Fire IP-dependent jobs the moment we have an IP, but only once each
+ useEffect(() => {
+ if (!ipAddress) return;
+ jobs.forEach((job) => {
+ if (!job.needsIp || fired.current.has(job.id)) return;
+ if (!eligible(job)) {
+ skipJob(job, skipReason(job));
+ return;
+ }
+ runJob(job, ipAddress);
+ });
+ }, [ipAddress, jobs, runJob, skipJob, eligible, skipReason]);
+
+ // Promote any card whose fallback resolves after the primary failed
+ useEffect(() => {
+ jobs.forEach((job) =>
+ job.cards.forEach((card) => {
+ if (!card.fallback) return;
+ const entry = state[card.id];
+ if (!entry || entry.state === 'success' || entry.state === 'loading') return;
+ if (card.fallback(state)) dispatch({ type: 'force', cardId: card.id, state: 'success' });
+ }),
+ );
+ }, [state, jobs]);
+
+ // Single client-side budget for stuck jobs, resets on new input
+ useEffect(() => {
+ if (!address || addressType === 'empt' || addressType === 'err') return;
+ const budget = parseInt((import.meta.env.PUBLIC_API_TIMEOUT_LIMIT as string) || '45000', 10);
+ const timer = setTimeout(() => {
+ const stuck = Object.entries(stateRef.current)
+ .filter(([id, e]) => {
+ if (e?.state !== 'loading') return false;
+ const owner = jobs.find((j) => j.cards.some((c) => c.id === id));
+ return !owner?.noClientTimeout;
+ })
+ .map(([id]) => id);
+ if (!stuck.length) return;
+ dispatch({
+ type: 'error',
+ cardIds: stuck,
+ outcome: 'timed-out',
+ error: 'Client-side timeout reached',
+ timeTaken: Date.now() - startTime,
+ });
+ stuck.forEach((id) => {
+ const owner = jobs.find((j) => j.cards.some((c) => c.id === id));
+ if (owner) controllers.current[owner.id]?.abort();
+ });
+ }, budget);
+ return () => clearTimeout(timer);
+ }, [address, addressType, jobs, startTime]);
+
+ const retry = useCallback(
+ (cardId: string) => {
+ if (state[cardId]?.state === 'loading') return;
+ const job = jobs.find((j) => j.cards.some((c) => c.id === cardId));
+ if (!job) return;
+ runJob(job, ipAddress);
+ },
+ [jobs, runJob, state, ipAddress],
+ );
+
+ return { state, retry, ipLookupError };
+};
+
+export default useJobs;
diff --git a/src/client/jobs/registry.ts b/src/client/jobs/registry.ts
new file mode 100644
index 000000000..ca36214d6
--- /dev/null
+++ b/src/client/jobs/registry.ts
@@ -0,0 +1,359 @@
+import { parseJson } from 'client/utils/parse-json';
+import { getLocation, parseShodanResults } from 'client/utils/result-processor';
+
+import ServerLocationCard from 'client/components/Results/ServerLocation';
+import ServerInfoCard from 'client/components/Results/ServerInfo';
+import HostNamesCard from 'client/components/Results/HostNames';
+import WhoIsCard from 'client/components/Results/WhoIs';
+import LighthouseCard from 'client/components/Results/Lighthouse';
+import ScreenshotCard from 'client/components/Results/Screenshot';
+import SslCertCard from 'client/components/Results/SslCert';
+import HeadersCard from 'client/components/Results/Headers';
+import CookiesCard from 'client/components/Results/Cookies';
+import RobotsTxtCard from 'client/components/Results/RobotsTxt';
+import DnsRecordsCard from 'client/components/Results/DnsRecords';
+import RedirectsCard from 'client/components/Results/Redirects';
+import TxtRecordCard from 'client/components/Results/TxtRecords';
+import ServerStatusCard from 'client/components/Results/ServerStatus';
+import OpenPortsCard from 'client/components/Results/OpenPorts';
+import TraceRouteCard from 'client/components/Results/TraceRoute';
+import CarbonFootprintCard from 'client/components/Results/CarbonFootprint';
+import DnsSecCard from 'client/components/Results/DnsSec';
+import HstsCard from 'client/components/Results/Hsts';
+import SitemapCard from 'client/components/Results/Sitemap';
+import DomainLookup from 'client/components/Results/DomainLookup';
+import DnsServerCard from 'client/components/Results/DnsServer';
+import TechStackCard from 'client/components/Results/TechStack';
+import SecurityTxtCard from 'client/components/Results/SecurityTxt';
+import ContentLinksCard from 'client/components/Results/ContentLinks';
+import SocialTagsCard from 'client/components/Results/SocialTags';
+import MailConfigCard from 'client/components/Results/MailConfig';
+import HttpSecurityCard from 'client/components/Results/HttpSecurity';
+import FirewallCard from 'client/components/Results/Firewall';
+import ArchivesCard from 'client/components/Results/Archives';
+import RankCard from 'client/components/Results/Rank';
+import BlockListsCard from 'client/components/Results/BlockLists';
+import ThreatsCard from 'client/components/Results/Threats';
+import TlsConnectionCard from 'client/components/Results/TlsConnection';
+import TlsSecurityAuditCard from 'client/components/Results/TlsSecurityAudit';
+import TlsClientCompatCard from 'client/components/Results/TlsClientCompat';
+import SubdomainsCard from 'client/components/Results/Subdomains';
+
+import type { JobSpec, JobContext, JobsState } from './types';
+
+const URL_ONLY = ['url'] as const;
+
+// Build a fetcher that hits a local /api path then maps the success body
+const fetchAndProcess =
+ (path: string, process: (raw: any) => any = (r) => r) =>
+ async (ctx: JobContext) => {
+ const target = path.includes('${ip}') ? ctx.ipAddress || '' : ctx.address;
+ const url = path.replace(/\$\{(ip|url)\}/g, target);
+ const res = await fetch(`${ctx.api}/${url}`, { signal: ctx.signal });
+ const raw = await parseJson(res);
+ return raw?.error ? raw : process(raw);
+ };
+
+// Sleep ms, reject AbortError if signal fires
+const sleep = (ms: number, signal: AbortSignal) =>
+ new Promise((resolve, reject) => {
+ if (signal.aborted) return reject(new DOMException('aborted', 'AbortError'));
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new DOMException('aborted', 'AbortError'));
+ };
+ const remove = () => {
+ signal.removeEventListener('abort', onAbort);
+ resolve();
+ };
+ const timer = setTimeout(remove, ms);
+ signal.addEventListener('abort', onAbort, { once: true });
+ });
+
+// Re-run fetchOnce while shouldRetry(raw) holds, sleeping delay ms between attempts
+const retrying = (
+ path: string,
+ shouldRetry: (raw: any) => boolean,
+ attempts: number,
+ delay: number,
+ onExhausted: (last: any) => any,
+) => {
+ const fetchOnce = fetchAndProcess(path);
+ return async (ctx: JobContext) => {
+ let last: any;
+ for (let i = 0; i < attempts; i++) {
+ last = await fetchOnce(ctx);
+ if (!shouldRetry(last)) return last;
+ if (i < attempts - 1) await sleep(delay, ctx.signal);
+ }
+ return onExhausted(last);
+ };
+};
+
+// Re-run while the body has { pending: true }
+const fetchAndPoll = (path: string) =>
+ retrying(
+ path,
+ (r) => !!r?.pending,
+ 6,
+ 30000,
+ () => ({
+ error: 'Timed-out waiting for assessment',
+ }),
+ );
+
+// Re-run on transient errors or when the server hints `retryable: true`
+const fetchAndRetry = (path: string) =>
+ retrying(
+ path,
+ (r) => !!r?.error || !!r?.retryable,
+ 3,
+ 2000,
+ (last) => last,
+ );
+
+const card = (
+ id: string,
+ title: string,
+ tags: string[],
+ Component: any,
+ extras: { pick?: any; fallback?: any } = {},
+) => ({ id, title, tags, Component, ...extras });
+
+// Pick a child key of the raw response, null when missing so cards hide cleanly
+const at = (key: string) => (raw: any) => raw?.[key] ?? null;
+
+export const jobs: JobSpec[] = [
+ {
+ id: 'get-ip',
+ cards: [],
+ expectedAddressTypes: [...URL_ONLY],
+ fetcher: fetchAndProcess('get-ip?url=${url}', (r) => r.ip),
+ },
+ {
+ id: 'location',
+ needsIp: true,
+ cards: [
+ card('location', 'Server Location', ['server'], ServerLocationCard, { pick: getLocation }),
+ ],
+ fetcher: fetchAndProcess('location?url=${ip}'),
+ },
+ {
+ id: 'ssl',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('ssl', 'SSL Certificate', ['server', 'security'], SslCertCard)],
+ fetcher: fetchAndProcess('ssl?url=${url}'),
+ },
+ {
+ id: 'whois',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [
+ card('domain', 'Domain Whois', ['server'], DomainLookup),
+ card('whois', 'Domain Info', ['server'], WhoIsCard),
+ ],
+ fetcher: fetchAndProcess('whois?url=${url}'),
+ },
+ {
+ id: 'quality',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('quality', 'Quality Summary', ['client'], LighthouseCard)],
+ fetcher: fetchAndRetry('quality?url=${url}'),
+ },
+ {
+ id: 'tech-stack',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('tech-stack', 'Tech Stack', ['client', 'meta'], TechStackCard)],
+ fetcher: fetchAndProcess('tech-stack?url=${url}'),
+ },
+ {
+ id: 'shodan',
+ needsIp: true,
+ cards: [
+ card('hosts', 'Host Names', ['server'], HostNamesCard, { pick: at('hostnames') }),
+ card('server-info', 'Server Info', ['server'], ServerInfoCard, { pick: at('serverInfo') }),
+ ],
+ fetcher: fetchAndProcess('shodan?url=${ip}', parseShodanResults),
+ },
+ {
+ id: 'cookies',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('cookies', 'Cookies', ['client', 'security'], CookiesCard)],
+ fetcher: fetchAndProcess('cookies?url=${url}'),
+ },
+ {
+ id: 'headers',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('headers', 'Headers', ['client', 'security'], HeadersCard)],
+ fetcher: fetchAndProcess('headers?url=${url}'),
+ },
+ {
+ id: 'dns',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('dns', 'DNS Records', ['server'], DnsRecordsCard)],
+ fetcher: fetchAndProcess('dns?url=${url}'),
+ },
+ {
+ id: 'http-security',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('http-security', 'HTTP Security', ['security'], HttpSecurityCard)],
+ fetcher: fetchAndProcess('http-security?url=${url}'),
+ },
+ {
+ id: 'tls-connection',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('tls-connection', 'TLS Connection', ['server', 'security'], TlsConnectionCard)],
+ fetcher: fetchAndProcess('tls-connection?url=${url}'),
+ },
+ {
+ id: 'tls-labs',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [
+ card('tls-security-audit', 'TLS Security Audit', ['security'], TlsSecurityAuditCard),
+ card('tls-client-compat', 'TLS Client Compatibility', ['security'], TlsClientCompatCard),
+ ],
+ fetcher: fetchAndPoll('tls-labs?url=${url}'),
+ },
+ {
+ id: 'subdomains',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('subdomains', 'Subdomains', ['server', 'meta'], SubdomainsCard)],
+ fetcher: fetchAndRetry('subdomains?url=${url}'),
+ },
+ {
+ id: 'trace-route',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('trace-route', 'Trace Route', ['server'], TraceRouteCard)],
+ fetcher: fetchAndProcess('trace-route?url=${url}'),
+ },
+ {
+ id: 'security-txt',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('security-txt', 'Security.Txt', ['security'], SecurityTxtCard)],
+ fetcher: fetchAndProcess('security-txt?url=${url}'),
+ },
+ {
+ id: 'dns-server',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('dns-server', 'Server Info', ['server'], DnsServerCard)],
+ fetcher: fetchAndProcess('dns-server?url=${url}'),
+ },
+ {
+ id: 'firewall',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('firewall', 'Firewall', ['server', 'security'], FirewallCard)],
+ fetcher: fetchAndProcess('firewall?url=${url}'),
+ },
+ {
+ id: 'dnssec',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('dnssec', 'DNSSEC', ['security'], DnsSecCard)],
+ fetcher: fetchAndProcess('dnssec?url=${url}'),
+ },
+ {
+ id: 'hsts',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('hsts', 'HSTS Check', ['security'], HstsCard)],
+ fetcher: fetchAndProcess('hsts?url=${url}'),
+ },
+ {
+ id: 'threats',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('threats', 'Threats', ['security'], ThreatsCard)],
+ fetcher: fetchAndProcess('threats?url=${url}'),
+ },
+ {
+ id: 'mail-config',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('mail-config', 'Email Configuration', ['server'], MailConfigCard)],
+ fetcher: fetchAndProcess('mail-config?url=${url}'),
+ },
+ {
+ id: 'archives',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('archives', 'Archive History', ['meta'], ArchivesCard)],
+ fetcher: fetchAndRetry('archives?url=${url}'),
+ },
+ {
+ id: 'rank',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('rank', 'Global Ranking', ['meta'], RankCard)],
+ fetcher: fetchAndProcess('rank?url=${url}'),
+ },
+ {
+ id: 'redirects',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('redirects', 'Redirects', ['meta'], RedirectsCard)],
+ fetcher: fetchAndProcess('redirects?url=${url}'),
+ },
+ {
+ id: 'linked-pages',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('linked-pages', 'Linked Pages', ['client', 'meta'], ContentLinksCard)],
+ fetcher: fetchAndProcess('linked-pages?url=${url}'),
+ },
+ {
+ id: 'robots-txt',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('robots-txt', 'Crawl Rules', ['meta'], RobotsTxtCard)],
+ fetcher: fetchAndProcess('robots-txt?url=${url}'),
+ },
+ {
+ id: 'status',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('status', 'Server Status', ['server'], ServerStatusCard)],
+ fetcher: fetchAndProcess('status?url=${url}'),
+ },
+ {
+ id: 'ports',
+ needsIp: true,
+ cards: [card('ports', 'Open Ports', ['server'], OpenPortsCard)],
+ fetcher: fetchAndProcess('ports?url=${ip}'),
+ },
+ {
+ id: 'txt-records',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('txt-records', 'TXT Records', ['server'], TxtRecordCard)],
+ fetcher: fetchAndProcess('txt-records?url=${url}'),
+ },
+ {
+ id: 'block-lists',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('block-lists', 'Block Lists', ['security', 'meta'], BlockListsCard)],
+ fetcher: fetchAndProcess('block-lists?url=${url}'),
+ },
+ {
+ id: 'sitemap',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('sitemap', 'Pages', ['meta'], SitemapCard)],
+ fetcher: fetchAndProcess('sitemap?url=${url}'),
+ },
+ {
+ id: 'screenshot',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [
+ card('screenshot', 'Screenshot', ['client', 'meta'], ScreenshotCard, {
+ fallback: (state: JobsState) => state.quality?.raw?.fullPageScreenshot?.screenshot,
+ }),
+ ],
+ fetcher: fetchAndProcess('screenshot?url=${url}'),
+ },
+ {
+ id: 'social-tags',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('social-tags', 'Social Tags', ['client', 'meta'], SocialTagsCard)],
+ fetcher: fetchAndProcess('social-tags?url=${url}'),
+ },
+ {
+ id: 'carbon',
+ expectedAddressTypes: [...URL_ONLY],
+ cards: [card('carbon', 'Carbon Footprint', ['meta'], CarbonFootprintCard)],
+ fetcher: fetchAndProcess('carbon?url=${url}'),
+ },
+];
+
+// Flat list of every card id (1+ per job). Used by ProgressBar and the result grid
+export const allCardIds: string[] = jobs.flatMap((j) => j.cards.map((c) => c.id));
+
+export const allCards: Array<{ jobId: string; card: JobSpec['cards'][number] }> = jobs.flatMap(
+ (j) => j.cards.map((card) => ({ jobId: j.id, card })),
+);
diff --git a/src/client/jobs/types.ts b/src/client/jobs/types.ts
new file mode 100644
index 000000000..244536ec3
--- /dev/null
+++ b/src/client/jobs/types.ts
@@ -0,0 +1,37 @@
+import type { ComponentType } from 'react';
+import type { AddressType } from 'client/utils/address-type-checker';
+import type { LoadingState } from 'client/components/misc/ProgressBar';
+
+export interface JobContext {
+ address: string;
+ ipAddress?: string;
+ api: string;
+ signal: AbortSignal;
+}
+
+export interface CardSpec {
+ id: string;
+ title: string;
+ tags: string[];
+ Component: ComponentType;
+ pick?: (raw: any) => any;
+ fallback?: (state: JobsState) => any;
+}
+
+export interface JobSpec {
+ id: string;
+ cards: CardSpec[];
+ fetcher: (ctx: JobContext) => Promise;
+ expectedAddressTypes?: AddressType[];
+ needsIp?: boolean;
+ noClientTimeout?: boolean;
+}
+
+export interface JobEntry {
+ state: LoadingState;
+ raw?: any;
+ error?: string;
+ timeTaken?: number;
+}
+
+export type JobsState = Record;
diff --git a/src/client/main.tsx b/src/client/main.tsx
new file mode 100644
index 000000000..b1cf2ebc9
--- /dev/null
+++ b/src/client/main.tsx
@@ -0,0 +1,8 @@
+import { BrowserRouter } from 'react-router-dom';
+import App from './App.tsx';
+
+export default () => (
+
+
+
+);
diff --git a/src/client/styles/colors.ts b/src/client/styles/colors.ts
new file mode 100644
index 000000000..232f277f4
--- /dev/null
+++ b/src/client/styles/colors.ts
@@ -0,0 +1,22 @@
+const colors = {
+ primary: '#c1fb41',
+ primaryLighter: '#cff97a',
+ textColor: '#ffffff',
+ textColorSecondary: '#a4b1cd',
+ background: '#141517',
+ backgroundDarker: '#000000',
+ backgroundLighter: '#242525',
+ bgShadowColor: '#101010',
+ fgShadowColor: '#3f550e',
+ primaryTransparent: '#9fef0012',
+
+ // Action Colors
+ info: '#04e4f4',
+ success: '#20e253',
+ warning: '#f6f000',
+ error: '#fca016',
+ danger: '#f80363',
+ neutral: '#272f4d',
+};
+
+export default colors;
diff --git a/src/styles/dimensions.ts b/src/client/styles/dimensions.ts
similarity index 71%
rename from src/styles/dimensions.ts
rename to src/client/styles/dimensions.ts
index 73d0b9c4c..c084db89c 100644
--- a/src/styles/dimensions.ts
+++ b/src/client/styles/dimensions.ts
@@ -22,9 +22,13 @@ export const applySize = (inputSize?: InputSize) => {
`,
};
switch (inputSize) {
- case 'small': return sizeSpecificStyles.small;
- case 'medium': return sizeSpecificStyles.medium;
- case 'large': return sizeSpecificStyles.large;
- default: return sizeSpecificStyles.small;
+ case 'small':
+ return sizeSpecificStyles.small;
+ case 'medium':
+ return sizeSpecificStyles.medium;
+ case 'large':
+ return sizeSpecificStyles.large;
+ default:
+ return sizeSpecificStyles.small;
}
};
diff --git a/src/client/styles/globals.tsx b/src/client/styles/globals.tsx
new file mode 100644
index 000000000..156141b37
--- /dev/null
+++ b/src/client/styles/globals.tsx
@@ -0,0 +1,30 @@
+import { Global, css } from '@emotion/react';
+
+const GlobalStyles = () => (
+
+);
+
+export default GlobalStyles;
diff --git a/src/client/styles/index.css b/src/client/styles/index.css
new file mode 100644
index 000000000..e380b340b
--- /dev/null
+++ b/src/client/styles/index.css
@@ -0,0 +1,41 @@
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ font-family: var(--font-mono);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background: #141517;
+ color: #fff;
+}
+
+code {
+ font-family: var(--font-mono);
+}
+#fancy-background {
+ color: var(--background, #141517);
+}
+
+::selection {
+ background: var(--primary, #d6fb41);
+ color: var(--background, #141517);
+}
+
+svg.rsm-svg {
+ background: #141517;
+ border-radius: 6px;
+}
+
+:root {
+ --toastify-color-dark: #111927 !important;
+ --toastify-color-info: #04e4f4 !important;
+ --toastify-color-success: #20e253 !important;
+ --toastify-color-warning: #f6f000 !important;
+ --toastify-color-error: #f80363 !important;
+}
+
+#fancy-background {
+ position: absolute;
+}
diff --git a/src/styles/typography.ts b/src/client/styles/typography.ts
similarity index 54%
rename from src/styles/typography.ts
rename to src/client/styles/typography.ts
index b9997c2cb..6a7970fbc 100644
--- a/src/styles/typography.ts
+++ b/src/client/styles/typography.ts
@@ -1,5 +1,15 @@
+export const TextSizes = {
+ xSmall: '0.75rem',
+ small: '1rem',
+ medium: '1.5rem',
+ large: '2rem',
+ xLarge: '3rem',
+ xxLarge: '4rem',
+};
+
export const TextReset = `
- font-family: PTMono, Helvetica, Arial, sans-serif;
+ font-size: ${TextSizes.small};
+ font-family: var(--font-mono);
font-weight: 400;
font-style: normal;
font-stretch: normal;
diff --git a/src/client/typings/file-types.d.ts b/src/client/typings/file-types.d.ts
new file mode 100644
index 000000000..1764f41e0
--- /dev/null
+++ b/src/client/typings/file-types.d.ts
@@ -0,0 +1 @@
+declare module '*.ttf';
diff --git a/src/client/typings/jsx.d.ts b/src/client/typings/jsx.d.ts
new file mode 100644
index 000000000..fd7332b8d
--- /dev/null
+++ b/src/client/typings/jsx.d.ts
@@ -0,0 +1,9 @@
+import type { JSX as ReactJSX } from 'react';
+
+declare global {
+ namespace JSX {
+ type Element = ReactJSX.Element;
+ }
+}
+
+export {};
diff --git a/src/client/typings/react-simple-maps.d.ts b/src/client/typings/react-simple-maps.d.ts
new file mode 100644
index 000000000..9841026f4
--- /dev/null
+++ b/src/client/typings/react-simple-maps.d.ts
@@ -0,0 +1 @@
+declare module 'react-simple-maps';
diff --git a/src/client/utils/address-type-checker.ts b/src/client/utils/address-type-checker.ts
new file mode 100644
index 000000000..94be8b7b3
--- /dev/null
+++ b/src/client/utils/address-type-checker.ts
@@ -0,0 +1,53 @@
+// Determine whether user input is a URL, IPv4, IPv6 or invalid
+
+export type AddressType = 'ipV4' | 'ipV6' | 'url' | 'err' | 'empt';
+
+const isIpV4 = (value: string): boolean => {
+ const p = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)';
+ return new RegExp(`^${p}\\.${p}\\.${p}\\.${p}$`).test(value);
+};
+
+// (you guessed it!) Checks if a string is valid IPv6 address
+const isIPv6 = (value: string): boolean => {
+ const parts = value.split(':');
+ if (parts.length < 2 || parts.length > 8) return false;
+ if ((parts[0] !== '' || parts[1] !== '') && !parts[0].match(/^[\da-f]{1,4}/i)) return false;
+ let empties = 0;
+ for (let i = 1; i < parts.length; i++) {
+ if (parts[i] === '') {
+ if (++empties > 1) return false;
+ continue;
+ }
+ if (!parts[i].match(/^[\da-f]{1,4}/i)) return false;
+ }
+ return true;
+};
+
+// Validate as a parseable URL via the native URL constructor
+const isUrl = (value: string): boolean => {
+ const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`;
+ try {
+ new URL(withScheme);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+// Input address type: URL, IPv4, IPv6 or invalid
+export const determineAddressType = (address: string | undefined): AddressType => {
+ if (!address || address.length <= 3) return 'empt';
+ if (isIpV4(address)) return 'ipV4';
+ if (isIPv6(address)) return 'ipV6';
+ if (isUrl(address)) return 'url';
+ return 'err';
+};
+
+// Strip protocol and path/query/hash so the route param stays a bare host
+export const normalizeAddress = (input: string | undefined): string => {
+ if (!input) return '';
+ let s = input.trim().replace(/^https?:\/\//i, '');
+ const stop = s.search(/[/?#]/);
+ if (stop !== -1) s = s.slice(0, stop);
+ return s;
+};
diff --git a/src/client/utils/docs.ts b/src/client/utils/docs.ts
new file mode 100644
index 000000000..b5ffe08a9
--- /dev/null
+++ b/src/client/utils/docs.ts
@@ -0,0 +1,721 @@
+export interface Doc {
+ id: string;
+ title: string;
+ description: string;
+ use: string;
+ resources: string[] | { title: string; link: string }[];
+ screenshot?: string;
+}
+
+const docs: Doc[] = [
+ {
+ id: 'get-ip',
+ title: 'IP Info',
+ description:
+ "An IP address (Internet Protocol address) is a numerical label assigned to each device connected to a network / the internet. The IP associated with a given domain can be found by querying the Domain Name System (DNS) for the domain's A (address) record.",
+ use: "Finding the IP of a given server is the first step to conducting further investigations, as it allows us to probe the server for additional info. Including creating a detailed map of a target's network infrastructure, pinpointing the physical location of a server, identifying the hosting service, and even discovering other domains that are hosted on the same IP address.",
+ resources: [
+ {
+ title: 'Understanding IP Addresses',
+ link: 'https://www.digitalocean.com/community/tutorials/understanding-ip-addresses-subnets-and-cidr-notation-for-networking',
+ },
+ { title: 'IP Addresses - Wiki', link: 'https://en.wikipedia.org/wiki/IP_address' },
+ { title: 'RFC-791 Internet Protocol', link: 'https://tools.ietf.org/html/rfc791' },
+ { title: 'whatismyipaddress.com', link: 'https://whatismyipaddress.com/' },
+ ],
+ },
+ {
+ id: 'ssl',
+ title: 'SSL Chain',
+ description:
+ 'SSL certificates are digital certificates that authenticate the identity of a website or server, enable secure encrypted communication (HTTPS), and establish trust between clients and servers. A valid SSL certificate is required for a website to be able to use the HTTPS protocol, and encrypt user + site data in transit. SSL certificates are issued by Certificate Authorities (CAs), which are trusted third parties that verify the identity and legitimacy of the certificate holder.',
+ use: 'SSL certificates not only provide the assurance that data transmission to and from the website is secure, but they also provide valuable OSINT data. Information from an SSL certificate can include the issuing authority, the domain name, its validity period, and sometimes even organization details. This can be useful for verifying the authenticity of a website, understanding its security setup, or even for discovering associated subdomains or other services.',
+ resources: [
+ { title: 'TLS - Wiki', link: 'https://en.wikipedia.org/wiki/Transport_Layer_Security' },
+ {
+ title: 'What is SSL (via Cloudflare learning)',
+ link: 'https://www.cloudflare.com/learning/ssl/what-is-ssl/',
+ },
+ { title: 'RFC-8446 - TLS', link: 'https://tools.ietf.org/html/rfc8446' },
+ { title: 'SSL Checker', link: 'https://www.sslshopper.com/ssl-checker.html' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-ssl',
+ },
+ {
+ id: 'dns',
+ title: 'DNS Records',
+ description:
+ 'This task involves looking up the DNS records associated with a specific domain. DNS is a system that translates human-readable domain names into IP addresses that computers use to communicate. Various types of DNS records exist, including A (address), MX (mail exchange), NS (name server), CNAME (canonical name), and TXT (text), among others.',
+ use: "Extracting DNS records can provide a wealth of information in an OSINT investigation. For example, A and AAAA records can disclose IP addresses associated with a domain, potentially revealing the location of servers. MX records can give clues about a domain's email provider. TXT records are often used for various administrative purposes and can sometimes inadvertently leak internal information. Understanding a domain's DNS setup can also be useful in understanding how its online infrastructure is built and managed.",
+ resources: [
+ {
+ title: 'What are DNS records? (via Cloudflare learning)',
+ link: 'https://www.cloudflare.com/learning/dns/dns-records/',
+ },
+ { title: 'DNS Record Types', link: 'https://en.wikipedia.org/wiki/List_of_DNS_record_types' },
+ { title: 'RFC-1035 - DNS', link: 'https://tools.ietf.org/html/rfc1035' },
+ { title: 'DNS Lookup (via MxToolbox)', link: 'https://mxtoolbox.com/DNSLookup.aspx' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-dns',
+ },
+ {
+ id: 'cookies',
+ title: 'Cookies',
+ description:
+ "The Cookies task involves examining the HTTP cookies set by the target website. Cookies are small pieces of data stored on the user's computer by the web browser while browsing a website. They hold a modest amount of data specific to a particular client and website, such as site preferences, the state of the user's session, or tracking information.",
+ use: "Cookies can disclose information about how the website tracks and interacts with its users. For instance, session cookies can reveal how user sessions are managed, and tracking cookies can hint at what kind of tracking or analytics frameworks are being used. Additionally, examining cookie policies and practices can offer insights into the site's security settings and compliance with privacy regulations.",
+ resources: [
+ {
+ title: 'HTTP Cookie Docs (Mozilla)',
+ link: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies',
+ },
+ {
+ title: 'What are Cookies (via Cloudflare Learning)',
+ link: 'https://www.cloudflare.com/learning/privacy/what-are-cookies/',
+ },
+ {
+ title: 'Testing for Cookie Attributes (OWASP)',
+ link: 'https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes',
+ },
+ { title: 'RFC-6265 - Coolies', link: 'https://tools.ietf.org/html/rfc6265' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-cookies',
+ },
+ {
+ id: 'robots-txt',
+ title: 'Crawl Rules',
+ description:
+ "Robots.txt is a file found (usually) at the root of a domain, and is used to implement the Robots Exclusion Protocol (REP) to indicate which pages should be ignored by which crawlers and bots. It's good practice to avoid search engine crawlers from over-loading your site, but should not be used to keep pages out of search results (use the noindex meta tag or header instead).",
+ use: "It's often useful to check the robots.txt file during an investigation, as it can sometimes disclose the directories and pages that the site owner doesn't want to be indexed, potentially because they contain sensitive information, or reveal the existence of otherwise hidden or unlinked directories. Additionally, understanding crawl rules may offer insights into a website's SEO strategies.",
+ resources: [
+ {
+ title: 'Google Search Docs - Robots.txt',
+ link: 'https://developers.google.com/search/docs/advanced/robots/intro',
+ },
+ {
+ title: 'Learn about robots.txt (via Moz.com)',
+ link: 'https://moz.com/learn/seo/robotstxt',
+ },
+ {
+ title: 'RFC-9309 - Robots Exclusion Protocol',
+ link: 'https://datatracker.ietf.org/doc/rfc9309/',
+ },
+ {
+ title: 'Robots.txt - wiki',
+ link: 'https://en.wikipedia.org/wiki/Robots_exclusion_standard',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-robots',
+ },
+ {
+ id: 'headers',
+ title: 'Headers',
+ description:
+ 'The Headers task involves extracting and interpreting the HTTP headers sent by the target website during the request-response cycle. HTTP headers are key-value pairs sent at the start of an HTTP response, or before the actual data. Headers contain important directives for how to handle the data being transferred, including cache policies, content types, encoding, server information, security policies, and more.',
+ use: "Analyzing HTTP headers can provide significant insights in an OSINT investigation. Headers can reveal specific server configurations, chosen technologies, caching directives, and various security settings. This information can help to determine a website's underlying technology stack, server-side security measures, potential vulnerabilities, and general operational practices.",
+ resources: [
+ {
+ title: 'HTTP Headers - Docs',
+ link: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers',
+ },
+ {
+ title: 'RFC-7231 Section 7 - Headers',
+ link: 'https://datatracker.ietf.org/doc/html/rfc7231#section-7',
+ },
+ {
+ title: 'List of header response fields',
+ link: 'https://en.wikipedia.org/wiki/List_of_HTTP_header_fields',
+ },
+ {
+ title: 'OWASP Secure Headers Project',
+ link: 'https://owasp.org/www-project-secure-headers/',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-headers',
+ },
+ {
+ id: 'quality',
+ title: 'Quality Metrics',
+ description:
+ 'Using Lighthouse, the Quality Metrics task measures the performance, accessibility, best practices, and SEO of the target website. This returns a simple checklist of 100 core metrics, along with a score for each category, to gauge the overall quality of a given site.',
+ use: "Useful for assessing a site's technical health, SEO issues, identify vulnerabilities, and ensure compliance with standards.",
+ resources: [
+ { title: 'Lighthouse Docs', link: 'https://developer.chrome.com/docs/lighthouse/' },
+ { title: 'Google Page Speed Tools', link: 'https://developers.google.com/speed' },
+ { title: 'W3 Accessibility Tools', link: 'https://www.w3.org/WAI/test-evaluate/' },
+ { title: 'Google Search Console', link: 'https://search.google.com/search-console' },
+ { title: 'SEO Checker', link: 'https://www.seobility.net/en/seocheck/' },
+ { title: 'PWA Builder', link: 'https://www.pwabuilder.com/' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-quality',
+ },
+ {
+ id: 'location',
+ title: 'Server Location',
+ description:
+ "The Server Location task determines the physical location of the server hosting a given website based on its IP address. This is done by looking up the IP in a location database, which maps the IP to a lat + long of known data centers and ISPs. From the latitude and longitude, it's then possible to show additional contextual info, like a pin on the map, along with address, flag, time zone, currency, etc.",
+ use: 'Knowing the server location is a good first step in better understanding a website. For site owners this aids in optimizing content delivery, ensuring compliance with data residency requirements, and identifying potential latency issues that may impact user experience in specific geographical regions. And for security researcher, assess the risk posed by specific regions or jurisdictions regarding cyber threats and regulations.',
+ resources: [
+ { title: 'IP Locator', link: 'https://geobytes.com/iplocator/' },
+ {
+ title: 'Internet Geolocation - Wiki',
+ link: 'https://en.wikipedia.org/wiki/Internet_geolocation',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-location',
+ },
+ {
+ id: 'hosts',
+ title: 'Associated Hosts',
+ description:
+ "This task involves identifying and listing all domains and subdomains (hostnames) that are associated with the website's primary domain. This process often involves DNS enumeration to discover any linked domains and hostnames, as well as looking at known DNS records.",
+ use: "During an investigation, understanding the full scope of a target's web presence is critical. Associated domains could lead to uncovering related projects, backup sites, development/test sites, or services linked to the main site. These can sometimes provide additional information or potential security vulnerabilities. A comprehensive list of associated domains and hostnames can also give an overview of the organization's structure and online footprint.",
+ resources: [
+ { title: 'DNS Enumeration - Wiki', link: 'https://en.wikipedia.org/wiki/DNS_enumeration' },
+ {
+ title: 'OWASP - Enumerate Applications on Webserver',
+ link: 'https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/01-Information_Gathering/04-Enumerate_Applications_on_Webserver',
+ },
+ { title: 'DNS Enumeration - DNS Dumpster', link: 'https://dnsdumpster.com/' },
+ { title: 'Subdomain Finder', link: 'https://subdomainfinder.c99.nl/' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-hosts',
+ },
+ {
+ id: 'redirects',
+ title: 'Redirect Chain',
+ description:
+ 'This task traces the sequence of HTTP redirects that occur from the original URL to the final destination URL. An HTTP redirect is a response with a status code that advises the client to go to another URL. Redirects can occur for several reasons, such as URL normalization (directing to the www version of the site), enforcing HTTPS, URL shorteners, or forwarding users to a new site location.',
+ use: 'Understanding the redirect chain can be useful for several reasons. From a security perspective, long or complicated redirect chains can be a sign of potential security risks, such as unencrypted redirects in the chain. Additionally, redirects can impact website performance and SEO, as each redirect introduces additional round-trip-time (RTT). For OSINT, understanding the redirect chain can help identify relationships between different domains or reveal the use of certain technologies or hosting providers.',
+ resources: [
+ {
+ title: 'HTTP Redirects - MDN',
+ link: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections',
+ },
+ { title: 'URL Redirection - Wiki', link: 'https://en.wikipedia.org/wiki/URL_redirection' },
+ { title: '301 Redirects explained', link: 'https://ahrefs.com/blog/301-redirects/' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-redirects',
+ },
+ {
+ id: 'txt-records',
+ title: 'TXT Records',
+ description:
+ 'TXT records are a type of DNS record that provides text information to sources outside your domain. They can be used for a variety of purposes, such as verifying domain ownership, ensuring email security, and even preventing unauthorized changes to your website.',
+ use: "The TXT records often reveal which external services and technologies are being used with a given domain. They may reveal details about the domain's email configuration, the use of specific services like Google Workspace or Microsoft 365, or security measures in place such as SPF and DKIM. Understanding these details can give an insight into the technologies used by the organization, their email security practices, and potential vulnerabilities.",
+ resources: [
+ {
+ title: 'TXT Records (via Cloudflare Learning)',
+ link: 'https://www.cloudflare.com/learning/dns/dns-records/dns-txt-record/',
+ },
+ { title: 'TXT Records - Wiki', link: 'https://en.wikipedia.org/wiki/TXT_record' },
+ { title: 'RFC-1464 - TXT Records', link: 'https://datatracker.ietf.org/doc/html/rfc1464' },
+ { title: 'TXT Record Lookup (via MxToolbox)', link: 'https://mxtoolbox.com/TXTLookup.aspx' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-txt-records',
+ },
+ {
+ id: 'status',
+ title: 'Server Status',
+ description: 'Checks if a server is online and responding to requests.',
+ use: '',
+ resources: [],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-status',
+ },
+ {
+ id: 'ports',
+ title: 'Open Ports',
+ description:
+ 'Open ports on a server are endpoints of communication which are available for establishing connections with clients. Each port corresponds to a specific service or protocol, such as HTTP (port 80), HTTPS (port 443), FTP (port 21), etc. The open ports on a server can be determined using techniques such as port scanning.',
+ use: 'Knowing which ports are open on a server can provide information about the services running on that server, useful for understanding the potential vulnerabilities of the system, or for understanding the nature of the services the server is providing.',
+ resources: [
+ {
+ title: 'List of TCP & UDP Port Numbers',
+ link: 'https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers',
+ },
+ {
+ title: 'NMAP - Port Scanning Basics',
+ link: 'https://nmap.org/book/man-port-scanning-basics.html',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-ports',
+ },
+ {
+ id: 'trace-route',
+ title: 'Traceroute',
+ description:
+ 'Traceroute is a network diagnostic tool used to track in real-time the pathway taken by a packet of information from one system to another. It records each hop along the route, providing details about the IPs of routers and the delay at each point.',
+ use: "In OSINT investigations, traceroute can provide insights about the routing paths and geography of the network infrastructure supporting a website or service. This can help to identify network bottlenecks, potential censorship or manipulation of network traffic, and give an overall sense of the network's structure and efficiency. Additionally, the IP addresses collected during the traceroute may provide additional points of inquiry for further OSINT investigation.",
+ resources: [
+ 'https://www.cloudflare.com/learning/network-layer/what-is-traceroute/',
+ 'https://tools.ietf.org/html/rfc1393',
+ 'https://en.wikipedia.org/wiki/Traceroute',
+ 'https://www.ripe.net/publications/docs/ripe-611',
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-trace-route',
+ },
+ {
+ id: 'carbon',
+ title: 'Carbon Footprint',
+ description:
+ "This task calculates the estimated carbon footprint of a website. It's based on the amount of data being transferred and processed, and the energy usage of the servers that host and deliver the website. The larger the website and the more complex its features, the higher its carbon footprint is likely to be.",
+ use: "From an OSINT perspective, understanding a website's carbon footprint doesn't directly provide insights into its internal workings or the organization behind it. However, it can still be valuable data in broader analyses, especially in contexts where environmental impact is a consideration. For example, it can be useful for activists, researchers, or ethical hackers who are interested in the sustainability of digital infrastructure, and who want to hold organizations accountable for their environmental impact.",
+ resources: [
+ { title: 'WebsiteCarbon - Carbon Calculator', link: 'https://www.websitecarbon.com/' },
+ { title: 'The Green Web Foundation', link: 'https://www.thegreenwebfoundation.org/' },
+ { title: 'The Eco Friendly Web Alliance', link: 'https://ecofriendlyweb.org/' },
+ { title: 'Reset.org', link: 'https://en.reset.org/' },
+ {
+ title: 'Your website is killing the planet - via Wired',
+ link: 'https://www.wired.co.uk/article/internet-carbon-footprint',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-carbon',
+ },
+ {
+ id: 'server-info',
+ title: 'Server Info',
+ description:
+ 'This task retrieves various pieces of information about the server hosting the target website. This can include the server type (e.g., Apache, Nginx), the hosting provider, the Autonomous System Number (ASN), and more. The information is usually obtained through a combination of IP address lookups and analysis of HTTP response headers.',
+ use: 'In an OSINT context, server information can provide valuable clues about the organization behind a website. For instance, the choice of hosting provider could suggest the geographical region in which the organization operates, while the server type could hint at the technologies used by the organization. The ASN could also be used to find other domains hosted by the same organization.',
+ resources: [
+ 'https://en.wikipedia.org/wiki/List_of_HTTP_header_fields',
+ 'https://en.wikipedia.org/wiki/Autonomous_system_(Internet)',
+ 'https://tools.ietf.org/html/rfc7231#section-7.4.2',
+ 'https://builtwith.com/',
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-server',
+ },
+ {
+ id: 'domain',
+ title: 'Whois Lookup',
+ description:
+ "This task retrieves Whois records for the target domain. Whois records are a rich source of information, including the name and contact information of the domain registrant, the domain's creation and expiration dates, the domain's nameservers, and more. The information is usually obtained through a query to a Whois database server.",
+ use: "In an OSINT context, Whois records can provide valuable clues about the entity behind a website. They can show when the domain was first registered and when it's set to expire, which could provide insights into the operational timeline of the entity. The contact information, though often redacted or anonymized, can sometimes lead to additional avenues of investigation. The nameservers could also be used to link together multiple domains owned by the same entity.",
+ resources: [
+ 'https://en.wikipedia.org/wiki/WHOIS',
+ 'https://www.icann.org/resources/pages/whois-2018-01-17-en',
+ 'https://whois.domaintools.com/',
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-domain',
+ },
+ {
+ id: 'whois',
+ title: 'Domain Info',
+ description:
+ "This task retrieves Whois records for the target domain. Whois records are a rich source of information, including the name and contact information of the domain registrant, the domain's creation and expiration dates, the domain's nameservers, and more. The information is usually obtained through a query to a Whois database server.",
+ use: "In an OSINT context, Whois records can provide valuable clues about the entity behind a website. They can show when the domain was first registered and when it's set to expire, which could provide insights into the operational timeline of the entity. The contact information, though often redacted or anonymized, can sometimes lead to additional avenues of investigation. The nameservers could also be used to link together multiple domains owned by the same entity.",
+ resources: [
+ 'https://en.wikipedia.org/wiki/WHOIS',
+ 'https://www.icann.org/resources/pages/whois-2018-01-17-en',
+ 'https://whois.domaintools.com/',
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-domain',
+ },
+ {
+ id: 'dnssec',
+ title: 'DNS Security Extensions',
+ description:
+ "Without DNSSEC, it's possible for MITM attackers to spoof records and lead users to phishing sites. This is because the DNS system includes no built-in methods to verify that the response to the request was not forged, or that any other part of the process wasn’t interrupted by an attacker. The DNS Security Extensions (DNSSEC) secures DNS lookups by signing your DNS records using public keys, so browsers can detect if the response has been tampered with. Another solution to this issue is DoH (DNS over HTTPS) and DoT (DNS over TLD).",
+ use: "DNSSEC information provides insight into an organization's level of cybersecurity maturity and potential vulnerabilities, particularly around DNS spoofing and cache poisoning. If no DNS secururity (DNSSEC, DoH, DoT, etc) is implemented, this may provide an entry point for an attacker.",
+ resources: [
+ 'https://dnssec-analyzer.verisignlabs.com/',
+ 'https://www.cloudflare.com/dns/dnssec/how-dnssec-works/',
+ 'https://en.wikipedia.org/wiki/Domain_Name_System_Security_Extensions',
+ 'https://www.icann.org/resources/pages/dnssec-what-is-it-why-important-2019-03-05-en',
+ 'https://support.google.com/domains/answer/6147083',
+ 'https://www.internetsociety.org/resources/deploy360/2013/dnssec-test-sites/',
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-dnssec',
+ },
+ {
+ id: 'hsts',
+ title: 'HTTP Strict Transport Security',
+ description:
+ 'HTTP Strict Transport Security (HSTS) is a web security policy ' +
+ 'mechanism that helps protect websites against protocol downgrade attacks and ' +
+ 'cookie hijacking. A website can be included in the HSTS preload list by ' +
+ 'conforming to a set of requirements and then submitting itself to the list.',
+ use: `There are several reasons why it's important for a site to be HSTS enabled:
+ 1. User bookmarks or manually types http://example.com and is subject to a man-in-the-middle attacker
+ HSTS automatically redirects HTTP requests to HTTPS for the target domain
+ 2. Web application that is intended to be purely HTTPS inadvertently contains HTTP links or serves content over HTTP
+ HSTS automatically redirects HTTP requests to HTTPS for the target domain
+ 3. A man-in-the-middle attacker attempts to intercept traffic from a victim user using an invalid certificate and hopes the user will accept the bad certificate
+ HSTS does not allow a user to override the invalid certificate message
+ `,
+ resources: [
+ 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security',
+ 'https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Strict_Transport_Security_Cheat_Sheet.html',
+ 'https://hstspreload.org/',
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-hsts',
+ },
+ {
+ id: 'dns-server',
+ title: 'DNS Server',
+ description:
+ "This check determines the DNS server(s) that the requested URL / IP resolves to. Also fires off a rudimentary check to see if the DNS server supports DoH, and weather it's vulnerable to DNS cache poisoning.",
+ use: '',
+ resources: [],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-dns-servers',
+ },
+ {
+ id: 'tech-stack',
+ title: 'Tech Stack',
+ description:
+ 'Checks what technologies a site is built with. ' +
+ 'This is done by fetching and parsing the site, then comparing it against a bit list of RegEx maintained by Wappalyzer to identify the unique fingerprints that different technologies leave.',
+ use:
+ "Identifying a website's tech stack aids in evaluating its security by exposing potential vulnerabilities, " +
+ 'informs competitive analyses and development decisions, and can guide tailored marketing strategies. ' +
+ 'Ethical application of this knowledge is crucial to avoid harmful activities like data theft or unauthorized intrusion.',
+ resources: [
+ {
+ title: 'Wappalyzer fingerprints',
+ link: 'https://github.com/wappalyzer/wappalyzer/tree/master/src/technologies',
+ },
+ { title: 'BuiltWith - Check what tech a site is using', link: 'https://builtwith.com/' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-tech',
+ },
+ {
+ id: 'sitemap',
+ title: 'Listed Pages',
+ description:
+ "This job finds and parses a site's listed sitemap. This file lists public sub-pages on the site, which the author wishes to be crawled by search engines. Sitemaps help with SEO, but are also useful for seeing all a sites public content at a glance.",
+ use: "Understand the structure of a site's public-facing content, and for site-owners, check that you're site's sitemap is accessible, parsable and contains everything you wish it to.",
+ resources: [
+ {
+ title: 'Learn about Sitemaps',
+ link: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview',
+ },
+ { title: 'Sitemap XML spec', link: 'https://www.sitemaps.org/protocol.html' },
+ { title: 'Sitemap tutorial', link: 'https://www.conductor.com/academy/xml-sitemap/' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-pages',
+ },
+ {
+ id: 'security-txt',
+ title: 'Security.txt',
+ description:
+ 'The security.txt file tells researchers how they can responsibly disclose any security issues found on your site. ' +
+ 'The standard was proposed in RFC 9116, and specifies that this file should include a point of contact (email address), ' +
+ 'as well as optionally other info, like a link to the security disclosure policy, PGP key, proffered language, policy expiry and more. ' +
+ 'The file should be located at the root of your domain, either at /security.txt or /.well-known/security.txt.',
+ use:
+ 'This is important, as without a defined point of contact a security researcher may be unable to report a critical security issue, ' +
+ 'or may use insecure or possibly public channels to do so. From an OSINT perspective, you may also glean info about a site including ' +
+ 'their posture on security, their CSAF provider, and meta data from the PGP public key.',
+ resources: [
+ { title: 'securitytxt.org', link: 'https://securitytxt.org/' },
+ { title: 'RFC-9116 Proposal', link: 'https://datatracker.ietf.org/doc/html/rfc9116' },
+ { title: 'RFC-9116 History', link: 'https://datatracker.ietf.org/doc/rfc9116/' },
+ { title: 'Security.txt (Wikipedia)', link: 'https://en.wikipedia.org/wiki/Security.txt' },
+ {
+ title: 'Example security.txt (Cloudflare)',
+ link: 'https://www.cloudflare.com/.well-known/security.txt',
+ },
+ {
+ title: 'Tutorial for creating security.txt (Pieter Bakker)',
+ link: 'https://pieterbakker.com/implementing-security-txt/',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-securitytxt',
+ },
+ {
+ id: 'linked-pages',
+ title: 'Linked Pages',
+ description:
+ 'Displays all internal and external links found on a site, identified by the href attributes attached to anchor elements.',
+ use:
+ 'For site owners, this is useful for diagnosing SEO issues, improving the site structure, understanding how content is inter-connected. External links can show partnerships, dependencies, and potential reputation risks. ' +
+ "From a security standpoint, the outbound links can help identify any potential malicious or compromised sites the website is unknowingly linking to. Analyzing internal links can aid in understanding the site's structure and potentially uncover hidden or vulnerable pages which are not intended to be public. " +
+ 'And for an OSINT investigator, it can aid in building a comprehensive understanding of the target, uncovering related entities, resources, or even potential hidden parts of the site.',
+ resources: [{ title: 'W3C Link Checker', link: 'https://validator.w3.org/checklink' }],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-links',
+ },
+ {
+ id: 'social-tags',
+ title: 'Social Tags',
+ description:
+ 'Websites can include certain meta tags, that tell search engines and social media platforms what info to display. This usually includes a title, description, thumbnail, keywords, author, social accounts, etc.',
+ use: 'Adding this data to your site will boost SEO, and as an OSINT researcher it can be useful to understand how a given web app describes itself',
+ resources: [
+ { title: 'SocialSharePreview.com', link: 'https://socialsharepreview.com/' },
+ {
+ title: 'The guide to social meta tags',
+ link: 'https://css-tricks.com/essential-meta-tags-social-media/',
+ },
+ { title: 'Web.dev metadata tags', link: 'https://web.dev/learn/html/metadata/' },
+ { title: 'Open Graph Protocol', link: 'https://ogp.me/' },
+ {
+ title: 'Twitter Cards',
+ link: 'https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards',
+ },
+ {
+ title: 'Facebook Open Graph',
+ link: 'https://developers.facebook.com/docs/sharing/webmasters',
+ },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-social',
+ },
+ {
+ id: 'mail-config',
+ title: 'Email Configuration',
+ description:
+ "DMARC (Domain-based Message Authentication, Reporting & Conformance): DMARC is an email authentication protocol that works with SPF and DKIM to prevent email spoofing and phishing. It allows domain owners to specify how to handle unauthenticated mail via a published policy in DNS, and provides a way for receiving mail servers to send feedback about emails' compliance to the sender. " +
+ "BIMI (Brand Indicators for Message Identification): BIMI is an emerging email standard that enables organizations to display a logo in their customers' email clients automatically. BIMI ties the logo to the domain's DMARC record, providing another level of visual assurance to recipients that the email is legitimate. " +
+ 'DKIM (DomainKeys Identified Mail): DKIM is an email security standard designed to make sure that messages were not altered in transit between the sending and recipient servers. It uses digital signatures linked to the domain of the sender to verify the sender and ensure message integrity. ' +
+ "SPF (Sender Policy Framework): SPF is an email authentication method designed to prevent email spoofing. It specifies which mail servers are authorized to send email on behalf of a domain by creating a DNS record. This helps protect against spam by providing a way for receiving mail servers to check that incoming mail from a domain comes from a host authorized by that domain's administrators.",
+ use: "This information is helpful for researchers as it helps assess a domain's email security posture, uncover potential vulnerabilities, and verify the legitimacy of emails for phishing detection. These details can also provide insight into the hosting environment, potential service providers, and the configuration patterns of a target organization, assisting in investigative efforts.",
+ resources: [
+ {
+ title: 'Intro to DMARC, DKIM, and SPF (via Cloudflare)',
+ link: 'https://www.cloudflare.com/learning/email-security/dmarc-dkim-spf/',
+ },
+ { title: 'EasyDMARC Domain Scanner', link: 'https://easydmarc.com/tools/domain-scanner' },
+ { title: 'MX Toolbox', link: 'https://mxtoolbox.com/' },
+ { title: 'RFC-7208 - SPF', link: 'https://datatracker.ietf.org/doc/html/rfc7208' },
+ { title: 'RFC-6376 - DKIM', link: 'https://datatracker.ietf.org/doc/html/rfc6376' },
+ { title: 'RFC-7489 - DMARC', link: 'https://datatracker.ietf.org/doc/html/rfc7489' },
+ { title: 'BIMI Group', link: 'https://bimigroup.org/' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-email',
+ },
+ {
+ id: 'firewall',
+ title: 'Firewall Detection',
+ description:
+ 'A WAF or web application firewall helps protect web applications by filtering and monitoring HTTP traffic between a web application and the Internet. It typically protects web applications from attacks such as cross-site forgery, cross-site-scripting (XSS), file inclusion, and SQL injection, among others.',
+ use: "It's useful to understand if a site is using a WAF, and which firewall software / service it is using, as this provides an insight into the sites protection against several attack vectors, but also may reveal vulnerabilities in the firewall itself.",
+ resources: [
+ {
+ title: 'What is a WAF (via Cloudflare Learning)',
+ link: 'https://www.cloudflare.com/learning/ddos/glossary/web-application-firewall-waf/',
+ },
+ {
+ title: 'OWASP - Web Application Firewalls',
+ link: 'https://owasp.org/www-community/Web_Application_Firewall',
+ },
+ {
+ title: 'Web Application Firewall Best Practices',
+ link: 'https://owasp.org/www-pdf-archive/Best_Practices_Guide_WAF_v104.en.pdf',
+ },
+ { title: 'WAF - Wiki', link: 'https://en.wikipedia.org/wiki/Web_application_firewall' },
+ ],
+ screenshot: 'https://pixelflare.cc/alicia/web-check/wc-firewall',
+ },
+ {
+ id: 'http-security',
+ title: 'HTTP Security Features',
+ description:
+ 'Correctly configured security HTTP headers adds a layer of protection against common attacks to your site. The main headers to be aware of are: ' +
+ 'HTTP Strict Transport Security (HSTS): Enforces the use of HTTPS, mitigating man-in-the-middle attacks and protocol downgrade attempts. ' +
+ 'Content Security Policy (CSP): Constrains web page resources to prevent cross-site scripting and data injection attacks. ' +
+ 'X-Content-Type-Options: Prevents browsers from MIME-sniffing a response away from the declared content type, curbing MIME-type confusion attacks. ' +
+ 'X-Frame-Options: Protects users from clickjacking attacks by controlling whether a browser should render the page in a ,