ipfs
This commit is contained in:
parent
9660c787fa
commit
96491be246
|
@ -78,6 +78,7 @@ services:
|
||||||
- caddy
|
- caddy
|
||||||
- handshake-api
|
- handshake-api
|
||||||
- dnslink-api
|
- dnslink-api
|
||||||
|
- ipfs-api
|
||||||
- website
|
- website
|
||||||
|
|
||||||
website:
|
website:
|
||||||
|
@ -174,3 +175,31 @@ services:
|
||||||
- 3100
|
- 3100
|
||||||
depends_on:
|
depends_on:
|
||||||
- caddy
|
- caddy
|
||||||
|
|
||||||
|
ipfs-api:
|
||||||
|
build:
|
||||||
|
context: ./packages/ipfs-api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: ipfs-api
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
networks:
|
||||||
|
shared:
|
||||||
|
ipv4_address: 10.10.10.58
|
||||||
|
expose:
|
||||||
|
- 3100
|
||||||
|
depends_on:
|
||||||
|
- ipfs-mongo
|
||||||
|
|
||||||
|
ipfs-mongo:
|
||||||
|
image: mongo:4.4.1
|
||||||
|
container_name: ipfs-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
volumes:
|
||||||
|
- ./docker/data/ipfs-mongo/db:/data/db
|
||||||
|
networks:
|
||||||
|
shared:
|
||||||
|
ipv4_address: 10.10.10.59
|
||||||
|
expose:
|
||||||
|
- 27017
|
||||||
|
|
|
@ -92,6 +92,12 @@ location /hns {
|
||||||
include /etc/nginx/conf.d/include/location-hns;
|
include /etc/nginx/conf.d/include/location-hns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /ipfs {
|
||||||
|
include /etc/nginx/conf.d/include/cors;
|
||||||
|
|
||||||
|
proxy_pass http://ipfs-api:3100;
|
||||||
|
}
|
||||||
|
|
||||||
location /hnsres {
|
location /hnsres {
|
||||||
include /etc/nginx/conf.d/include/cors;
|
include /etc/nginx/conf.d/include/cors;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
dist
|
|
@ -0,0 +1,19 @@
|
||||||
|
FROM node:16.9.0-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/app
|
||||||
|
|
||||||
|
# copy package configuration and install node modules
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn --frozen-lockfile
|
||||||
|
|
||||||
|
# copy over source files
|
||||||
|
COPY src src
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# build application
|
||||||
|
ENV NODE_ENV production
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
|
|
||||||
|
CMD ["yarn", "start"]
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "ipfs-to-skynet",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "src/server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rimraf dist && tsc",
|
||||||
|
"lint": "tslint --project tsconfig.json",
|
||||||
|
"watch": "tsnodemon -x 'ts-node src/server.ts'",
|
||||||
|
"start": "ts-node src/server.ts"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@skynetlabs/skynet-nodejs": "2.2.0",
|
||||||
|
"axios": "0.21.4",
|
||||||
|
"cors": "2.8.5",
|
||||||
|
"dotenv": "10.0.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"got": "11.8.2",
|
||||||
|
"mime-types": "2.1.32",
|
||||||
|
"mongodb": "4.1.2",
|
||||||
|
"skynet-js": "4.0.13-beta",
|
||||||
|
"tar-fs": "2.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "4.17.13",
|
||||||
|
"@types/got": "9.6.12",
|
||||||
|
"@types/mime-types": "2.1.1",
|
||||||
|
"@types/node": "16.9.2",
|
||||||
|
"rimraf": "3.0.2",
|
||||||
|
"ts-node": "10.2.1",
|
||||||
|
"tslint": "6.1.3",
|
||||||
|
"tsnodemon": "^1.2.2",
|
||||||
|
"typescript": "4.4.3"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const API_HOSTNAME = process.env.API_HOSTNAME || "0.0.0.0";
|
||||||
|
export const API_PORT = process.env.API_PORT || "3100";
|
||||||
|
export const UPLOAD_PATH = process.env.UPLOAD_PATH || "/tmp";
|
||||||
|
export const IPFS_INFURA_API =
|
||||||
|
process.env.IPFS_INFURA_API || "https://ipfs.infura.io:5001";
|
||||||
|
export const IPFS_GATEWAY =
|
||||||
|
process.env.IPFS_GATEWAY || "https://cloudflare-ipfs.com/ipfs/";
|
||||||
|
export const SKYNET_PORTAL = process.env.SKYNET_PORTAL || "https://siasky.net";
|
||||||
|
export const MONGO_CONNECTIONSTRING =
|
||||||
|
process.env.MONGO_CONNECTIONSTRING ||
|
||||||
|
"mongodb://root:password@ipfs-mongo:27017";
|
||||||
|
export const MONGO_DBNAME = process.env.MONGO_DBNAME || "ipfs-to-skynet";
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {
|
||||||
|
Collection,
|
||||||
|
CreateIndexesOptions,
|
||||||
|
Db,
|
||||||
|
MongoClient,
|
||||||
|
MongoClientOptions,
|
||||||
|
} from "mongodb";
|
||||||
|
|
||||||
|
export class MongoDB {
|
||||||
|
private db: Db;
|
||||||
|
private client: MongoClient;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private connectionString: string,
|
||||||
|
private databaseName: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async connect() {
|
||||||
|
const options: MongoClientOptions = {
|
||||||
|
sslValidate: true,
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelay: 1000,
|
||||||
|
connectTimeoutMS: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client = await MongoClient.connect(this.connectionString, options);
|
||||||
|
this.db = this.client.db(this.databaseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createCollection<T>(
|
||||||
|
collectionName: string
|
||||||
|
): Promise<Collection<T>> {
|
||||||
|
return await this.db.createCollection<T>(collectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async dropCollection(collectionName: string): Promise<void> {
|
||||||
|
await this.db.dropCollection(collectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ensureCollection<T>(
|
||||||
|
collectionName: string
|
||||||
|
): Promise<Collection<T>> {
|
||||||
|
const collections = await this.db
|
||||||
|
.listCollections({ name: collectionName })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const collection = collections.length
|
||||||
|
? (this.db.collection(collectionName) as Collection<T>)
|
||||||
|
: await this.createCollection<T>(collectionName);
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ensureIndex(
|
||||||
|
collectionName: string,
|
||||||
|
fieldOrSpec: any,
|
||||||
|
options?: CreateIndexesOptions
|
||||||
|
): Promise<string> {
|
||||||
|
const collection = await this.ensureCollection(collectionName);
|
||||||
|
const ensured = await collection.createIndex(fieldOrSpec, options);
|
||||||
|
return ensured;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCollection<T>(
|
||||||
|
collectionName: string
|
||||||
|
): Promise<Collection<T>> {
|
||||||
|
return this.ensureCollection<T>(collectionName);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
import cors from "cors";
|
||||||
|
import express, { Request, Response } from "express";
|
||||||
|
import { rmdirSync, unlinkSync } from "fs";
|
||||||
|
import { extension as toExtension } from "mime-types";
|
||||||
|
import { Collection } from "mongodb";
|
||||||
|
import {
|
||||||
|
API_HOSTNAME,
|
||||||
|
API_PORT,
|
||||||
|
MONGO_CONNECTIONSTRING,
|
||||||
|
MONGO_DBNAME,
|
||||||
|
UPLOAD_PATH,
|
||||||
|
} from "./consts";
|
||||||
|
import { MongoDB } from "./mongodb";
|
||||||
|
import { IRecord } from "./types";
|
||||||
|
import {
|
||||||
|
contentType,
|
||||||
|
download,
|
||||||
|
extractArchive,
|
||||||
|
isDirectory,
|
||||||
|
uploadDirectory,
|
||||||
|
uploadFile,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
require("dotenv").config();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// create the database connection
|
||||||
|
console.log("creating database connection");
|
||||||
|
const mongo = new MongoDB(MONGO_CONNECTIONSTRING, MONGO_DBNAME);
|
||||||
|
await mongo.connect();
|
||||||
|
|
||||||
|
// ensure the database model (ensureIndex will ensure the collection exists)
|
||||||
|
console.log("ensuring database model");
|
||||||
|
const recordsDB = await mongo.getCollection<IRecord>("records");
|
||||||
|
await mongo.ensureIndex("records", "cid", { unique: true });
|
||||||
|
await recordsDB.deleteMany({});
|
||||||
|
|
||||||
|
// create the server
|
||||||
|
console.log("creating express server");
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// create the routes
|
||||||
|
app.get("/migrate/:cid", (req: Request, res: Response) => {
|
||||||
|
return handleGetLink(req, res, recordsDB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// start the server
|
||||||
|
app.listen(parseInt(API_PORT, 10), API_HOSTNAME, () => {
|
||||||
|
console.log(`IPFS to Skynet API listening at ${API_HOSTNAME}:${API_PORT}`);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function handleGetLink(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
recordsDB: Collection<IRecord>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { cid } = req.params;
|
||||||
|
|
||||||
|
const record = await recordsDB.findOne({ cid });
|
||||||
|
if (record && record.skylink) {
|
||||||
|
res.status(200).send({ skylink: record.skylink });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record && !record.skylink) {
|
||||||
|
// TODO: we can retry the IPFS download and skynet upload here if
|
||||||
|
// time.Since(createdAt) exceeds some threshold
|
||||||
|
res.status(200).send({ error: "processing" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert an empty record for the cid
|
||||||
|
await recordsDB.insertOne({ cid, createdAt: new Date(), skylink: "" });
|
||||||
|
|
||||||
|
// reupload the cid and return the skylink
|
||||||
|
const skylink = await reuploadFile(cid, recordsDB);
|
||||||
|
res.status(200).send({ skylink });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reuploadFile(
|
||||||
|
cid: string,
|
||||||
|
recordsDB: Collection<IRecord>
|
||||||
|
): Promise<string> {
|
||||||
|
// get the content type
|
||||||
|
const ct = await contentType(cid);
|
||||||
|
const ext = toExtension(ct);
|
||||||
|
|
||||||
|
// find out whether it's a directory
|
||||||
|
const isDir = await isDirectory(cid);
|
||||||
|
|
||||||
|
// upload directory
|
||||||
|
if (isDir) {
|
||||||
|
// download cid as archive
|
||||||
|
const tarPath = `${UPLOAD_PATH}/${cid}.tar`;
|
||||||
|
await download(cid, tarPath, isDir);
|
||||||
|
|
||||||
|
// untar the archive
|
||||||
|
const dirPath = `${UPLOAD_PATH}/${cid}`;
|
||||||
|
await extractArchive(tarPath, dirPath);
|
||||||
|
|
||||||
|
// upload the directory
|
||||||
|
const dirPathExtracted = `${UPLOAD_PATH}/${cid}/${cid}`;
|
||||||
|
const skylink = await uploadDirectory(dirPathExtracted);
|
||||||
|
|
||||||
|
// cleanup files
|
||||||
|
unlinkSync(tarPath);
|
||||||
|
rmdirSync(dirPath, { recursive: true });
|
||||||
|
|
||||||
|
// update record
|
||||||
|
await recordsDB.updateOne({ cid }, { $set: { skylink } });
|
||||||
|
|
||||||
|
return skylink;
|
||||||
|
}
|
||||||
|
|
||||||
|
// download cid as file
|
||||||
|
const filePath = `${UPLOAD_PATH}/${cid}.${ext}`;
|
||||||
|
await download(cid, filePath, isDir);
|
||||||
|
|
||||||
|
// upload the file
|
||||||
|
const skylink = await uploadFile(filePath);
|
||||||
|
|
||||||
|
// cleanup files
|
||||||
|
unlinkSync(filePath);
|
||||||
|
|
||||||
|
// update record
|
||||||
|
await recordsDB.updateOne({ cid }, { $set: { skylink } });
|
||||||
|
|
||||||
|
return skylink;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface IRecord {
|
||||||
|
cid: string;
|
||||||
|
createdAt: Date;
|
||||||
|
skylink: string;
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { SkynetClient } from "@skynetlabs/skynet-nodejs";
|
||||||
|
import { createReadStream, createWriteStream } from "fs";
|
||||||
|
import got from "got";
|
||||||
|
import { extract } from "tar-fs";
|
||||||
|
import { IPFS_GATEWAY, IPFS_INFURA_API, SKYNET_PORTAL } from "./consts";
|
||||||
|
|
||||||
|
const client = new SkynetClient(SKYNET_PORTAL);
|
||||||
|
|
||||||
|
export async function contentType(cid: string): Promise<string> {
|
||||||
|
const url = `${IPFS_GATEWAY}/${cid}`;
|
||||||
|
const response = await got.head(url);
|
||||||
|
return response.headers["content-type"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDirectory(cid: string): Promise<boolean> {
|
||||||
|
const url = `${IPFS_INFURA_API}/api/v0/object/get?arg=${cid}&encoding=json`;
|
||||||
|
const json = await got.get(url).json();
|
||||||
|
return Boolean(json["Links"].length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function download(
|
||||||
|
cid: string,
|
||||||
|
destination: string,
|
||||||
|
directory: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const url = directory
|
||||||
|
? `${IPFS_INFURA_API}/api/v0/get?arg=${cid}&archive=true`
|
||||||
|
: `${IPFS_GATEWAY}/${cid}`;
|
||||||
|
|
||||||
|
console.log(url);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const downloadStream = got.stream(url);
|
||||||
|
downloadStream.on("error", (error) => {
|
||||||
|
console.error(`Download failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileWriterStream = createWriteStream(destination);
|
||||||
|
fileWriterStream
|
||||||
|
.on("error", (error) => {
|
||||||
|
console.error(`Could not write file to system: ${error.message}`);
|
||||||
|
reject(error);
|
||||||
|
})
|
||||||
|
.on("finish", () => {
|
||||||
|
console.log(`File downloaded to ${destination}`);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadStream.pipe(fileWriterStream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractArchive(src: string, dst: string) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
createReadStream(src)
|
||||||
|
.pipe(extract(dst))
|
||||||
|
.on("finish", resolve)
|
||||||
|
.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(filePath: string): Promise<string> {
|
||||||
|
const response = await client.uploadFile(filePath);
|
||||||
|
if (response.startsWith("sia://")) {
|
||||||
|
return response.slice("sia://".length);
|
||||||
|
}
|
||||||
|
throw new Error("upload failed, skylink not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDirectory(dirPath: string): Promise<string> {
|
||||||
|
const response = await client.uploadDirectory(dirPath);
|
||||||
|
if (response.startsWith("sia://")) {
|
||||||
|
return response.slice("sia://".length);
|
||||||
|
}
|
||||||
|
throw new Error("upload failed, skylink not found");
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"target": "es5",
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": false,
|
||||||
|
"lib": ["es2020"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"typeRoots": ["./types", "./node_modules/@types"]
|
||||||
|
},
|
||||||
|
"include": ["./src"],
|
||||||
|
"exclude": ["./node_modules", "./dist"],
|
||||||
|
"compileOnSave": true
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
Reference in New Issue