diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml deleted file mode 100644 index 426035ef4..000000000 --- a/.github/workflows/deploy-aws.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: πŸš€ Deploy to AWS - -on: - workflow_dispatch: - push: - branches: - - master - tags: - - '*' - paths: - - api/** - - serverless.yml - - package.json - - .github/workflows/deploy-aws.yml - -jobs: - deploy-api: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 16 - - - name: Cache node_modules - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Create GitHub deployment for API - uses: chrnorm/deployment-action@releases/v2 - id: deployment_api - with: - token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} - environment: AWS (Backend API) - ref: ${{ github.ref }} - - - name: Install Serverless CLI and dependencies - run: | - npm i -g serverless - yarn - - - name: Deploy to AWS - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} - run: serverless deploy - - - name: Update GitHub deployment status (API) - if: always() - uses: chrnorm/deployment-status@v2 - with: - token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} - state: '${{ job.status }}' - deployment_id: ${{ steps.deployment_api.outputs.deployment_id }} - ref: ${{ github.ref }} - - deploy-frontend: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 16 - - - name: Cache node_modules - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Create GitHub deployment for Frontend - uses: chrnorm/deployment-action@v2 - id: deployment_frontend - with: - token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} - environment: AWS (Frontend Web UI) - ref: ${{ github.ref }} - - - name: Install dependencies and build - run: | - yarn install - yarn build - - - name: Setup AWS - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Upload to S3 - env: - AWS_S3_BUCKET: 'web-check-frontend' - run: aws s3 sync ./build/ s3://$AWS_S3_BUCKET/ --delete - - - name: Invalidate CloudFront cache - uses: chetan/invalidate-cloudfront-action@v2 - env: - DISTRIBUTION: E30XKAM2TG9FD8 - PATHS: '/*' - AWS_REGION: 'us-east-1' - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - - name: Update GitHub deployment status (Frontend) - if: always() - uses: chrnorm/deployment-status@v2 - with: - token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} - state: '${{ job.status }}' - deployment_id: ${{ steps.deployment_frontend.outputs.deployment_id }} - ref: ${{ github.ref }} 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/api/carbon.js b/api/carbon.js index 1e8e01665..0ca5b631e 100644 --- a/api/carbon.js +++ b/api/carbon.js @@ -16,6 +16,18 @@ const GRID_INTENSITY = 442; const RENEWABLE_INTENSITY = 50; const LITRES_PER_GRAM = 0.5562; +// Reference median grams CO2 per visit, drawn from websitecarbon's published average. +// Used to estimate a percentile rank since we lack their measured-sites dataset +const REFERENCE_MEDIAN_GRAMS = 0.8; + +// Approximate percentile via log2 distance from the reference median. +// 1 doubling above median drops 25 points; clamp to [1, 99] +const estimateCleanerThan = (grams) => { + if (!grams || grams <= 0) return 0; + const pct = 50 - 25 * Math.log2(grams / REFERENCE_MEDIAN_GRAMS); + return Math.max(1, Math.min(99, 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, { @@ -66,11 +78,13 @@ const carbonHandler = async (url) => { } 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: computeCarbon(bytes), + statistics, + cleanerThan: estimateCleanerThan(statistics.co2.grid.grams), scanUrl: url, }; }; diff --git a/astro.config.mjs b/astro.config.mjs index a5d02e47c..0bbad1137 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,9 +1,9 @@ import { defineConfig } from 'astro/config'; +import { loadEnv } from 'vite'; // Integrations import svelte from '@astrojs/svelte'; import react from '@astrojs/react'; -import partytown from '@astrojs/partytown'; import sitemap from '@astrojs/sitemap'; // Adapters @@ -12,12 +12,12 @@ import netlifyAdapter from '@astrojs/netlify'; import nodeAdapter from '@astrojs/node'; import cloudflareAdapter from '@astrojs/cloudflare'; -// Helper function to unwrap both Vite and Node environment variables -const unwrapEnvVar = (varName, fallbackValue) => { - const classicEnvVar = process?.env && process.env[varName]; - const viteEnvVar = import.meta.env[varName]; - return classicEnvVar || viteEnvVar || fallbackValue; -}; +// 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(); @@ -35,7 +35,7 @@ const base = unwrapEnvVar('BASE_URL', '/'); const isBossServer = unwrapEnvVar('BOSS_SERVER', false); // Initialize Astro integrations -const integrations = [svelte(), react(), partytown(), sitemap()]; +const integrations = [svelte(), react(), sitemap()]; // Set the appropriate adapter, based on the deploy target function getAdapter(target) { diff --git a/package.json b/package.json index 82d5b681b..cba0d3c89 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "puppeteer-core": "^24.42.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-masonry-css": "^1.0.16", "react-router-dom": "^7.14.2", "react-simple-maps": "^3.0.0", "react-toastify": "^11.1.0", @@ -73,7 +72,6 @@ "@astrojs/cloudflare": "^13.3.1", "@astrojs/netlify": "^7.0.8", "@astrojs/node": "^10.0.6", - "@astrojs/partytown": "^2.1.7", "@astrojs/sitemap": "^3.7.2", "@astrojs/svelte": "^8.1.0", "@astrojs/ts-plugin": "^1.10.7", diff --git a/public/fonts/Hubot-Sans/Hubot-Sans.ttf b/public/fonts/Hubot-Sans/Hubot-Sans.ttf deleted file mode 100644 index 2b74a40d2..000000000 Binary files a/public/fonts/Hubot-Sans/Hubot-Sans.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/Hubot-Sans.woff2 b/public/fonts/Hubot-Sans/Hubot-Sans.woff2 deleted file mode 100644 index c82812131..000000000 Binary files a/public/fonts/Hubot-Sans/Hubot-Sans.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-Black.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-Black.otf deleted file mode 100644 index 1931b3741..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-Black.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-BlackItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-BlackItalic.otf deleted file mode 100644 index 7d765d1ae..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-BlackItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-Bold.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-Bold.otf deleted file mode 100644 index 518391bdb..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-Bold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-BoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-BoldItalic.otf deleted file mode 100644 index 5504dab1b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-BoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraBold.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraBold.otf deleted file mode 100644 index 1204363ef..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraBold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraBoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraBoldItalic.otf deleted file mode 100644 index 0a2fb079b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraBoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraLight.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraLight.otf deleted file mode 100644 index c945af27f..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraLight.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraLightItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraLightItalic.otf deleted file mode 100644 index d73534f17..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-ExtraLightItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-Italic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-Italic.otf deleted file mode 100644 index 03868be7e..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-Italic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-Light.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-Light.otf deleted file mode 100644 index f142b34c0..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-Light.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-LightItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-LightItalic.otf deleted file mode 100644 index b8c9b031e..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-LightItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-Medium.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-Medium.otf deleted file mode 100644 index 9ba599ba8..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-Medium.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-MediumItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-MediumItalic.otf deleted file mode 100644 index 5bce90305..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-MediumItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-Regular.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-Regular.otf deleted file mode 100644 index 8d2e29e3b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-Regular.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-SemiBold.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-SemiBold.otf deleted file mode 100644 index c17a1a7e5..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-SemiBold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSans-SemiBoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSans-SemiBoldItalic.otf deleted file mode 100644 index 29fed557f..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSans-SemiBoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Black.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Black.otf deleted file mode 100644 index 93846286e..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Black.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-BlackItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-BlackItalic.otf deleted file mode 100644 index 2093a1425..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-BlackItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Bold.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Bold.otf deleted file mode 100644 index 9648ee72a..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Bold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-BoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-BoldItalic.otf deleted file mode 100644 index 8be495b27..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-BoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraBold.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraBold.otf deleted file mode 100644 index 735e71f8b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraBold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraBoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraBoldItalic.otf deleted file mode 100644 index b765fd8ed..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraBoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraLight.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraLight.otf deleted file mode 100644 index f1f0bbc94..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraLight.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraLightItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraLightItalic.otf deleted file mode 100644 index aa1c2dc6b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-ExtraLightItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Italic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Italic.otf deleted file mode 100644 index c2c00d9bc..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Italic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Light.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Light.otf deleted file mode 100644 index 4ab8ac058..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Light.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-LightItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-LightItalic.otf deleted file mode 100644 index d581fc816..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-LightItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Medium.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Medium.otf deleted file mode 100644 index b708673b4..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Medium.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-MediumItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-MediumItalic.otf deleted file mode 100644 index 0a5459717..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-MediumItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Regular.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Regular.otf deleted file mode 100644 index f1c26f9c9..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-Regular.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-SemiBold.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-SemiBold.otf deleted file mode 100644 index 59681e95b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-SemiBold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-SemiBoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-SemiBoldItalic.otf deleted file mode 100644 index 24323d590..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansCondensed-SemiBoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Black.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Black.otf deleted file mode 100644 index 7788a789e..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Black.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-BlackItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-BlackItalic.otf deleted file mode 100644 index d4ed13e4b..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-BlackItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Bold.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Bold.otf deleted file mode 100644 index b9eeb7d35..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Bold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-BoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-BoldItalic.otf deleted file mode 100644 index adfdd78db..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-BoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraBold.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraBold.otf deleted file mode 100644 index f34ca1e83..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraBold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraBoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraBoldItalic.otf deleted file mode 100644 index 67d951c92..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraBoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraLight.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraLight.otf deleted file mode 100644 index 260be7d23..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraLight.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraLightItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraLightItalic.otf deleted file mode 100644 index 20e5ef3f8..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-ExtraLightItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Italic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Italic.otf deleted file mode 100644 index 23acbd324..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Italic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Light.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Light.otf deleted file mode 100644 index e6457e1e8..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Light.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-LightItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-LightItalic.otf deleted file mode 100644 index ef082e7e0..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-LightItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Medium.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Medium.otf deleted file mode 100644 index 3fd3fb3d2..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Medium.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-MediumItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-MediumItalic.otf deleted file mode 100644 index c14d0e041..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-MediumItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Regular.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Regular.otf deleted file mode 100644 index 097c41087..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-Regular.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-SemiBold.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-SemiBold.otf deleted file mode 100644 index 0fd65cc9a..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-SemiBold.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-SemiBoldItalic.otf b/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-SemiBoldItalic.otf deleted file mode 100644 index 39c2e1f0c..000000000 Binary files a/public/fonts/Hubot-Sans/OTF/HubotSansExpanded-SemiBoldItalic.otf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-Black.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-Black.ttf deleted file mode 100644 index 48efb8b0f..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-Black.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-BlackItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-BlackItalic.ttf deleted file mode 100644 index b79c3bcee..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-BlackItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraBold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraBold.ttf deleted file mode 100644 index 0e48f5517..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraBold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraBoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraBoldItalic.ttf deleted file mode 100644 index 229a706f9..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraBoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraLight.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraLight.ttf deleted file mode 100644 index 54710c144..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraLight.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraLightItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraLightItalic.ttf deleted file mode 100644 index a2fa306ba..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-ExtraLightItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-Light.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-Light.ttf deleted file mode 100644 index cdc4c5882..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-Light.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-LightItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-LightItalic.ttf deleted file mode 100644 index b12418ca9..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-LightItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-Medium.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-Medium.ttf deleted file mode 100644 index 4a0d7407e..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-Medium.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-MediumItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-MediumItalic.ttf deleted file mode 100644 index 56bfc468c..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-MediumItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSans-SemiBoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSans-SemiBoldItalic.ttf deleted file mode 100644 index f965b2e2b..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSans-SemiBoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Black.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Black.ttf deleted file mode 100644 index ff33433be..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Black.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-BlackItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-BlackItalic.ttf deleted file mode 100644 index 74c0cb120..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-BlackItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Bold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Bold.ttf deleted file mode 100644 index 937775ae4..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Bold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-BoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-BoldItalic.ttf deleted file mode 100644 index 5bbbf9957..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-BoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraBold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraBold.ttf deleted file mode 100644 index e8edf2ef2..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraBold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraBoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraBoldItalic.ttf deleted file mode 100644 index ebcf745d7..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraBoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraLight.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraLight.ttf deleted file mode 100644 index 2b926b58e..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraLight.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraLightItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraLightItalic.ttf deleted file mode 100644 index 59d8318ca..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-ExtraLightItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Italic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Italic.ttf deleted file mode 100644 index 310c6cd13..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Italic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Light.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Light.ttf deleted file mode 100644 index 0389708bf..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Light.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-LightItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-LightItalic.ttf deleted file mode 100644 index b270fa1cb..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-LightItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Medium.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Medium.ttf deleted file mode 100644 index ad02d630d..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Medium.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-MediumItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-MediumItalic.ttf deleted file mode 100644 index ef837a23c..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-MediumItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Regular.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Regular.ttf deleted file mode 100644 index 7004de71f..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-Regular.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-SemiBold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-SemiBold.ttf deleted file mode 100644 index 9c01e8653..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-SemiBold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-SemiBoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-SemiBoldItalic.ttf deleted file mode 100644 index 7aef86e42..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansCondensed-SemiBoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Black.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Black.ttf deleted file mode 100644 index 7fd97d918..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Black.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-BlackItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-BlackItalic.ttf deleted file mode 100644 index 764c2fb99..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-BlackItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Bold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Bold.ttf deleted file mode 100644 index eae654f32..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Bold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-BoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-BoldItalic.ttf deleted file mode 100644 index 31038be6a..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-BoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraBold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraBold.ttf deleted file mode 100644 index d87dc12b7..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraBold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraBoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraBoldItalic.ttf deleted file mode 100644 index dff888c29..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraBoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraLight.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraLight.ttf deleted file mode 100644 index eb308bad6..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraLight.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraLightItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraLightItalic.ttf deleted file mode 100644 index 1ca3833e6..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-ExtraLightItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Italic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Italic.ttf deleted file mode 100644 index e53a1bf41..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Italic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Light.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Light.ttf deleted file mode 100644 index 04f47da89..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Light.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-LightItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-LightItalic.ttf deleted file mode 100644 index bd2a555db..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-LightItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Medium.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Medium.ttf deleted file mode 100644 index 62b838777..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Medium.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-MediumItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-MediumItalic.ttf deleted file mode 100644 index e5b39b692..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-MediumItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Regular.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Regular.ttf deleted file mode 100644 index a446d1e02..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-Regular.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-SemiBold.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-SemiBold.ttf deleted file mode 100644 index 797444247..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-SemiBold.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-SemiBoldItalic.ttf b/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-SemiBoldItalic.ttf deleted file mode 100644 index fba5ac92b..000000000 Binary files a/public/fonts/Hubot-Sans/TTF/HubotSansExpanded-SemiBoldItalic.ttf and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Black.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Black.woff2 deleted file mode 100644 index 5b68c1d45..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Black.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-BlackItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-BlackItalic.woff2 deleted file mode 100644 index ec0d285a4..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-BlackItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBold.woff2 deleted file mode 100644 index 6e91ab5c6..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBoldItalic.woff2 deleted file mode 100644 index 4e5a9565e..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraLight.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraLight.woff2 deleted file mode 100644 index 1172db76b..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraLight.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraLightItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraLightItalic.woff2 deleted file mode 100644 index f3403800f..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraLightItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Light.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Light.woff2 deleted file mode 100644 index 28495e5e3..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Light.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-LightItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-LightItalic.woff2 deleted file mode 100644 index bc5616027..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-LightItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Medium.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-Medium.woff2 deleted file mode 100644 index cfcd32517..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-Medium.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-MediumItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-MediumItalic.woff2 deleted file mode 100644 index dffe1462c..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-MediumItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBoldItalic.woff2 deleted file mode 100644 index b0be7d0c3..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Black.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Black.woff2 deleted file mode 100644 index 9bd31919e..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Black.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-BlackItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-BlackItalic.woff2 deleted file mode 100644 index 2ed4c8b6d..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-BlackItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Bold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Bold.woff2 deleted file mode 100644 index 873382d10..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Bold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-BoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-BoldItalic.woff2 deleted file mode 100644 index 11ecf048c..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-BoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraBold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraBold.woff2 deleted file mode 100644 index 81e9cf2cb..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraBold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraBoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraBoldItalic.woff2 deleted file mode 100644 index d94bf8f36..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraBoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraLight.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraLight.woff2 deleted file mode 100644 index 93793d5cb..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraLight.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraLightItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraLightItalic.woff2 deleted file mode 100644 index 55656557e..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-ExtraLightItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Italic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Italic.woff2 deleted file mode 100644 index b416d82fb..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Italic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Light.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Light.woff2 deleted file mode 100644 index 2a6b79a81..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Light.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-LightItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-LightItalic.woff2 deleted file mode 100644 index dd9c9cf6c..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-LightItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Medium.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Medium.woff2 deleted file mode 100644 index 3d58451a9..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Medium.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-MediumItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-MediumItalic.woff2 deleted file mode 100644 index 50341f5bb..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-MediumItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Regular.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Regular.woff2 deleted file mode 100644 index 1580ee680..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-Regular.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-SemiBold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-SemiBold.woff2 deleted file mode 100644 index fa701994c..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-SemiBold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-SemiBoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-SemiBoldItalic.woff2 deleted file mode 100644 index 7560dea30..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansCondensed-SemiBoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Black.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Black.woff2 deleted file mode 100644 index 986ef5009..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Black.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-BlackItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-BlackItalic.woff2 deleted file mode 100644 index a72a530a9..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-BlackItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Bold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Bold.woff2 deleted file mode 100644 index 409c9a0d8..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Bold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-BoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-BoldItalic.woff2 deleted file mode 100644 index 9c827830f..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-BoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraBold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraBold.woff2 deleted file mode 100644 index 59979d2db..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraBold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraBoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraBoldItalic.woff2 deleted file mode 100644 index 9a8346eb0..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraBoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraLight.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraLight.woff2 deleted file mode 100644 index b7209ab9a..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraLight.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraLightItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraLightItalic.woff2 deleted file mode 100644 index 0af2bd269..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-ExtraLightItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Italic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Italic.woff2 deleted file mode 100644 index 26d3753fe..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Italic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Light.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Light.woff2 deleted file mode 100644 index a8304f4b2..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Light.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-LightItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-LightItalic.woff2 deleted file mode 100644 index b45656ae0..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-LightItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Medium.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Medium.woff2 deleted file mode 100644 index 0f90b1a8f..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Medium.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-MediumItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-MediumItalic.woff2 deleted file mode 100644 index 0b5c93a14..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-MediumItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Regular.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Regular.woff2 deleted file mode 100644 index e39154837..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-Regular.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-SemiBold.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-SemiBold.woff2 deleted file mode 100644 index 4de6cf701..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-SemiBold.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-SemiBoldItalic.woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-SemiBoldItalic.woff2 deleted file mode 100644 index f84554238..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSansExpanded-SemiBoldItalic.woff2 and /dev/null differ diff --git a/public/fonts/Hubot-Sans/WOFF2/HubotSans[slnt,wdth,wght].woff2 b/public/fonts/Hubot-Sans/WOFF2/HubotSans[slnt,wdth,wght].woff2 deleted file mode 100644 index c82812131..000000000 Binary files a/public/fonts/Hubot-Sans/WOFF2/HubotSans[slnt,wdth,wght].woff2 and /dev/null differ diff --git a/public/fonts/Inter-Black.ttf b/public/fonts/Inter-Black.ttf deleted file mode 100644 index b27822bae..000000000 Binary files a/public/fonts/Inter-Black.ttf and /dev/null differ diff --git a/public/fonts/Inter-Bold.ttf b/public/fonts/Inter-Bold.ttf deleted file mode 100644 index fe23eeb9c..000000000 Binary files a/public/fonts/Inter-Bold.ttf and /dev/null differ diff --git a/public/fonts/Inter-ExtraBold.ttf b/public/fonts/Inter-ExtraBold.ttf deleted file mode 100644 index 874b1b0dd..000000000 Binary files a/public/fonts/Inter-ExtraBold.ttf and /dev/null differ diff --git a/public/fonts/Inter-ExtraLight.ttf b/public/fonts/Inter-ExtraLight.ttf deleted file mode 100644 index c993e8221..000000000 Binary files a/public/fonts/Inter-ExtraLight.ttf and /dev/null differ diff --git a/public/fonts/Inter-Light.ttf b/public/fonts/Inter-Light.ttf deleted file mode 100644 index 71188f5cb..000000000 Binary files a/public/fonts/Inter-Light.ttf and /dev/null differ diff --git a/public/fonts/Inter-Medium.ttf b/public/fonts/Inter-Medium.ttf deleted file mode 100644 index a01f3777a..000000000 Binary files a/public/fonts/Inter-Medium.ttf and /dev/null differ diff --git a/public/fonts/Inter-Regular.ttf b/public/fonts/Inter-Regular.ttf deleted file mode 100644 index 5e4851f0a..000000000 Binary files a/public/fonts/Inter-Regular.ttf and /dev/null differ diff --git a/public/fonts/Inter-SemiBold.ttf b/public/fonts/Inter-SemiBold.ttf deleted file mode 100644 index ecc7041e2..000000000 Binary files a/public/fonts/Inter-SemiBold.ttf and /dev/null differ diff --git a/public/fonts/Inter-Thin.ttf b/public/fonts/Inter-Thin.ttf deleted file mode 100644 index fe77243fc..000000000 Binary files a/public/fonts/Inter-Thin.ttf and /dev/null differ diff --git a/public/fonts/Inter-VariableFont_slnt,wght.ttf b/public/fonts/Inter-VariableFont_slnt,wght.ttf deleted file mode 100644 index e72470871..000000000 Binary files a/public/fonts/Inter-VariableFont_slnt,wght.ttf and /dev/null differ 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/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..b88d09ec2 --- /dev/null +++ b/src/client/analysis/rules/quality.ts @@ -0,0 +1,38 @@ +import type { Analyzer, Severity } from '../types'; + +const LABELS: Record = { + performance: 'Performance', + accessibility: 'Accessibility', + 'best-practices': 'Best Practices', + seo: 'SEO', + pwa: 'PWA', +}; + +// 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..99a5c9d9f --- /dev/null +++ b/src/client/analysis/rules/threats.ts @@ -0,0 +1,27 @@ +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 phishUrl = d.phishTank?.url0?.in_database; + if (phishUrl === 'true' || phishUrl === true) { + 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/components/Form/Button.tsx b/src/client/components/Form/Button.tsx index 490ab2538..984619480 100644 --- a/src/client/components/Form/Button.tsx +++ b/src/client/components/Form/Button.tsx @@ -23,7 +23,7 @@ const StyledButton = styled.button` cursor: pointer; border: none; border-radius: 0.25rem; - font-family: PTMono; + font-family: var(--font-mono); box-sizing: border-box; width: -moz-available; display: flex; diff --git a/src/client/components/Form/Card.tsx b/src/client/components/Form/Card.tsx index 649e88338..53dea32a3 100644 --- a/src/client/components/Form/Card.tsx +++ b/src/client/components/Form/Card.tsx @@ -12,8 +12,7 @@ export const StyledCard = styled.section<{ styles?: string }>` border-radius: 8px; padding: 1rem; position: relative; - margin 0.5rem; - max-height: 64rem; + max-height: 54rem; overflow: auto; ${(props) => props.styles} `; diff --git a/src/client/components/Form/Input.tsx b/src/client/components/Form/Input.tsx index fe3cb41eb..e8a7ce840 100644 --- a/src/client/components/Form/Input.tsx +++ b/src/client/components/Form/Input.tsx @@ -34,7 +34,7 @@ const StyledInput = styled.input` color: ${colors.textColor}; border: none; border-radius: 0.25rem; - font-family: PTMono; + font-family: var(--font-mono); box-shadow: 3px 3px 0px ${colors.backgroundDarker}; &:focus { outline: 1px solid ${colors.primary}; diff --git a/src/client/components/Form/Nav.tsx b/src/client/components/Form/Nav.tsx index f3015ab60..749b8edeb 100644 --- a/src/client/components/Form/Nav.tsx +++ b/src/client/components/Form/Nav.tsx @@ -6,7 +6,7 @@ import Heading from 'client/components/Form/Heading'; import colors from 'client/styles/colors'; const Header = styled(StyledCard)` - margin: 1rem auto; + margin: 0 auto; display: flex; flex-wrap: wrap; align-items: baseline; diff --git a/src/client/components/Form/Row.tsx b/src/client/components/Form/Row.tsx index 3bb276ad8..6b030e25a 100644 --- a/src/client/components/Form/Row.tsx +++ b/src/client/components/Form/Row.tsx @@ -219,7 +219,7 @@ const Row = (props: RowProps) => { {lbl} )} - copyToClipboard(val)}> + copyToClipboard(val)}> {formatValue(val)} {plaintext && {plaintext}</PlainText>} diff --git a/src/client/components/Results/CarbonFootprint.tsx b/src/client/components/Results/CarbonFootprint.tsx index 7220c1667..545b26c05 100644 --- a/src/client/components/Results/CarbonFootprint.tsx +++ b/src/client/components/Results/CarbonFootprint.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import styled from '@emotion/styled'; import { Card } from 'client/components/Form/Card'; import Row from 'client/components/Form/Row'; @@ -13,47 +12,51 @@ const LearnMoreInfo = styled.p` } `; -const CarbonCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => { - const carbons = props.data.statistics; - const initialUrl = props.data.scanUrl; +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 [carbonData, setCarbonData] = useState<{ c?: number; p?: number }>({}); +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`; +}; - useEffect(() => { - const fetchCarbonData = async () => { - try { - const response = await fetch( - `https://api.websitecarbon.com/b?url=${encodeURIComponent(initialUrl)}`, - ); - const data = await response.json(); - setCarbonData(data); - } catch (error) { - console.error('Error fetching carbon data:', error); - } - }; - fetchCarbonData(); - }, [initialUrl]); +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 ( <Card heading={props.title} actionButtons={props.actionButtons}> - {!carbons?.adjustedBytes && !carbonData.c && ( - <p>Unable to calculate carbon footprint for host</p> - )} + {!carbons?.adjustedBytes && <p>Unable to calculate carbon footprint for host</p>} {carbons?.adjustedBytes > 0 && ( <> - <Row lbl="HTML Initial Size" val={`${carbons.adjustedBytes} bytes`} /> - <Row - lbl="CO2 for Initial Load" - val={`${(carbons.co2.grid.grams * 1000).toPrecision(4)} grams`} - /> - <Row lbl="Energy Usage for Load" val={`${(carbons.energy * 1000).toPrecision(4)} KWg`} /> + <Row lbl="HTML Initial Size" val={formatBytes(carbons.adjustedBytes)} /> + <Row lbl="CO2 for Initial Load" val={formatGrams(carbons.co2.grid.grams)} /> + <Row lbl="Energy Usage for Load" val={formatKwh(carbons.energy)} /> + {cleanerThan > 0 && ( + <Row lbl="Cleaner than average page (est.)" val={`${cleanerThan}%`} /> + )} </> )} - {carbonData.c && <Row lbl="CO2 Emitted" val={`${carbonData.c} grams`} />} - {carbonData.p && <Row lbl="Better than average site by" val={`${carbonData.p}%`} />} <br /> <LearnMoreInfo> - Learn more at <a href="https://www.websitecarbon.com/">websitecarbon.com</a> + Calculated using the{' '} + <a + href="https://sustainablewebdesign.org/estimating-digital-emissions" + target="_blank" + rel="noreferrer" + > + Sustainable Web Model v4 + </a> </LearnMoreInfo> </Card> ); diff --git a/src/client/components/Results/Cookies.tsx b/src/client/components/Results/Cookies.tsx index 5891cd641..6d269c475 100644 --- a/src/client/components/Results/Cookies.tsx +++ b/src/client/components/Results/Cookies.tsx @@ -34,20 +34,20 @@ const CookiesCard = (props: { data: any; title: string; actionButtons: any }): J }); return ( <ExpandableRow - key={`cookie-${index}`} + key={`header-cookie-${index}-${cookie.name}`} lbl={cookie.name} val={cookie.value} rowList={attributes} /> ); })} - {clientCookies.map((cookie: any) => { + {clientCookies.map((cookie: any, index: number) => { const nameValPairs = Object.keys(cookie).map((key: string) => { return { lbl: key, val: cookie[key] }; }); return ( <ExpandableRow - key={`cookie-${cookie.name}`} + key={`client-cookie-${index}-${cookie.name}`} lbl={cookie.name} val="" rowList={nameValPairs} diff --git a/src/client/components/Results/TechStack.tsx b/src/client/components/Results/TechStack.tsx index 07bbd5423..d10bbdb1b 100644 --- a/src/client/components/Results/TechStack.tsx +++ b/src/client/components/Results/TechStack.tsx @@ -38,6 +38,11 @@ const TechStackRow = styled.div` .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; diff --git a/src/client/components/misc/AdditionalResources.tsx b/src/client/components/misc/AdditionalResources.tsx index ce8513060..b5c4ccc5d 100644 --- a/src/client/components/misc/AdditionalResources.tsx +++ b/src/client/components/misc/AdditionalResources.tsx @@ -25,7 +25,7 @@ const ResourceListOuter = styled.ul` cursor: pointer; border: none; border-radius: 0.25rem; - font-family: PTMono; + font-family: var(--font-mono); box-sizing: border-box; width: -moz-available; box-shadow: 3px 3px 0px ${colors.backgroundDarker}; @@ -86,10 +86,11 @@ const Note = styled.small` `; const CardStyles = ` - margin: 0 auto 1rem auto; + margin: 0 auto; width: 95vw; position: relative; transition: all 0.2s ease-in-out; + max-height: 100%; `; const resources = [ @@ -316,7 +317,10 @@ const AdditionalResources = (props: { url?: string }): JSX.Element => { <br /> At the time of listing, all of the above were available and free to use - if this changes, please report it via GitHub ( - <a href="https://github.com/lissy93/web-check">lissy93/web-check</a>). + <a target="_blank" rel="noreferrer" href="https://github.com/lissy93/web-check"> + lissy93/web-check + </a> + ). </Note> </Card> ); diff --git a/src/client/components/misc/AdvisoryPanel.tsx b/src/client/components/misc/AdvisoryPanel.tsx new file mode 100644 index 000000000..6e869c796 --- /dev/null +++ b/src/client/components/misc/AdvisoryPanel.tsx @@ -0,0 +1,165 @@ +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<Severity, SevMeta> = { + 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; + 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<Severity, Finding[]> = { + 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 ( + <Wrapper> + <Heading as="h2" align="left" color={colors.primary}> + Advisory + </Heading> + {visible.map((sev) => { + const meta = META[sev]; + const items = grouped[sev]; + return ( + <details + key={sev} + id={`advisory-${sev}`} + open={meta.defaultOpen} + style={{ background: `${meta.color}0D` }} + > + <summary style={{ color: meta.color }}> + {meta.label} + <span className="count">({items.length})</span> + </summary> + <ul className="findings"> + {items.map((f, i) => ( + <li key={`${f.cardId}-${i}`}> + <span className="glyph" style={{ color: meta.color }} aria-label={meta.label}> + {meta.glyph} + </span> + <span className="body"> + <button type="button" className="jump" onClick={() => onJumpTo(f.cardId)}> + {f.title} + </button> + {f.detail && <span className="detail">{f.detail}</span>} + </span> + </li> + ))} + </ul> + </details> + ); + })} + </Wrapper> + ); +}; + +export default AdvisoryPanel; diff --git a/src/client/components/misc/Loader.tsx b/src/client/components/misc/Loader.tsx index c4a7acaf7..48248cdf0 100644 --- a/src/client/components/misc/Loader.tsx +++ b/src/client/components/misc/Loader.tsx @@ -5,7 +5,7 @@ import Heading from 'client/components/Form/Heading'; import colors from 'client/styles/colors'; const LoaderContainer = styled(StyledCard)` - margin: 0 auto 1rem auto; + margin: 0 auto; width: 95vw; position: relative; transition: all 0.2s ease-in-out; @@ -29,7 +29,7 @@ const LoaderContainer = styled(StyledCard)` height: 0; overflow: hidden; opacity: 0; - margin: 0; + margin: -1rem 0 0 0; padding: 0; svg { width: 0; diff --git a/src/client/components/misc/ProgressBar.tsx b/src/client/components/misc/ProgressBar.tsx index 97a79f449..6e369977d 100644 --- a/src/client/components/misc/ProgressBar.tsx +++ b/src/client/components/misc/ProgressBar.tsx @@ -1,19 +1,83 @@ +import { useState, useEffect, 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 { useState, useEffect, type ReactNode } from 'react'; +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 STATUS_EMOJI: Record<LoadingState, string> = { + success: 'βœ…', + loading: 'πŸ”„', + error: '❌', + 'timed-out': '⏸️', + skipped: '⏭️', +}; + +const STATE_COLOR: Record<LoadingState, string> = { + success: colors.success, + loading: colors.info, + error: colors.danger, + 'timed-out': colors.warning, + skipped: colors.neutral, +}; + +// Tally jobs by their loading state in a single pass +const countByState = (jobs: LoadingJob[]): Record<LoadingState, number> => { + const counts: Record<LoadingState, number> = { + 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<LoadingState, number> => { + const counts = countByState(jobs); + const total = jobs.length || 1; + return Object.fromEntries( + Object.entries(counts).map(([k, v]) => [k, (v / total) * 100]), + ) as Record<LoadingState, number>; +}; const LoadCard = styled(Card)` - margin: 0 auto 1rem auto; + margin: 0 auto; width: 95vw; position: relative; - transition: all 0.2s ease-in-out; - &.hidden { - height: 0; +`; + +// 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; - margin: 0; - padding: 0; + min-height: 0; + } + &.collapsed { + grid-template-rows: 0fr; + opacity: 0; + transform: translateY(-0.5rem); + pointer-events: none; } `; @@ -25,31 +89,31 @@ const ProgressBarContainer = styled.div` overflow: hidden; `; -const ProgressBarSegment = styled.div<{ color: string; color2: string; width: number }>` +const ProgressBarSegment = styled.div<{ color: string; width: number }>` height: 1rem; display: inline-block; - width: ${(props) => props.width}%; - background: ${(props) => props.color}; - background: ${(props) => - props.color2 - ? `repeating-linear-gradient( 315deg, ${props.color}, ${props.color} 3px, ${props.color2} 3px, ${props.color2} 6px )` - : props.color}; + 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 Details = styled.details` - transition: all 0.2s ease-in-out; summary { margin: 0.5rem 0; font-weight: bold; cursor: pointer; - } - summary:before { - content: 'β–Ί'; - position: absolute; - margin-left: -1rem; - color: ${colors.primary}; - cursor: pointer; + &:before { + content: 'β–Ί'; + position: absolute; + margin-left: -1rem; + color: ${colors.primary}; + } } &[open] summary:before { content: 'β–Ό'; @@ -59,11 +123,24 @@ const Details = styled.details` padding: 0.25rem; border-radius: 4px; width: fit-content; - li b { - cursor: pointer; - } - i { - color: ${colors.textColorSecondary}; + 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 { @@ -89,14 +166,6 @@ const AboutPageLink = styled.a` const SummaryContainer = styled.div` margin: 0.5rem 0; - b { - margin: 0; - font-weight: bold; - } - p { - margin: 0; - opacity: 0.75; - } &.error-info { color: ${colors.danger}; } @@ -106,30 +175,72 @@ const SummaryContainer = styled.div` &.loading-info { color: ${colors.info}; } - .skipped { + .skipped, + .success, + .error, + .timed-out { margin-left: 0.75rem; + } + .skipped { color: ${colors.warning}; } .success { - margin-left: 0.75rem; color: ${colors.success}; } + .error { + color: ${colors.danger}; + } + .timed-out { + color: ${colors.error}; + } `; -const ReShowContainer = styled.div` - margin: 0 auto 1rem auto; +const ReShowRow = styled.div` + margin: 0 auto; width: 95vw; display: flex; - justify-content: flex-end; - &.hidden { - height: 0; - overflow: hidden; - margin: 0; - padding: 0; + 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; + } + } } - button { - background: none; - position: static; +`; + +// 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; } `; @@ -143,7 +254,7 @@ const DismissButton = styled.button` border: none; padding: 0.25rem 0.5rem; border-radius: 4px; - font-family: PTMono; + font-family: var(--font-mono); cursor: pointer; &:hover { color: ${colors.primary}; @@ -154,16 +265,15 @@ const FailedJobActionButton = styled.button` margin: 0.1rem 0.1rem 0.1rem 0.5rem; background: ${colors.background}; color: ${colors.textColorSecondary}; - border: none; + border: 1px solid ${colors.textColorSecondary}; padding: 0.25rem 0.5rem; border-radius: 4px; - font-family: PTMono; + font-family: var(--font-mono); cursor: pointer; - border: 1px solid ${colors.textColorSecondary}; transition: all 0.2s ease-in-out; &:hover { color: ${colors.primary}; - border: 1px solid ${colors.primary}; + border-color: ${colors.primary}; } `; @@ -179,311 +289,247 @@ const ErrorModalContent = styled.div` } `; -export type LoadingState = 'success' | 'loading' | 'skipped' | 'error' | 'timed-out'; - -export interface LoadingJob { - name: string; - state: LoadingState; - error?: string; - timeTaken?: number; - retry?: () => void; -} - -import { allCardIds } from 'client/jobs/registry'; - -const jobNames = allCardIds; - interface JobListItemProps { job: LoadingJob; showJobDocs: (name: string) => void; - showErrorModal: ( - name: string, - state: LoadingState, - timeTaken: number | undefined, - error: string, - isInfo?: boolean, - ) => void; - barColors: Record<LoadingState, [string, string]>; + showErrorModal: (job: LoadingJob, isInfo?: boolean) => void; } -const getStatusEmoji = (state: LoadingState): string => { - switch (state) { - case 'success': - return 'βœ…'; - case 'loading': - return 'πŸ”„'; - case 'error': - return '❌'; - case 'timed-out': - return '⏸️'; - case 'skipped': - return '⏭️'; - default: - return '❓'; - } -}; - -const JobListItem: React.FC<JobListItemProps> = ({ - job, - showJobDocs, - showErrorModal, - barColors, -}) => { +// 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 actionButton = - retry && state !== 'success' && state !== 'loading' ? ( - <FailedJobActionButton onClick={retry}>↻ Retry</FailedJobActionButton> - ) : null; - - const showModalButton = error && ['error', 'timed-out', 'skipped'].includes(state) && ( - <FailedJobActionButton - onClick={() => showErrorModal(name, state, timeTaken, error, state === 'skipped')} - > - {state === 'timed-out' ? 'β–  Show Timeout Reason' : 'β–  Show Error'} - </FailedJobActionButton> - ); + const canRetry = retry && state !== 'success' && state !== 'loading'; + const canShowError = error && (state === 'error' || state === 'timed-out' || state === 'skipped'); return ( - <li key={name}> - <b onClick={() => showJobDocs(name)}> - {getStatusEmoji(state)} {name} - </b> - <span style={{ color: barColors[state][0] }}> ({state})</span>. + <li> + <button type="button" className="docs" onClick={() => showJobDocs(name)}> + {STATUS_EMOJI[state]} {name} + </button> + <span style={{ color: STATE_COLOR[state] }}> ({state})</span>. <i>{timeTaken && state !== 'loading' ? ` Took ${timeTaken} ms` : ''}</i> - {actionButton} - {showModalButton} + {canRetry && ( + <FailedJobActionButton type="button" onClick={retry}> + ↻ Retry + </FailedJobActionButton> + )} + {canShowError && ( + <FailedJobActionButton + type="button" + onClick={() => showErrorModal(job, state === 'skipped')} + > + {state === 'timed-out' ? 'β–  Show Timeout Reason' : 'β–  Show Error'} + </FailedJobActionButton> + )} </li> ); }; -export const initialJobs = jobNames.map((job: string) => { - return { - name: job, - state: 'loading' as LoadingState, - retry: () => {}, - }; -}); - -export const calculateLoadingStatePercentages = ( - loadingJobs: LoadingJob[], -): Record<LoadingState | string, number> => { - const totalJobs = loadingJobs.length; - - // Initialize count object - const stateCount: Record<LoadingState, number> = { - success: 0, - loading: 0, - 'timed-out': 0, - error: 0, - skipped: 0, - }; - - // Count the number of each state - loadingJobs.forEach((job) => { - stateCount[job.state] += 1; - }); - - // Convert counts to percentages - const statePercentage: Record<LoadingState, number> = { - success: (stateCount['success'] / totalJobs) * 100, - loading: (stateCount['loading'] / totalJobs) * 100, - 'timed-out': (stateCount['timed-out'] / totalJobs) * 100, - error: (stateCount['error'] / totalJobs) * 100, - skipped: (stateCount['skipped'] / totalJobs) * 100, - }; - - return statePercentage; +// Single-line "Running X of Y / Finished in Z" status with shared elapsed time +const RunningText = ({ jobs, elapsedMs }: { jobs: LoadingJob[]; elapsedMs: number }): ReactNode => { + const total = allCardIds.length; + const done = total - jobs.filter((j) => j.state === 'loading').length; + const isDone = done >= total; + return ( + <p className="run-status"> + {isDone ? 'Finished in ' : `Running ${done} of ${total} jobs - `} + {elapsedMs >= 10_000 ? `${(elapsedMs / 1000).toFixed(1)} s` : `${elapsedMs} ms`} + </p> + ); }; -const MillisecondCounter = (props: { isDone: boolean }) => { - const { isDone } = props; - const [milliseconds, setMilliseconds] = useState<number>(0); +// Compact one-liner shown alongside the "Show Load State" button when collapsed +const LoadSummary = ({ + jobs, + elapsedMs, + onOpen, +}: { + jobs: LoadingJob[]; + elapsedMs: number; + onOpen: () => void; +}): ReactNode => { + const total = allCardIds.length; + const counts = countByState(jobs); + const extras: string[] = []; + if (counts.error) extras.push(`${counts.error} failed`); + if (counts['timed-out']) extras.push(`${counts['timed-out']} timed out`); + if (counts.skipped) extras.push(`${counts.skipped} skipped`); + return ( + <span className="summary"> + {counts.success}/{total} lookups complete + {extras.length > 0 && ( + <> + {' '} + <button type="button" className="extras" onClick={onOpen}> + ({extras.join(', ')}) + </button> + </> + )} + {elapsedMs ? `, took ${(elapsedMs / 1000).toFixed(1)}s` : ''} + </span> + ); +}; - useEffect(() => { - let timer: NodeJS.Timeout; - // Start the timer as soon as the component mounts - if (!isDone) { - timer = setInterval(() => { - setMilliseconds((milliseconds) => milliseconds + 100); - }, 100); - } - // Clean up the interval on unmount - return () => { - clearInterval(timer); - }; - }, [isDone]); // If the isDone prop changes, the effect will re-run +const pluralJobs = (n: number) => `${n} ${n === 1 ? 'job' : 'jobs'}`; - return <span>{milliseconds} ms</span>; -}; +type ChipKey = 'success' | 'skipped' | 'timed-out' | 'error'; -const RunningText = (props: { state: LoadingJob[]; count: number }): JSX.Element => { - const loadingTasksCount = - jobNames.length - props.state.filter((val: LoadingJob) => val.state === 'loading').length; - const isDone = loadingTasksCount >= jobNames.length; - return ( - <p className="run-status"> - {isDone ? 'Finished in ' : `Running ${loadingTasksCount} of ${jobNames.length} jobs - `} - <MillisecondCounter isDone={isDone} /> - </p> - ); +const CHIPS: Record<ChipKey, { cls: string; label: string }> = { + success: { cls: 'success', label: 'successful' }, + skipped: { cls: 'skipped', label: 'skipped' }, + 'timed-out': { cls: 'timed-out', label: 'timed out' }, + error: { cls: 'error', label: 'failed' }, }; -const SummaryText = (props: { state: LoadingJob[]; count: number }): JSX.Element => { - const totalJobs = jobNames.length; - let failedTasksCount = props.state.filter((val: LoadingJob) => val.state === 'error').length; - let loadingTasksCount = props.state.filter((val: LoadingJob) => val.state === 'loading').length; - let skippedTasksCount = props.state.filter((val: LoadingJob) => val.state === 'skipped').length; - let successTasksCount = props.state.filter((val: LoadingJob) => val.state === 'success').length; - - const jobz = (jobCount: number) => `${jobCount} ${jobCount === 1 ? 'job' : 'jobs'}`; - - const skippedInfo = - skippedTasksCount > 0 ? ( - <span className="skipped">{jobz(skippedTasksCount)} skipped </span> - ) : null; - const successInfo = - successTasksCount > 0 ? ( - <span className="success">{jobz(successTasksCount)} successful </span> - ) : null; - const failedInfo = - failedTasksCount > 0 ? <span className="error">{jobz(failedTasksCount)} failed </span> : null; - - if (loadingTasksCount > 0) { +// Inline tally chip; renders nothing for zero so callers can always include it +const Chip = ({ count, cls, label }: { count: number; cls: string; label: string }) => + count > 0 ? ( + <span className={cls}> + {pluralJobs(count)} {label}{' '} + </span> + ) : null; + +// Heading-style summary that adapts to loading, all-success and partial-failure +const SummaryText = ({ jobs }: { jobs: LoadingJob[] }): ReactNode => { + const total = allCardIds.length; + const counts = countByState(jobs); + const chips = (keys: ChipKey[]) => + keys.map((k) => <Chip key={k} count={counts[k]} {...CHIPS[k]} />); + + if (counts.loading > 0) { return ( <SummaryContainer className="loading-info"> <b> - Loading {totalJobs - loadingTasksCount} / {totalJobs} Jobs + Loading {total - counts.loading} / {total} Jobs </b> - {skippedInfo} + {chips(['skipped', 'timed-out', 'error'])} </SummaryContainer> ); } - - if (failedTasksCount === 0) { + const hasIssues = counts.error > 0 || counts['timed-out'] > 0; + if (!hasIssues) { return ( <SummaryContainer className="success-info"> - <b>{successTasksCount} Jobs Completed Successfully</b> - {skippedInfo} + <b>{counts.success} Jobs Completed Successfully</b> + {chips(['skipped'])} </SummaryContainer> ); } - return ( <SummaryContainer className="error-info"> - {successInfo} - {skippedInfo} - {failedInfo} + {chips(['success', 'skipped', 'timed-out', 'error'])} </SummaryContainer> ); }; -const ProgressLoader = (props: { +interface ProgressLoaderProps { loadStatus: LoadingJob[]; showModal: (err: ReactNode) => void; showJobDocs: (job: string) => void; -}): JSX.Element => { - const [hideLoader, setHideLoader] = useState<boolean>(false); - const loadStatus = props.loadStatus; - const percentages = calculateLoadingStatePercentages(loadStatus); - - const loadingTasksCount = - jobNames.length - loadStatus.filter((val: LoadingJob) => val.state === 'loading').length; - const isDone = loadingTasksCount >= jobNames.length; - - const makeBarColor = (colorCode: string): [string, string] => { - const amount = 10; - const darkerColorCode = - '#' + - colorCode - .replace(/^#/, '') - .replace(/../g, (colorCode) => - ('0' + Math.min(255, Math.max(0, parseInt(colorCode, 16) - amount)).toString(16)).slice( - -2, - ), - ); - return [colorCode, darkerColorCode]; - }; +} - const barColors: Record<LoadingState | string, [string, string]> = { - success: isDone ? makeBarColor(colors.primary) : makeBarColor(colors.success), - loading: makeBarColor(colors.info), - error: makeBarColor(colors.danger), - 'timed-out': makeBarColor(colors.warning), - skipped: makeBarColor(colors.neutral), - }; +// 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 all jobs finish, leaving the "Finished in" line briefly visible + useEffect(() => { + if (!isDone) return; + const t = setTimeout(() => setHideLoader(true), 1500); + return () => clearTimeout(t); + }, [isDone]); + + const colorFor = (state: LoadingState) => + state === 'success' && isDone ? colors.primary : STATE_COLOR[state]; - const showErrorModal = ( - name: string, - state: LoadingState, - timeTaken: number | undefined, - error: string, - isInfo?: boolean, - ) => { - const errorContent = ( + const showErrorModal = (job: LoadingJob, isInfo?: boolean) => { + showModal( <ErrorModalContent> - <Heading as="h3">Error Details for {name}</Heading> + <Heading as="h3">Error Details for {job.name}</Heading> <p> - The {name} job failed with an {state} state after {timeTaken} ms. The server responded + The {job.name} job failed with an {job.state} state + {job.timeTaken !== undefined ? ` after ${job.timeTaken} ms` : ''}. The server responded with the following error: </p> - {/* If isInfo == true, then add .info className to pre */} - <pre className={isInfo ? 'info' : 'error'}>{error}</pre> - </ErrorModalContent> + <pre className={isInfo ? 'info' : 'error'}>{job.error}</pre> + </ErrorModalContent>, ); - props.showModal(errorContent); }; return ( - <> - <ReShowContainer className={!hideLoader ? 'hidden' : ''}> - <DismissButton onClick={() => setHideLoader(false)}>Show Load State</DismissButton> - </ReShowContainer> - <LoadCard className={hideLoader ? 'hidden' : ''}> - <ProgressBarContainer> - {Object.keys(percentages).map((state: string | LoadingState) => ( - <ProgressBarSegment - color={barColors[state][0]} - color2={barColors[state][1]} - title={`${state} (${Math.round(percentages[state])}%)`} - width={percentages[state]} - key={`progress-bar-${state}`} + <div> + <Collapsible className={!hideLoader ? 'collapsed' : ''} aria-hidden={!hideLoader}> + <div className="inner"> + <ReShowRow> + <LoadSummary + jobs={loadStatus} + elapsedMs={elapsedMs} + onOpen={() => setHideLoader(false)} /> - ))} - </ProgressBarContainer> - - <StatusInfoWrapper> - <SummaryText state={loadStatus} count={loadStatus.length} /> - <RunningText state={loadStatus} count={loadStatus.length} /> - </StatusInfoWrapper> - - <Details> - <summary>Show Details</summary> - <ul> - {loadStatus.map((job: LoadingJob) => ( - <JobListItem - key={job.name} - job={job} - showJobDocs={props.showJobDocs} - showErrorModal={showErrorModal} - barColors={barColors} - /> - ))} - </ul> - {loadStatus.filter((val: LoadingJob) => val.state === 'error').length > 0 && ( - <p className="error"> - <b>Check the browser console for logs and more info</b> - <br /> - 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. - </p> - )} - <AboutPageLink href="/check/about" target="_blank" rel="noreferer"> - Learn More about Web-Check - </AboutPageLink> - </Details> - <DismissButton onClick={() => setHideLoader(true)}>Dismiss</DismissButton> - </LoadCard> - </> + <ShowLoadStateButton type="button" onClick={() => setHideLoader(false)}> + Show Load State + </ShowLoadStateButton> + </ReShowRow> + </div> + </Collapsible> + <Collapsible className={hideLoader ? 'collapsed' : ''} aria-hidden={hideLoader}> + <div className="inner"> + <LoadCard> + <ProgressBarContainer> + {(Object.keys(percentages) as LoadingState[]).map((state) => ( + <ProgressBarSegment + key={`progress-bar-${state}`} + color={colorFor(state)} + width={percentages[state]} + title={`${state} (${Math.round(percentages[state])}%)`} + /> + ))} + </ProgressBarContainer> + <StatusInfoWrapper> + <SummaryText jobs={loadStatus} /> + <RunningText jobs={loadStatus} elapsedMs={elapsedMs} /> + </StatusInfoWrapper> + <Details> + <summary>Show Details</summary> + <ul> + {loadStatus.map((job) => ( + <JobListItem + key={job.name} + job={job} + showJobDocs={showJobDocs} + showErrorModal={showErrorModal} + /> + ))} + </ul> + {loadStatus.some((j) => j.state === 'error') && ( + <p className="error"> + <b>Check the browser console for logs and more info</b> + <br /> + 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. + </p> + )} + <AboutPageLink href="/check/about" target="_blank" rel="noreferrer"> + Learn More about Web-Check + </AboutPageLink> + </Details> + <DismissButton type="button" onClick={() => setHideLoader(true)}> + Dismiss + </DismissButton> + </LoadCard> + </div> + </Collapsible> + </div> ); }; 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<HTMLDivElement>(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 ( + <Grid ref={containerRef} className={className} gap={gap}> + {columns.map((column, index) => ( + <Column key={index} gap={gap}> + {column} + </Column> + ))} + </Grid> + ); +}; + +export default ResultsMasonryGrid; diff --git a/src/client/components/misc/SelfScanMsg.tsx b/src/client/components/misc/SelfScanMsg.tsx index 6ae238f69..5f24637c1 100644 --- a/src/client/components/misc/SelfScanMsg.tsx +++ b/src/client/components/misc/SelfScanMsg.tsx @@ -44,7 +44,10 @@ const SelfScanMsg = () => { <br /> <span> But if you want to see how this site is built, why not check out the{' '} - <a href="https://github.com/lissy93/web-check">source code</a>? + <a target="_blank" rel="noreferrer" href="https://github.com/lissy93/web-check"> + source code + </a> + ? </span> <br /> <i>Do me a favour, and drop the repo a Star while you're there</i> πŸ˜‰ diff --git a/src/client/components/misc/ViewRaw.tsx b/src/client/components/misc/ViewRaw.tsx index b5a62493d..9e77dc78b 100644 --- a/src/client/components/misc/ViewRaw.tsx +++ b/src/client/components/misc/ViewRaw.tsx @@ -5,12 +5,13 @@ import { Card } from 'client/components/Form/Card'; import Button from 'client/components/Form/Button'; const CardStyles = ` -margin: 0 auto 1rem auto; +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}; } diff --git a/src/client/jobs/registry.ts b/src/client/jobs/registry.ts index a9a163095..fc463e67e 100644 --- a/src/client/jobs/registry.ts +++ b/src/client/jobs/registry.ts @@ -140,10 +140,19 @@ export const jobs: JobSpec[] = [ fetcher: fetchAndProcess('http-security?url=${url}'), }, { - id: 'social-tags', + id: 'tls-connection', expectedAddressTypes: [...URL_ONLY], - cards: [card('social-tags', 'Social Tags', ['client', 'meta'], SocialTagsCard)], - fetcher: fetchAndProcess('social-tags?url=${url}'), + 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: fetchAndProcess('tls-labs?url=${url}'), }, { id: 'trace-route', @@ -205,31 +214,6 @@ export const jobs: JobSpec[] = [ cards: [card('rank', 'Global Ranking', ['meta'], RankCard)], fetcher: fetchAndProcess('rank?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: '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: fetchAndProcess('tls-labs?url=${url}'), - }, { id: 'redirects', expectedAddressTypes: [...URL_ONLY], @@ -278,6 +262,22 @@ export const jobs: JobSpec[] = [ 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], diff --git a/src/client/styles/globals.tsx b/src/client/styles/globals.tsx index 83aff8d6d..156141b37 100644 --- a/src/client/styles/globals.tsx +++ b/src/client/styles/globals.tsx @@ -3,12 +3,6 @@ import { Global, css } from '@emotion/react'; const GlobalStyles = () => ( <Global styles={css` - @font-face { - font-family: PTMono; - font-style: normal; - font-weight: 400; - src: url('/fonts/PTMono.ttf') format('ttf'); - } body, div, a, @@ -23,7 +17,7 @@ const GlobalStyles = () => ( h4, button, section { - font-family: PTMono; + font-family: var(--font-mono); color: #fff; } #fancy-background p span { diff --git a/src/client/styles/index.css b/src/client/styles/index.css index 489f61bcb..e380b340b 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -1,29 +1,10 @@ -@font-face { - font-family: 'PTMono'; - src: url('/fonts/PTMono-Regular.ttf') format('truetype'); - font-weight: normal; - font-style: normal; -} - html { scroll-behavior: smooth; } body { margin: 0; - font-family: - 'PTMono', - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - 'Roboto', - 'Oxygen', - 'Ubuntu', - 'Cantarell', - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; + font-family: var(--font-mono); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background: #141517; @@ -31,7 +12,7 @@ body { } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-family: var(--font-mono); } #fancy-background { color: var(--background, #141517); diff --git a/src/client/styles/typography.ts b/src/client/styles/typography.ts index 299ff749b..6a7970fbc 100644 --- a/src/client/styles/typography.ts +++ b/src/client/styles/typography.ts @@ -9,7 +9,7 @@ export const TextSizes = { export const TextReset = ` font-size: ${TextSizes.small}; - font-family: PTMono, Helvetica, Arial, sans-serif; + font-family: var(--font-mono); font-weight: 400; font-style: normal; font-stretch: normal; diff --git a/src/client/utils/result-processor.ts b/src/client/utils/result-processor.ts index 2905c5784..3daf77b37 100644 --- a/src/client/utils/result-processor.ts +++ b/src/client/utils/result-processor.ts @@ -106,12 +106,18 @@ export const getHostNames = (response: any): HostNames | null => { export interface ShodanResults { hostnames: HostNames | null; serverInfo: ServerInfo | null; + vulns: string[]; } export const parseShodanResults = (response: any): ShodanResults => { return { hostnames: getHostNames(response), serverInfo: getServerInfo(response), + vulns: Array.isArray(response?.vulns) + ? response.vulns + : response?.vulns && typeof response.vulns === 'object' + ? Object.keys(response.vulns) + : [], }; }; diff --git a/src/client/views/Home.tsx b/src/client/views/Home.tsx index 72b817917..7ac2de661 100644 --- a/src/client/views/Home.tsx +++ b/src/client/views/Home.tsx @@ -19,7 +19,7 @@ const HomeContainer = styled.section` justify-content: center; align-items: center; height: 100%; - font-family: 'PTMono'; + font-family: var(--font-mono); padding: 1.5rem 1rem 4rem 1rem; footer { z-index: 1; diff --git a/src/client/views/Results.tsx b/src/client/views/Results.tsx index c16f66a0f..7db23cf81 100644 --- a/src/client/views/Results.tsx +++ b/src/client/views/Results.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import styled from '@emotion/styled'; import { ToastContainer } from 'react-toastify'; -import Masonry from 'react-masonry-css'; import colors from 'client/styles/colors'; import Heading from 'client/components/Form/Heading'; @@ -18,98 +17,40 @@ import ProgressBar, { } from 'client/components/misc/ProgressBar'; import ActionButtons from 'client/components/misc/ActionButtons'; import AdditionalResources from 'client/components/misc/AdditionalResources'; +import AdvisoryPanel from 'client/components/misc/AdvisoryPanel'; +import ResultsMasonryGrid from 'client/components/misc/ResultsMasonryGrid'; import ViewRaw from 'client/components/misc/ViewRaw'; import { determineAddressType, type AddressType } from 'client/utils/address-type-checker'; import { hasData } from 'client/utils/result-processor'; import useJobs from 'client/hooks/useJobs'; import { jobs, allCards, allCardIds } from 'client/jobs/registry'; +import { runAnalysis } from 'client/analysis/registry'; const ResultsOuter = styled.div` display: flex; flex-direction: column; - .masonry-grid { - display: flex; - width: auto; - } - .masonry-grid-col section { - margin: 1rem 0.5rem; - } -`; - -const ResultsContent = styled.section` - width: 95vw; - display: grid; - grid-auto-flow: dense; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1rem; - margin: auto; - width: calc(100% - 2rem); - padding-bottom: 1rem; + padding-top: 1rem; `; -const FilterButtons = styled.div` +const ResultsContent = styled.section` width: 95vw; - margin: auto; - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 1rem; - .one-half { - display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: center; - } - button, - input, - .toggle-filters { - background: ${colors.backgroundLighter}; - color: ${colors.textColor}; - border: none; - border-radius: 4px; - font-family: 'PTMono'; - padding: 0.25rem 0.5rem; - border: 1px solid transparent; - transition: all 0.2s ease-in-out; - } - button, - .toggle-filters { - cursor: pointer; - text-transform: capitalize; - box-shadow: 2px 2px 0px ${colors.bgShadowColor}; - transition: all 0.2s ease-in-out; - &:hover { - box-shadow: 4px 4px 0px ${colors.bgShadowColor}; - color: ${colors.primary}; + margin: 0 auto; + @keyframes cardFlash { + 0%, + 30% { + outline: 2px solid ${colors.primary}; + outline-offset: 4px; } - &.selected { - border: 1px solid ${colors.primary}; - color: ${colors.primary}; + 100% { + outline: 2px solid transparent; + outline-offset: 4px; } } - input:focus { - border: 1px solid ${colors.primary}; - outline: none; - } - .clear { - color: ${colors.textColor}; - text-decoration: underline; - cursor: pointer; - font-size: 0.8rem; - opacity: 0.8; - } - .toggle-filters { - font-size: 0.8rem; - } - .control-options { - display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: center; - a { - text-decoration: none; - } + .flash > section { + animation: cardFlash 1.2s ease-out; + border-radius: 8px; } `; @@ -135,9 +76,6 @@ const Results = (props: { address?: string }): JSX.Element => { const [addressType, setAddressType] = useState<AddressType>('empt'); const [modalOpen, setModalOpen] = useState(false); const [modalContent, setModalContent] = useState<ReactNode>(<></>); - const [showFilters, setShowFilters] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [tags, setTags] = useState<string[]>([]); useEffect(() => { if (addressType === 'empt') setAddressType(determineAddressType(address)); @@ -186,14 +124,6 @@ const Results = (props: { address?: string }): JSX.Element => { setModalOpen(true); }; - const updateTags = (tag: string) => - setTags(tags.includes(tag) ? tags.filter((t) => t !== tag) : [tag]); - - const clearFilters = () => { - setTags([]); - setSearchTerm(''); - }; - // Resolve each card's data, applying picker and falling back when needed const renderable = allCards.map(({ jobId, card }) => { const entry = jobsState[card.id]; @@ -203,11 +133,19 @@ const Results = (props: { address?: string }): JSX.Element => { return { jobId, card, data, entry }; }); - const cardsToShow = renderable.filter(({ card, data, entry }) => { - const tagMatch = tags.length === 0 || card.tags.some((t) => tags.includes(t)); - const searchMatch = card.title.toLowerCase().includes(searchTerm.toLowerCase()); - return tagMatch && searchMatch && hasData(data) && !entry?.error; - }); + const cardsToShow = renderable.filter(({ data, entry }) => hasData(data) && !entry?.error); + + const findings = useMemo(() => runAnalysis(jobsState), [jobsState]); + + const jumpToCard = (id: string) => { + const el = document.getElementById(`card-${id}`); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.classList.remove('flash'); + void el.offsetWidth; + el.classList.add('flash'); + window.setTimeout(() => el.classList.remove('flash'), 1300); + }; return ( <ResultsOuter> @@ -225,91 +163,26 @@ const Results = (props: { address?: string }): JSX.Element => { </Nav> <ProgressBar loadStatus={loadingJobs} showModal={showErrorModal} showJobDocs={showInfo} /> <Loader show={loadingJobs.filter((j) => j.state !== 'loading').length < 5} /> - <FilterButtons> - {showFilters ? ( - <> - <div className="one-half"> - <span className="group-label">Filter by</span> - {['server', 'client', 'meta'].map((tag) => ( - <button - key={tag} - className={tags.includes(tag) ? 'selected' : ''} - onClick={() => updateTags(tag)} - > - {tag} - </button> - ))} - {(tags.length > 0 || searchTerm.length > 0) && ( - <span onClick={clearFilters} className="clear"> - Clear Filters - </span> - )} - </div> - <div className="one-half"> - <span className="group-label">Search</span> - <input - type="text" - placeholder="Filter Results" - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - /> - <span className="toggle-filters" onClick={() => setShowFilters(false)}> - Hide - </span> - </div> - </> - ) : ( - <div className="control-options"> - <span className="toggle-filters" onClick={() => setShowFilters(true)}> - Show Filters - </span> - <a href="#view-download-raw-data"> - <span className="toggle-filters">Export Data</span> - </a> - <a href="/about"> - <span className="toggle-filters">Learn about the Results</span> - </a> - <a href="/about#additional-resources"> - <span className="toggle-filters">More tools</span> - </a> - <a target="_blank" rel="noreferrer" href="https://github.com/lissy93/web-check"> - <span className="toggle-filters">View GitHub</span> - </a> - </div> - )} - </FilterButtons> + <AdvisoryPanel findings={findings} onJumpTo={jumpToCard} /> <ResultsContent> - <Masonry - breakpointCols={{ - 10000: 12, - 4000: 9, - 3600: 8, - 3200: 7, - 2800: 6, - 2400: 5, - 2000: 4, - 1600: 3, - 1200: 2, - 800: 1, - }} - className="masonry-grid" - columnClassName="masonry-grid-col" - > + <ResultsMasonryGrid minColWidth={336}> {cardsToShow.map(({ card, data }) => ( - <ErrorBoundary title={card.title} key={`eb-${card.id}`}> - <card.Component - key={card.id} - data={data} - title={card.title} - actionButtons={makeActionButtons( - card.title, - () => retry(card.id), - () => showInfo(card.id), - )} - /> - </ErrorBoundary> + <div id={`card-${card.id}`} key={`eb-${card.id}`}> + <ErrorBoundary title={card.title}> + <card.Component + key={card.id} + data={data} + title={card.title} + actionButtons={makeActionButtons( + card.title, + () => retry(card.id), + () => showInfo(card.id), + )} + /> + </ErrorBoundary> + </div> ))} - </Masonry> + </ResultsMasonryGrid> </ResultsContent> <ViewRaw everything={renderable.map((r) => ({ @@ -319,7 +192,6 @@ const Results = (props: { address?: string }): JSX.Element => { }))} /> <AdditionalResources url={address} /> - <Footer /> <Modal isOpen={modalOpen} closeModal={() => setModalOpen(false)}> {modalContent} </Modal> @@ -330,6 +202,7 @@ const Results = (props: { address?: string }): JSX.Element => { theme="dark" position="bottom-right" /> + <Footer /> </ResultsOuter> ); }; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 5d1a8cc94..b8ec484d3 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -12,11 +12,14 @@ interface Props { description?: string; keywords?: string; customSchemaJson?: any; + preloadBodyFont?: boolean; breadcrumbs?: Array<{ name: string; item: string; }>; } + +const { preloadBodyFont = true } = Astro.props; --- <!doctype html> @@ -25,13 +28,17 @@ interface Props { <ClientRouter /> <MetaTags {...Astro.props} /> <slot name="head" /> - <link - href="/fonts/Hubot-Sans/Hubot-Sans.woff2" - as="font" - rel="preload" - type="font/woff2" - crossorigin="anonymous" - /> + { + preloadBodyFont && ( + <link + href="/fonts/Hubot-Sans/WOFF2/HubotSans-Regular.woff2" + as="font" + rel="preload" + type="font/woff2" + crossorigin="anonymous" + /> + ) + } </head> <body> <slot /> diff --git a/src/pages/check/[...target].astro b/src/pages/check/[...target].astro index 592f6224a..1747203d9 100644 --- a/src/pages/check/[...target].astro +++ b/src/pages/check/[...target].astro @@ -14,7 +14,15 @@ if (searchUrl) { } --- -<BaseLayout> +<BaseLayout preloadBodyFont={false}> + <link + slot="head" + href="/fonts/PTMono-Regular.woff2" + as="font" + rel="preload" + type="font/woff2" + crossorigin="anonymous" + /> <Main client:only="react" /> </BaseLayout> diff --git a/src/pages/self-hosted-setup.astro b/src/pages/self-hosted-setup.astro index 17362c0a3..373bd2f40 100644 --- a/src/pages/self-hosted-setup.astro +++ b/src/pages/self-hosted-setup.astro @@ -166,7 +166,7 @@ const cardData = [ background-color: var(--background); padding: 0.5rem 2rem 0.5rem 0.5rem; display: block; - font-family: PTMono, source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-family: var(--font-mono); z-index: 2; border-radius: 3px; width: fit-content; diff --git a/src/pages/web-check-api/index.astro b/src/pages/web-check-api/index.astro index ba76e2ed2..e5719fb67 100644 --- a/src/pages/web-check-api/index.astro +++ b/src/pages/web-check-api/index.astro @@ -40,7 +40,7 @@ import Footer from '@components/scafold/Footer.astro'; <h3>API Source</h3> <p>View, edit, download or deploy the API from source.</p> <div class="buttons"> - <a href="https://github.com/xray-web/web-check-api"> + <a href="https://github.com/xray-web/web-check-api" target="_blank" rel="noreferrer"> <img src="/assets/images/github.svg" /> GitHub </a> @@ -154,7 +154,7 @@ import Footer from '@components/scafold/Footer.astro'; background-color: var(--background); padding: 0.5rem 2rem 0.5rem 0.5rem; display: block; - font-family: PTMono, source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-family: var(--font-mono); z-index: 2; border-radius: 3px; width: fit-content; diff --git a/src/styles/global.scss b/src/styles/global.scss index e83be1781..b8bafa841 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -25,7 +25,7 @@ html { } body { - font-family: 'Hubot Sans', 'Inter', 'Helvetica Neue', Arial, sans-serif; + font-family: var(--font-sans); line-height: 1; color: var(--text-color); background: var(--background); diff --git a/src/styles/typography.scss b/src/styles/typography.scss index 2b47ca113..bf88a0b7d 100644 --- a/src/styles/typography.scss +++ b/src/styles/typography.scss @@ -1,76 +1,20 @@ -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-Thin.ttf') format('truetype'); - font-weight: 100; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-ExtraLight.ttf') format('truetype'); - font-weight: 200; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-Light.ttf') format('truetype'); - font-weight: 300; - font-style: normal; - font-display: swap; +:root { + --font-sans: 'Hubot Sans', 'Inter', 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'PTMono', ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace; } @font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-Regular.ttf') format('truetype'); + font-family: 'PTMono'; + src: + url('/fonts/PTMono-Regular.woff2') format('woff2'), + url('/fonts/PTMono-Regular.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; } -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-Medium.ttf') format('truetype'); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-SemiBold.ttf') format('truetype'); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-Bold.ttf') format('truetype'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-ExtraBold.ttf') format('truetype'); - font-weight: 800; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Inter'; - src: url('/fonts/Inter-Black.ttf') format('truetype'); - font-weight: 900; - font-style: normal; - font-display: swap; -} - -// Regular Weights and Styles +// Hubot Sans β€” used for Astro marketing pages (homepage, account, api docs, self-hosted-setup) +// Only the weights actually referenced in the codebase are declared. @font-face { font-family: 'Hubot Sans'; src: @@ -94,9 +38,9 @@ @font-face { font-family: 'Hubot Sans'; src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-Bold.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-Bold.ttf') format('truetype'); - font-weight: 700; + url('/fonts/Hubot-Sans/WOFF2/HubotSans-SemiBold.woff2') format('woff2'), + url('/fonts/Hubot-Sans/TTF/HubotSans-SemiBold.ttf') format('truetype'); + font-weight: 600; font-style: normal; font-display: swap; } @@ -104,20 +48,9 @@ @font-face { font-family: 'Hubot Sans'; src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-BoldItalic.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-BoldItalic.ttf') format('truetype'); + url('/fonts/Hubot-Sans/WOFF2/HubotSans-Bold.woff2') format('woff2'), + url('/fonts/Hubot-Sans/TTF/HubotSans-Bold.ttf') format('truetype'); font-weight: 700; - font-style: italic; - font-display: swap; -} - -// Light, Medium, and ExtraBold variants -@font-face { - font-family: 'Hubot Sans'; - src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-Light.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-Light.ttf') format('truetype'); - font-weight: 300; font-style: normal; font-display: swap; } @@ -125,49 +58,9 @@ @font-face { font-family: 'Hubot Sans'; src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-LightItalic.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-LightItalic.ttf') format('truetype'); - font-weight: 300; - font-style: italic; - font-display: swap; -} - -@font-face { - font-family: 'Hubot Sans'; - src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-Medium.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-Medium.ttf') format('truetype'); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Hubot Sans'; - src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-MediumItalic.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-MediumItalic.ttf') format('truetype'); - font-weight: 500; - font-style: italic; - font-display: swap; -} - -@font-face { - font-family: 'Hubot Sans'; - src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBold.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-ExtraBold.ttf') format('truetype'); - font-weight: 800; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Hubot Sans'; - src: - url('/fonts/Hubot-Sans/WOFF2/HubotSans-ExtraBoldItalic.woff2') format('woff2'), - url('/fonts/Hubot-Sans/TTF/HubotSans-ExtraBoldItalic.ttf') format('truetype'); - font-weight: 800; + url('/fonts/Hubot-Sans/WOFF2/HubotSans-BoldItalic.woff2') format('woff2'), + url('/fonts/Hubot-Sans/TTF/HubotSans-BoldItalic.ttf') format('truetype'); + font-weight: 700; font-style: italic; font-display: swap; } diff --git a/yarn.lock b/yarn.lock index 8d57936c9..9bac55d3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -116,14 +116,6 @@ send "^1.2.1" server-destroy "^1.0.1" -"@astrojs/partytown@^2.1.7": - version "2.1.7" - resolved "https://registry.npmjs.org/@astrojs/partytown/-/partytown-2.1.7.tgz" - integrity sha512-dbffmNmJ+sAJ0/aXSaLX4aI04EZS/2C6Mm/+fmd4ikqWO7hV6nIi0sug8Z3c+yqedJNi1swFvpwluWmGjLHNzw== - dependencies: - "@qwik.dev/partytown" "^0.13.2" - mrmime "^2.0.1" - "@astrojs/prism@4.0.1": version "4.0.1" resolved "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz" @@ -1861,13 +1853,6 @@ tar-fs "^3.1.1" yargs "^17.7.2" -"@qwik.dev/partytown@^0.13.2": - version "0.13.2" - resolved "https://registry.npmjs.org/@qwik.dev/partytown/-/partytown-0.13.2.tgz" - integrity sha512-Umls4bSkuzqLVcGvf8OgwIn/OldproSAbaQ/iYGe8VPYBpl2CaOSxabWwkeC72LDFqxVL0b0q8XlI8MuChDyzg== - dependencies: - dotenv "^16.4.7" - "@reduxjs/toolkit@^1.9.0 || 2.x.x": version "2.11.2" resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz" @@ -4251,7 +4236,7 @@ dot-prop@9.0.0, dot-prop@^9.0.0: dependencies: type-fest "^4.18.2" -dotenv@^16.3.1, dotenv@^16.4.7: +dotenv@^16.3.1: version "16.6.1" resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz" integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== @@ -7976,11 +7961,6 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-masonry-css@^1.0.16: - version "1.0.16" - resolved "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.16.tgz" - integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== - "react-redux@8.x.x || 9.x.x": version "9.2.0" resolved "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz"