479 lines
19 KiB
JavaScript
479 lines
19 KiB
JavaScript
/**
|
|
* 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>} 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<boolean>}
|
|
* - 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
|
|
};
|