feat: Initial code drop
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Server settings
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# IPFS settings
|
||||||
|
IPFS_GATEWAY=https://ipfs.io
|
||||||
|
IPFS_API_URL=http://localhost:5001
|
||||||
|
IPFS_API_PORT=5001
|
||||||
|
|
||||||
|
# Handshake resolution settings
|
||||||
|
# Options: 'doh', 'dot', 'local'
|
||||||
|
RESOLUTION_METHOD=doh
|
||||||
|
|
||||||
|
# HNSDoH.com settings
|
||||||
|
HNS_DOH_URL=https://hnsdoh.com/dns-query
|
||||||
|
HNS_DOT_HOST=hnsdoh.com
|
||||||
|
HNS_DOT_PORT=853
|
||||||
|
|
||||||
|
# Local resolver settings
|
||||||
|
LOCAL_RESOLVER_HOST=127.0.0.1
|
||||||
|
LOCAL_RESOLVER_PORT=53
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
CACHE_ENABLED=true
|
||||||
|
CACHE_TTL_SECONDS=3600
|
||||||
|
|
||||||
|
# Log settings
|
||||||
|
LOG_LEVEL=info
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
|
||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Fire Portal
|
||||||
|
Fire Portal is an experimental IPFS gateway for Handshake domains
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This gateway allows you to access IPFS content through Handshake domain names. It resolves Handshake domains and maps them to their corresponding IPFS content identifiers (CIDs).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Resolves Handshake domains to IPFS content
|
||||||
|
- Uses HNSDoH.com for secure DNS resolution (DoH and DoT supported)
|
||||||
|
- Supports TXT records with `ipfs=` and `ip6=` prefixes for IPFS CIDs
|
||||||
|
- Caches IPFS content for faster access
|
||||||
|
- Simple web interface for manual lookups
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/fireportal.git
|
||||||
|
cd fireportal
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configure environment variables
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Edit the `.env` file to configure:
|
||||||
|
- IPFS gateway settings
|
||||||
|
- Handshake resolution method (DoH, DoT, or local)
|
||||||
|
- HNSDoH.com settings
|
||||||
|
- Cache settings
|
||||||
|
- Server port
|
||||||
|
|
||||||
|
### Resolution Methods
|
||||||
|
Fire Portal supports multiple methods for resolving Handshake domains:
|
||||||
|
|
||||||
|
- `doh`: Uses DNS-over-HTTPS via HNSDoH.com (default, recommended)
|
||||||
|
- `dot`: Uses DNS-over-TLS via HNSDoH.com
|
||||||
|
- `local`: Uses a local Handshake resolver
|
||||||
|
|
||||||
|
### Supported TXT Record Formats
|
||||||
|
|
||||||
|
Fire Portal supports several TXT record formats for Handshake domains:
|
||||||
|
|
||||||
|
| Format | Example | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `ipfs=Hash...` | `ipfs=QmdbRRQ2CYSFRUEQcUC7TtbsmsWU9411KaHiVJXZFscBNn` | Standard format with equals sign |
|
||||||
|
| `ipfs:Hash...` | `ipfs:QmdbRRQ2CYSFRUEQcUC7TtbsmsWU9411KaHiVJXZFscBNn` | Alternative format with colon |
|
||||||
|
| `ip6=Hash...` | `ip6=QmdbRRQ2CYSFRUEQcUC7TtbsmsWU9411KaHiVJXZFscBNn` | Legacy format (equivalent to ipfs=) |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```bash
|
||||||
|
# Start the server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then access Handshake+IPFS content via:
|
||||||
|
- `http://localhost:3000/hns/example/` (replace "example" with a Handshake domain)
|
||||||
|
- Direct web interface at `http://localhost:3000`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
### 1. Basic Server Testing
|
||||||
|
```bash
|
||||||
|
# Start the server
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Verify the server is running
|
||||||
|
curl http://localhost:3000/api/status
|
||||||
|
# Should return: {"status":"online","version":"0.1.0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Testing with Sample Handshake Domains
|
||||||
|
You can test with known Handshake domains that have IPFS content:
|
||||||
|
|
||||||
|
- `http://localhost:3000/hns/welcome/` - The Handshake welcome page
|
||||||
|
- `http://localhost:3000/hns/blog.namebase/` - Namebase blog
|
||||||
29
config.js
Normal file
29
config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PORT: process.env.PORT || 3000,
|
||||||
|
|
||||||
|
// IPFS settings
|
||||||
|
IPFS_GATEWAY: process.env.IPFS_GATEWAY || 'https://ipfs.io',
|
||||||
|
IPFS_API_URL: process.env.IPFS_API_URL,
|
||||||
|
IPFS_API_PORT: process.env.IPFS_API_PORT || 5001,
|
||||||
|
|
||||||
|
// Handshake settings
|
||||||
|
RESOLUTION_METHOD: process.env.RESOLUTION_METHOD || 'doh', // Options: 'doh', 'dot', 'local'
|
||||||
|
|
||||||
|
// HNSDoH.com settings
|
||||||
|
HNS_DOH_URL: process.env.HNS_DOH_URL || 'https://hnsdoh.com/dns-query',
|
||||||
|
HNS_DOT_HOST: process.env.HNS_DOT_HOST || 'hnsdoh.com',
|
||||||
|
HNS_DOT_PORT: parseInt(process.env.HNS_DOT_PORT || '853', 10),
|
||||||
|
|
||||||
|
// Local resolver settings
|
||||||
|
LOCAL_RESOLVER_HOST: process.env.LOCAL_RESOLVER_HOST || '127.0.0.1',
|
||||||
|
LOCAL_RESOLVER_PORT: process.env.LOCAL_RESOLVER_PORT || 53,
|
||||||
|
|
||||||
|
// Cache settings
|
||||||
|
CACHE_ENABLED: process.env.CACHE_ENABLED !== 'false',
|
||||||
|
CACHE_TTL_SECONDS: parseInt(process.env.CACHE_TTL_SECONDS || '3600', 10),
|
||||||
|
|
||||||
|
// Log settings
|
||||||
|
LOG_LEVEL: process.env.LOG_LEVEL || 'info'
|
||||||
|
};
|
||||||
333
lib/handshake.js
Normal file
333
lib/handshake.js
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
const dns = require('dns').promises;
|
||||||
|
const NodeCache = require('node-cache');
|
||||||
|
const config = require('../config');
|
||||||
|
const {
|
||||||
|
RESOLUTION_METHOD,
|
||||||
|
HNS_DOH_URL,
|
||||||
|
HNS_DOT_HOST,
|
||||||
|
HNS_DOT_PORT,
|
||||||
|
LOCAL_RESOLVER_HOST,
|
||||||
|
LOCAL_RESOLVER_PORT,
|
||||||
|
CACHE_ENABLED,
|
||||||
|
CACHE_TTL_SECONDS
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// Setup cache
|
||||||
|
const cache = new NodeCache({
|
||||||
|
stdTTL: CACHE_TTL_SECONDS,
|
||||||
|
checkperiod: CACHE_TTL_SECONDS * 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Handshake domain to an IPFS CID
|
||||||
|
* @param {string} domain - The Handshake domain to resolve
|
||||||
|
* @returns {Promise<string|null>} - IPFS CID or null if not found
|
||||||
|
*/
|
||||||
|
async function resolveHandshake(domain) {
|
||||||
|
// Check cache first
|
||||||
|
if (CACHE_ENABLED) {
|
||||||
|
const cachedCid = cache.get(`hns:${domain}`);
|
||||||
|
if (cachedCid) {
|
||||||
|
console.log(`Cache hit for ${domain}`);
|
||||||
|
return cachedCid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let cid = null;
|
||||||
|
|
||||||
|
// Choose resolution method based on configuration
|
||||||
|
switch (RESOLUTION_METHOD) {
|
||||||
|
case 'doh':
|
||||||
|
console.log(`Resolving ${domain} using DNS-over-HTTPS via HNSDoH.com`);
|
||||||
|
cid = await resolveViaDoH(domain);
|
||||||
|
break;
|
||||||
|
case 'dot':
|
||||||
|
console.log(`Resolving ${domain} using DNS-over-TLS via HNSDoH.com`);
|
||||||
|
cid = await resolveViaDot(domain);
|
||||||
|
break;
|
||||||
|
case 'local':
|
||||||
|
console.log(`Resolving ${domain} using local resolver`);
|
||||||
|
cid = await resolveLocal(domain);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Default to DoH if method is not recognized
|
||||||
|
console.log(`Unknown resolution method, defaulting to DoH for ${domain}`);
|
||||||
|
cid = await resolveViaDoH(domain);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result if we got a valid CID
|
||||||
|
if (cid && CACHE_ENABLED) {
|
||||||
|
cache.set(`hns:${domain}`, cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cid;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error resolving ${domain}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve domain using DNS-over-HTTPS via HNSDoH.com
|
||||||
|
* @param {string} domain - The domain to resolve
|
||||||
|
* @returns {Promise<string|null>} - IPFS CID or null
|
||||||
|
*/
|
||||||
|
async function resolveViaDoH(domain) {
|
||||||
|
try {
|
||||||
|
console.log(`Using wire format DoH for ${domain}`);
|
||||||
|
|
||||||
|
// Create the DNS wire format query
|
||||||
|
const queryId = Math.floor(Math.random() * 65535);
|
||||||
|
const wireQuery = createDnsWireQuery(domain, queryId, 16); // 16 is TXT record type
|
||||||
|
|
||||||
|
// Send the DNS-over-HTTPS query using wire format
|
||||||
|
const response = await fetch(HNS_DOH_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/dns-message',
|
||||||
|
'Accept': 'application/dns-message'
|
||||||
|
},
|
||||||
|
body: wireQuery
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DoH query failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the wire format response
|
||||||
|
const responseBuffer = await response.arrayBuffer();
|
||||||
|
const txtRecords = parseDnsWireResponse(new Uint8Array(responseBuffer));
|
||||||
|
|
||||||
|
if (txtRecords && txtRecords.length > 0) {
|
||||||
|
// Extract IPFS CID from TXT records
|
||||||
|
return extractCidFromRecords(txtRecords.map(txt => [txt]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DoH resolution error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a DNS wire format query
|
||||||
|
* @param {string} domain - Domain name to query
|
||||||
|
* @param {number} id - Query ID
|
||||||
|
* @param {number} type - Record type (e.g. 16 for TXT)
|
||||||
|
* @returns {Uint8Array} - Wire format DNS query
|
||||||
|
*/
|
||||||
|
function createDnsWireQuery(domain, id, type) {
|
||||||
|
// DNS header: 12 bytes
|
||||||
|
// ID (2 bytes) + Flags (2 bytes) + QDCOUNT (2 bytes) + ANCOUNT (2 bytes) + NSCOUNT (2 bytes) + ARCOUNT (2 bytes)
|
||||||
|
const header = new Uint8Array(12);
|
||||||
|
|
||||||
|
// Set ID (2 bytes)
|
||||||
|
header[0] = (id >> 8) & 0xff;
|
||||||
|
header[1] = id & 0xff;
|
||||||
|
|
||||||
|
// Set flags (RD = 1)
|
||||||
|
header[2] = 0x01; // QR=0, OPCODE=0, AA=0, TC=0, RD=1
|
||||||
|
header[3] = 0x00; // RA=0, Z=0, RCODE=0
|
||||||
|
|
||||||
|
// Set QDCOUNT = 1 (we're making 1 query)
|
||||||
|
header[4] = 0x00;
|
||||||
|
header[5] = 0x01;
|
||||||
|
|
||||||
|
// ANCOUNT, NSCOUNT, ARCOUNT all 0
|
||||||
|
|
||||||
|
// Prepare the domain name in DNS format (length-prefixed labels)
|
||||||
|
const labels = domain.split('.');
|
||||||
|
let domainBuffer = [];
|
||||||
|
|
||||||
|
for (const label of labels) {
|
||||||
|
if (label.length > 0) {
|
||||||
|
domainBuffer.push(label.length);
|
||||||
|
for (let i = 0; i < label.length; i++) {
|
||||||
|
domainBuffer.push(label.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add terminating zero
|
||||||
|
domainBuffer.push(0);
|
||||||
|
|
||||||
|
// Add QTYPE (16 = TXT) and QCLASS (1 = IN)
|
||||||
|
domainBuffer = domainBuffer.concat([0x00, type, 0x00, 0x01]);
|
||||||
|
|
||||||
|
// Combine header and query
|
||||||
|
const query = new Uint8Array(header.length + domainBuffer.length);
|
||||||
|
query.set(header);
|
||||||
|
query.set(domainBuffer, header.length);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a DNS wire format response for TXT records
|
||||||
|
* @param {Uint8Array} response - Wire format DNS response
|
||||||
|
* @returns {string[]} - Array of TXT record values
|
||||||
|
*/
|
||||||
|
function parseDnsWireResponse(response) {
|
||||||
|
try {
|
||||||
|
// Extract basic header information
|
||||||
|
const id = (response[0] << 8) | response[1];
|
||||||
|
const flags = (response[2] << 8) | response[3];
|
||||||
|
const qdCount = (response[4] << 8) | response[5];
|
||||||
|
const anCount = (response[6] << 8) | response[7];
|
||||||
|
const nsCount = (response[8] << 8) | response[9];
|
||||||
|
const arCount = (response[10] << 8) | response[11];
|
||||||
|
|
||||||
|
// Check if response code indicates an error
|
||||||
|
const rcode = flags & 0x0f;
|
||||||
|
if (rcode !== 0) {
|
||||||
|
console.error(`DNS response code error: ${rcode}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip over the question section
|
||||||
|
let offset = 12;
|
||||||
|
for (let i = 0; i < qdCount; i++) {
|
||||||
|
// Skip domain name until we reach a terminator or a pointer
|
||||||
|
while (offset < response.length) {
|
||||||
|
const len = response[offset++];
|
||||||
|
if (len === 0) break;
|
||||||
|
if ((len & 0xc0) === 0xc0) {
|
||||||
|
// This is a pointer (2 bytes)
|
||||||
|
offset++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip QTYPE and QCLASS (4 bytes)
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process answer section for TXT records
|
||||||
|
const txtRecords = [];
|
||||||
|
for (let i = 0; i < anCount; i++) {
|
||||||
|
// Skip the name field until we reach a terminator or a pointer
|
||||||
|
while (offset < response.length) {
|
||||||
|
const len = response[offset++];
|
||||||
|
if (len === 0) break;
|
||||||
|
if ((len & 0xc0) === 0xc0) {
|
||||||
|
// This is a pointer (2 bytes)
|
||||||
|
offset++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read TYPE, CLASS, TTL, RDLENGTH (10 bytes total)
|
||||||
|
const type = (response[offset] << 8) | response[offset + 1];
|
||||||
|
offset += 8; // Skip TYPE, CLASS, TTL
|
||||||
|
const rdLength = (response[offset] << 8) | response[offset + 1];
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
// If this is a TXT record (type 16), extract it
|
||||||
|
if (type === 16) {
|
||||||
|
let txt = '';
|
||||||
|
const endOffset = offset + rdLength;
|
||||||
|
|
||||||
|
// TXT record format: each string prefixed by a length byte
|
||||||
|
while (offset < endOffset) {
|
||||||
|
const strLen = response[offset++];
|
||||||
|
for (let j = 0; j < strLen; j++) {
|
||||||
|
txt += String.fromCharCode(response[offset + j]);
|
||||||
|
}
|
||||||
|
offset += strLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
txtRecords.push(txt);
|
||||||
|
} else {
|
||||||
|
// Skip this record
|
||||||
|
offset += rdLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return txtRecords;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing DNS wire response:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve domain using DNS-over-TLS via HNSDoH.com
|
||||||
|
* Note: This requires a DoT client implementation.
|
||||||
|
* Since Node.js doesn't have a built-in DoT client, this is a placeholder.
|
||||||
|
* In a production environment, use a proper DoT client library.
|
||||||
|
* @param {string} domain - The domain to resolve
|
||||||
|
* @returns {Promise<string|null>} - IPFS CID or null
|
||||||
|
*/
|
||||||
|
async function resolveViaDot(domain) {
|
||||||
|
console.warn('DNS-over-TLS resolution is not fully implemented. Using DoH as fallback.');
|
||||||
|
// In a real implementation, you would:
|
||||||
|
// 1. Establish a TLS connection to HNS_DOT_HOST:HNS_DOT_PORT
|
||||||
|
// 2. Send a DNS query for TXT records
|
||||||
|
// 3. Parse the response and extract the IPFS CID
|
||||||
|
|
||||||
|
// For now, fallback to DoH
|
||||||
|
return resolveViaDoH(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve domain using local DNS resolver
|
||||||
|
* @param {string} domain - The domain to resolve
|
||||||
|
* @returns {Promise<string|null>} - IPFS CID or null
|
||||||
|
*/
|
||||||
|
async function resolveLocal(domain) {
|
||||||
|
try {
|
||||||
|
// Configure DNS resolver to use local nameserver
|
||||||
|
const resolver = new dns.Resolver();
|
||||||
|
resolver.setServers([`${LOCAL_RESOLVER_HOST}:${LOCAL_RESOLVER_PORT}`]);
|
||||||
|
|
||||||
|
// Try to get TXT records
|
||||||
|
const records = await resolver.resolveTxt(`${domain}.`);
|
||||||
|
|
||||||
|
// Look for IPFS CID in TXT records
|
||||||
|
return extractCidFromRecords(records);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Local resolver error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract IPFS CID from DNS TXT records
|
||||||
|
* @param {string[][]} records - Array of TXT record arrays
|
||||||
|
* @returns {string|null} - IPFS CID or null
|
||||||
|
*/
|
||||||
|
function extractCidFromRecords(records) {
|
||||||
|
if (!records || !records.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten and look for ipfs= or ip6= prefixes
|
||||||
|
for (const recordSet of records) {
|
||||||
|
for (const record of recordSet) {
|
||||||
|
// Support multiple formats
|
||||||
|
if (record.startsWith('ipfs=')) {
|
||||||
|
return record.substring(5);
|
||||||
|
}
|
||||||
|
if (record.startsWith('ipfs:')) {
|
||||||
|
return record.substring(5);
|
||||||
|
}
|
||||||
|
if (record.startsWith('ip6=')) {
|
||||||
|
return record.substring(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the record for debugging
|
||||||
|
console.log(`Found TXT record: ${record}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveHandshake
|
||||||
|
};
|
||||||
113
lib/ipfs.js
Normal file
113
lib/ipfs.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const NodeCache = require('node-cache');
|
||||||
|
const config = require('../config');
|
||||||
|
const { IPFS_GATEWAY, CACHE_ENABLED, CACHE_TTL_SECONDS } = config;
|
||||||
|
|
||||||
|
// Setup cache
|
||||||
|
const cache = new NodeCache({
|
||||||
|
stdTTL: CACHE_TTL_SECONDS,
|
||||||
|
checkperiod: CACHE_TTL_SECONDS * 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// MIME type mapping helper
|
||||||
|
const mimeTypes = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.txt': 'text/plain'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch content from IPFS by CID and path
|
||||||
|
* @param {string} cid - IPFS Content Identifier
|
||||||
|
* @param {string} path - Optional path within the CID
|
||||||
|
* @returns {Promise<{data: Buffer, mimeType: string}|null>} - Content and MIME type or null
|
||||||
|
*/
|
||||||
|
async function fetchFromIpfs(cid, path = '') {
|
||||||
|
const contentPath = path ? `${cid}/${path}` : cid;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (CACHE_ENABLED) {
|
||||||
|
const cachedContent = cache.get(`ipfs:${contentPath}`);
|
||||||
|
if (cachedContent) {
|
||||||
|
console.log(`Cache hit for IPFS content: ${contentPath}`);
|
||||||
|
return cachedContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the HTTP gateway directly instead of the IPFS client
|
||||||
|
const result = await fetchViaGateway(cid, path);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type if not set
|
||||||
|
if (!result.mimeType) {
|
||||||
|
result.mimeType = getMimeType(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
if (CACHE_ENABLED) {
|
||||||
|
cache.set(`ipfs:${contentPath}`, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${contentPath} from IPFS:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch content via IPFS HTTP gateway
|
||||||
|
* @param {string} cid - IPFS Content Identifier
|
||||||
|
* @param {string} path - Path within the CID
|
||||||
|
* @returns {Promise<{data: Buffer, mimeType: string}|null>} - Content and MIME type or null
|
||||||
|
*/
|
||||||
|
async function fetchViaGateway(cid, path) {
|
||||||
|
try {
|
||||||
|
const url = new URL(`${IPFS_GATEWAY}/ipfs/${cid}${path ? '/' + path : ''}`);
|
||||||
|
console.log(`Fetching from IPFS gateway: ${url}`);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Gateway returned ${response.status} for ${url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await response.arrayBuffer());
|
||||||
|
const mimeType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
return { data, mimeType };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gateway fetch error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine MIME type from file path
|
||||||
|
* @param {string} path - File path
|
||||||
|
* @returns {string} - MIME type or default
|
||||||
|
*/
|
||||||
|
function getMimeType(path) {
|
||||||
|
if (!path) return 'application/octet-stream';
|
||||||
|
|
||||||
|
const extension = path.split('.').pop();
|
||||||
|
if (!extension) return 'application/octet-stream';
|
||||||
|
|
||||||
|
return mimeTypes['.' + extension] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchFromIpfs
|
||||||
|
};
|
||||||
6482
package-lock.json
generated
Normal file
6482
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "fireportal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "An IPFS gateway for Handshake domains",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"test": "jest",
|
||||||
|
"mock": "node tests/mock-resolver.js & node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ipfs",
|
||||||
|
"handshake",
|
||||||
|
"gateway",
|
||||||
|
"hns",
|
||||||
|
"decentralized"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ipfs-http-client": "^56.0.3",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"node-cache": "^5.1.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"winston": "^3.8.2",
|
||||||
|
"body-parser": "^1.20.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^2.0.22",
|
||||||
|
"jest": "^29.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
public/app.js
Normal file
67
public/app.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const domainInput = document.getElementById('domainInput');
|
||||||
|
const searchBtn = document.getElementById('searchBtn');
|
||||||
|
const exampleLinks = document.querySelectorAll('.example-link');
|
||||||
|
const statusIndicator = document.getElementById('status-indicator');
|
||||||
|
|
||||||
|
// Check server status
|
||||||
|
checkServerStatus();
|
||||||
|
|
||||||
|
// Search button click handler
|
||||||
|
searchBtn.addEventListener('click', () => {
|
||||||
|
navigateToHnsDomain();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter key press handler
|
||||||
|
domainInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
navigateToHnsDomain();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example link click handlers
|
||||||
|
exampleLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const domain = e.target.getAttribute('data-domain');
|
||||||
|
domainInput.value = domain;
|
||||||
|
navigateToHnsDomain();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to navigate to HNS domain
|
||||||
|
function navigateToHnsDomain() {
|
||||||
|
const domain = domainInput.value.trim();
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
alert('Please enter a Handshake domain');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up domain input (remove trailing slashes)
|
||||||
|
const cleanDomain = domain.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// Navigate to the HNS domain
|
||||||
|
window.location.href = `/hns/${cleanDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server status
|
||||||
|
async function checkServerStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'online') {
|
||||||
|
statusIndicator.textContent = 'Online';
|
||||||
|
statusIndicator.classList.add('online');
|
||||||
|
} else {
|
||||||
|
statusIndicator.textContent = 'Degraded';
|
||||||
|
statusIndicator.classList.add('offline');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusIndicator.textContent = 'Offline';
|
||||||
|
statusIndicator.classList.add('offline');
|
||||||
|
console.error('Error checking server status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
17
public/demo.html
Normal file
17
public/demo.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>IPFS.act</title>
|
||||||
|
</head>
|
||||||
|
<body style="background-color: black; color: blueviolet;text-align: center;">
|
||||||
|
<h1>IPFS.act</h1>
|
||||||
|
<p>This is a demo page running on IPFS.</p>
|
||||||
|
<p>It is designed to showcase the capabilities of IPFS and Handshake domains.</p>
|
||||||
|
|
||||||
|
<div style="position: absolute; bottom: 25px; left: 0; width: 100%; text-align: center;">
|
||||||
|
<a href="https://nathan.woodburn.au" style="color: white;">© Nathan.Woodburn/</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
public/index.html
Normal file
74
public/index.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Fire Portal</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="icon" href="https://woodburn.au/favicon.png" type="image/png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Fire Portal</h1>
|
||||||
|
<p>IPFS Gateway for Handshake Domains</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="search-section">
|
||||||
|
<h2>Access Handshake + IPFS Content</h2>
|
||||||
|
<div class="search-form">
|
||||||
|
<input type="text" id="domainInput" placeholder="Enter a Handshake domain (e.g., example/)">
|
||||||
|
<button id="searchBtn">Go</button>
|
||||||
|
</div>
|
||||||
|
<div class="example-note">
|
||||||
|
<p>Examples:</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#" class="example-link" data-domain="ipfs.act/">ipfs.act/</a> - Example Page created by Nathan.Woodburn/</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="info-section">
|
||||||
|
<h2>About Fire Portal</h2>
|
||||||
|
<p>
|
||||||
|
Fire Portal connects Handshake domains to IPFS content, enabling truly decentralized websites.
|
||||||
|
It resolves Handshake domains, finds their IPFS content identifiers, and serves the content.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>How it works</h3>
|
||||||
|
<ol>
|
||||||
|
<li>A Handshake domain is resolved to get its DNS records</li>
|
||||||
|
<li>IPFS content identifiers are extracted from TXT records</li>
|
||||||
|
<li>Content is fetched from the IPFS network</li>
|
||||||
|
<li>The content is served to your browser</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>URL format:</strong> <code>https://ipfs.woodburn.au/hns/[domain]/[path]</code><br>
|
||||||
|
Replace <code>[domain]</code> with any Handshake domain and <code>[path]</code> with an optional path.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="info-section">
|
||||||
|
<h2>How to setup IPFS on your domain</h2>
|
||||||
|
<p>
|
||||||
|
To set up your Handshake domain to work with IPFS, follow these steps:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>Get the IPFS hash of your content using any IPFS content hosting provider</li>
|
||||||
|
<li>Update your Handshake domain's DNS records with a TXT record</li>
|
||||||
|
<li>Use the format: <code>ipfs=Qm...your-ipfs-hash</code></li>
|
||||||
|
<li>Wait a few minutes and you should be able to see your IPFS content on the domain</li>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2025 <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a> | <a href="https://git.woodburn.au/nathanwoodburn/fireportal">Git Repo</a></p>
|
||||||
|
<p><small>Status: <span id="status-indicator">Checking...</span></small></p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
163
public/styles.css
Normal file
163
public/styles.css
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #ff5722;
|
||||||
|
--secondary-color: #ffab91;
|
||||||
|
--dark-color: #212121;
|
||||||
|
--light-color: #f5f5f5;
|
||||||
|
--accent-color: #ff9800;
|
||||||
|
--error-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--dark-color);
|
||||||
|
background-color: var(--light-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: var(--dark-color);
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-note {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-note ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol, ul {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-indicator {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
section {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
server.js
Normal file
120
server.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const { resolveHandshake } = require('./lib/handshake');
|
||||||
|
const { fetchFromIpfs } = require('./lib/ipfs');
|
||||||
|
const { PORT } = require('./config');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.get('/hns/:domain/*', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const domain = req.params.domain;
|
||||||
|
const subPath = req.params[0] || '';
|
||||||
|
|
||||||
|
console.log(`Processing request for domain: ${domain}, path: ${subPath}`);
|
||||||
|
|
||||||
|
// Resolve Handshake domain to get IPFS CID
|
||||||
|
const cid = await resolveHandshake(domain);
|
||||||
|
|
||||||
|
if (!cid) {
|
||||||
|
console.warn(`No IPFS CID found for domain: ${domain}`);
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Domain not found or has no IPFS record',
|
||||||
|
domain: domain
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Resolved ${domain} to IPFS CID: ${cid}`);
|
||||||
|
|
||||||
|
// Fetch content from IPFS
|
||||||
|
const content = await fetchFromIpfs(cid, subPath);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Content not found on IPFS network',
|
||||||
|
cid: cid,
|
||||||
|
path: subPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate content type
|
||||||
|
if (content.mimeType) {
|
||||||
|
res.setHeader('Content-Type', content.mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the content
|
||||||
|
res.send(content.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Server error processing request',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also add a route without the trailing wildcard to handle root domain requests
|
||||||
|
app.get('/hns/:domain', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const domain = req.params.domain;
|
||||||
|
|
||||||
|
console.log(`Processing request for domain root: ${domain}`);
|
||||||
|
|
||||||
|
// Resolve Handshake domain to get IPFS CID
|
||||||
|
const cid = await resolveHandshake(domain);
|
||||||
|
|
||||||
|
if (!cid) {
|
||||||
|
console.warn(`No IPFS CID found for domain: ${domain}`);
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Domain not found or has no IPFS record',
|
||||||
|
domain: domain
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Resolved ${domain} to IPFS CID: ${cid}`);
|
||||||
|
|
||||||
|
// Fetch content from IPFS (root path)
|
||||||
|
const content = await fetchFromIpfs(cid, '');
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Content not found on IPFS network',
|
||||||
|
cid: cid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate content type
|
||||||
|
if (content.mimeType) {
|
||||||
|
res.setHeader('Content-Type', content.mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the content
|
||||||
|
res.send(content.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Server error processing request',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status endpoint
|
||||||
|
app.get('/api/status', (req, res) => {
|
||||||
|
res.json({ status: 'online', version: '0.1.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Fire Portal server running on port ${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user