/** * Peer Directory Plugin * * Full implementation of the peer.directory website using the P2NS Plugin SDK. * This plugin provides a web interface for browsing and searching P2NS domains. * * The plugin serves: * - Static files from the www/ directory (HTML, CSS, JS, images) * - Dynamic API endpoints for domain listing and search */ // Import the P2NS Plugin SDK which provides access to system functionality const sdk = require('../../includes/plugins/sdk'); /** * Categorize and collect all domains from the P2NS system * * This function: * 1. Fetches all DNS entries from the P2NS network * 2. Extracts domain claims (who owns which domains) * 3. Includes all plugin domains (domains in plugin-sites/) * 4. Categorizes each domain as 'plugin', 'internal', 'local', or 'remote' * 5. Retrieves the hash for each domain * 6. Returns a structured list of domains with metadata * * @returns {Promise} Array of domain objects with { domain, type, hash, isLocal } */ async function categorizeDomains() { // Check if DNS service is initialized and ready // If not, return empty array to prevent errors if (!sdk.utils.isDNSReady()) { return []; } // Fetch all DNS entries from the P2NS network // These entries contain domain claims, hashes, and other metadata const allEntries = await sdk.dns.getAllEntries(); // If no entries exist yet (system just started), return at least the plugin domains // Plugin domains are domains in plugin-sites/ with valid config.json files if (!allEntries || allEntries.length === 0) { // Get all plugin domains const pluginDomains = sdk.plugin.getAllPluginDomains(); // Get list of internal domains (includes plugins + p2ns.admin) const internalDomains = await sdk.utils.getInternalDomains(); // Return them formatted as domain objects // Plugin domains get type 'plugin', others get type 'internal' return internalDomains.map(domain => { const isPlugin = pluginDomains.includes(domain); return { domain, type: isPlugin ? 'plugin' : 'internal', // Mark as plugin or internal hash: 'internal', // Internal/plugin domains don't have P2P hashes isLocal: true // Internal/plugin domains are always local }; }); } // Get the local writer key (this node's identity in the P2P network) // This is used to determine if we own a domain (isLocal = true) // Use the SDK's localPeerId which returns the persistent hyperswarm public key const localWriter = sdk.state.localPeerId; // Get list of internal domains for comparison const internalDomains = await sdk.utils.getInternalDomains(); // Get all domains that have plugins loaded // Plugin domains are domains in plugin-sites/ with valid config.json files const pluginDomains = sdk.plugin.getAllPluginDomains(); // Convert to Set for faster lookups and ensure we have plugin domains const pluginDomainsSet = new Set(pluginDomains); if (pluginDomains.length > 0) { sdk.log.debug('peer.directory', `Found ${pluginDomains.length} plugin domains: ${pluginDomains.join(', ')}`); } // Step 1: Collect all domain claims from DNS entries // DNS entries with keys like "claim:example.tld:claimant-id" indicate ownership // We build a map: domain -> Set of claimant IDs const domainClaimants = new Map(); for (const entry of allEntries) { // Check if this entry is a domain claim if (entry.key.startsWith('claim:')) { // Parse the claim key: "claim:domain:claimant" const parts = entry.key.split(':'); if (parts.length === 3) { const domain = parts[1]; // The domain being claimed const claimant = parts[2]; // The peer ID claiming the domain // Initialize Set for this domain if it doesn't exist if (!domainClaimants.has(domain)) { domainClaimants.set(domain, new Set()); } // Add this claimant to the set of claimants for this domain domainClaimants.get(domain).add(claimant); } } } // Step 2: Build a unique set of all domains // Start with all plugin domains and add all claimed domains // This ensures all plugin sites are included in the directory const domains = new Set(); // Add all plugin domains first (these are guaranteed to be shown) for (const pluginDomain of pluginDomains) { domains.add(pluginDomain); } // Add all domains from DNS claims for (const domain of domainClaimants.keys()) { domains.add(domain); } // Step 3: Build the final domain list with metadata const domainList = []; for (const domain of domains) { // Check if this domain has a plugin (is in plugin-sites/) // Use Set for faster lookup - plugin domains should always be detected const isPlugin = pluginDomainsSet.has(domain); // Debug logging for plugin detection issues if (!isPlugin && pluginDomains.length > 0) { // Log if we have plugin domains but this one isn't detected sdk.log.debug('peer.directory', `Domain ${domain} not in plugin list. Available plugins: ${Array.from(pluginDomainsSet).join(', ')}`); } else if (isPlugin) { sdk.log.debug('peer.directory', `Domain ${domain} correctly detected as plugin`); } // Get the P2P hash for this domain (used for routing/connection) // Plugin and internal domains don't have P2P hashes, so use 'internal' or 'none' let hash = await sdk.dns.getHashForDomain(domain); // Determine if this is an internal domain (managed by this P2NS instance) // Note: p2ns.admin is always internal but may not have a plugin // Also, any domain with hash='internal' (plugin type) should be marked as internal let isInternal = internalDomains.includes(domain); if (hash === 'internal') { // Domain has an internal hash (plugin type), mark it as internal isInternal = true; } // If hash is null, set it based on domain type if (!hash) { hash = (isPlugin || isInternal ? 'internal' : 'none'); } // Get all claimants for this domain const claimants = domainClaimants.get(domain) || new Set(); // Check if we (the local node) own this domain by checking the resolved claimant // The resolved claimant is the one that won the consensus vote, not just any claimant let isLocal = false; if (localWriter && (isInternal || isPlugin)) { // Internal and plugin domains are always local isLocal = true; } else if (localWriter) { // For P2P domains, check if we are the resolved claimant (owner) try { const consensusState = await sdk.dns.getConsensusState(domain); isLocal = consensusState.resolvedClaimant === localWriter; } catch (err) { // If consensus check fails, fall back to checking if we're in the claimants set // This is a fallback for edge cases where consensus state might not be available isLocal = claimants.has(localWriter); sdk.log.debug('peer.directory', `Consensus check failed for ${domain}, using fallback: ${err.message}`); } } // Categorize the domain type: // - 'local': Domain we own in the P2P network (includes plugin domains) // - 'internal': Internal domain without plugin (e.g., p2ns.admin if no plugin, or domains with hash='internal') // - 'remote': Domain owned by other peers // Domains with hash='internal' (plugin type) should always be 'internal', not 'local' let type; if (hash === 'internal') { // Domains with internal hash (plugin type) are always 'internal' type = 'internal'; } else if (isPlugin) { // Plugin domains are 'local' type = 'local'; } else if (isInternal) { // Internal domains are 'internal' type = 'internal'; } else if (isLocal) { // Locally owned P2P domains are 'local' type = 'local'; } else { type = 'remote'; // Remote domains owned by other peers } // Add this domain to the list with all its metadata domainList.push({ domain, // Domain name (e.g., 'example.tld') type, // 'plugin', 'internal', 'local', or 'remote' hash, // P2P hash or 'internal' or 'none' isLocal: isLocal || isInternal || isPlugin // True if we own it, it's internal, or it's a plugin }); } return domainList; } /** * Sort domains according to the specified sort option * * This function provides multiple sorting strategies: * - By type (plugin, internal, local, remote) - default * - By domain name (alphabetical, ascending or descending) * - By hash (alphabetical, ascending or descending) * * When sorting by type, domains are ordered: remote (0) < local (1) < internal (2) * Within the same type, domains are sorted alphabetically by name. * * @param {Array} domains - Array of domain objects to sort * @param {string} sortBy - Sort option: 'type', 'name', 'name-desc', 'hash', 'hash-desc', 'type-desc' * @returns {Array} Sorted array of domain objects (mutates original array) */ function sortDomains(domains, sortBy = 'type') { // Define priority order for domain types (lower number = higher priority) // Default order: remote (0) < local (1) < internal (2) const typeOrder = { remote: 0, local: 1, internal: 2, plugin: 3 }; // Sort by domain name (ascending - A to Z) if (sortBy === 'name') { return domains.sort((a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' })); } // Sort by domain name (descending - Z to A) else if (sortBy === 'name-desc') { return domains.sort((a, b) => b.domain.localeCompare(a.domain, undefined, { sensitivity: 'base' })); } // Sort by hash (ascending - alphabetical) else if (sortBy === 'hash') { return domains.sort((a, b) => { const hashA = (a.hash || 'none').toLowerCase(); const hashB = (b.hash || 'none').toLowerCase(); return hashA.localeCompare(hashB, undefined, { sensitivity: 'base' }); }); } // Sort by hash (descending - reverse alphabetical) else if (sortBy === 'hash-desc') { return domains.sort((a, b) => { const hashA = (a.hash || 'none').toLowerCase(); const hashB = (b.hash || 'none').toLowerCase(); return hashB.localeCompare(hashA, undefined, { sensitivity: 'base' }); }); } // Sort by type (descending - remote, local, internal) then alphabetically else if (sortBy === 'type-desc') { return domains.sort((a, b) => { // First compare by type (reverse order) const typeDiff = typeOrder[b.type] - typeOrder[a.type]; if (typeDiff !== 0) return typeDiff; // If same type, sort alphabetically by domain name return a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }); }); } // Default: Sort by type (ascending - remote, local, internal) then alphabetically else { return domains.sort((a, b) => { // First compare by type (remote < local < internal) const typeDiff = typeOrder[a.type] - typeOrder[b.type]; if (typeDiff !== 0) return typeDiff; // If same type, sort alphabetically by domain name return a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }); }); } } /** * Plugin Handler * * This is the main HTTP request handler for the peer.directory plugin. * It processes incoming HTTP requests and routes them to appropriate handlers. * * Request Flow: * 1. Parse the request URL to extract path and query parameters * 2. Check if the path matches any API endpoints we handle * 3. If matched, process the request and return a response * 4. If not matched, return false to allow static file serving from www/ * * @param {Object} req - Node.js HTTP request object * @param {Object} res - Node.js HTTP response object * @returns {Promise} * - true: Request was handled by this plugin * - false: Request should fall back to static file serving */ async function handler(req, res) { try { // Use SDK router to parse the request URL // This automatically handles domain prefixes (e.g., '/peer.directory/domains' -> 'domains') // Returns: { path: 'domains', query: { page: '1', limit: '10' }, method: 'GET' } const { path, query } = sdk.router.parseRequest(req); // Handle root path (/) - return false to serve index.html from www/ directory // The plugin system will automatically serve static files from www/ when handler returns false if (path === '' || path === '/') { return false; } // Handle the /domains API endpoint // This endpoint returns a paginated, sortable list of all P2NS domains // Example: GET /domains?page=1&limit=10&sort=type if (path === 'domains' || path.startsWith('domains?')) { sdk.log.info('peer.directory', 'Serving domains list'); // Check if DNS service is ready before attempting to fetch domains // If not ready, return a 503 Service Unavailable response if (!sdk.utils.isDNSReady()) { sdk.log.error('peer.directory', 'DNS service not initialized'); // Return empty result set with error status return sdk.router.json(res, { domains: [], total: 0, page: 1, limit: 10, totalPages: 0 }, 503); } try { // Parse and validate query parameters // page: Which page to return (1-indexed, minimum 1) const page = Math.max(1, parseInt(query.page) || 1); // limit: How many domains per page (between 1 and 10, default 10) const limit = Math.max(1, Math.min(10, parseInt(query.limit) || 10)); // sort: How to sort the domains (validate against allowed options) const validSorts = ['type', 'name', 'name-desc', 'hash', 'hash-desc', 'type-desc']; const sortBy = validSorts.includes(query.sort) ? query.sort : 'type'; // Step 1: Fetch and categorize all domains from the P2NS network // This gets domains from DNS entries and categorizes them as internal/local/remote let domainList = await categorizeDomains(); // Step 2: Apply the requested sorting // This mutates domainList in place according to the sortBy parameter domainList = sortDomains(domainList, sortBy); // Step 3: Calculate pagination metadata const total = domainList.length; // Total number of domains const totalPages = Math.ceil(total / limit); // Total number of pages const startIndex = (page - 1) * limit; // Starting index for this page // Step 4: Extract the domains for the requested page // slice(startIndex, startIndex + limit) gets the domains for this page const paginatedDomains = domainList.slice(startIndex, startIndex + limit); // Step 5: Return the paginated response as JSON // The response includes: // - domains: Array of domain objects for this page // - total: Total number of domains (for pagination UI) // - page: Current page number // - limit: Domains per page // - totalPages: Total number of pages (for pagination UI) return sdk.router.json(res, { domains: paginatedDomains, total, page, limit, totalPages }); } catch (err) { // If anything goes wrong while processing, log the error and return empty result sdk.log.error('peer.directory', `Failed to fetch domains: ${err.message}`); return sdk.router.json(res, { domains: [], total: 0, page: 1, limit: 10, totalPages: 0 }, 500); } } // Handle favicon requests - browsers automatically request /favicon.ico // We don't have a favicon, so return 404 if (path === 'favicon.ico') { sdk.log.debug('peer.directory', 'Favicon requested, returning 404'); return sdk.router.notFound(res, 'Favicon Not Found'); } // For all other paths, return false to allow static file serving // This means requests for CSS, JS, images, etc. will be served from www/ directory return false; } catch (err) { // Catch any unexpected errors in the handler // Log the error and return a 500 Internal Server Error response sdk.log.error('peer.directory', `Error handling request: ${err.message}`); return sdk.router.error(res, 'Internal Server Error', 500); } } /** * Plugin Initialization Hook * * Called automatically when the plugin is loaded by the P2NS system. * This is the place to: * - Initialize resources * - Set up timers or intervals * - Pre-load data * - Check system state * * This function is called once when the plugin is first loaded. */ async function onInit() { sdk.log.info('peer.directory', 'Plugin initialized'); // Check if DNS service is ready // DNS service may not be ready immediately on startup, so we check and log the status if (sdk.utils.isDNSReady()) { sdk.log.info('peer.directory', 'DNS service is ready'); } else { // This is not necessarily an error - DNS service may still be initializing sdk.log.warn('peer.directory', 'DNS service is not ready yet'); } // Register admin panel actions sdk.admin.registerAction('refreshDomains', async () => { // Force refresh by invalidating any caches // The plugin itself doesn't cache, but this action can be useful return { success: true, message: 'Domain list will be refreshed on next request', timestamp: Date.now() }; }, { label: 'Refresh Domains', description: 'Trigger a refresh of the domain list', icon: '🔄' }); // Register admin panel settings sdk.admin.registerSetting('defaultSort', { type: 'select', label: 'Default Sort Order', description: 'Default sorting method for domain list', default: 'type', options: [ { value: 'type', label: 'By Type' }, { value: 'name', label: 'By Name (A-Z)' }, { value: 'name-desc', label: 'By Name (Z-A)' }, { value: 'hash', label: 'By Hash' } ] }); sdk.admin.registerSetting('itemsPerPage', { type: 'number', label: 'Items Per Page', description: 'Number of domains to show per page', default: 10 }); } /** * Plugin Shutdown Hook * * Called automatically when the plugin is unloaded by the P2NS system. * This is the place to: * - Clean up resources * - Close connections * - Stop timers or intervals * - Save state if needed * * This function is called when: * - The plugin is being reloaded * - The P2NS system is shutting down * - The plugin is being disabled */ async function onShutdown() { sdk.log.info('peer.directory', 'Plugin shutting down'); // Currently no cleanup needed, but this is where you would: // - Clear any caches // - Close database connections // - Stop background tasks } // Export the plugin interface // The plugin system expects an object with these three optional functions: // - handler: Main HTTP request handler (required if you want dynamic routes) // - onInit: Initialization hook (optional) // - onShutdown: Cleanup hook (optional) module.exports = { handler, // HTTP request handler - processes all incoming requests onInit, // Called when plugin loads onShutdown // Called when plugin unloads };