UNPKG

nostr-deploy-server

Version:

Node.js server for hosting static websites under npub subdomains using Nostr protocol and Blossom servers

451 lines (398 loc) 14.9 kB
import axios, { AxiosResponse } from 'axios'; import * as mimeTypes from 'mime-types'; import { FileResponse } from '../types'; import { CacheService } from '../utils/cache'; import { ConfigManager } from '../utils/config'; import { logger } from '../utils/logger'; export class BlossomHelper { private config: ConfigManager; private requestTimeout: number; private maxFileSizeBytes: number; constructor() { this.config = ConfigManager.getInstance(); const configData = this.config.getConfig(); this.requestTimeout = configData.requestTimeoutMs; this.maxFileSizeBytes = configData.maxFileSizeMB * 1024 * 1024; } /** * Fetch file from Blossom servers */ public async fetchFile( sha256: string, servers: string[], path?: string ): Promise<FileResponse | null> { logger.debug(`🔍 BlossomHelper.fetchFile called for ${sha256.substring(0, 8)}...`); // Check cache first logger.debug(`🗄️ Checking cache for ${sha256.substring(0, 8)}...`); const cached = await CacheService.getFileContent(sha256); if (cached) { logger.info(`🎯 File cache HIT for ${sha256.substring(0, 8)}... (${cached.length} bytes)`); logger.debug( `Cache hit details: content type: ${cached.constructor.name}, length: ${cached.length}` ); // Get content type and fix it if necessary let contentType = this.getContentTypeFromPath(path || ''); contentType = this.fixMimeType(contentType, path || '', cached); return { content: cached, contentType: contentType, contentLength: cached.length, sha256, }; } else { logger.info( `💔 File cache MISS for ${sha256.substring(0, 8)}... - fetching from Blossom servers` ); } // Try each server in sequence for (const server of servers) { try { logger.debug(`🌐 Attempting to fetch ${sha256.substring(0, 8)}... from ${server}`); const result = await this.fetchFromServer(server, sha256, path); if (result) { // Cache successful result logger.info( `💾 Caching file content for ${sha256.substring(0, 8)}... (${ result.content.length } bytes)` ); logger.debug( `Caching details: content type: ${result.content.constructor.name}, length: ${result.content.length}` ); try { await CacheService.setFileContent(sha256, result.content); logger.info(`✅ File successfully cached for ${sha256.substring(0, 8)}...`); // Verify the cache was set correctly const verification = await CacheService.getFileContent(sha256); if (verification) { logger.debug(`🔍 Cache verification successful: ${verification.length} bytes stored`); } else { logger.warn(`⚠️ Cache verification FAILED for ${sha256.substring(0, 8)}...`); } } catch (cacheError) { logger.error( `❌ Failed to cache file content for ${sha256.substring(0, 8)}...:`, cacheError ); } logger.logBlossom('fetchFile', sha256, server, true, { size: result.contentLength, contentType: result.contentType, cached: true, }); return result; } } catch (error) { logger.logBlossom('fetchFile', sha256, server, false, { error: error instanceof Error ? error.message : 'Unknown error', }); continue; // Try next server } } logger.error(`❌ Failed to fetch file ${sha256.substring(0, 8)}... from all servers`, { servers, serverCount: servers.length, }); return null; } /** * Fetch file from a specific Blossom server */ private async fetchFromServer( server: string, sha256: string, path?: string ): Promise<FileResponse | null> { try { // Ensure server URL doesn't end with slash const baseUrl = server.endsWith('/') ? server.slice(0, -1) : server; const url = `${baseUrl}/${sha256}`; logger.debug(`Fetching ${sha256.substring(0, 8)}... from ${server}`); const response: AxiosResponse = await axios.get(url, { timeout: this.requestTimeout, responseType: 'arraybuffer', maxContentLength: this.maxFileSizeBytes, maxBodyLength: this.maxFileSizeBytes, validateStatus: (status) => status === 200, headers: { 'User-Agent': 'Nostr-Static-Server/1.0.0', }, }); if (!response.data) { throw new Error('Empty response body'); } // Convert ArrayBuffer to Uint8Array const content = new Uint8Array(response.data); // Verify file size if (content.length > this.maxFileSizeBytes) { throw new Error(`File too large: ${content.length} bytes (max: ${this.maxFileSizeBytes})`); } // Get content type from response headers or guess from path let contentType = response.headers['content-type'] || this.getContentTypeFromPath(path || '') || 'application/octet-stream'; // Clean up content type (remove charset if present for binary files) if (contentType.includes(';') && !contentType.startsWith('text/')) { contentType = contentType.split(';')[0].trim(); } // Get content length from response or calculate const contentLength = parseInt(response.headers['content-length'] || '0', 10) || content.length; // Validate SHA256 if we want to be extra cautious (optional) if (this.config.getConfig().maxFileSizeMB < 10) { // Only validate smaller files const calculatedHash = await this.calculateSHA256(content); if (calculatedHash !== sha256) { logger.warn( `SHA256 mismatch for file from ${server}: expected ${sha256}, got ${calculatedHash}` ); // Don't throw error, just log warning as some servers might serve different content } } // Fix incorrect MIME types from Blossom servers contentType = this.fixMimeType(contentType, path || '', content); return { content, contentType, contentLength, sha256, }; } catch (error) { if (axios.isAxiosError(error)) { const status = error.response?.status; const statusText = error.response?.statusText; if (status === 404) { throw new Error(`File not found: ${sha256}`); } else if (status === 413) { throw new Error(`File too large`); } else if (status === 429) { throw new Error(`Rate limited by server`); } else if (error.code === 'ECONNABORTED') { throw new Error(`Request timeout (${this.requestTimeout}ms)`); } else { throw new Error(`HTTP ${status} ${statusText}: ${error.message}`); } } throw error; } } /** * Fix incorrect MIME types from Blossom servers * This function corrects common MIME type mismatches for major file types */ private fixMimeType(serverContentType: string, path: string, content: Uint8Array): string { if (!path) return serverContentType; const ext = path.toLowerCase().split('.').pop(); if (!ext) return serverContentType; // Get the expected MIME type based on file extension const expectedMimeType = this.getContentTypeFromPath(path); // List of correct MIME types for major file types that we want to enforce const criticalMimeTypes: Record<string, string[]> = { 'text/html': ['html', 'htm'], 'text/css': ['css'], 'application/javascript': ['js'], 'text/javascript': ['js'], // Alternative for JavaScript 'application/json': ['json'], 'text/xml': ['xml'], 'application/xml': ['xml'], 'image/png': ['png'], 'image/jpeg': ['jpg', 'jpeg'], 'image/gif': ['gif'], 'image/svg+xml': ['svg'], 'image/x-icon': ['ico'], 'font/woff': ['woff'], 'font/woff2': ['woff2'], 'font/ttf': ['ttf'], 'application/vnd.ms-fontobject': ['eot'], }; // Check if this is a critical file type that we want to fix const isCriticalFile = Object.values(criticalMimeTypes).some((extensions) => extensions.includes(ext) ); if (!isCriticalFile) { return serverContentType; // Don't modify MIME types for non-critical files } // List of commonly incorrect MIME types that servers might return const incorrectMimeTypes = [ 'application/json', 'text/plain', 'application/octet-stream', 'binary/octet-stream', 'text/html', // Sometimes HTML is returned for non-HTML files ]; // If server returned an incorrect MIME type for a critical file, fix it if ( incorrectMimeTypes.includes(serverContentType) || !this.isMimeTypeCorrectForExtension(serverContentType, ext) ) { // Perform additional content-based validation for key file types if (this.validateContentMatchesExtension(content, ext)) { logger.warn( `Correcting incorrect MIME type for ${path}: ${serverContentType} -> ${expectedMimeType}` ); return expectedMimeType; } } return serverContentType; } /** * Check if the MIME type is correct for the given file extension */ private isMimeTypeCorrectForExtension(mimeType: string, extension: string): boolean { const mimeTypeMap: Record<string, string[]> = { html: ['text/html'], htm: ['text/html'], css: ['text/css'], js: ['application/javascript', 'text/javascript'], json: ['application/json'], xml: ['text/xml', 'application/xml'], png: ['image/png'], jpg: ['image/jpeg'], jpeg: ['image/jpeg'], gif: ['image/gif'], svg: ['image/svg+xml'], ico: ['image/x-icon', 'image/vnd.microsoft.icon'], woff: ['font/woff', 'application/font-woff'], woff2: ['font/woff2', 'application/font-woff2'], ttf: ['font/ttf', 'application/font-ttf'], eot: ['application/vnd.ms-fontobject'], }; const validMimeTypes = mimeTypeMap[extension.toLowerCase()]; return validMimeTypes ? validMimeTypes.includes(mimeType.toLowerCase()) : true; } /** * Validate that file content matches the expected file type based on extension * This provides an additional layer of validation by checking file signatures/content */ private validateContentMatchesExtension(content: Uint8Array, extension: string): boolean { if (content.length === 0) return false; const ext = extension.toLowerCase(); const contentStart = content.slice(0, Math.min(1024, content.length)); const textContent = new TextDecoder('utf-8', { fatal: false }).decode(contentStart); switch (ext) { case 'html': case 'htm': // Check for HTML doctype, html tags, or common HTML patterns return /<!doctype\s+html|<html|<head|<body|<div|<span|<p\s|<h[1-6]/i.test(textContent); case 'css': // Check for CSS patterns: selectors, properties, at-rules return /[@.]?[a-zA-Z-]+\s*\{|@(import|media|keyframes|charset)|\/\*[\s\S]*?\*\/|[a-zA-Z-]+\s*:\s*[^;]+;/i.test( textContent ); case 'js': // Check for JavaScript patterns: functions, variables, common keywords return /(function|var|let|const|class|import|export|require|module\.exports|console\.|document\.|window\.|=>|\{|\})/i.test( textContent ); case 'json': try { JSON.parse(textContent); return true; } catch { // Check for JSON-like structure return /^\s*[\{\[]/.test(textContent) && /[\}\]]\s*$/.test(textContent); } case 'xml': return /^\s*<\?xml|<[a-zA-Z][^>]*>/i.test(textContent); case 'svg': return /<svg/i.test(textContent); case 'png': // PNG file signature return ( content[0] === 0x89 && content[1] === 0x50 && content[2] === 0x4e && content[3] === 0x47 ); case 'jpg': case 'jpeg': // JPEG file signature return content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff; case 'gif': // GIF file signature return ( content[0] === 0x47 && content[1] === 0x49 && content[2] === 0x46 && content[3] === 0x38 && (content[4] === 0x37 || content[4] === 0x39) ); default: // For extensions we don't have specific validation, assume content is valid return true; } } /** * Get content type from file path */ private getContentTypeFromPath(path: string): string { if (!path) return 'application/octet-stream'; const contentType = mimeTypes.lookup(path); if (contentType) { return contentType; } // Fallback based on extension const ext = path.toLowerCase().split('.').pop(); switch (ext) { case 'html': case 'htm': return 'text/html'; case 'css': return 'text/css'; case 'js': return 'application/javascript'; case 'json': return 'application/json'; case 'png': return 'image/png'; case 'jpg': case 'jpeg': return 'image/jpeg'; case 'gif': return 'image/gif'; case 'svg': return 'image/svg+xml'; case 'ico': return 'image/x-icon'; case 'woff': return 'font/woff'; case 'woff2': return 'font/woff2'; case 'ttf': return 'font/ttf'; case 'eot': return 'application/vnd.ms-fontobject'; case 'pdf': return 'application/pdf'; case 'txt': return 'text/plain'; case 'md': return 'text/markdown'; case 'xml': return 'application/xml'; case 'zip': return 'application/zip'; case 'tar': return 'application/x-tar'; case 'gz': return 'application/gzip'; default: return 'application/octet-stream'; } } /** * Calculate SHA256 hash of content */ private async calculateSHA256(content: Uint8Array): Promise<string> { // Use Web Crypto API if available (Node.js 16+) if (typeof crypto !== 'undefined' && crypto.subtle) { const hashBuffer = await crypto.subtle.digest('SHA-256', content); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); } // Fallback to Node.js crypto module const nodeCrypto = require('crypto'); const hash = nodeCrypto.createHash('sha256'); hash.update(content); return hash.digest('hex'); } }