← back to Jill Website

src/services/linkValidationService.ts

125 lines

import axios, { AxiosError } from 'axios';

export interface LinkCheckResult {
  url: string;
  isValid: boolean;
  httpStatus?: number;
  errorMessage?: string;
  responseTimeMs: number;
}

export class LinkValidationService {
  private readonly timeout: number = 10000; // 10 seconds
  private readonly maxRedirects: number = 5;

  /**
   * Check if a single URL is valid and accessible
   */
  async checkLink(url: string): Promise<LinkCheckResult> {
    const startTime = Date.now();

    try {
      // P0 SSRF fix 2026-05-04: was POSTable to {url:'http://169.254.169.254/...'}
      // or any private IP. Reject non-http(s) protocols + private/loopback/link-local
      // hostnames. Note: this is a hostname check; full DNS-resolve-then-recheck
      // would be needed to fully defeat DNS rebinding (deferred — admin-only path).
      const parsed = new URL(url);
      if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
        throw new Error(`unsupported protocol: ${parsed.protocol}`);
      }
      const h = parsed.hostname.toLowerCase();
      const PRIVATE = /^(localhost|127\.|10\.|169\.254\.|192\.168\.|::1$|\[::1\]$|fc[0-9a-f]{2}:|fd[0-9a-f]{2}:)/i;
      const CGNAT = /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./;
      const TWELVE = /^172\.(1[6-9]|2[0-9]|3[01])\./;
      if (PRIVATE.test(h) || CGNAT.test(h) || TWELVE.test(h)) {
        throw new Error(`private/loopback host blocked: ${h}`);
      }

      const response = await axios.head(url, {
        timeout: this.timeout,
        maxRedirects: this.maxRedirects,
        validateStatus: (status) => status < 500, // Accept all statuses < 500
        headers: {
          'User-Agent': 'Mozilla/5.0 (compatible; LinkValidator/1.0)',
        },
      });

      const responseTimeMs = Date.now() - startTime;
      const isValid = response.status >= 200 && response.status < 400;

      return {
        url,
        isValid,
        httpStatus: response.status,
        responseTimeMs,
      };
    } catch (error) {
      const responseTimeMs = Date.now() - startTime;

      if (axios.isAxiosError(error)) {
        const axiosError = error as AxiosError;

        return {
          url,
          isValid: false,
          httpStatus: axiosError.response?.status,
          errorMessage: this.getErrorMessage(axiosError),
          responseTimeMs,
        };
      }

      return {
        url,
        isValid: false,
        errorMessage: error instanceof Error ? error.message : 'Unknown error',
        responseTimeMs,
      };
    }
  }

  /**
   * Check multiple links in parallel
   */
  async checkLinks(urls: string[]): Promise<LinkCheckResult[]> {
    const promises = urls.map((url) => this.checkLink(url));
    return Promise.all(promises);
  }

  /**
   * Extract user-friendly error message from Axios error
   */
  private getErrorMessage(error: AxiosError): string {
    if (error.code === 'ECONNABORTED') {
      return 'Connection timeout';
    }
    if (error.code === 'ENOTFOUND') {
      return 'Domain not found';
    }
    if (error.code === 'ECONNREFUSED') {
      return 'Connection refused';
    }
    if (error.code === 'ETIMEDOUT') {
      return 'Request timeout';
    }
    if (error.response) {
      return `HTTP ${error.response.status}: ${error.response.statusText}`;
    }
    return error.message || 'Network error';
  }

  /**
   * Validate URL format without making HTTP request
   */
  isValidUrlFormat(url: string): boolean {
    try {
      const parsed = new URL(url);
      return parsed.protocol === 'http:' || parsed.protocol === 'https:';
    } catch {
      return false;
    }
  }
}

// Export singleton instance
export const linkValidationService = new LinkValidationService();