Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Deploy to Gandi + IPFS

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'true'

- name: Setup Node.js
uses: ./.github/workflows/node.js

- name: Setup Quarto
uses: ./.github/workflows/quarto

- name: Build Site (Quarto + Next.js)
run: npm run build

- name: List build outputs
run: ls -lA ./out

- name: Deploy to Gandi via Git
env:
GANDI_GIT_URL: ${{ secrets.GANDI_GIT_URL }}
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git remote add gandi $GANDI_GIT_URL || true
git push gandi main --force

- name: Install ipfs-car CLI
run: npm install -g ipfs-car

- name: Package Build as CAR
run: ipfs-car pack out --output deploy.car

- name: Upload CAR to IPFS (via Gandi Gateway)
id: ipfs_upload
env:
DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }}
run: |
RESPONSE=$(curl -s -X POST https://www.real-currents.com/api/deploy \
-H "x-api-key: $DEPLOY_SECRET" \
-H "Content-Type: application/vnd.ipld.car" \
--data-binary @deploy.car)

echo "IPFS Response: $RESPONSE"
CID=$(echo $RESPONSE | jq -r '.cid')
echo "cid=$CID" >> $GITHUB_OUTPUT
echo "Deployed to IPFS: $CID"

- name: Update IPNS Pointer (xr-baseline-0)
env:
DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }}
CID: ${{ steps.ipfs_upload.outputs.cid }}
run: |
curl -X POST https://www.real-currents.com/api/ipns/publish \
-H "x-api-key: $DEPLOY_SECRET" \
-H "Content-Type: application/json" \
-d "{\"key\": \"xr-baseline-0\", \"cid\": \"$CID\"}"

echo "IPNS updated: xr-baseline-0 -> $CID"

- name: Log Deployment
run: |
echo "Live site updated at https://www.real-currents.com"
echo "IPFS snapshot: https://www.real-currents.com/ipfs/${{ steps.ipfs_upload.outputs.cid }}"
echo "IPNS stable link: https://www.real-currents.com/ipns/xr-baseline-0"
11 changes: 4 additions & 7 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@ const nextConfig = {
"eslint": {
"ignoreDuringBuilds": true,
},
// "ignorePatterns": [
// "jest.config.js",
// "lib",
// "src/components/SvelteMainComponent.tsx",
//
// ],
"images": {
"unoptimized": true,
},
"output": "export", // <=== enables static exports
// "output": "standalone",
"reactStrictMode": true,
"trailingSlash": true,
"typescript": {
// !! WARN !!
// Dangerously allow production builds to
Expand Down
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
"version": "1.0.0",
"author": "John Hall",
"license": "CC-BY-3.0",
"main": "index.js",
"main": "server.js",
"private": true,
"repository": "Real-Currents/www",
"dependencies": {
"dateformat": "^5.0.3",
"express-rate-limit": "^7.1.5",
"ipfs-http-client": "^60.0.1",
"mime-types": "^2.1.35",
"next": "^14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1"
Expand All @@ -29,7 +32,7 @@
"build": "npm run render:markdown && npm run build:next",
"build:next": "next build",
"clean": "rm -rf _freeze/* _site/*",
"dev": "npm run render:markdown && npm run dev:next",
"dev": "npm run render:markdown && node server.js",
"dev:next": "next dev",
"lint": "next lint",
"next": "next",
Expand All @@ -41,7 +44,8 @@
"render:js-demos": "bash -c \"cd content/js-demos && quarto render --output-dir . && quarto render index.qmd --to gfm --output README.md --output-dir . && quarto render index.qmd --to html --output index.html --output-dir . && cd ../../ && ls -l ./ && cp -LR site_libs public/ && rm -rf site_libs && rm search.json\"",
"render:posts": "cd content/posts && quarto render --output-dir .",
"render:readme": "quarto render content/README.qmd --to gfm --output README.md",
"start": "python -m http.server -d out 3000",
"start": "NODE_ENV=production node server.js",
"start:static": "python -m http.server -d out 3000",
"start:adb-port-forwarding": "adb reverse tcp:3000 tcp:3000"
},
"type": "module"
Expand Down
197 changes: 197 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { create } from 'ipfs-http-client';
import mime from 'mime-types';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

const ipfs = create({
host: process.env.IPFS_NODE_HOST,
port: 443,
protocol: 'https',
headers: {
authorization: `Basic ${Buffer.from(
`${process.env.IPFS_USER}:${process.env.IPFS_PASS}`
).toString('base64')}`
}
});

const ipnsCache = new Map();
const IPNS_CACHE_TTL = 5 * 60 * 1000;

async function handleIPFSRequest(req, res, pathname) {
try {
const parts = pathname.split('/').filter(Boolean);
const cid = parts[1];
const filePath = parts.slice(2).join('/') || 'index.html';

const chunks = [];
for await (const chunk of ipfs.cat(`${cid}/${filePath}`)) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);

res.setHeader('Content-Type', mime.lookup(filePath) || 'application/octet-stream');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('Access-Control-Allow-Origin', '*');
res.end(content);
} catch (err) {
console.error('IPFS fetch error:', err);
res.statusCode = 404;
res.end('IPFS content not found');
}
}

async function handleIPNSRequest(req, res, pathname) {
try {
const parts = pathname.split('/').filter(Boolean);
const ipnsName = parts[1];
const subPath = parts.slice(2).join('/') || 'index.html';

const cacheKey = ipnsName;
const cached = ipnsCache.get(cacheKey);
let cid;

if (cached && Date.now() - cached.timestamp < IPNS_CACHE_TTL) {
cid = cached.cid;
console.log(`IPNS cache hit: ${ipnsName} -> ${cid}`);
} else {
console.log(`Resolving IPNS: ${ipnsName}`);
const resolved = await ipfs.name.resolve(`/ipns/${ipnsName}`);

for await (const result of resolved) {
cid = result.replace('/ipfs/', '');
break;
}

ipnsCache.set(cacheKey, { cid, timestamp: Date.now() });
console.log(`IPNS resolved: ${ipnsName} -> ${cid}`);
}

const chunks = [];
for await (const chunk of ipfs.cat(`${cid}/${subPath}`)) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);

res.setHeader('Content-Type', mime.lookup(subPath) || 'application/octet-stream');
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('X-IPFS-Path', `/ipfs/${cid}/${subPath}`);
res.end(content);
} catch (err) {
console.error('IPNS resolution error:', err);
res.statusCode = 404;
res.end('IPNS name not found or resolution failed');
}
}

async function handleDeployRequest(req, res) {
if (req.headers['x-api-key'] !== process.env.DEPLOY_SECRET) {
res.statusCode = 401;
return res.end('Unauthorized');
}

try {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const carData = Buffer.concat(chunks);

const result = await ipfs.dag.import(carData);

let rootCid;
for await (const item of result) {
if (item.root) {
rootCid = item.root.cid.toString();
break;
}
}

console.log(`Deployed to IPFS: ${rootCid}`);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ cid: rootCid }));
} catch (err) {
console.error('Deploy error:', err);
res.statusCode = 500;
res.end(JSON.stringify({ error: err.message }));
}
}

async function handleIPNSPublish(req, res) {
if (req.headers['x-api-key'] !== process.env.DEPLOY_SECRET) {
res.statusCode = 401;
return res.end('Unauthorized');
}

try {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = JSON.parse(Buffer.concat(chunks).toString());
const { key, cid } = body;

if (!key || !cid) {
res.statusCode = 400;
return res.end('Missing key or cid');
}

console.log(`Publishing IPNS: ${key} -> ${cid}`);
const result = await ipfs.name.publish(`/ipfs/${cid}`, { key });

ipnsCache.delete(key);

let ipnsName;
for await (const item of result) {
ipnsName = item.name;
break;
}

console.log(`IPNS published: ${ipnsName}`);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ipns: ipnsName, cid }));
} catch (err) {
console.error('IPNS publish error:', err);
res.statusCode = 500;
res.end(JSON.stringify({ error: err.message }));
}
}

app.prepare().then(() => {
createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true);
const { pathname } = parsedUrl;

if (pathname.startsWith('/ipfs/')) {
return await handleIPFSRequest(req, res, pathname);
}

if (pathname.startsWith('/ipns/')) {
return await handleIPNSRequest(req, res, pathname);
}

if (pathname === '/api/deploy' && req.method === 'POST') {
return await handleDeployRequest(req, res);
}

if (pathname === '/api/ipns/publish' && req.method === 'POST') {
return await handleIPNSPublish(req, res);
}

return handle(req, res, parsedUrl);
} catch (err) {
console.error('Server error:', err);
res.statusCode = 500;
res.end('Internal Server Error');
}
}).listen(process.env.PORT || 3000, (err) => {
if (err) throw err;
console.log(`> Ready on http://localhost:${process.env.PORT || 3000}`);
});
});