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