はじめに
IPFSのノード(以下、単に「IPFS」と書きます。)がまずまず安定して使えるようになってきたところで、次に気になるのがIPFSへのファイル、特に画像ファイル等のアップロードの方法です。
IPFSに画像をアップロードする方法で、大掛かりな準備を必要としないものは以下の2通りに限られるのではないかと思っています。
- IPFSノードが稼働しているPCのコマンドプロンプトで直接ipfsコマンドを実行する。
- cURL等を使ってHTTP RPC ARIにPOSTメソッドでアクセスする。
しかし、画像や動画データの編集をIPFSノードが稼働しているPCで行うことはほぼないため、直接ipfsコマンドを実行しようとすると前段の処理として当該のPCにアップロードする必要があります。また、cURLを使う方法では当該のPCへのアップロードが不要なかわりに、ちょっと複雑なオプションを付加してコマンドを実行する必要があります。
どちらの方法を使うにしても使い勝手はいまいちな感じです。
そこで、もう少しIPFSへのアップロードを直感的に行えるようにすべく、まずはブラウザ経由でアップロードできないか考えることにしました。
システム構成
大好評稼働中のIPFSはHTTP RPC APIでのアクセス用のポートをpublicには公開していないので、ブラウザから直接大好評稼働中のIPFSのHTTP RPC APIへアクセスする方法ではなく、中継用のHTTPサーバをNode.jsを使って構築し、このHTTPサーバを介してアクセスさせることにします。
すなわち、下図のような構成となります。
中継用のHTTPサーバ(上図中央の「HTTP Server(Node.js)」と書いてある箱です。)ではブラウザから発行されたHTTP POSTメソッド(上図ではHTTP RPC APIへのアクセス時のPOSTメソッドとの区別のため「Simple POST」と書いています。)のbody部分に含まれているデータ(画像データなどのバイナリデータを想定していますが、以下単に「データ」と書きます。)の部分を抜き出して、IPFSのHTTP RPC API用のパラメータとしてセットし、IPFSに送ります(上図では「Conversion」と書いている部分に相当する処理です)。
なお、IPFSからはCIDがJSON形式で返されますので、それをそのままブラウザに返します。
また、ブラウザ側ではドラッグ&ドロップでアップロードの対象となるファイルを指定できる仕組みを構築することにします(この部分の詳細な説明はこの記事では省略します)。
データ抜き出し用のプログラム
コード例
…と書くと何やら怪しさ満点の見出しですが、上図の「Conversion」の部分を実行するためのプログラムを以下に示します。
Node.jsのためのプログラムですので、プログラム言語はサーバサイドのJavaScriptになります。
スポンサーリンク
// See https://pandanote.info/?p=8723 for details. | |
import * as fs from 'fs'; | |
import axios from 'axios'; | |
import FormData from 'form-data'; | |
function getBoundary(header) { | |
var tmpct = header['content-type'].split(/;/); | |
var boundary = tmpct.filter(function(e) { | |
return e.trim().startsWith("boundary="); | |
}); | |
if (boundary.length > 0) { | |
return boundary[0].trim().replace("boundary=",""); | |
} | |
return null; | |
} | |
function uploadToIpfs(v, req, res) { | |
let buffer = []; | |
req.setEncoding('binary'); | |
let length = 0; | |
req.on('data', function(chunk) { | |
length += chunk.length; | |
buffer += chunk; | |
}); | |
req.on('end', async function() { | |
console.log(req.headers); | |
// console.log(buffer.length); | |
var boundary = getBoundary(req.headers); | |
if (boundary != null) { | |
var partlist = []; | |
var attrlist = {}; | |
// 0: start, | |
// 1: in-part(text), | |
// 2: in-part(pre-content), | |
// 3: in-part(binary) | |
var mode = 0; | |
var linebuf = []; | |
var i = 0; | |
while (i < buffer.length) { | |
const cp = i > 0?buffer.charCodeAt(i-1) & 0xff: 0; | |
const c = buffer.charCodeAt(i) & 0xff; | |
if (mode != 3) { | |
if (cp == 0x0d && c == 0x0a) { | |
var lb = linebuf.trim(); | |
linebuf = []; | |
if (lb == "--"+boundary) { | |
mode = 1; | |
} else if (mode == 1) { | |
if (lb.startsWith("Content-Disposition")) { | |
var attrs = lb.replace(/^Content-Disposition:\s+/,"").split(/;/); | |
attrs.forEach(attr => { | |
var at = attr.trim().split(/=/); | |
if (at.length < 2) { | |
attrlist[at[0]] = ""; | |
} else { | |
attrlist[at[0]] = at[1].replace(/^\"([^\"]+)\"/,"$1"); | |
} | |
}); | |
//console.log(attrlist); | |
} else if (lb.startsWith("Content-Type")) { | |
attrlist["Content-Type"] = lb.replace(/^Content-Type:\s+/,""); | |
partlist.push(attrlist); | |
attrlist = {}; | |
mode = 2; | |
//console.log(partlist); | |
} | |
} else if (mode == 2) { | |
mode = 3; | |
var ccp = 0; | |
var cc = 0; | |
do { | |
i+=2; | |
ccp = buffer.length > i-1? buffer.charCodeAt(i-1) & 0xff: 0; | |
cc = buffer.length > i? buffer.charCodeAt(i) & 0xff: 0; | |
//console.log(ccp+":"+cc); | |
} while(ccp == 0x0d && cc == 0x0a); | |
i-=2; | |
} | |
} else { | |
linebuf += buffer[i]; | |
} | |
} else { | |
var j = buffer.indexOf("--"+boundary,i); | |
if (j > i) { | |
partlist[partlist.length-1]["content"] = buffer.slice(i,j-2); | |
mode = 0; | |
linebuf = []; | |
//console.log(partlist); | |
i = j-1; | |
//console.log(partlist[partlist.length-1]["content"].length); | |
} | |
} | |
i++; | |
} | |
let buf = Buffer.from(partlist[partlist.length-1]["content"],'binary'); | |
var ipfsform = new FormData(); | |
ipfsform.append("image",buf); | |
axios.post('http://localhost:5001/api/v0/add', ipfsform, | |
{ headers: ipfsform.getHeaders() }) | |
.then(response => { | |
res.writeHead(200, {"Content-Type": "text/plain"}); | |
res.write(JSON.stringify(response.data)); | |
res.end(); | |
}) | |
.catch(error => { | |
console.log(error); | |
res.writeHead(500, {"Content-Type": "text/plain"}); | |
res.write(error); | |
res.end(); | |
}); | |
} else { | |
// console.log(error); | |
res.writeHead(400, {"Content-Type": 'text/html; charset=utf-8'}); | |
res.write(fs.readFileSync("static/400.html")); | |
res.end(); | |
} | |
}); | |
} |
17行目から始まるuploadToIpfs関数は以下の3個の引数を取ります。
- 第1引数(v): サーバのインスタンス。http.createServerで作られるものです。
- 第2引数(req): サーバに送られてきたリクエストです。
- 第3引数(res): サーバからブラウザに対して送信するレスポンスです。
注意点
注意が必要と思われるポイントは以下の通りです。
リクエストのencoding
リクエストのインスタンスにはencodingとして”binary”と指定する必要があるようです(19行目。この指定がないとデータが化けるようです)。
Content-Disposition及びContent-Typeの取り扱い
Content-Disposition及びContent-Typeが記述されている行を探すためにPOSTメソッドのbodyデータを1バイトずつ取り出してlinebuf変数に格納しています(40,41行目)。
データの直後のboundaryの探し方
データの直後のboundaryを探すために前項の方法と同じ方法で処理をしようとすると現実的な時間では処理が終わらないことが実験の結果わかりました。
そこで、バッファのデータを格納した変数に対してindexOfを実行してboundaryの場所を探しています(83行目)。
データは画像データ等を想定してしますので、データ中にboundaryの文字列と一致する文字列が出現する確率は(限りなく0に近いものの)0ではありませんが、RFC7578[1]の4.1節に
As with other multipart types, the parts are delimited with a
boundary delimiter, constructed using CRLF, “–“, and the value of
the “boundary” parameter. The boundary is supplied as a “boundary”
parameter to the multipart/form-data type. As noted in Section 5.1
of [RFC2046], the boundary delimiter MUST NOT appear inside any of
the encapsulated parts, and it is often necessary to enclose the
“boundary” parameter values in quotes in the Content-Type header
…と書いてあります。
そこで、データ中にboundaryの文字列(=CRLF+”–“+(変数”boundary”の値))と一致する文字列が出現する確率は0とみなす(出現してしまっているデータが送られてきた場合には上記RFCの使用を満たしていないため不正なデータであるとみなす。)ことにしました。
IPFSに送信するデータ用のバッファのencoding
ブラウザから送られてきたPOSTメソッドのbodyから抜き出したデータを格納するためのBufferのencodingは”binary”としています(95行目)。
Bufferのencodingを”binary”とすることの是非についてはこの記事を最初に書いた時点(2022年3月)において調べた範囲では諸説ある感じですが、Node.js本家のv17.8.0時点のドキュメント[2]を見る限り、”legacy”ではあるものの、”obsolete”にはなっていないようですので、そのまま使用することにしました。
HTTP RPC APIのURL
98行目にIPFSのHTTP RPC APIのURLを記述していますが、この行は次節の動作確認時に使用したURLとは異なります。
動作確認
前節のコードを含んだHTTPサーバを使って画像ファイルをIPFSにアップロードしてみた際に取得した動画がこちらになります↓
ブラウザに画像ファイル等をドラッグ&ドロップすると一旦Node.jsのサーバにそのデータがアップロードされ、さらにNode.jsがそのデータを #IPFS のHTTP RPC APIを使ってIPFSにアップロードするだけの誰得感満載の動画。
JavaScriptでバイナリのデータを扱うのは難しいですね。#NFT #lifeinyokohama pic.twitter.com/AcaHUaXlnK— pandanote.info (@Pandanote_info) March 19, 2022
ブラウザのあるエリアに画像をドラッグ&ドロップするとその画像がアップロードできていることが確認できます。
まとめ
ここまでJavaScriptにおけるバイナリデータの取り扱いの方法についてのみフォーカスして書きました。
バイナリデータを扱う際にはデータ列を1バイトずつ取り出して何らかの処理を行うことが必要になることがありますが、charCodeAt()メソッドとその戻り値に対して0xffとの論理積をとることが頭に入っていれば、いろいろと応用ができそうです。
この記事は以上です。