feat: initial version

This commit is contained in:
Derrick Hammer 2023-06-15 01:34:42 -04:00
parent 4facc9d564
commit c42f218100
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
10 changed files with 677 additions and 1 deletions

22
.presetterrc.json Normal file
View File

@ -0,0 +1,22 @@
{
"preset": [
"presetter-preset-essentials",
"presetter-preset-hybrid"
],
"config": {
"tsconfig": {
"compilerOptions": {
"lib": [
"ES2020",
"dom"
]
}
},
"prettier": {
"singleQuote": false
}
},
"variable": {
"source": "src"
}
}

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) <year> <copyright holders>
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:

35
package.json Normal file
View File

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

74
src/cid.ts Normal file
View File

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

492
src/client.ts Normal file
View File

@ -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<void> {
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<void>("/api/v1/account/register", {
email: this.email,
password: this.password,
pubkey: await this.getPubkeyHex(),
} as RegisterRequest);
}
async login(): Promise<LoginResponse> {
return this.post<LoginResponse>("/api/v1/auth/login", {
email: this._options.email,
password: this._options.password,
} as LoginRequest);
}
async isLoggedIn() {
const ret = await this.get<Response>("/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<void> {
if (!this._options.privateKey) {
throw new Error("Private key is required");
}
const challenge = await this.post<PubkeyChallengeResponse>(
"/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<LoginResponse>(
"/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<void> {
return this.post<void>("/api/v1/auth/logout", request);
}
async getBasicUploadLimit(): Promise<bigint> {
if (this.uploadLimit) {
return this.uploadLimit;
}
this.uploadLimit = (
await this.get<UploadLimitResponse>("/api/v1/files/upload/limit", {
auth: true,
})
).limit;
return this.uploadLimit;
}
async downloadFile(cid: string): Promise<ReadableStream> {
return await this.get<any>(`/api/v1/files/download/${cid}`, {
auth: true,
raw: true,
});
}
async downloadProof(cid: string): Promise<ArrayBuffer> {
return await new Response(
await this.get<any>(`/api/v1/files/proof/${cid}`, {
auth: true,
raw: true,
}),
).arrayBuffer();
}
private async fetch<T>(path: string, options: FetchOptions): Promise<T> {
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<T>(path: string, options: FetchOptions = {}): Promise<T> {
return this.fetch<T>(path, { ...options, method: "GET" });
}
private async post<T>(
path: string,
data: any,
options: FetchOptions = {},
): Promise<T> {
return this.fetch<T>(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<string> {
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<string>;
private async uploadFileSmall(
stream: ReadableStream,
hashStream: ReadableStream,
): Promise<string>;
private async uploadFileSmall(stream: Uint8Array): Promise<string>;
private async uploadFileSmall(stream: NodeJS.ReadableStream): Promise<string>;
private async uploadFileSmall(stream: any): Promise<string> {
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<UploadResponse>(
"/api/v1/files/upload",
formData,
{ auth: true },
);
return response.cid;
}
private async uploadFileTus(stream: Blob, size?: bigint): Promise<string>;
private async uploadFileTus(
stream: ReadableStream,
hashStream: ReadableStream,
size?: bigint,
): Promise<string>;
private async uploadFileTus(
stream: Uint8Array,
size?: bigint,
): Promise<string>;
private async uploadFileTus(
stream: NodeJS.ReadableStream,
hashStream: ReadableStream,
size?: bigint,
): Promise<string>;
private async uploadFileTus(
stream: any,
hashStream?: any,
size?: bigint,
): Promise<string> {
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<UploadStatusResponse>(`/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<string> {
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");
}
}

2
src/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./client.js";
export * from "./cid.js";

5
src/requests/account.ts Normal file
View File

@ -0,0 +1,5 @@
export interface RegisterRequest {
email: string;
password: string;
pubkey: string;
}

22
src/requests/auth.ts Normal file
View File

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

13
src/responses/auth.ts Normal file
View File

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

11
src/responses/files.ts Normal file
View File

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