This commit is contained in:
Karol Wypchlo 2021-09-17 16:10:54 +02:00
parent 9660c787fa
commit 96491be246
No known key found for this signature in database
GPG Key ID: C92C016317A964D0
12 changed files with 1889 additions and 0 deletions

View File

@ -78,6 +78,7 @@ services:
- caddy
- handshake-api
- dnslink-api
- ipfs-api
- website
website:
@ -174,3 +175,31 @@ services:
- 3100
depends_on:
- 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

View File

@ -92,6 +92,12 @@ 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 {
include /etc/nginx/conf.d/include/cors;

1
packages/ipfs-api/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -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"]

View File

@ -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"
}
}

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
export interface IRecord {
cid: string;
createdAt: Date;
skylink: string;
}

View File

@ -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");
}

View File

@ -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
}

1473
packages/ipfs-api/yarn.lock Normal file

File diff suppressed because it is too large Load Diff