import { AViewerPluginSync, FileTransferPlugin, pathJoin, serialize, ThreeViewer, timeout, uiButton, uiFolderContainer, uiInput, UiObjectConfig, uiToggle, } from 'threepipe' import {AwsClient, AwsV4Signer} from 'aws4fetch' /** * AWSClientPlugin * Provides `fetch` function that performs a fetch request with AWS v4 signing. * This is useful for connecting to AWS services like S3 directly from the client. * It also interfaces with the {@link FileTransferPlugin} to directly upload file when exported with the viewer or the plugin. * Note: Make sure to use keys with limited privileges and correct CORS settings. * All the keys will be stored in plain text if `serializeSettings` is set to true * * {@todo Make an example for AWSClient Plugin} */ @uiFolderContainer('AWS/S3 Client') export class AWSClientPlugin extends AViewerPluginSync<'fileUpload'> { static readonly PluginType = 'AWSClientPlugin1' uiConfig?: UiObjectConfig enabled = true private _connected = false // do not serialize in exported file. readonly serializeWithViewer = false private _client: AwsClient | undefined dependencies = [FileTransferPlugin] constructor() { super() } @serialize() @uiInput('Access Key ID', (t: AWSClientPlugin)=>({ disabled: ()=>!t.enabled || t._connected, })) accessKeyId = '' @serialize() @uiInput('Access Key Secret', (t: AWSClientPlugin)=>({ disabled: ()=>!t.enabled || t._connected, })) accessKeySecret = '' @serialize() @uiInput('Endpoint URL', (t: AWSClientPlugin)=>({ disabled: ()=>!t.enabled || t._connected, })) endpointURL = '' @serialize() @uiInput('Path Prefix', (t: AWSClientPlugin)=>({ disabled: ()=>!t.enabled, })) pathPrefix = 'webgi' @serialize() @uiToggle('Remember', (t: AWSClientPlugin)=>({ disabled: ()=>!t.enabled || t._connected, })) serializeSettings = false @uiButton(undefined, (t: AWSClientPlugin)=>({ label: ()=>t._connected ? 'Disconnect' : 'Connect', })) toggleConnection = ()=>{ if (this._connected) { this.disconnect() } else { this.connect() } } /** * Set to true to use a proxy for all requests. * This can be used to move the access credentials to the server side or set custom headers. * This is required for some services like cloudflare R2 that do not support CORS. * usage: `AWSClientPlugin.USE_PROXY = true`, optionally set `AWSClientPlugin.PROXY_URL` to a custom proxy. */ static USE_PROXY = false static PROXY_URL = 'https://r2-s3-api.repalash.com/{path}' connect() { if (this._connected) this.disconnect() this._client = new AwsClient({ accessKeyId: this.accessKeyId, secretAccessKey: this.accessKeySecret, }) this._connected = true this.refreshUi() } refreshUi() { this.uiConfig?.uiRefresh?.(true, 'postFrame') } disconnect() { this._client = undefined this._connected = false this.refreshUi() } get connected(): boolean { return this._connected } get client(): AwsClient | undefined { return this._client } toJSON(meta?: any): any { if (!this.serializeSettings) return {type: (this as any).constructor.PluginType} return super.toJSON(meta) } private _savedExportFile?: FileTransferPlugin['defaultActions']['exportFile'] onAdded(viewer: ThreeViewer) { super.onAdded(viewer) const tr = viewer.getPlugin(FileTransferPlugin)! this._savedExportFile = tr.actions.exportFile || tr.defaultActions.exportFile if (!this._savedExportFile) throw new Error('FileTransferPlugin must have exportFile action') tr.actions.exportFile = this.exportFile } onRemove(viewer: ThreeViewer) { const tr = viewer.getPlugin(FileTransferPlugin)! tr.actions.exportFile = this._savedExportFile! this._savedExportFile = undefined super.onRemove(viewer) } exportFile: FileTransferPlugin['defaultActions']['exportFile'] = async(blob, name, onProgress)=>{ const viewer = this._viewer if (!viewer) return const tr = viewer.getPlugin(FileTransferPlugin) if (!tr) return const defaultExport = this._savedExportFile ?? tr.defaultActions.exportFile if (!this._connected) { await defaultExport(blob, name) return } const path = pathJoin([this.endpointURL, this.pathPrefix, name]) const response = await this.fetch(path, { method: 'PUT', body: blob, }, onProgress) if (!response.ok) { viewer.console.error('Error uploading file', response) await defaultExport(blob, name) return } this.dispatchEvent({type: 'fileUpload', name, blob, response, path}) viewer.console.log('File uploaded', response) } fetchFunction = fetch async fetch(input: RequestInfo, init: RequestInit, _onProgress?: (d: {state?: string, progress?: number})=>void) { if (!this._client) throw new Error('Not connected') for (let i = 0; i <= this._client.retries; i++) { // todo: add onProgress (using futch in dom.ts?): https://github.com/github/fetch/issues/89 const signed = await sign2(this._client, input, init) let url = signed.url.toString() if (AWSClientPlugin.USE_PROXY && url && !url.includes(AWSClientPlugin.PROXY_URL)) { // const options: RequestInit = { // headers: signed.headers, // method: signed.method, // body: signed.body, // // ts-expect-error this is a valid option // // duplex: 'half', // todo; get from request? // } // https://github.com/sindresorhus/ky/blob/2af72bfa7a391662a8ee6b1671979069f7f20737/source/core/Ky.ts#L176 // https://issues.chromium.org/issues/40237822 // if (supportsRequestStreams) { // // @ts-expect-error - Types are outdated. // options.duplex = 'half' // } url = AWSClientPlugin.PROXY_URL.replace('{path}', url) } // try { // signed = new Request(url, options) // } catch (e) { // if (e instanceof TypeError) { // // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943 // signed = new Request(url, Object.assign({duplex: 'half'}, options)) // } else throw e // } const f = this.fetchFunction // required to first put it in a variable and then call. const fetched = f(url, signed) if (i === this._client.retries) { return fetched // No need to await if we're returning anyway } const res = await fetched if (res.status < 500 && res.status !== 429) { return res } await timeout(Math.random() * this._client.initRetryMs * Math.pow(2, i)) } throw new Error('An unknown error occurred, ensure retries is not negative') } } export type AwsRequestInit = RequestInit & { aws?: { accessKeyId?: string | undefined; secretAccessKey?: string | undefined; sessionToken?: string | undefined; service?: string | undefined; region?: string | undefined; cache?: Map | undefined; datetime?: string | undefined; signQuery?: boolean | undefined; appendSessionToken?: boolean | undefined; allHeaders?: boolean | undefined; singleEncode?: boolean | undefined; } | undefined; } export async function sign2(client: AwsClient, input: RequestInfo, init?: AwsRequestInit) { if (input instanceof Request) { const {method, url, headers, body} = input init = Object.assign({method, url, headers}, init) if (init.body == null && headers.has('Content-Type')) { init.body = body != null && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer() } input = url console.warn('There could be a bug in chrome with cloning Request objects, see https://bugs.chromium.org/p/chromium/issues/detail?id=1360943') } const signer = new AwsV4Signer(Object.assign({url: input}, init, client, init && init.aws)) const signed = Object.assign({}, init, await signer.sign()) delete signed.aws return signed // try { // return new Request(signed.url.toString(), signed) // } catch (e) { // if (e instanceof TypeError) { // // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943 // return new Request(signed.url.toString(), Object.assign({duplex: 'half'}, signed)) // } // throw e // } } // https://github.com/sindresorhus/ky/blob/main/source/core/constants.ts // https://issues.chromium.org/issues/40237822 // todo: right now we are using try catch like in aws4fetch // export const supportsRequestStreams = (() => { // let duplexAccessed = false // let hasContentType = false // const supportsReadableStream = typeof globalThis.ReadableStream === 'function' // const supportsRequest = typeof globalThis.Request === 'function' // // if (supportsReadableStream && supportsRequest) { // hasContentType = new globalThis.Request('https://empty.invalid', { // body: new globalThis.ReadableStream(), // method: 'POST', // // @ts-expect-error - Types are outdated. // get duplex() { // duplexAccessed = true // return 'half' // }, // }).headers.has('Content-Type') // } // // return duplexAccessed && !hasContentType // })()