diff --git a/.github/README.md b/.github/README.md index 279685d..89165be 100644 --- a/.github/README.md +++ b/.github/README.md @@ -42,7 +42,7 @@ None of this is hard to find with a series of basic curl commands, or a combinat
IP Address - + ###### Description The IP Address task involves mapping the user provided URL to its corresponding IP address through a process known as Domain Name System (DNS) resolution. An IP address is a unique identifier given to every device on the Internet, and when paired with a domain name, it allows for accurate routing of online requests and responses. diff --git a/.github/screenshots/wc_ip-adress.png b/.github/screenshots/wc_ip-adress.png new file mode 100644 index 0000000..24104e5 Binary files /dev/null and b/.github/screenshots/wc_ip-adress.png differ diff --git a/api/content-links.js b/api/content-links.js index 45c3f50..7e1ee8a 100644 --- a/api/content-links.js +++ b/api/content-links.js @@ -29,6 +29,19 @@ const handler = async (url) => { 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 there were no links, then mark as skipped and show reasons + if (internalLinks.length === 0 && externalLinks.length === 0) { + return { + statusCode: 400, + body: JSON.stringify({ + skipped: 'No internal or external links found. ' + + 'This may be due to the website being dynamically rendered, using a client-side framework (like React), and without SSR enabled. ' + + 'That would mean that the static HTML returned from the HTTP request doesn\'t contain any meaningful content for Web-Check to analyze. ' + + 'You can rectify this by using a headless browser to render the page instead.', + }), + }; + } + return { internal: internalLinks, external: externalLinks }; }; diff --git a/api/get-carbon.js b/api/get-carbon.js index 42dd6f8..40cecdf 100644 --- a/api/get-carbon.js +++ b/api/get-carbon.js @@ -34,6 +34,13 @@ const handler = async (url) => { }).on('error', reject); }); + if (!carbonData.statistics || (carbonData.statistics.adjustedBytes === 0 && carbonData.statistics.energy === 0)) { + return { + statusCode: 200, + body: JSON.stringify({ skipped: 'Not enough info to get carbon data' }), + }; + } + carbonData.scanUrl = url; return carbonData; } catch (error) { diff --git a/api/mail-config.js b/api/mail-config.js new file mode 100644 index 0000000..f575d40 --- /dev/null +++ b/api/mail-config.js @@ -0,0 +1,79 @@ +const dns = require('dns').promises; +const URL = require('url-parse'); + +exports.handler = async (event, context) => { + try { + let domain = event.queryStringParameters.url; + const parsedUrl = new URL(domain); + domain = parsedUrl.hostname || parsedUrl.pathname; + + // Get MX records + const mxRecords = await dns.resolveMx(domain); + + // Get TXT records + const txtRecords = await dns.resolveTxt(domain); + + // Filter for only email related TXT records (SPF, DKIM, DMARC, and certain provider verifications) + const emailTxtRecords = txtRecords.filter(record => { + const recordString = record.join(''); + return ( + recordString.startsWith('v=spf1') || + recordString.startsWith('v=DKIM1') || + recordString.startsWith('v=DMARC1') || + recordString.startsWith('protonmail-verification=') || + recordString.startsWith('google-site-verification=') || // Google Workspace + recordString.startsWith('MS=') || // Microsoft 365 + recordString.startsWith('zoho-verification=') || // Zoho + recordString.startsWith('titan-verification=') || // Titan + recordString.includes('bluehost.com') // BlueHost + ); + }); + + // Identify specific mail services + const mailServices = emailTxtRecords.map(record => { + const recordString = record.join(''); + if (recordString.startsWith('protonmail-verification=')) { + return { provider: 'ProtonMail', value: recordString.split('=')[1] }; + } else if (recordString.startsWith('google-site-verification=')) { + return { provider: 'Google Workspace', value: recordString.split('=')[1] }; + } else if (recordString.startsWith('MS=')) { + return { provider: 'Microsoft 365', value: recordString.split('=')[1] }; + } else if (recordString.startsWith('zoho-verification=')) { + return { provider: 'Zoho', value: recordString.split('=')[1] }; + } else if (recordString.startsWith('titan-verification=')) { + return { provider: 'Titan', value: recordString.split('=')[1] }; + } else if (recordString.includes('bluehost.com')) { + return { provider: 'BlueHost', value: recordString }; + } else { + return null; + } + }).filter(record => record !== null); + + // Check MX records for Yahoo + const yahooMx = mxRecords.filter(record => record.exchange.includes('yahoodns.net')); + if (yahooMx.length > 0) { + mailServices.push({ provider: 'Yahoo', value: yahooMx[0].exchange }); + } + + return { + statusCode: 200, + body: JSON.stringify({ + mxRecords, + txtRecords: emailTxtRecords, + mailServices, + }), + }; + } catch (error) { + if (error.code === 'ENOTFOUND' || error.code === 'ENODATA') { + return { + statusCode: 200, + body: JSON.stringify({ skipped: 'No mail server in use on this domain' }), + }; + } else { + return { + statusCode: 500, + body: JSON.stringify({ error: error.message }), + }; + } + } +}; diff --git a/api/sitemap.js b/api/sitemap.js index f68cce0..816d4f1 100644 --- a/api/sitemap.js +++ b/api/sitemap.js @@ -1,33 +1,64 @@ +const commonMiddleware = require('./_common/middleware'); + const axios = require('axios'); const xml2js = require('xml2js'); -const middleware = require('./_common/middleware'); -const fetchSitemapHandler = async (url) => { - let sitemapUrl; +const handler = async (url) => { + let sitemapUrl = `${url}/sitemap.xml`; try { - // Fetch robots.txt - const robotsRes = await axios.get(`${url}/robots.txt`); - const robotsTxt = robotsRes.data.split('\n'); + // Try to fetch sitemap directly + let sitemapRes; + try { + sitemapRes = await axios.get(sitemapUrl, { timeout: 5000 }); + } catch (error) { + if (error.response && error.response.status === 404) { + // If sitemap not found, try to fetch it from robots.txt + const robotsRes = await axios.get(`${url}/robots.txt`, { timeout: 5000 }); + const robotsTxt = robotsRes.data.split('\n'); - for (let line of robotsTxt) { - if (line.startsWith('Sitemap:')) { - sitemapUrl = line.split(' ')[1]; + for (let line of robotsTxt) { + if (line.toLowerCase().startsWith('sitemap:')) { + sitemapUrl = line.split(' ')[1].trim(); + break; + } + } + + if (!sitemapUrl) { + return { + statusCode: 404, + body: JSON.stringify({ skipped: 'No sitemap found' }), + }; + } + + sitemapRes = await axios.get(sitemapUrl, { timeout: 5000 }); + } else { + throw error; // If other error, throw it } } - if (!sitemapUrl) { - throw new Error('Sitemap not found in robots.txt'); - } + const parser = new xml2js.Parser(); + const sitemap = await parser.parseStringPromise(sitemapRes.data); - // Fetch sitemap - const sitemapRes = await axios.get(sitemapUrl); - const sitemap = await xml2js.parseStringPromise(sitemapRes.data); - - return sitemap; + return { + statusCode: 200, + body: JSON.stringify(sitemap), + }; } catch (error) { - throw new Error(error.message); + // If error occurs + console.log(error.message); + if (error.code === 'ECONNABORTED') { + return { + statusCode: 500, + body: JSON.stringify({ error: 'Request timed out' }), + }; + } else { + return { + statusCode: 500, + body: JSON.stringify({ error: error.message }), + }; + } } }; -exports.handler = middleware(fetchSitemapHandler); +exports.handler = commonMiddleware(handler); diff --git a/api/social-tags.js b/api/social-tags.js new file mode 100644 index 0000000..9b0af39 --- /dev/null +++ b/api/social-tags.js @@ -0,0 +1,68 @@ +const axios = require('axios'); +const cheerio = require('cheerio'); + +exports.handler = async (event, context) => { + let url = event.queryStringParameters.url; + + // Check if url includes protocol + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://' + url; + } + + try { + const response = await axios.get(url); + const html = response.data; + const $ = cheerio.load(html); + + 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') + }; + + if (Object.keys(metadata).length === 0) { + return { + statusCode: 200, + body: JSON.stringify({ skipped: 'No metadata found' }), + }; + } + + return { + statusCode: 200, + body: JSON.stringify(metadata), + }; + } catch (error) { + return { + statusCode: 500, + body: JSON.stringify({ error: 'Failed fetching data' }), + }; + } +}; diff --git a/src/components/Form/Card.tsx b/src/components/Form/Card.tsx index c178d92..1721e97 100644 --- a/src/components/Form/Card.tsx +++ b/src/components/Form/Card.tsx @@ -30,7 +30,7 @@ export const Card = (props: CardProps): JSX.Element => { { actionButtons && actionButtons } - { heading && {heading} } + { heading && {heading} } {children} diff --git a/src/components/Form/Heading.tsx b/src/components/Form/Heading.tsx index 49dd715..0dd18a5 100644 --- a/src/components/Form/Heading.tsx +++ b/src/components/Form/Heading.tsx @@ -10,6 +10,7 @@ interface HeadingProps { inline?: boolean; children: React.ReactNode; id?: string; + className?: string; }; const StyledHeading = styled.h1` @@ -47,10 +48,14 @@ const StyledHeading = styled.h1` ${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 } = props; + const { children, as, size, align, color, inline, id, className } = props; return ( - + {children} ); diff --git a/src/components/Results/ContentLinks.tsx b/src/components/Results/ContentLinks.tsx index d2ea0a5..87df6c1 100644 --- a/src/components/Results/ContentLinks.tsx +++ b/src/components/Results/ContentLinks.tsx @@ -43,9 +43,8 @@ const getPathName = (link: string) => { }; const ContentLinksCard = (props: { data: any, title: string, actionButtons: any }): JSX.Element => { - const { internal, external} = props.data; - console.log('Internal Links', internal); - console.log('External Links', external); + const internal = props.data.internal || []; + const external = props.data.external || []; return ( Summary @@ -71,17 +70,6 @@ const ContentLinksCard = (props: { data: any, title: string, actionButtons: any ))}
)} - {/* {portData.openPorts.map((port: any) => ( - - {port} - - ) - )} -
- - Unable to establish connections to:
- {portData.failedPorts.join(', ')} -
*/} ); } diff --git a/src/components/Results/MailConfig.tsx b/src/components/Results/MailConfig.tsx new file mode 100644 index 0000000..b69c16d --- /dev/null +++ b/src/components/Results/MailConfig.tsx @@ -0,0 +1,45 @@ + +import { Card } from 'components/Form/Card'; +import Row from 'components/Form/Row'; +import Heading from 'components/Form/Heading'; +import colors from '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) => ( + + {record.exchange} + {record.priority ? `Priority: ${record.priority}` : ''} + + )) + } + { mailServer.mailServices.length > 0 && External Mail Services} + { mailServer.mailServices && mailServer.mailServices.map((service: any) => ( + + )) + } + + { mailServer.txtRecords && Mail-related TXT Records} + { mailServer.txtRecords && mailServer.txtRecords.map((record: any) => ( + + {record} + + )) + } + + ); +} + +export default MailConfigCard; diff --git a/src/components/Results/SocialTags.tsx b/src/components/Results/SocialTags.tsx new file mode 100644 index 0000000..d22dc0c --- /dev/null +++ b/src/components/Results/SocialTags.tsx @@ -0,0 +1,44 @@ + +import { Card } from 'components/Form/Card'; +import Row from 'components/Form/Row'; +import colors from '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 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 &&
Banner
} +
+ ); +} + +export default SocialTagsCard; diff --git a/src/components/misc/AdditionalResources.tsx b/src/components/misc/AdditionalResources.tsx index 663fc8f..4f3b50c 100644 --- a/src/components/misc/AdditionalResources.tsx +++ b/src/components/misc/AdditionalResources.tsx @@ -8,12 +8,13 @@ margin: 0; padding: 1rem; display: grid; gap: 0.5rem; -grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); +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; + padding: 0.25rem 0.5rem; background: ${colors.background}; border-radius: 8px; text-decoration: none; @@ -37,27 +38,32 @@ li a.resource-wrap { } } img { - width: 4rem; + width: 2.5rem; border-radius: 4px; margin: 0.25rem 0.1rem 0.1rem 0.1rem; } +p, a { + margin: 0; +} +a.resource-link { + color: ${colors.primary}; + opacity: 0.75; + font-size: 0.9rem; + transition: all 0.2s ease-in-out; +} +.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; - p, a { - margin: 0; - } - a.resource-link { - color: ${colors.primary}; - opacity: 0.75; - font-size: 0.9rem; - transition: all 0.2s ease-in-out; - } - .resource-title { - font-weight: bold; - } .resource-description { color: ${colors.textColorSecondary}; font-size: 0.9rem; @@ -155,6 +161,13 @@ const resources = [ 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://i.ibb.co/5LXBDfD/Built-with.png', + description: 'View the tech stack of a website', + searchLink: 'https://builtwith.com/{URL}', + }, { title: 'DNS Dumpster', link: 'https://dnsdumpster.com/', @@ -192,7 +205,7 @@ const resources = [ ]; const makeLink = (resource: any, scanUrl: string | undefined): string => { - return (scanUrl && resource.searchLink) ? resource.searchLink.replaceAll('{URL}', scanUrl.replace('https://', '')) : '#'; + return (scanUrl && resource.searchLink) ? resource.searchLink.replaceAll('{URL}', scanUrl.replace('https://', '')) : resource.link; }; const AdditionalResources = (props: { url?: string }): JSX.Element => { @@ -203,12 +216,14 @@ const AdditionalResources = (props: { url?: string }): JSX.Element => { return (
  • - -

    {resource.title}

    {new URL(resource.link).hostname} -

    {resource.description}

    -
    +
    + +
    +

    {resource.description}

    +
    +
  • ); diff --git a/src/components/misc/Footer.tsx b/src/components/misc/Footer.tsx index 432d31a..dcf61f4 100644 --- a/src/components/misc/Footer.tsx +++ b/src/components/misc/Footer.tsx @@ -41,7 +41,7 @@ const Link = styled.a` `; const Footer = (props: { isFixed?: boolean }): JSX.Element => { - const licenseUrl = 'https://github.com/lissy93/web-check/blob/main/LICENSE'; + 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 ( diff --git a/src/components/misc/ProgressBar.tsx b/src/components/misc/ProgressBar.tsx index 1d2be06..9803205 100644 --- a/src/components/misc/ProgressBar.tsx +++ b/src/components/misc/ProgressBar.tsx @@ -167,6 +167,9 @@ p { } pre { color: ${colors.danger}; + &.info { + color: ${colors.warning}; + } } `; @@ -191,6 +194,7 @@ const jobNames = [ 'hosts', 'quality', 'cookies', + 'ssl', // 'server-info', 'redirects', 'robots-txt', @@ -202,7 +206,9 @@ const jobNames = [ 'sitemap', 'hsts', 'security-txt', + 'social-tags', 'linked-pages', + 'mail-config', // 'whois', 'features', 'carbon', @@ -360,7 +366,7 @@ const ProgressLoader = (props: { loadStatus: LoadingJob[], showModal: (err: Reac } }; - const showErrorModal = (name: string, state: LoadingState, timeTaken: number | undefined, error: string) => { + const showErrorModal = (name: string, state: LoadingState, timeTaken: number | undefined, error: string, isInfo?: boolean) => { const errorContent = ( Error Details for {name} @@ -368,7 +374,8 @@ const ProgressLoader = (props: { loadStatus: LoadingJob[], showModal: (err: Reac The {name} job failed with an {state} state after {timeTaken} ms. The server responded with the following error:

    -
    {error}
    + { /* If isInfo == true, then add .info className to pre */} +
    {error}
    ); props.showModal(errorContent); @@ -409,6 +416,7 @@ const ProgressLoader = (props: { loadStatus: LoadingJob[], showModal: (err: Reac {(timeTaken && state !== 'loading') ? ` Took ${timeTaken} ms` : '' } { (retry && state !== 'success' && state !== 'loading') && ↻ Retry } { (error && state === 'error') && showErrorModal(name, state, timeTaken, error)}>■ Show Error } + { (error && state === 'skipped') && showErrorModal(name, state, timeTaken, error, true)}>■ Show Reason } ); }) diff --git a/src/hooks/motherOfAllHooks.ts b/src/hooks/motherOfAllHooks.ts index 8ff5ff0..fae8b16 100644 --- a/src/hooks/motherOfAllHooks.ts +++ b/src/hooks/motherOfAllHooks.ts @@ -38,21 +38,20 @@ const useMotherOfAllHooks = (params: UseIpAddressProps { return fetchRequest() .then((res: any) => { - if (!res) { + if (!res) { // No response :( + updateLoadingJobs(jobId, 'error', res.error || 'No response', reset); + } else if (res.error) { // Response returned an error message updateLoadingJobs(jobId, 'error', res.error, reset); - throw new Error('No response'); + } else if (res.skipped) { // Response returned a skipped message + updateLoadingJobs(jobId, 'skipped', res.skipped, reset); + } else { // Yay, everything went to plan :) + setResult(res); + updateLoadingJobs(jobId, 'success', '', undefined, res); } - if (res.error) { - updateLoadingJobs(jobId, 'error', res.error, reset); - throw new Error(res.error); - } - // All went to plan, set results and mark as done - setResult(res); - updateLoadingJobs(jobId, 'success', '', undefined, res); }) .catch((err) => { - // Something fucked up, log the error - updateLoadingJobs(jobId, 'error', err.error || err.message, reset); + // Something fucked up + updateLoadingJobs(jobId, 'error', err.error || err.message || 'Unknown error', reset); throw err; }) } @@ -69,6 +68,7 @@ const useMotherOfAllHooks = (params: UseIpAddressProps {}); diff --git a/src/index.css b/src/index.css index 5ab1c57..e0502ea 100644 --- a/src/index.css +++ b/src/index.css @@ -5,6 +5,10 @@ font-style: normal; } +html { + scroll-behavior: smooth; +} + body { margin: 0; font-family: 'PTMono', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', diff --git a/src/pages/About.tsx b/src/pages/About.tsx index 622b3f3..922e00a 100644 --- a/src/pages/About.tsx +++ b/src/pages/About.tsx @@ -5,9 +5,9 @@ import Heading from 'components/Form/Heading'; import Footer from 'components/misc/Footer'; import Nav from 'components/Form/Nav'; import Button from 'components/Form/Button'; +import AdditionalResources from 'components/misc/AdditionalResources'; import { StyledCard } from 'components/Form/Card'; -import docs, { about, license, fairUse } from 'utils/docs'; - +import docs, { about, license, fairUse, supportUs } from 'utils/docs'; const AboutContainer = styled.div` width: 95vw; @@ -16,6 +16,11 @@ margin: 2rem auto; padding-bottom: 1rem; header { margin 1rem 0; + width: auto; +} +section { + width: auto; + .inner-heading { display: none; } } `; @@ -32,6 +37,9 @@ const Section = styled(StyledCard)` margin-bottom: 2rem; overflow: clip; max-height: 100%; + section { + clear: both; + } h3 { font-size: 1.5rem; } @@ -71,13 +79,25 @@ const Section = styled(StyledCard)` } } } - .screenshot { - float: right; - break-inside: avoid; - max-width: 300px; - max-height: 28rem; - border-radius: 6px; + .example-screenshot { + float: right; + display: inline-flex; + flex-direction: column; clear: both; + max-width: 300px; + img { + float: right; + break-inside: avoid; + max-width: 300px; + // max-height: 30rem; + border-radius: 6px; + clear: both; + } + figcaption { + font-size: 0.8rem; + text-align: center; + opacity: 0.7; + } } `; @@ -85,7 +105,6 @@ const makeAnchor = (title: string): string => { return title.toLowerCase().replace(/[^\w\s]|_/g, "").replace(/\s+/g, "-"); }; - const About = (): JSX.Element => { return (
    @@ -118,9 +137,13 @@ const About = (): JSX.Element => {
    {docs.map((section, sectionIndex: number) => (
    + { sectionIndex > 0 &&
    } {section.title} - {section.screenshot && - {`Example + {section.screenshot && +
    + {`Example +
    Fig.{sectionIndex + 1} - Example of {section.title}
    +
    } {section.description && <> Description @@ -142,11 +165,24 @@ const About = (): JSX.Element => { ))} } - { sectionIndex < docs.length - 1 &&
    } + {/* { sectionIndex < docs.length - 1 &&
    } */}
    ))} + API Documentation +
    +

    // Coming soon...

    +
    + + Additional Resources + + + Support Us +
    + {supportUs.map((para, index: number) => (

    ))} +

    + Terms & Info
    License @@ -166,6 +202,11 @@ const About = (): JSX.Element => { Privacy

    Analytics are used on the demo instance (via a self-hosted Plausible instance), this only records the URL you visited but no personal data. + There's also some basic error logging (via a self-hosted GlitchTip instance), this is only used to help me fix bugs. +
    +
    + Neither your IP address, browser/OS/hardware info, nor any other data will ever be collected or logged. + (You may verify this yourself, either by inspecting the source code or the using developer tools)


    Support diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 33be540..bf2afba 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -179,8 +179,15 @@ const Home = (): JSX.Element => {