Files
n8n-backup-v2/workflows/lozEf1Wq3012xbAk.json
2026-01-02 22:48:32 +00:00

500 lines
22 KiB
JSON

{
"id": "lozEf1Wq3012xbAk",
"name": "NPM to Homepage (SSH)",
"nodes": [
{
"parameters": {
"jsCode": "// Extract the token from Supabase response\nconst tokenRecords = items;\n\nif (!tokenRecords || tokenRecords.length === 0 || !tokenRecords[0].json.token) {\n throw new Error('❌ NPM token not found in Supabase. Please run the NPM Auth Token Manager workflow first.');\n}\n\nconst tokenRecord = tokenRecords[0].json;\n\nconsole.log('✅ Loaded NPM token from Supabase');\nconsole.log('Token expires at:', tokenRecord.expires_at);\nconsole.log('Last updated:', tokenRecord.last_updated);\n\nreturn [{\n json: {\n token: tokenRecord.token,\n expiresAt: tokenRecord.expires_at\n }\n}];"
},
"id": "401c1f93-a150-4dc3-aa2d-da007edacb8f",
"name": "Extract Token Value",
"position": [
496,
32
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Extracts the token value and validates it exists."
},
{
"parameters": {
"url": "https://npm.dfw.ben.io/api/nginx/proxy-hosts",
"options": {},
"headerParametersUi": {
"parameter": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.token }}"
}
]
}
},
"id": "85e32a06-5c0e-409a-95d9-5eec7d4c71f3",
"name": "Get NPM Hosts",
"position": [
688,
32
],
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"notes": "Fetches the list of all configured proxy hosts from NPM using the stored long-lived token."
},
{
"parameters": {},
"id": "796fba9f-a65d-49d4-bdda-4f09ac7ad926",
"name": "Merge Data Sources",
"position": [
880,
48
],
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"notes": "Merges NPM hosts and Supabase mappings into a single execution path to prevent duplicate processing."
},
{
"parameters": {
"jsCode": "// Process merged data: NPM hosts array and Supabase mappings\n// Input 1: NPM hosts (array)\n// Input 2: Supabase mappings (records)\n\nlet hosts = [];\nlet mappings = {};\n\n// Process all incoming items to extract hosts and mappings\nfor (const item of items) {\n const data = item.json;\n \n // Check if this is NPM hosts data (array at root)\n if (Array.isArray(data)) {\n hosts = data;\n console.log('Received NPM hosts:', hosts.length);\n }\n // Check if this is Supabase data (has service_name and icon_reference)\n else if (data.service_name && data.icon_reference) {\n mappings[data.service_name.toLowerCase()] = data.icon_reference;\n }\n}\n\nconsole.log('=== Supabase Mappings Loaded ===');\nconsole.log('Total mappings from Supabase:', Object.keys(mappings).length);\nfor (const [key, value] of Object.entries(mappings).slice(0, 5)) {\n console.log(` ${key} -> ${value}`);\n}\nif (Object.keys(mappings).length > 5) {\n console.log(` ... and ${Object.keys(mappings).length - 5} more`);\n}\n\nconsole.log('NPM hosts retrieved:', hosts.length);\n\nreturn [{ json: { databaseMappings: mappings, unmappedDefault: 'streamlink', npmHosts: hosts } }];"
},
"id": "e04e3082-2173-4615-b9f2-cd6aa0ddefcd",
"name": "Cache Mappings in Memory",
"position": [
1088,
48
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Processes merged data from both sources (NPM hosts and Supabase mappings). Combines them into a single output for downstream processing."
},
{
"parameters": {
"jsCode": "// Process NPM hosts with Supabase mappings\n// This node receives a single input from Cache Mappings in Memory\n// which contains both databaseMappings and npmHosts\n\nconst inputData = items[0].json;\nconst hosts = inputData.npmHosts || [];\nconst databaseMappings = inputData.databaseMappings || {};\n\nconsole.log('=== Process ALL Hosts (Supabase Mappings) ===');\nconsole.log('NPM hosts received:', hosts.length);\nconsole.log('Database mappings loaded:', Object.keys(databaseMappings).length);\n\nif (!Array.isArray(hosts) || hosts.length === 0) {\n console.log('No hosts to process');\n return [{ json: { hosts: [], staticMappings: databaseMappings } }];\n}\n\n// Filter enabled hosts and extract service names\nconst enabledHosts = hosts.filter(host => host && host.enabled && host.domain_names && host.domain_names.length > 0);\nconst serviceNames = enabledHosts.map(host => host.domain_names[0].split('.')[0]);\n\nconsole.log('Enabled hosts:', enabledHosts.length);\nconsole.log('Found services:', serviceNames.length);\nconsole.log('Services:', serviceNames.slice(0, 10).join(', ') + (serviceNames.length > 10 ? '...' : ''));\n\nreturn [{ json: { hosts: enabledHosts, staticMappings: databaseMappings } }];"
},
"id": "71e9143a-8ea4-4f98-9149-be75edf02731",
"name": "Process ALL Hosts",
"position": [
880,
256
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Receives NPM hosts array directly. Uses mappings from Supabase database. Pure dynamic lookup - no hardcoded values."
},
{
"parameters": {
"jsCode": "const item_json = items.length > 0 && items[0].json ? items[0].json : {};\nconst hosts = item_json.hosts || [];\nconst staticMappings = item_json.staticMappings || {};\nconst unmappedDefault = $node['Cache Mappings in Memory'].json.unmappedDefault || 'streamlink';\n\nconsole.log('=== Create Services from Supabase Mappings ===');\nconsole.log('Unmapped default:', unmappedDefault);\n\nconst allNewServices = [];\nif (hosts && hosts.length > 0) {\n for (const host of hosts) {\n if (!host?.enabled || !host?.domain_names?.length) continue;\n const domain = host.domain_names[0];\n const serviceName = domain.split('.')[0];\n const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1);\n const protocol = host.ssl_forced ? 'https' : 'http';\n const url = `${protocol}://${domain}`;\n \n // Check Supabase mappings (ground truth)\n let iconReference = staticMappings[serviceName.toLowerCase()];\n \n // Fallback to unmapped default if not found in Supabase\n if (!iconReference) {\n iconReference = unmappedDefault;\n }\n \n const iconName = `sh-${iconReference}`;\n const serviceObject = {};\n serviceObject[capitalizedServiceName] = { icon: iconName, href: url, ping: url, statusStyle: 'dot' };\n allNewServices.push(serviceObject);\n \n console.log(`${serviceName} -> ${iconReference}`);\n }\n}\n\nconsole.log('Total services created:', allNewServices.length);\nreturn [{ json: { newServices: allNewServices } }];"
},
"id": "7c1703e7-6bec-48e5-a433-d6fce1dd4009",
"name": "Create Homepage Services",
"position": [
1120,
256
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Creates Homepage service objects using mappings from Supabase database. Uses 'streamlink' as unmapped default for any services not in database."
},
{
"parameters": {
"values": {
"string": [
{
"name": "homepageServicesPath",
"value": "/opt/homepage/config/services.yaml"
}
]
},
"options": {}
},
"id": "961f50ee-94d0-4ffd-ad8a-6ff68ff19710",
"name": "SSH Config",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [
96,
464
],
"notes": "Stores SSH path configuration for Homepage services.yaml"
},
{
"parameters": {
"jsCode": "// Reset pairedItem tracking by returning items without paired metadata\n// This allows the SSH command to access $() references without pairing conflicts\nreturn items.map(item => ({ json: item.json || item }));"
},
"id": "f5c635b4-f37e-40b9-a66f-e08f89dc07a4",
"name": "Reset Pairing for SSH",
"position": [
288,
464
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Resets pairedItem tracking to prevent errors when accessing variables from earlier in the workflow. Allows SSH node to reference config without pairing conflicts."
},
{
"parameters": {
"authentication": "privateKey",
"command": "=cat {{ $node[\"SSH Config\"].json.homepageServicesPath }}"
},
"id": "78840546-a019-4ab7-be64-34bfb641847c",
"name": "Read services.yaml via SSH",
"position": [
496,
464
],
"type": "n8n-nodes-base.ssh",
"typeVersion": 1,
"credentials": {
"sshPrivateKey": {
"id": "jBV4AQlsHxWnFXsp",
"name": "homepage.sshkey"
}
},
"notes": "Reads the current services.yaml file from your remote Homepage server via SSH."
},
{
"parameters": {
"jsCode": "// Enhanced YAML parser for Homepage format - PRESERVES INDENTATION\nconsole.log('=== DEBUG YAML to JSON ===');\n\nconst existingYamlString = $('Read services.yaml via SSH').item.json.stdout;\nconsole.log('Existing YAML length:', existingYamlString?.length || 0);\n\nfunction parseHomepageYaml(yamlString) {\n try {\n const lines = yamlString.split('\\n');\n const result = [];\n let currentGroup = null;\n let currentServices = [];\n let currentService = null;\n \n for (let i = 0; i < lines.length; i++) {\n const fullLine = lines[i];\n const line = fullLine.trim();\n \n if (!line || line.startsWith('#')) continue;\n \n const indent = fullLine.length - fullLine.trimStart().length;\n \n if (indent === 0 && line.match(/^-\\s*([^:]+):$/)) {\n if (currentGroup !== null && currentServices.length > 0) {\n result.push({ [currentGroup]: currentServices });\n }\n currentGroup = line.match(/^-\\s*([^:]+):$/)[1].trim();\n currentServices = [];\n currentService = null;\n console.log('Found group:', currentGroup);\n continue;\n }\n \n if (indent === 4 && line.match(/^-\\s*([^:]+):$/) && currentGroup !== null) {\n const serviceName = line.match(/^-\\s*([^:]+):$/)[1].trim();\n currentService = { [serviceName]: {} };\n currentServices.push(currentService);\n console.log('Found service:', serviceName);\n continue;\n }\n \n if (indent >= 8 && line.includes(':') && currentService !== null) {\n const match = line.match(/^([^:]+):\\s*(.*)$/);\n if (match) {\n const key = match[1].trim();\n let value = match[2].trim();\n \n if (value === 'null' || value === '~') {\n value = null;\n } else if (value === 'true' || value === 'false') {\n value = value === 'true';\n } else if (!isNaN(value) && value !== '') {\n value = Number(value);\n } else if ((value.startsWith('\"') && value.endsWith('\"')) || (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n \n const serviceName = Object.keys(currentService)[0];\n currentService[serviceName][key] = value;\n }\n }\n }\n \n if (currentGroup !== null && currentServices.length > 0) {\n result.push({ [currentGroup]: currentServices });\n }\n \n console.log('Final result groups:', result.length);\n result.forEach(g => {\n const groupName = Object.keys(g)[0];\n console.log(` ${groupName}: ${g[groupName].length} services`);\n });\n \n return result;\n } catch (error) {\n console.error('Error parsing YAML:', error);\n return [];\n }\n}\n\nlet doc;\ntry {\n doc = parseHomepageYaml(existingYamlString);\n console.log('Parsed YAML groups:', doc.length);\n} catch (e) {\n console.error('Error parsing existing YAML', e);\n doc = [];\n}\n\nreturn [{ json: { services: doc } }];"
},
"id": "d145ccd1-4804-4143-b849-3a0b46bb5c22",
"name": "YAML to JSON",
"position": [
672,
464
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Parses the existing YAML using built-in parser and converts it to a JSON object."
},
{
"parameters": {
"jsCode": "// DE-DUPLICATE Links section - keep only unique services\nconsole.log('=== UPDATE YAML file (De-duplication Fix v2) ===');\n\nconst newServices = $('Create Homepage Services').item.json.newServices;\nconst existingYamlString = $('Read services.yaml via SSH').item.json.stdout;\n\nconst lines = existingYamlString.split('\\n');\nlet output = [];\nlet linksStartIndex = -1;\n\n// Find where Links section starts\nfor (let i = 0; i < lines.length; i++) {\n if (lines[i].trim() === '- Links:') {\n linksStartIndex = i;\n console.log('Found Links section at line:', i);\n break;\n }\n}\n\nif (linksStartIndex === -1) {\n // No Links section - add everything plus new Links section\n output = lines.filter(l => l.trim());\n output.push('');\n output.push('- Links:');\n for (const service of newServices) {\n const serviceName = Object.keys(service)[0];\n const config = service[serviceName];\n output.push(` - ${serviceName}:`);\n for (const [key, value] of Object.entries(config)) {\n const formattedValue = typeof value === 'string' ? `\"${value}\"` : value;\n output.push(` ${key}: ${formattedValue}`);\n }\n }\n} else {\n // Links section exists - replace it entirely with new services\n // Step 1: Add everything BEFORE Links section\n for (let i = 0; i < linksStartIndex; i++) {\n output.push(lines[i]);\n }\n \n // Step 2: Add Links header\n output.push('- Links:');\n \n // Step 3: Add ONLY new NPM services (no duplicates)\n console.log('Adding', newServices.length, 'unique NPM services');\n for (const service of newServices) {\n const serviceName = Object.keys(service)[0];\n const config = service[serviceName];\n output.push(` - ${serviceName}:`);\n for (const [key, value] of Object.entries(config)) {\n const formattedValue = typeof value === 'string' ? `\"${value}\"` : value;\n output.push(` ${key}: ${formattedValue}`);\n }\n }\n \n console.log('Removed all old Links content, added fresh NPM services');\n}\n\n// Remove trailing empty lines and ensure single trailing newline\nwhile (output.length > 0 && !output[output.length - 1].trim()) {\n output.pop();\n}\noutput.push('');\n\nconst newYamlString = output.join('\\n');\nconsole.log('Final YAML length:', newYamlString.length, 'lines:', output.length);\nconsole.log('NPM services added:', newServices.length);\n\nreturn [{ json: { yaml: newYamlString } }];"
},
"id": "307414bb-d7c2-4513-a7ca-8c09e9fd1094",
"name": "Update YAML file",
"position": [
848,
464
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "REPLACES the entire Links section with fresh NPM hosts (de-duplication fix). Previously preserved old services causing endless growth. Now clears and rebuilds Links section each run."
},
{
"parameters": {
"jsCode": "// Encode YAML as Base64 to safely transfer via SSH\nconst yamlContent = $('Update YAML file').item.json.yaml;\nconsole.log('Original YAML length:', yamlContent.length);\n\n// Convert string to Base64\nconst base64Yaml = Buffer.from(yamlContent).toString('base64');\nconsole.log('Base64 encoded length:', base64Yaml.length);\nconsole.log('Base64 preview:', base64Yaml.substring(0, 50) + '...');\n\nreturn [{ json: { base64Yaml: base64Yaml, originalLength: yamlContent.length } }];"
},
"id": "eeba120e-fb90-4a9f-b395-1c06cfb46e11",
"name": "Prepare Base64 YAML",
"position": [
1040,
464
],
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"notes": "Encodes the YAML content as Base64 to safely pass it through SSH without shell interpretation issues."
},
{
"parameters": {
"authentication": "privateKey",
"command": "=cp {{ $node[\"SSH Config\"].json.homepageServicesPath }} {{ $node[\"SSH Config\"].json.homepageServicesPath }}.backup.$(date +%Y%m%d_%H%M%S)"
},
"id": "769f1bc8-5922-4d5f-993c-e1741e7b3dbf",
"name": "Backup Current Config via SSH",
"position": [
1232,
464
],
"type": "n8n-nodes-base.ssh",
"typeVersion": 1,
"credentials": {
"sshPrivateKey": {
"id": "jBV4AQlsHxWnFXsp",
"name": "homepage.sshkey"
}
},
"notes": "Creates a timestamped backup of the current services.yaml before writing the new one."
},
{
"parameters": {
"authentication": "privateKey",
"command": "=echo {{ $node[\"Prepare Base64 YAML\"].json.base64Yaml }} | base64 -d > {{$node[\"SSH Config\"].json.homepageServicesPath}}\necho 'Write successful' && wc -l {{$node[\"SSH Config\"].json.homepageServicesPath}}"
},
"id": "84611366-6e89-4d1c-a36a-9ceaf7cb9068",
"name": "Write services.yaml via SSH",
"position": [
1440,
464
],
"type": "n8n-nodes-base.ssh",
"typeVersion": 1,
"credentials": {
"sshPrivateKey": {
"id": "jBV4AQlsHxWnFXsp",
"name": "homepage.sshkey"
}
},
"notes": "Decodes Base64 YAML and writes to remote file, safely avoiding shell escaping issues."
},
{
"parameters": {
"rule": {
"interval": [
{}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
96,
32
],
"id": "afe8836d-d01d-435a-a4a1-e208251da69b",
"name": "Schedule Trigger"
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT * FROM npm_tokens WHERE service_name = 'npm_dfw';",
"additionalFields": {}
},
"id": "96cf0d45-237b-4d33-a50d-fb54fa975761",
"name": "Load NPM Token from Postgres",
"type": "n8n-nodes-base.postgres",
"typeVersion": 1,
"position": [
304,
32
],
"credentials": {
"postgres": {
"id": "Ik8CFyap8ic2Md3M",
"name": "n8n-infra"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT * FROM service_mappings;",
"additionalFields": {}
},
"id": "953a7ece-973f-41ef-a9ba-c9d3dffd8f01",
"name": "Load Icon Mappings from Postgres",
"type": "n8n-nodes-base.postgres",
"typeVersion": 1,
"position": [
496,
224
],
"credentials": {
"postgres": {
"id": "Ik8CFyap8ic2Md3M",
"name": "n8n-infra"
}
}
}
],
"connections": {
"Extract Token Value": {
"main": [
[
{
"node": "Get NPM Hosts",
"type": "main",
"index": 0
}
]
]
},
"Get NPM Hosts": {
"main": [
[
{
"node": "Merge Data Sources",
"type": "main",
"index": 0
},
{
"node": "Load Icon Mappings from Postgres",
"type": "main",
"index": 0
}
]
]
},
"Merge Data Sources": {
"main": [
[
{
"node": "Cache Mappings in Memory",
"type": "main",
"index": 0
}
]
]
},
"Cache Mappings in Memory": {
"main": [
[
{
"node": "Process ALL Hosts",
"type": "main",
"index": 0
}
]
]
},
"Process ALL Hosts": {
"main": [
[
{
"node": "Create Homepage Services",
"type": "main",
"index": 0
}
]
]
},
"Create Homepage Services": {
"main": [
[
{
"node": "Reset Pairing for SSH",
"type": "main",
"index": 0
},
{
"node": "SSH Config",
"type": "main",
"index": 0
}
]
]
},
"SSH Config": {
"main": [
[
{
"node": "Reset Pairing for SSH",
"type": "main",
"index": 0
}
]
]
},
"Reset Pairing for SSH": {
"main": [
[
{
"node": "Read services.yaml via SSH",
"type": "main",
"index": 0
}
]
]
},
"Read services.yaml via SSH": {
"main": [
[
{
"node": "YAML to JSON",
"type": "main",
"index": 0
}
]
]
},
"YAML to JSON": {
"main": [
[
{
"node": "Update YAML file",
"type": "main",
"index": 0
}
]
]
},
"Update YAML file": {
"main": [
[
{
"node": "Prepare Base64 YAML",
"type": "main",
"index": 0
}
]
]
},
"Prepare Base64 YAML": {
"main": [
[
{
"node": "Backup Current Config via SSH",
"type": "main",
"index": 0
}
]
]
},
"Backup Current Config via SSH": {
"main": [
[
{
"node": "Write services.yaml via SSH",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Load NPM Token from Postgres",
"type": "main",
"index": 0
}
]
]
},
"Load NPM Token from Postgres": {
"main": [
[
{
"node": "Extract Token Value",
"type": "main",
"index": 0
}
]
]
},
"Load Icon Mappings from Postgres": {
"main": [
[
{
"node": "Merge Data Sources",
"type": "main",
"index": 1
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
},
"triggerCount": 1,
"versionId": "0720cdd2-cd1e-4b49-8d1e-25d1dfaefa66",
"owner": {
"type": "personal",
"projectId": "FeLO36wNUAcn61Wj",
"projectName": "Ben W <admin@ben.io>",
"personalEmail": "admin@ben.io"
},
"parentFolderId": "eWW72giJDI4fxlWw",
"isArchived": false
}