Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add local ipfs pinning service api adaptor #69

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
uploadAdd,
uploadList
} from './can.js'
import {
startPinService
} from './pin.js'

const cli = sade('w3')

Expand Down Expand Up @@ -147,6 +150,13 @@ cli.command('can upload ls')
.option('--pre', 'If true, return the page of results preceding the cursor')
.action(uploadList)

cli.command('ps')
.describe('Start IPFS pinning service server')
.option('--port', 'Override the default port to listen on (:1337)', 1337)
.option('--host', 'Override the default host to listen on (127.0.0.1)', '127.0.0.1')
.option('--key', 'Override the default bearer token for api <w3cli agent did>')
.action(startPinService)

// show help text if no command provided
cli.command('help [cmd]', 'Show help text', { default: true })
.action(cmd => {
Expand Down
121 changes: 121 additions & 0 deletions pin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { CID } from 'multiformats'
import http from 'node:http'
import { getPkg, getClient } from './lib.js'

/**
* a pinning service api on your localhost
*
* ## Example
* w3 ps --port 1337
*/
export async function startPinService ({ port, host = '127.0.0.1', key }) {
const pkg = getPkg()
const pinCache = new Map()
const client = await getClient()
const whoami = client.agent().did()
const token = key ?? whoami
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The http api bound to loopback ip, so token auth is less critical, but kubo requires the user to provide a key when setting up a pin service remote.

@alanshaw suggested setting this as the space DID that we want the local pin service to write to, which would be rad, but we also need changes to the w3up-client to allow users to pass in a space did (with) when doing an upload. The upload client supports it but it's not exposed in w3up-client

const api = http.createServer(async (req, res) => {
if (req.headers.authorization !== `Bearer ${token}`) {
return send({ res, status: 401, body: { error: { reason: 'Unauthorized; access token is missing or invalid' } } })
}
const { pathname } = new URL(req.url, `http://${req.headers.host}`)
if (pathname === '/' || pathname === '') {
return send({ res, body: { service: 'w3', version: pkg.version } })
}
if (req.method === 'POST' && pathname === '/pins') {
const body = await getJsonBody(req)
const pinStatus = await addPin({ ...body, client })
pinCache.set(pinStatus.requestid, pinStatus)
return send({ res, body: pinStatus })
}
if (req.method === 'GET' && pathname.startsWith('/pins/')) {
const requestid = pathname.split('/').at(2)
const pinStatus = pinCache.get(requestid)
if (pinStatus) {
return send({ res, body: pinStatus })
}
return send({ res, status: 404, body: { error: { reason: 'Not Found', details: requestid } } })
}
return send({ res, status: 501, body: { error: { reason: 'Not Implmented', details: `${req.method} ${pathname}` } } })
})
api.listen(port, host, () => {
console.log(`⁂ IPFS Pinning Service on http://127.0.0.1:1337

## Add w3 as a remote
$ ipfs pin remote service add w3 'http://${host}:${port}' '${token}'

## Pin to w3
$ ipfs pin remote add --service w3 <cid>

## Waiting for requests`)
})
}

/**
* @param {object} config
* @param {import('@web3-storage/w3up-client').Client} confg.client
* @param {string} config.cid
* @param {string} [config.ipfsGatewayUrl]
* @param {AbortSignal} [config.signal]
*/
export async function addPin ({ client, cid, ipfsGatewayUrl = 'http://127.0.0.1:8080', signal }) {
const rootCID = CID.parse(cid)
const ipfsUrl = new URL(`/ipfs/${cid}?format=car`, ipfsGatewayUrl, { signal })
const res = await fetch(ipfsUrl)
const storedCID = await client.uploadCAR({ stream: () => res.body }, {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should tweak the w3up-client uploadCAR to allow users to pass a stream, as currently it requires a blob but just calls stream on it, and it means we can't pass a stream directly, and need this madness.

onShardStored: (car) => console.log(`${new Date().toISOString()} ${car.cid} shard stored`),
rootCID,
signal

})
console.log(`${new Date().toISOString()} ${storedCID} uploaded`)
return {
// we use cid as requestid to avoid needing to track extra state
requestid: storedCID.toString(),
status: 'pinned',
created: new Date().toISOString(),
pin: {
cid: storedCID.toString()
},
delgates: []
}
}

/**
* @param {object} config
* @param {http.OutgoingMessage} config.res
* @param {object} config.body
* @param {number} [config.status]
* @param {string} [config.contentType]
*/
function send ({ res, body, status = 200, contentType = 'application/json' }) {
res.setHeader('Content-Type', 'application/json')
res.writeHead(status)
const str = contentType === 'application/json' ? JSON.stringify(body) : body
res.end(str)
}

/**
* @param {http.IncomingMessage} req
*/
export async function getJsonBody (req) {
const contentlength = parseInt(req.headers['content-length'] || 0, 10)
if (contentlength > 100 * 1024) {
throw new Error('Request body too large')
}
const contentType = req.headers['content-type']
if (contentType !== 'application/json') {
throw new Error('Request body must be be content-type: application/json')
}
let body = ''
for await (const chonk of req) {
body += chonk
if (Buffer.byteLength(body, 'utf-8') > contentlength) {
throw new Error('Request body size exceeds specfied content-length')
}
}
if (Buffer.byteLength(body, 'utf-8') !== contentlength) {
throw new Error('Request body size does not match specified content-length')
}
return JSON.parse(body)
}