diff --git a/.presetterrc.json b/.presetterrc.json new file mode 100644 index 0000000..0a17c7a --- /dev/null +++ b/.presetterrc.json @@ -0,0 +1,22 @@ +{ + "preset": [ + "presetter-preset-essentials", + "presetter-preset-hybrid" + ], + "config": { + "tsconfig": { + "compilerOptions": { + "lib": [ + "ES2020", + "dom" + ] + } + }, + "prettier": { + "singleQuote": false + } + }, + "variable": { + "source": "src" + } +} diff --git a/LICENSE b/LICENSE index 2071b23..bd66f50 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +Copyright (c) 2023 Hammer Technologies LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ff1840 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@lumeweb/libportal", + "version": "0.1.0", + "main": "lib/index.js", + "module": "lib/index.mjs", + "types": "lib/index.d.ts", + "exports": { + ".": { + "require": "./lib/index.js", + "import": "./lib/index.mjs" + }, + "./package.json": "./package.json" + }, + "devDependencies": { + "presetter": "^3.5.5", + "presetter-preset-essentials": "^3.5.5", + "presetter-preset-hybrid": "^3.5.5" + }, + "readme": "ERROR: No README data found!", + "_id": "@lumeweb/libportal@0.1.0", + "scripts": { + "prepare": "presetter bootstrap" + }, + "dependencies": { + "@noble/curves": "^1.1.0", + "@noble/hashes": "^1.3.1", + "detect-node": "^2.1.0", + "multiformats": "^11.0.2", + "node-fetch": "^3.3.1", + "p-defer": "^4.0.0", + "stream-to-blob": "^2.0.1", + "tus-js-client": "^3.1.0", + "web-streams-polyfill": "^3.2.1" + } +} diff --git a/src/cid.ts b/src/cid.ts new file mode 100644 index 0000000..b690006 --- /dev/null +++ b/src/cid.ts @@ -0,0 +1,74 @@ +import { base58btc } from "multiformats/bases/base58"; +import * as edUtils from "@noble/curves/abstract/utils"; + +export const MAGIC_BYTES = new Uint8Array([0x26, 0x1f]); + +export interface CID { + hash: Uint8Array; + size: bigint; +} + +export function encodeCid(hash: Uint8Array, size: bigint); +export function encodeCid(hash: string, size: bigint); +export function encodeCid(hash: any, size: bigint) { + if (typeof hash === "string") { + hash = edUtils.hexToBytes(hash); + } + + if (!(hash instanceof Uint8Array)) { + throw new Error(); + } + + if (!size) { + throw new Error("size required"); + } + + size = BigInt(size); + + const sizeBytes = new Uint8Array(8); + const sizeView = new DataView(sizeBytes.buffer); + sizeView.setBigInt64(0, size, true); + + const prefixedHash = Uint8Array.from([...MAGIC_BYTES, ...hash, ...sizeBytes]); + return base58btc.encode(prefixedHash).toString(); +} + +export function decodeCid(cid: string): CID { + let bytes = base58btc.decode(cid); + + if (!arrayBufferEqual(bytes.slice(0, 2).buffer, bytes.buffer)) { + throw new Error("Invalid cid"); + } + + bytes = bytes.slice(2); + let cidHash = bytes.slice(0, 32); + let size = bytes.slice(32); + const sizeView = new DataView(size.buffer); + + return { + hash: cidHash, + size: sizeView.getBigInt64(0, true), + }; +} + +function arrayBufferEqual(buf1, buf2) { + if (buf1 === buf2) { + return true; + } + + if (buf1.byteLength !== buf2.byteLength) { + return false; + } + + var view1 = new DataView(buf1); + var view2 = new DataView(buf2); + + var i = buf1.byteLength; + while (i--) { + if (view1.getUint8(i) !== view2.getUint8(i)) { + return false; + } + } + + return true; +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..40b8ddb --- /dev/null +++ b/src/client.ts @@ -0,0 +1,492 @@ +import { ed25519 as ed } from "@noble/curves/ed25519"; +import * as edUtils from "@noble/curves/abstract/utils"; + +import { RegisterRequest } from "./requests/account.js"; +import fetch, { + FormData, + Blob, + RequestInit, + Response, + HeadersInit, +} from "node-fetch"; +import { + LoginRequest, + LogoutRequest, + PubkeyChallengeRequest, + PubkeyLoginRequest, +} from "./requests/auth.js"; +import { + UploadLimitResponse, + UploadResponse, + UploadStatusResponse, +} from "./responses/files.js"; + +import TUS from "tus-js-client"; +import streamToBlob from "stream-to-blob"; + +import defer from "p-defer"; +import { blake3 } from "@noble/hashes/blake3"; +import { encodeCid } from "./cid.js"; +import { Readable as NodeReadableStream } from "stream"; +import { + AuthStatusResponse, + LoginResponse, + PubkeyChallengeResponse, +} from "./responses/auth.js"; + +export interface ClientOptions { + portalUrl: string; + email?: string; + password?: string; + privateKey?: Uint8Array; +} + +interface FetchOptions { + auth?: boolean; + raw?: boolean; + fullResponse?: boolean; + method?: "GET" | "POST"; + data?: any; +} + +export class Client { + private _options: ClientOptions; + private jwtSessionKey?: string; + private uploadLimit?: bigint; + + constructor(options: ClientOptions) { + if (!options) { + throw new Error("ClientOptions is required "); + } + if (!options.portalUrl) { + throw new Error("Portal url is required"); + } + + this._options = options; + } + + get email(): string { + return this._options.email as string; + } + + set email(email: string) { + this._options.email = email; + } + + get password(): string { + return this._options.password as string; + } + + set password(password: string) { + this._options.email = password; + } + + set privateKey(key: string) { + this._options.privateKey = edUtils.hexToBytes(key); + } + + async useNewPubkeyAccount() { + this._options.privateKey = ed.utils.randomPrivateKey(); + this._options.password = undefined; + } + + async register(): Promise { + if (!this._options.email) { + throw new Error("Email required"); + } + if (!this._options.password && !this._options.privateKey) { + throw new Error("Password or private key required"); + } + return this.post("/api/v1/account/register", { + email: this.email, + password: this.password, + pubkey: await this.getPubkeyHex(), + } as RegisterRequest); + } + + async login(): Promise { + return this.post("/api/v1/auth/login", { + email: this._options.email, + password: this._options.password, + } as LoginRequest); + } + + async isLoggedIn() { + const ret = await this.get("/api/v1/auth/status", { + auth: true, + fullResponse: true, + }); + if (!ret.ok) { + if (ret.status === 401) { + return false; + } + + throw new Error( + `Unrecognized status code: ${ret.status} ${await ret.text()}`, + ); + } + + const json = (await ret.json()) as AuthStatusResponse; + + return json.status as boolean; + } + + async loginPubkey(): Promise { + if (!this._options.privateKey) { + throw new Error("Private key is required"); + } + + const challenge = await this.post( + "/api/v1/auth/pubkey/challenge", + { + pubkey: await this.getPubkeyHex(), + } as PubkeyChallengeRequest, + ); + + const signature = ed.sign( + new TextEncoder().encode(challenge.challenge), + this._options.privateKey, + ); + + const loginRet = await this.post( + "/api/v1/auth/pubkey/login", + { + pubkey: this.getPubkeyHex(), + challenge: challenge.challenge, + signature: edUtils.bytesToHex(signature), + } as PubkeyLoginRequest, + ); + + this.jwtSessionKey = loginRet.token; + } + + logout(request: LogoutRequest): Promise { + return this.post("/api/v1/auth/logout", request); + } + + async getBasicUploadLimit(): Promise { + if (this.uploadLimit) { + return this.uploadLimit; + } + + this.uploadLimit = ( + await this.get("/api/v1/files/upload/limit", { + auth: true, + }) + ).limit; + + return this.uploadLimit; + } + + async downloadFile(cid: string): Promise { + return await this.get(`/api/v1/files/download/${cid}`, { + auth: true, + raw: true, + }); + } + + async downloadProof(cid: string): Promise { + return await new Response( + await this.get(`/api/v1/files/proof/${cid}`, { + auth: true, + raw: true, + }), + ).arrayBuffer(); + } + + private async fetch(path: string, options: FetchOptions): Promise { + let fetchOptions: RequestInit & { headers: HeadersInit } = { + method: options.method, + headers: {}, + }; + if (options.auth) { + fetchOptions.headers["Authorization"] = `Bearer ${this.jwtSessionKey}`; + } + + if (options.data) { + fetchOptions.body = options.data; + + if (!(fetchOptions.body instanceof FormData)) { + fetchOptions.headers["Content-Type"] = "application/json"; + fetchOptions.body = JSON.stringify(fetchOptions.body); + } + } + + const response = await fetch(this.getEndpoint(path), fetchOptions); + + if (!options.fullResponse) { + if (!response.ok) { + if (response.status === 401) { + throw new Error("Account required to take action"); + } + throw new Error(`Request failed: ${await response.text()}`); + } + } + + if (options.fullResponse) { + return response as T; + } + if (options.raw) { + return response.body as T; + } + + const out = await response.text(); + + if (out) { + return JSON.parse(out) as T; + } + + return null as T; + } + + private async get(path: string, options: FetchOptions = {}): Promise { + return this.fetch(path, { ...options, method: "GET" }); + } + + private async post( + path: string, + data: any, + options: FetchOptions = {}, + ): Promise { + return this.fetch(path, { ...options, method: "POST", data }); + } + + private getEndpoint(path: string) { + return `${this._options.portalUrl}${path}`; + } + + getPubkeyHex() { + return edUtils.bytesToHex( + ed.getPublicKey(this._options.privateKey as Uint8Array), + ); + } + + async uploadFile(stream: Blob, size?: bigint); + async uploadFile( + stream: ReadableStream, + hashStream: ReadableStream, + size: bigint, + ); + async uploadFile(stream: Uint8Array, size?: bigint); + async uploadFile( + stream: NodeJS.ReadableStream, + hashStream: NodeJS.ReadableStream, + size?: bigint, + ); + async uploadFile( + stream: any, + hashStream?: any, + size?: bigint, + ): Promise { + if (stream instanceof Uint8Array || stream instanceof Blob) { + size = BigInt(stream.length); + } + + if (["bigint", "number"].includes(typeof hashStream)) { + size = BigInt(hashStream); + hashStream = undefined; + } + + const uploadLimit = await this.getBasicUploadLimit(); + if ((size as bigint) <= uploadLimit) { + return this.uploadFileSmall(stream); + } + + return this.uploadFileTus(stream, hashStream, size); + } + + private async uploadFileSmall(stream: Blob): Promise; + private async uploadFileSmall( + stream: ReadableStream, + hashStream: ReadableStream, + ): Promise; + private async uploadFileSmall(stream: Uint8Array): Promise; + private async uploadFileSmall(stream: NodeJS.ReadableStream): Promise; + private async uploadFileSmall(stream: any): Promise { + if (stream instanceof ReadableStream) { + stream = await streamToBlob(stream); + } + + if (stream instanceof NodeReadableStream) { + let data = new Uint8Array(); + for await (const chunk of stream) { + data = Uint8Array.from([...data, ...chunk]); + } + + stream = data; + } + + if (stream instanceof Uint8Array) { + stream = new Blob([Buffer.from(stream)]); + } + + if (!(stream instanceof Blob) && !(stream instanceof NodeReadableStream)) { + throw new Error("Invalid stream"); + } + + const formData = new FormData(); + formData.set("file", stream as Blob); + + const response = await this.post( + "/api/v1/files/upload", + formData, + { auth: true }, + ); + + return response.cid; + } + + private async uploadFileTus(stream: Blob, size?: bigint): Promise; + private async uploadFileTus( + stream: ReadableStream, + hashStream: ReadableStream, + size?: bigint, + ): Promise; + private async uploadFileTus( + stream: Uint8Array, + size?: bigint, + ): Promise; + private async uploadFileTus( + stream: NodeJS.ReadableStream, + hashStream: ReadableStream, + size?: bigint, + ): Promise; + private async uploadFileTus( + stream: any, + hashStream?: any, + size?: bigint, + ): Promise { + if (["bigint", "number"].includes(typeof hashStream)) { + size = BigInt(hashStream); + hashStream = undefined; + } + + const ret = defer(); + let hash = ""; + + if (stream instanceof ReadableStream) { + hash = await this.computeHash(hashStream); + } + + if (stream instanceof NodeReadableStream) { + hash = await this.computeHash(hashStream); + } + + if (stream instanceof Uint8Array) { + stream = new Blob([stream]); + size = stream.size; + hash = await this.computeHash(stream); + } + + if ( + !(stream instanceof ReadableStreamDefaultReader) && + !(stream instanceof Blob) && + !(stream instanceof NodeReadableStream) + ) { + throw new Error("Invalid stream"); + } + + const checkFileExistsError = (error: TUS.DetailedError): boolean => { + return error?.originalResponse?.getStatus() === 304; + }; + + const upload = new TUS.Upload(stream, { + endpoint: this.getEndpoint("/api/v1/files/tus"), + retryDelays: [0, 3000, 5000, 10000, 20000], + metadata: { + hash, + }, + chunkSize: + stream instanceof ReadableStreamDefaultReader || + stream instanceof NodeReadableStream + ? Number(await this.getBasicUploadLimit()) + : undefined, + uploadSize: size ? Number(size) : undefined, + onError: function (error) { + if (checkFileExistsError(error as TUS.DetailedError)) { + ret.resolve(upload.url); + return; + } + ret.reject(error); + }, + onSuccess: function () { + ret.resolve(upload.url); + }, + onShouldRetry: function (error) { + return !checkFileExistsError(error as TUS.DetailedError); + }, + }); + + const prevUploads = await upload.findPreviousUploads(); + if (prevUploads.length) { + upload.resumeFromPreviousUpload(prevUploads[0]); + } else { + upload.start(); + } + + await ret.promise; + + const cid = encodeCid(hash, size as bigint); + + while (true) { + const status = await this.getUploadStatus(cid as string); + + if (status.status === "uploaded") { + break; + } + + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + + return cid as string; + } + + async getUploadStatus(cid: string) { + return this.get(`/api/v1/files/status/${cid}`, { + auth: true, + }); + } + + private async computeHash(stream: Blob); + private async computeHash(stream: ReadableStream); + private async computeHash(stream: Uint8Array); + private async computeHash(stream: NodeJS.ReadableStream); + private async computeHash(stream: any): Promise { + if (stream instanceof Uint8Array) { + stream = new Blob([stream]); + } + + if (stream instanceof ReadableStream) { + const hasher = blake3.create({}); + const forks = stream.tee(); + const reader = forks[0].getReader(); + + // @ts-ignore + for await (const chunk of reader.iterator()) { + hasher.update(chunk); + } + + return edUtils.bytesToHex(hasher.digest()); + } + + if (stream instanceof NodeReadableStream) { + const hasher = blake3.create({}); + + for await (const chunk of stream) { + hasher.update(chunk); + } + + return edUtils.bytesToHex(hasher.digest()); + } + + if (stream instanceof Blob) { + const output = blake3(new Uint8Array(await stream.arrayBuffer())); + + return edUtils.bytesToHex(output); + } + + throw new Error("Invalid stream"); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1cdbac9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./client.js"; +export * from "./cid.js"; diff --git a/src/requests/account.ts b/src/requests/account.ts new file mode 100644 index 0000000..e59ab2b --- /dev/null +++ b/src/requests/account.ts @@ -0,0 +1,5 @@ +export interface RegisterRequest { + email: string; + password: string; + pubkey: string; +} diff --git a/src/requests/auth.ts b/src/requests/auth.ts new file mode 100644 index 0000000..636a19d --- /dev/null +++ b/src/requests/auth.ts @@ -0,0 +1,22 @@ +// Pubkey Login Request Interface +export interface PubkeyLoginRequest { + pubkey: string; + challenge: string; + signature: string; +} + +// Pubkey Challenge Request Interface +export interface PubkeyChallengeRequest { + pubkey: string; +} + +// Login Request Interface +export interface LoginRequest { + email: string; + password: string; +} + +// Logout Request Interface +export interface LogoutRequest { + token: string; +} diff --git a/src/responses/auth.ts b/src/responses/auth.ts new file mode 100644 index 0000000..ea43438 --- /dev/null +++ b/src/responses/auth.ts @@ -0,0 +1,13 @@ +// Challenge Response Interface +export interface PubkeyChallengeResponse { + challenge: string; +} + +// Login Response Interface +export interface LoginResponse { + token: string; +} + +export interface AuthStatusResponse { + status: boolean; +} diff --git a/src/responses/files.ts b/src/responses/files.ts new file mode 100644 index 0000000..cdbd3b5 --- /dev/null +++ b/src/responses/files.ts @@ -0,0 +1,11 @@ +// Status Response Interface +export interface UploadStatusResponse { + status: "uploaded" | "uploading" | "not_found"; +} +// Upload Response Interface +export interface UploadResponse { + cid: string; +} +export interface UploadLimitResponse { + limit: bigint; +}