feat: initial version

This commit is contained in:
Derrick Hammer 2023-12-10 15:03:56 -05:00
parent 7207563d66
commit b3671237b9
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
15 changed files with 21709 additions and 1 deletions

12
.presetterrc.json Normal file
View File

@ -0,0 +1,12 @@
{
"preset": [
"@lumeweb/node-library-preset"
],
"config": {
"tsconfig": {
"compilerOptions": {
"allowImportingTsExtensions": true
}
}
}
}

View File

@ -1,6 +1,8 @@
MIT License
Copyright (c) 2023 LumeWeb
Copyright (c) 2023 Hammer Technologies LLC
Copyright (c) 2022 parajbs
Copyright (c) 2020 Nebulous
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:

20288
npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "@lumeweb/s5-js",
"version": "0.1.0",
"type": "module",
"devDependencies": {
"@lumeweb/node-library-preset": "^0.2.7",
"presetter": "*"
},
"readme": "ERROR: No README data found!",
"_id": "@lumeweb/s5-js@0.1.0",
"scripts": {
"prepare": "presetter bootstrap",
"build": "run build",
"semantic-release": "semantic-release"
},
"dependencies": {
"@lumeweb/libs5": "^0.1.0-develop.77",
"@noble/hashes": "^1.3.2",
"axios": "^1.6.2",
"tus-js-client": "^4.0.0",
"url-join": "^5.0.0"
}
}

324
src/client.ts Normal file
View File

@ -0,0 +1,324 @@
import axios, {
AxiosError,
AxiosProgressEvent,
AxiosRequestConfig,
} from "axios";
import type { AxiosResponse, ResponseType, Method } from "axios";
import {
uploadFile,
uploadLargeFile,
uploadDirectory,
uploadDirectoryRequest,
uploadSmallFile,
uploadSmallFileRequest,
uploadLargeFileRequest,
} from "./upload.ts";
import { downloadFile, getCidUrl, getMetadata } from "./download.ts";
import { defaultPortalUrl, ensureUrl } from "./utils/url.ts";
import {
buildRequestHeaders,
buildRequestUrl,
ExecuteRequestError,
Headers,
} from "./request.ts";
/**
* Custom client options.
*
* @property [APIKey] - Authentication password to use for a single S5 node.
* @property [s5ApiKey] - Authentication API key to use for a S5 portal (sets the "S5-Api-Key" header).
* @property [customUserAgent] - Custom user agent header to set.
* @property [customCookie] - Custom cookie header to set. WARNING: the Cookie header cannot be set in browsers. This is meant for usage in server contexts.
* @property [onDownloadProgress] - Optional callback to track download progress.
* @property [onUploadProgress] - Optional callback to track upload progress.
* @property [loginFn] - A function that, if set, is called when a 401 is returned from the request before re-trying the request.
*/
export type CustomClientOptions = {
APIKey?: string;
s5ApiKey?: string;
customUserAgent?: string;
customCookie?: string;
onDownloadProgress?: (progress: number, event: ProgressEvent) => void;
onUploadProgress?: (progress: number, event: ProgressEvent) => void;
loginFn?: (config?: RequestConfig) => Promise<void>;
};
/**
* Config options for a single request.
*
* @property endpointPath - The endpoint to contact.
* @property [data] - The data for a POST request.
* @property [url] - The full url to contact. Will be computed from the portalUrl and endpointPath if not provided.
* @property [method] - The request method.
* @property [headers] - Any request headers to set.
* @property [subdomain] - An optional subdomain to add to the URL.
* @property [query] - Query parameters.
* @property [extraPath] - An additional path to append to the URL, e.g. a 46-character cid.
* @property [responseType] - The response type.
* @property [transformRequest] - A function that allows manually transforming the request.
* @property [transformResponse] - A function that allows manually transforming the response.
*/
export type RequestConfig = CustomClientOptions & {
endpointPath?: string;
data?: FormData | Record<string, unknown>;
url?: string;
method?: Method;
headers?: Headers;
subdomain?: string;
query?: { [key: string]: string | undefined };
extraPath?: string;
responseType?: ResponseType;
transformRequest?: (data: unknown) => string;
transformResponse?: (data: string) => Record<string, unknown>;
};
// Add a response interceptor so that we always return an error of type
// `ExecuteResponseError`.
axios.interceptors.response.use(
function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger.
// Do something with response data.
return response;
},
function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error.
return Promise.reject(ExecuteRequestError.From(error as AxiosError));
},
);
/**
* The S5 Client which can be used to access S5-net.
*/
export class S5Client {
customOptions: CustomClientOptions;
// The initial portal URL, the value of `defaultPortalUrl()` if `new
// S5Client` is called without a given portal. This initial URL is used to
// resolve the final portal URL.
protected initialPortalUrl: string;
// The resolved API portal URL. The request won't be made until needed, or
// `initPortalUrl()` is called. The request is only made once, for all S5
// Clients.
protected static resolvedPortalUrl?: Promise<string>;
// The custom portal URL, if one was passed in to `new S5Client()`.
protected customPortalUrl?: string;
// Set methods (defined in other files).
// Upload
uploadFile = uploadFile;
protected uploadSmallFile = uploadSmallFile;
protected uploadSmallFileRequest = uploadSmallFileRequest;
protected uploadLargeFile = uploadLargeFile;
protected uploadLargeFileRequest = uploadLargeFileRequest;
uploadDirectory = uploadDirectory;
protected uploadDirectoryRequest = uploadDirectoryRequest;
// Download
downloadFile = downloadFile;
getCidUrl = getCidUrl;
getMetadata = getMetadata;
/**
* The S5 Client which can be used to access S5-net.
*
* @class
* @param [initialPortalUrl] The initial portal URL to use to access S5, if specified. A request will be made to this URL to get the actual portal URL. To use the default portal while passing custom options, pass "".
* @param [customOptions] Configuration for the client.
*/
constructor(initialPortalUrl = "", customOptions: CustomClientOptions = {}) {
if (initialPortalUrl === "") {
// Portal was not given, use the default portal URL. We'll still make a request for the resolved portal URL.
initialPortalUrl = defaultPortalUrl();
} else {
// Portal was given, don't make the request for the resolved portal URL.
this.customPortalUrl = ensureUrl(initialPortalUrl);
}
this.initialPortalUrl = initialPortalUrl;
this.customOptions = customOptions;
}
/* istanbul ignore next */
/**
* Make the request for the API portal URL.
*
* @returns - A promise that resolves when the request is complete.
*/
async initPortalUrl(): Promise<void> {
if (this.customPortalUrl) {
// Tried to make a request for the API portal URL when a custom URL was already provided.
return;
}
// Try to resolve the portal URL again if it's never been called or if it
// previously failed.
if (!S5Client.resolvedPortalUrl) {
S5Client.resolvedPortalUrl = this.resolvePortalUrl();
} else {
try {
await S5Client.resolvedPortalUrl;
} catch (e) {
S5Client.resolvedPortalUrl = this.resolvePortalUrl();
}
}
// Wait on the promise and throw if it fails.
await S5Client.resolvedPortalUrl;
return;
}
/* istanbul ignore next */
/**
* Returns the API portal URL. Makes the request to get it if not done so already.
*
* @returns - the portal URL.
*/
async portalUrl(): Promise<string> {
if (this.customPortalUrl) {
return this.customPortalUrl;
}
// Make the request if needed and not done so.
await this.initPortalUrl();
return await S5Client.resolvedPortalUrl!; // eslint-disable-line
}
/**
* Creates and executes a request.
*
* @param config - Configuration for the request.
* @returns - The response from axios.
* @throws - Will throw `ExecuteRequestError` if the request fails. This error contains the original Axios error.
*/
async executeRequest(config: RequestConfig): Promise<AxiosResponse> {
const params = {
method: config.method,
data: config.data,
responseType: config.responseType,
transformRequest: config.transformRequest,
transformResponse: config.transformResponse,
maxContentLength: Infinity,
maxBodyLength: Infinity,
// Allow cross-site cookies.
withCredentials: true,
} as AxiosRequestConfig;
params["url"] = await buildRequestUrl(this, {
baseUrl: config.url,
endpointPath: config.endpointPath,
subdomain: config.subdomain,
extraPath: config.extraPath,
query: config.query,
});
// Build headers.
params["headers"] = buildRequestHeaders(
config.headers,
config.customUserAgent,
config.customCookie,
config.s5ApiKey,
);
params["auth"] = config.APIKey
? { username: "", password: config.APIKey }
: undefined;
if (config.onDownloadProgress) {
params.onDownloadProgress = function (event: AxiosProgressEvent) {
// Avoid NaN for 0-byte file.
/* istanbul ignore next: Empty file test doesn't work yet. */
const progress = event.total ? event.loaded / event.total : 1;
// @ts-expect-error TS complains even though we've ensured this is defined.
config.onDownloadProgress(progress, event);
};
}
if (config.onUploadProgress) {
params.onUploadProgress = function (event: AxiosProgressEvent) {
// Avoid NaN for 0-byte file.
/* istanbul ignore next: event.total is always 0 in Node. */
const progress = event.total ? event.loaded / event.total : 1;
// @ts-expect-error TS complains even though we've ensured this is defined.
config.onUploadProgress(progress, event);
};
}
// NOTE: The error type is `ExecuteRequestError`. We set up a response
// interceptor above that does the conversion from `AxiosError`.
try {
return await axios(params);
} catch (e) {
// If `loginFn` is set and we get an Unauthorized response...
if (config.loginFn && (e as ExecuteRequestError).responseStatus === 401) {
// Try logging in again.
await config.loginFn(config);
// Unset the login function on the recursive call so that we don't try
// to login again, avoiding infinite loops.
return await this.executeRequest({ ...config, loginFn: undefined });
}
throw e;
}
}
// ===============
// Private Methods
// ===============
/**
* Gets the current server URL for the portal. You should generally use
* `portalUrl` instead - this method can be used for detecting whether the
* current URL is a server URL.
*
* @returns - The portal server URL.
*/
protected async resolvePortalServerUrl(): Promise<string> {
const response = await this.executeRequest({
...this.customOptions,
method: "head",
url: this.initialPortalUrl,
});
if (!response.headers) {
throw new Error(
"Did not get 'headers' in response despite a successful request. Please try again and report this issue to the devs if it persists.",
);
}
const portalUrl = response.headers["s5-server-api"];
if (!portalUrl) {
throw new Error("Could not get server portal URL for the given portal");
}
return portalUrl;
}
/**
* Make a request to resolve the provided `initialPortalUrl`.
*
* @returns - The portal URL.
*/
protected async resolvePortalUrl(): Promise<string> {
const response = await this.executeRequest({
...this.customOptions,
method: "head",
url: this.initialPortalUrl,
});
if (!response.headers) {
throw new Error(
"Did not get 'headers' in response despite a successful request. Please try again and report this issue to the devs if it persists.",
);
}
const portalUrl = response.headers["s5-portal-api"];
if (!portalUrl) {
throw new Error("Could not get portal URL for the given portal");
}
return portalUrl;
}
}

141
src/download.ts Normal file
View File

@ -0,0 +1,141 @@
import { ResponseType } from "axios";
import { S5Client } from "./client.ts";
import { BaseCustomOptions, DEFAULT_BASE_OPTIONS } from "./utils/options.ts";
import path from "path";
/**
* Custom download options.
*
* @property [endpointDownload] - The relative URL path of the portal endpoint to contact.
* @property [download=false] - Indicates to `getCidUrl` whether the file should be downloaded (true) or opened in the browser (false). `downloadFile` and `openFile` override this value.
* @property [path] - A path to append to the cid, e.g. `dir1/dir2/file`. A Unix-style path is expected. Each path component will be URL-encoded.
* @property [range] - The Range request header to set for the download. Not applicable for in-borwser downloads.
* @property [responseType] - The response type.
* @property [subdomain=false] - Whether to return the final cid in subdomain format.
*/
export type CustomDownloadOptions = BaseCustomOptions & {
endpointDownload?: string;
download?: boolean;
path?: string;
range?: string;
responseType?: ResponseType;
subdomain?: boolean;
};
export type CustomGetMetadataOptions = BaseCustomOptions & {
endpointGetMetadata?: string;
};
/**
* The response for a get metadata request.
*
* @property metadata - The metadata in JSON format.
* @property portalUrl - The URL of the portal.
* @property cid - 46-character cid.
*/
export type GetMetadataResponse = {
metadata: Record<string, unknown>;
};
export const DEFAULT_DOWNLOAD_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointDownload: "/",
download: false,
path: undefined,
range: undefined,
responseType: undefined,
subdomain: false,
};
const DEFAULT_GET_METADATA_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointGetMetadata: "/s5/metadata",
};
/**
* Initiates a download of the content of the cid within the browser.
*
* @param this - S5Client
* @param cid - 46-character cid, or a valid cid URL. Can be followed by a path. Note that the cid will not be encoded, so if your path might contain special characters, consider using `customOptions.path`.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointDownload="/"] - The relative URL path of the portal endpoint to contact.
* @returns - The full URL that was used.
* @throws - Will throw if the cid does not contain a cid or if the path option is not a string.
*/
export async function downloadFile(
this: S5Client,
cid: string,
customOptions?: CustomDownloadOptions,
): Promise<string> {
const opts = {
...DEFAULT_DOWNLOAD_OPTIONS,
...this.customOptions,
...customOptions,
download: true,
};
const url = await this.getCidUrl(cid, opts);
// Download the url.
window.location.assign(url);
return url;
}
/**
* Constructs the full URL for the given cid.
*
* @param this - S5Client
* @param cid - Base64 cid, or a valid URL that contains a cid. See `downloadFile`.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointDownload="/"] - The relative URL path of the portal endpoint to contact.
* @returns - The full URL for the cid.
* @throws - Will throw if the cid does not contain a cid or if the path option is not a string.
*/
export async function getCidUrl(
this: S5Client,
cid: string,
customOptions?: CustomDownloadOptions,
): Promise<string> {
const opts = {
...DEFAULT_DOWNLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
console.log(opts);
const portalUrl = await this.portalUrl();
return path.join(portalUrl, cid);
}
/**
* Gets only the metadata for the given cid without the contents.
*
* @param this - S5Client
* @param cid - Base64 cid.
* @param [customOptions] - Additional settings that can optionally be set. See `downloadFile` for the full list.
* @param [customOptions.endpointGetMetadata="/"] - The relative URL path of the portal endpoint to contact.
* @returns - The metadata in JSON format. Empty if no metadata was found.
* @throws - Will throw if the cid does not contain a cid .
*/
export async function getMetadata(
this: S5Client,
cid: string,
customOptions?: CustomGetMetadataOptions,
): Promise<GetMetadataResponse> {
const opts = {
...DEFAULT_GET_METADATA_OPTIONS,
...this.customOptions,
...customOptions,
};
const response = await this.executeRequest({
...opts,
method: "get",
extraPath: cid,
});
return response.data;
}

1
src/index.ts Normal file
View File

@ -0,0 +1 @@
export { S5Client } from "./client.ts";

227
src/request.ts Normal file
View File

@ -0,0 +1,227 @@
import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios";
import { S5Client } from "./client.ts";
import {
addUrlQuery,
addUrlSubdomain,
ensureUrlPrefix,
makeUrl,
} from "./utils/url.ts";
export type Headers = { [key: string]: string };
/**
* Helper function that builds the request headers.
*
* @param [baseHeaders] - Any base headers.
* @param [customUserAgent] - A custom user agent to set.
* @param [customCookie] - A custom cookie.
* @param [s5ApiKey] - Authentication key to use for a S5 portal.
* @returns - The built headers.
*/
export function buildRequestHeaders(
baseHeaders?: Headers,
customUserAgent?: string,
customCookie?: string,
s5ApiKey?: string,
): Headers {
const returnHeaders = { ...baseHeaders };
// Set some headers from common options.
if (customUserAgent) {
returnHeaders["User-Agent"] = customUserAgent;
}
if (customCookie) {
returnHeaders["Cookie"] = customCookie;
}
if (s5ApiKey) {
returnHeaders["S5-Api-Key"] = s5ApiKey;
}
return returnHeaders;
}
/**
* Helper function that builds the request URL. Ensures that the final URL
* always has a protocol prefix for consistency.
*
* @param client - The S5 client.
* @param parts - The URL parts to use when constructing the URL.
* @param [parts.baseUrl] - The base URL to use, instead of the portal URL.
* @param [parts.endpointPath] - The endpoint to contact.
* @param [parts.subdomain] - An optional subdomain to add to the URL.
* @param [parts.extraPath] - An optional path to append to the URL.
* @param [parts.query] - Optional query parameters to append to the URL.
* @returns - The built URL.
*/
export async function buildRequestUrl(
client: S5Client,
parts: {
baseUrl?: string;
endpointPath?: string;
subdomain?: string;
extraPath?: string;
query?: { [key: string]: string | undefined };
},
): Promise<string> {
let url;
// Get the base URL, if not passed in.
if (!parts.baseUrl) {
url = await client.portalUrl();
} else {
url = parts.baseUrl;
}
// Make sure the URL has a protocol.
url = ensureUrlPrefix(url);
if (parts.endpointPath) {
url = makeUrl(url, parts.endpointPath);
}
if (parts.extraPath) {
url = makeUrl(url, parts.extraPath);
}
if (parts.subdomain) {
url = addUrlSubdomain(url, parts.subdomain);
}
if (parts.query) {
url = addUrlQuery(url, parts.query);
}
return url;
}
/**
* The error type returned by the SDK whenever it makes a network request
* (internally, this happens in `executeRequest`). It implements, so is
* compatible with, `AxiosError`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ExecuteRequestError<T = any, D = any>
extends Error
implements AxiosError
{
originalError: AxiosError;
responseStatus: number | null;
responseMessage: string | null;
// Properties required by `AxiosError`.
config: InternalAxiosRequestConfig<D>;
code?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
request?: any;
response?: AxiosResponse<T, D>;
isAxiosError: boolean;
// eslint-disable-next-line @typescript-eslint/ban-types
toJSON: () => object;
/**
* Creates an `ExecuteRequestError`.
*
* @param message - The error message.
* @param axiosError - The original Axios error.
* @param responseStatus - The response status, if found in the original error.
* @param responseMessage - The response message, if found in the original error.
*/
constructor(
message: string,
axiosError: AxiosError<T, D>,
responseStatus: number | null,
responseMessage: string | null,
) {
// Include this check since `ExecuteRequestError` implements `AxiosError`,
// but we only expect original errors from Axios here. Anything else
// indicates a likely developer/logic bug.
if (axiosError instanceof ExecuteRequestError) {
throw new Error(
"Could not instantiate an `ExecuteRequestError` from an `ExecuteRequestError`, an original error from axios was expected",
);
}
// Set `Error` fields.
super(message);
this.name = "ExecuteRequestError";
// Set `ExecuteRequestError` fields.
this.originalError = axiosError;
this.responseStatus = responseStatus;
this.responseMessage = responseMessage;
// Set properties required by `AxiosError`.
//
// NOTE: `Object.assign` doesn't work because Typescript can't detect that
// required fields are set in this constructor.
if (!axiosError.config) {
throw new Error("axiosError.config is undefined");
}
this.config = axiosError.config;
this.code = axiosError.code;
this.request = axiosError.request;
this.response = axiosError.response;
this.isAxiosError = axiosError.isAxiosError;
this.toJSON = axiosError.toJSON;
// Required for `instanceof` to work.
Object.setPrototypeOf(this, ExecuteRequestError.prototype);
}
/**
* Gets the full, descriptive error response returned from the portal.
*
* @param err - The Axios error.
* @returns - A new error if the error response is malformed, or the error message otherwise.
*/
static From(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
err: AxiosError<any, any>,
): ExecuteRequestError {
/* istanbul ignore next */
if (!err.response) {
return new ExecuteRequestError(
`Error response did not contain expected field 'response'.`,
err,
null,
null,
);
}
/* istanbul ignore next */
if (!err.response.status) {
return new ExecuteRequestError(
`Error response did not contain expected field 'response.status'.`,
err,
null,
null,
);
}
const status = err.response.status;
// If we don't get an error message, just return the status code.
/* istanbul ignore next */
if (!err.response.data) {
return new ExecuteRequestError(
`Request failed with status code ${status}`,
err,
status,
null,
);
}
/* istanbul ignore next */
if (!err.response.data.message) {
return new ExecuteRequestError(
`Request failed with status code ${status}`,
err,
status,
null,
);
}
// Return the error message. Pass along the original Axios error.
return new ExecuteRequestError(
`Request failed with status code ${err.response.status}: ${err.response.data.message}`,
err,
status,
err.response.data.message,
);
}
}

416
src/upload.ts Normal file
View File

@ -0,0 +1,416 @@
import { AxiosResponse } from "axios";
import { DetailedError, HttpRequest, Upload } from "tus-js-client";
import { blake3 } from "@noble/hashes/blake3";
import { Buffer } from "buffer";
import { getFileMimeType } from "./utils/file.ts";
import { BaseCustomOptions, DEFAULT_BASE_OPTIONS } from "./utils/options.ts";
import { S5Client } from "./client.ts";
import type { JsonData } from "./utils/types.ts";
import { buildRequestHeaders, buildRequestUrl } from "./request.ts";
import { CID_HASH_TYPES, CID_TYPES } from "@lumeweb/libs5";
/**
* The tus chunk size is (4MiB - encryptionOverhead) * dataPieces, set as default.
*/
export const TUS_CHUNK_SIZE = (1 << 22) * 8;
/**
* The retry delays, in ms. Data is stored in for up to 20 minutes, so the
* total delays should not exceed that length of time.
*/
const DEFAULT_TUS_RETRY_DELAYS = [0, 5_000, 15_000, 60_000, 300_000, 600_000];
/**
* The portal file field name.
*/
const PORTAL_FILE_FIELD_NAME = "file";
/**
* Custom upload options.
*
* @property [endpointUpload] - The relative URL path of the portal endpoint to contact.
* @property [endpointDirectoryUpload] - The relative URL path of the portal endpoint to contact for Directorys.
* @property [endpointLargeUpload] - The relative URL path of the portal endpoint to contact for large uploads.
* @property [customFilename] - The custom filename to use when uploading files.
* @property [largeFileSize=32943040] - The size at which files are considered "large" and will be uploaded using the tus resumable upload protocol. This is the size of one chunk by default (32 mib). Note that this does not affect the actual size of chunks used by the protocol.
* @property [errorPages] - Defines a mapping of error codes and subfiles which are to be served in case we are serving the respective error code. All subfiles referred like this must be defined with absolute paths and must exist.
* @property [retryDelays=[0, 5_000, 15_000, 60_000, 300_000, 600_000]] - An array or undefined, indicating how many milliseconds should pass before the next attempt to uploading will be started after the transfer has been interrupted. The array's length indicates the maximum number of attempts.
* @property [tryFiles] - Allows us to set a list of potential subfiles to return in case the requested one does not exist or is a directory. Those subfiles might be listed with relative or absolute paths. If the path is absolute the file must exist.
*/
export type CustomUploadOptions = BaseCustomOptions & {
endpointUpload?: string;
endpointDirectoryUpload: string;
endpointLargeUpload?: string;
customFilename?: string;
errorPages?: JsonData;
tryFiles?: string[];
// Large files.
largeFileSize?: number;
retryDelays?: number[];
};
/**
* The response to an upload request.
*
* @property cid - 46-character cid.
*/
export type UploadRequestResponse = {
cid: string;
};
/**
* The response to an upload request.
*
* @property cid - 46-character cid.
*/
export type UploadTusRequestResponse = {
data: { cid: string };
};
export const DEFAULT_UPLOAD_OPTIONS = {
...DEFAULT_BASE_OPTIONS,
endpointUpload: "/s5/upload",
endpointDirectoryUpload: "/s5/upload/directory",
endpointLargeUpload: "/s5/upload/tus",
customFilename: "",
errorPages: { 404: "/404.html" },
tryFiles: ["index.html"],
// Large files.
largeFileSize: TUS_CHUNK_SIZE,
retryDelays: DEFAULT_TUS_RETRY_DELAYS,
};
/**
* Uploads a file to S5-net.
*
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointUpload="/s5/upload"] - The relative URL path of the portal endpoint to contact for small uploads.
* @param [customOptions.endpointDirectoryUpload="/s5/upload/directory"] - The relative URL path of the portal endpoint to contact for Directory uploads.
* @param [customOptions.endpointLargeUpload="/s5/upload/tus"] - The relative URL path of the portal endpoint to contact for large uploads.
* @returns - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
export async function uploadFile(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
if (file.size < opts.largeFileSize) {
return this.uploadSmallFile(file, opts);
} else {
return this.uploadLargeFile(file, opts);
}
}
/**
* Uploads a small file to S5-net.
*
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointUpload="/s5/upload"] - The relative URL path of the portal endpoint to contact.
* @returns - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
export async function uploadSmallFile(
this: S5Client,
file: File,
customOptions: CustomUploadOptions,
): Promise<UploadRequestResponse> {
const response = await this.uploadSmallFileRequest(file, customOptions);
return { cid: response.data.cid };
}
/**
* Makes a request to upload a small file to S5-net.
*
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointPath="/s5/upload"] - The relative URL path of the portal endpoint to contact.
* @returns - The upload response.
*/
export async function uploadSmallFileRequest(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<AxiosResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
const formData = new FormData();
file = ensureFileObjectConsistency(file);
if (opts.customFilename) {
formData.append(PORTAL_FILE_FIELD_NAME, file, opts.customFilename);
} else {
formData.append(PORTAL_FILE_FIELD_NAME, file);
}
return await this.executeRequest({
...opts,
endpointPath: opts.endpointUpload,
method: "post",
data: formData,
});
}
/* istanbul ignore next */
/**
* Uploads a large file to S5-net using tus.
*
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointLargeUpload="/s5/upload/tus"] - The relative URL path of the portal endpoint to contact.
* @returns - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
export async function uploadLargeFile(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
const response = await this.uploadLargeFileRequest(file, customOptions);
return response.data;
}
/* istanbul ignore next */
/**
* Makes a request to upload a file to S5-net.
*
* @param this - S5Client
* @param file - The file to upload.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointLargeUpload="/s5/upload/tus"] - The relative URL path of the portal endpoint to contact.
* @returns - The upload response.
*/
export async function uploadLargeFileRequest(
this: S5Client,
file: File,
customOptions?: CustomUploadOptions,
): Promise<UploadTusRequestResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
// Validation.
const url = await buildRequestUrl(this, {
endpointPath: opts.endpointLargeUpload,
});
const headers = buildRequestHeaders(
undefined,
opts.customUserAgent,
opts.customCookie,
opts.s5ApiKey,
);
file = ensureFileObjectConsistency(file);
let filename = file.name;
if (opts.customFilename) {
filename = opts.customFilename;
}
const onProgress =
opts.onUploadProgress &&
function (bytesSent: number, bytesTotal: number) {
const progress = bytesSent / bytesTotal;
// @ts-expect-error TS complains.
opts.onUploadProgress(progress, { loaded: bytesSent, total: bytesTotal });
};
const hasher = blake3.create({});
const chunkSize = 1024 * 1024;
let position = 0;
while (position <= file.size) {
const chunk = file.slice(position, position + chunkSize);
hasher.update(new Uint8Array(await chunk.arrayBuffer()));
position += chunkSize;
}
const b3hash = hasher.digest();
const hash = Buffer.concat([
Buffer.alloc(1, CID_HASH_TYPES.BLAKE3),
Buffer.from(b3hash),
]);
const cid = Buffer.concat([
Buffer.alloc(1, CID_TYPES.RAW),
hash,
numberToBuffer(file.size),
]);
/**
* convert a number to Buffer.
*
* @param value - File objects to upload, indexed by their path strings.
* @returns - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
function numberToBuffer(value: number) {
const view = Buffer.alloc(16);
let lastIndex = 15;
for (let index = 0; index <= 15; ++index) {
if (value % 256 !== 0) {
lastIndex = index;
}
view[index] = value % 256;
value = value >> 8;
}
return view.subarray(0, lastIndex + 1);
}
return new Promise((resolve, reject) => {
const tusOpts = {
endpoint: url,
// retryDelays: opts.retryDelays,
metadata: {
hash: hash
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace("=", ""),
filename,
filetype: file.type,
},
headers,
onProgress,
onBeforeRequest: function (req: HttpRequest) {
const xhr = req.getUnderlyingObject();
xhr.withCredentials = true;
},
onError: (error: Error | DetailedError) => {
// Return error body rather than entire error.
const res = (error as DetailedError).originalResponse;
const newError = res ? new Error(res.getBody().trim()) || error : error;
reject(newError);
},
onSuccess: async () => {
if (!upload.url) {
reject(new Error("'upload.url' was not set"));
return;
}
const resCid =
"u" +
cid
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace("=", "");
const resolveData = { data: { cid: resCid } };
resolve(resolveData);
},
};
const upload = new Upload(file, tusOpts);
upload.start();
});
}
/**
* Uploads a directory to S5-net.
*
* @param this - S5Client
* @param directory - File objects to upload, indexed by their path strings.
* @param filename - The name of the directory.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointPath="/s5/upload/directory"] - The relative URL path of the portal endpoint to contact.
* @returns - The returned cid.
* @throws - Will throw if the request is successful but the upload response does not contain a complete response.
*/
export async function uploadDirectory(
this: S5Client,
directory: Record<string, File>,
filename: string,
customOptions?: CustomUploadOptions,
): Promise<UploadRequestResponse> {
const response = await this.uploadDirectoryRequest(
directory,
filename,
customOptions,
);
return { cid: response.data.cid };
}
/**
* Makes a request to upload a directory to S5-net.
*
* @param this - S5Client
* @param directory - File objects to upload, indexed by their path strings.
* @param filename - The name of the directory.
* @param [customOptions] - Additional settings that can optionally be set.
* @param [customOptions.endpointPath="/s5/upload/directory"] - The relative URL path of the portal endpoint to contact.
* @returns - The upload response.
* @throws - Will throw if the input filename is not a string.
*/
export async function uploadDirectoryRequest(
this: S5Client,
directory: Record<string, File>,
filename: string,
customOptions?: CustomUploadOptions,
): Promise<AxiosResponse> {
const opts = {
...DEFAULT_UPLOAD_OPTIONS,
...this.customOptions,
...customOptions,
};
const formData = new FormData();
Object.entries(directory).forEach(([path, file]) => {
file = ensureFileObjectConsistency(file as File);
formData.append(path, file as File, path);
});
const query: { [key: string]: string | undefined } = { filename };
if (opts.tryFiles) {
query.tryfiles = JSON.stringify(opts.tryFiles);
}
if (opts.errorPages) {
query.errorpages = JSON.stringify(opts.errorPages);
}
return await this.executeRequest({
...opts,
endpointPath: opts.endpointDirectoryUpload,
method: "post",
data: formData,
query,
});
}
/**
* Sometimes file object might have had the type property defined manually with
* Object.defineProperty and some browsers (namely firefox) can have problems
* reading it after the file has been appended to form data. To overcome this,
* we recreate the file object using native File constructor with a type defined
* as a constructor argument.
*
* @param file - The input file.
* @returns - The processed file.
*/
function ensureFileObjectConsistency(file: File): File {
return new File([file], file.name, { type: getFileMimeType(file) });
}

24
src/utils/file.ts Normal file
View File

@ -0,0 +1,24 @@
import mime from "mime/lite";
import path from "path";
import { trimPrefix } from "./string.ts";
/**
* Get the file mime type. In case the type is not provided, try to guess the
* file type based on the extension.
*
* @param file - The file.
* @returns - The mime type.
*/
export function getFileMimeType(file: File): string {
if (file.type) return file.type;
let ext = path.extname(file.name);
ext = trimPrefix(ext, ".");
if (ext !== "") {
const mimeType = mime.getType(ext);
if (mimeType) {
return mimeType;
}
}
return "";
}

21
src/utils/options.ts Normal file
View File

@ -0,0 +1,21 @@
import { CustomClientOptions } from "../client.ts";
// TODO: Unnecessary, remove.
/**
* Base custom options for methods hitting the API.
*/
export type BaseCustomOptions = CustomClientOptions;
// TODO: Move to client.ts.
/**
* The default base custom options.
*/
export const DEFAULT_BASE_OPTIONS = {
APIKey: "",
s5ApiKey: "",
customUserAgent: "",
customCookie: "",
onDownloadProgress: undefined,
onUploadProgress: undefined,
loginFn: undefined,
};

63
src/utils/string.ts Normal file
View File

@ -0,0 +1,63 @@
/**
* Prepends the prefix to the given string only if the string does not already start with the prefix.
*
* @param str - The string.
* @param prefix - The prefix.
* @returns - The prefixed string.
*/
export function ensurePrefix(str: string, prefix: string): string {
if (!str.startsWith(prefix)) {
str = `${prefix}${str}`;
}
return str;
}
/**
* Removes a prefix from the beginning of the string.
*
* @param str - The string to process.
* @param prefix - The prefix to remove.
* @param [limit] - Maximum amount of times to trim. No limit by default.
* @returns - The processed string.
*/
export function trimPrefix(
str: string,
prefix: string,
limit?: number,
): string {
while (str.startsWith(prefix)) {
if (limit !== undefined && limit <= 0) {
break;
}
str = str.slice(prefix.length);
if (limit) {
limit -= 1;
}
}
return str;
}
/**
* Removes a suffix from the end of the string.
*
* @param str - The string to process.
* @param suffix - The suffix to remove.
* @param [limit] - Maximum amount of times to trim. No limit by default.
* @returns - The processed string.
*/
export function trimSuffix(
str: string,
suffix: string,
limit?: number,
): string {
while (str.endsWith(suffix)) {
if (limit !== undefined && limit <= 0) {
break;
}
str = str.substring(0, str.length - suffix.length);
if (limit) {
limit -= 1;
}
}
return str;
}

1
src/utils/types.ts Normal file
View File

@ -0,0 +1 @@
export type JsonData = Record<string, unknown>;

120
src/utils/url.ts Normal file
View File

@ -0,0 +1,120 @@
import urljoin from "url-join";
import parse from "url-parse";
import { trimSuffix } from "./string.ts";
import { throwValidationError } from "./validation.ts";
export const DEFAULT_S5_PORTAL_URL = "https://localhost:5522";
/**
* @deprecated please use DEFAULT_S5_PORTAL_URL.
*/
export const defaultS5PortalUrl = DEFAULT_S5_PORTAL_URL;
export const URI_S5_PREFIX = "s5://";
/**
* @deprecated please use URI_S5_PREFIX.
*/
export const uriS5Prefix = URI_S5_PREFIX;
/**
* Returns the default portal URL.
*
* @returns - The portal URL.
*/
export function defaultPortalUrl(): string {
/* istanbul ignore next */
if (typeof window === "undefined") return "/"; // default to path root on ssr
return window.location.origin;
}
/**
* Adds a subdomain to the given URL.
*
* @param url - The URL.
* @param subdomain - The subdomain to add.
* @returns - The final URL.
*/
export function addUrlSubdomain(url: string, subdomain: string): string {
const urlObj = new URL(url);
urlObj.hostname = `${subdomain}.${urlObj.hostname}`;
const str = urlObj.toString();
return trimSuffix(str, "/");
}
/**
* Adds a query to the given URL.
*
* @param url - The URL.
* @param query - The query parameters.
* @returns - The final URL.
*/
export function addUrlQuery(
url: string,
query: { [key: string]: string | undefined },
): string {
const parsed = parse(url, true);
// Combine the desired query params with the already existing ones.
query = { ...parsed.query, ...query };
parsed.set("query", query);
return parsed.toString();
}
/**
* Prepends the prefix to the given string only if the string does not already start with the prefix.
*
* @param str - The string.
* @param prefix - The prefix.
* @returns - The prefixed string.
*/
export function ensurePrefix(str: string, prefix: string): string {
if (!str.startsWith(prefix)) {
str = `${prefix}${str}`;
}
return str;
}
/**
* Ensures that the given string is a URL.
*
* @param url - The given string.
* @returns - The URL.
*/
export function ensureUrl(url: string): string {
if (url.startsWith("http://")) {
return url;
}
return ensurePrefix(url, "https://");
}
/**
* Ensures that the given string is a URL with a protocol prefix.
*
* @param url - The given string.
* @returns - The URL.
*/
export function ensureUrlPrefix(url: string): string {
if (url === "localhost") {
return "http://localhost/";
}
if (!/^https?:(\/\/)?/i.test(url)) {
return `https://${url}`;
}
return url;
}
/**
* Properly joins paths together to create a URL. Takes a variable number of
* arguments.
*
* @param args - Array of URL parts to join.
* @returns - Final URL constructed from the input parts.
*/
export function makeUrl(...args: string[]): string {
if (args.length === 0) {
throwValidationError("args", args, "parameter", "non-empty");
}
return ensureUrl(args.reduce((acc, cur) => urljoin(acc, cur)));
}

45
src/utils/validation.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* Throws an error for the given value
*
* @param name - The name of the value.
* @param value - The actual value.
* @param valueKind - The kind of value that is being checked (e.g. "parameter", "response field", etc.)
* @param expected - The expected aspect of the value that could not be validated (e.g. "type 'string'" or "non-null").
* @throws - Will always throw.
*/
export function throwValidationError(
name: string,
value: unknown,
valueKind: string,
expected: string,
): void {
throw validationError(name, value, valueKind, expected);
}
/**
* Returns an error for the given value
*
* @param name - The name of the value.
* @param value - The actual value.
* @param valueKind - The kind of value that is being checked (e.g. "parameter", "response field", etc.)
* @param expected - The expected aspect of the value that could not be validated (e.g. "type 'string'" or "non-null").
* @returns - The validation error.
*/
export function validationError(
name: string,
value: unknown,
valueKind: string,
expected: string,
): Error {
let actualValue: string;
if (value === undefined) {
actualValue = "type 'undefined'";
} else if (value === null) {
actualValue = "type 'null'";
} else {
actualValue = `type '${typeof value}', value '${value}'`;
}
return new Error(
`Expected ${valueKind} '${name}' to be ${expected}, was ${actualValue}`,
);
}