diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b4edfa4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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" diff --git a/next.config.mjs b/next.config.mjs index 6a5d37d..7cb0c4f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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 diff --git a/package.json b/package.json index 6acf81c..bc45ecf 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", @@ -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" diff --git a/server.js b/server.js new file mode 100644 index 0000000..5171e0e --- /dev/null +++ b/server.js @@ -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}`); + }); +});