threepipe
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AWSClientPlugin.ts 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import {
  2. AViewerPluginEventMap,
  3. AViewerPluginSync,
  4. FileTransferPlugin,
  5. pathJoin,
  6. serialize,
  7. ThreeViewer,
  8. timeout,
  9. uiButton,
  10. uiFolderContainer,
  11. uiInput,
  12. UiObjectConfig,
  13. uiToggle,
  14. } from 'threepipe'
  15. import {AwsClient, AwsV4Signer} from 'aws4fetch'
  16. export interface AWSClientPluginEventMap extends AViewerPluginEventMap {
  17. fileUpload: {
  18. name: string
  19. blob: Blob
  20. response: Response
  21. path: string
  22. }
  23. }
  24. /**
  25. * AWSClientPlugin
  26. * Provides `fetch` function that performs a fetch request with AWS v4 signing.
  27. * This is useful for connecting to AWS services like S3 directly from the client.
  28. * It also interfaces with the {@link FileTransferPlugin} to directly upload file when exported with the viewer or the plugin.
  29. * Note: Make sure to use keys with limited privileges and correct CORS settings.
  30. * All the keys will be stored in plain text if `serializeSettings` is set to true
  31. *
  32. * {@todo Make an example for AWSClient Plugin}
  33. */
  34. @uiFolderContainer('AWS/S3 Client')
  35. export class AWSClientPlugin extends AViewerPluginSync<AWSClientPluginEventMap> {
  36. static readonly PluginType = 'AWSClientPlugin1'
  37. declare uiConfig: UiObjectConfig
  38. enabled = true
  39. private _connected = false
  40. // do not serialize in exported file.
  41. readonly serializeWithViewer = false
  42. private _client: AwsClient | undefined
  43. dependencies = [FileTransferPlugin]
  44. constructor() {
  45. super()
  46. }
  47. @serialize()
  48. @uiInput('Access Key ID', (t: AWSClientPlugin)=>({
  49. disabled: ()=>!t.enabled || t._connected,
  50. }))
  51. accessKeyId = ''
  52. @serialize()
  53. @uiInput('Access Key Secret', (t: AWSClientPlugin)=>({
  54. disabled: ()=>!t.enabled || t._connected,
  55. }))
  56. accessKeySecret = ''
  57. @serialize()
  58. @uiInput('Endpoint URL', (t: AWSClientPlugin)=>({
  59. disabled: ()=>!t.enabled || t._connected,
  60. }))
  61. endpointURL = ''
  62. @serialize()
  63. @uiInput('Path Prefix', (t: AWSClientPlugin)=>({
  64. disabled: ()=>!t.enabled,
  65. }))
  66. pathPrefix = 'webgi'
  67. @serialize()
  68. @uiToggle('Remember', (t: AWSClientPlugin)=>({
  69. disabled: ()=>!t.enabled || t._connected,
  70. }))
  71. serializeSettings = false
  72. @uiButton(undefined, (t: AWSClientPlugin)=>({
  73. label: ()=>t._connected ? 'Disconnect' : 'Connect',
  74. }))
  75. toggleConnection = ()=>{
  76. if (this._connected) {
  77. this.disconnect()
  78. } else {
  79. this.connect()
  80. }
  81. }
  82. /**
  83. * Set to true to use a proxy for all requests.
  84. * This can be used to move the access credentials to the server side or set custom headers.
  85. * This is required for some services like cloudflare R2 that do not support CORS.
  86. * usage: `AWSClientPlugin.USE_PROXY = true`, optionally set `AWSClientPlugin.PROXY_URL` to a custom proxy.
  87. */
  88. static USE_PROXY = false
  89. static PROXY_URL = 'https://r2-s3-api.repalash.com/{path}'
  90. connect() {
  91. if (this._connected) this.disconnect()
  92. this._client = new AwsClient({
  93. accessKeyId: this.accessKeyId,
  94. secretAccessKey: this.accessKeySecret,
  95. })
  96. this._connected = true
  97. this.refreshUi()
  98. }
  99. refreshUi() {
  100. this.uiConfig?.uiRefresh?.(true, 'postFrame')
  101. }
  102. disconnect() {
  103. this._client = undefined
  104. this._connected = false
  105. this.refreshUi()
  106. }
  107. get connected(): boolean {
  108. return this._connected
  109. }
  110. get client(): AwsClient | undefined {
  111. return this._client
  112. }
  113. toJSON(meta?: any): any {
  114. if (!this.serializeSettings) return {type: (this as any).constructor.PluginType}
  115. return super.toJSON(meta)
  116. }
  117. private _savedExportFile?: FileTransferPlugin['defaultActions']['exportFile']
  118. onAdded(viewer: ThreeViewer) {
  119. super.onAdded(viewer)
  120. const tr = viewer.getPlugin(FileTransferPlugin)!
  121. this._savedExportFile = tr.actions.exportFile || tr.defaultActions.exportFile
  122. if (!this._savedExportFile) throw new Error('FileTransferPlugin must have exportFile action')
  123. tr.actions.exportFile = this.exportFile
  124. }
  125. onRemove(viewer: ThreeViewer) {
  126. const tr = viewer.getPlugin(FileTransferPlugin)!
  127. tr.actions.exportFile = this._savedExportFile!
  128. this._savedExportFile = undefined
  129. super.onRemove(viewer)
  130. }
  131. exportFile: FileTransferPlugin['defaultActions']['exportFile'] = async(blob, name, onProgress)=>{
  132. const viewer = this._viewer
  133. if (!viewer) return
  134. const tr = viewer.getPlugin(FileTransferPlugin)
  135. if (!tr) return
  136. const defaultExport = this._savedExportFile ?? tr.defaultActions.exportFile
  137. if (!this._connected) {
  138. await defaultExport(blob, name)
  139. return
  140. }
  141. const path = pathJoin([this.endpointURL, this.pathPrefix, name])
  142. const response = await this.fetch(path, {
  143. method: 'PUT',
  144. body: blob,
  145. }, onProgress)
  146. if (!response.ok) {
  147. viewer.console.error('Error uploading file', response)
  148. await defaultExport(blob, name)
  149. return
  150. }
  151. this.dispatchEvent({type: 'fileUpload', name, blob, response, path})
  152. viewer.console.log('File uploaded', response)
  153. }
  154. fetchFunction = fetch
  155. async fetch(input: RequestInfo, init: RequestInit, _onProgress?: (d: {state?: string, progress?: number})=>void) {
  156. if (!this._client) throw new Error('Not connected')
  157. for (let i = 0; i <= this._client.retries; i++) {
  158. // todo: add onProgress (using futch in dom.ts?): https://github.com/github/fetch/issues/89
  159. const signed = await sign2(this._client, input, init)
  160. let url = signed.url.toString()
  161. if (AWSClientPlugin.USE_PROXY && url && !url.includes(AWSClientPlugin.PROXY_URL)) {
  162. // const options: RequestInit = {
  163. // headers: signed.headers,
  164. // method: signed.method,
  165. // body: signed.body,
  166. // // ts-expect-error this is a valid option
  167. // // duplex: 'half', // todo; get from request?
  168. // }
  169. // https://github.com/sindresorhus/ky/blob/2af72bfa7a391662a8ee6b1671979069f7f20737/source/core/Ky.ts#L176
  170. // https://issues.chromium.org/issues/40237822
  171. // if (supportsRequestStreams) {
  172. // // @ts-expect-error - Types are outdated.
  173. // options.duplex = 'half'
  174. // }
  175. url = AWSClientPlugin.PROXY_URL.replace('{path}', url)
  176. }
  177. // try {
  178. // signed = new Request(url, options)
  179. // } catch (e) {
  180. // if (e instanceof TypeError) {
  181. // // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
  182. // signed = new Request(url, Object.assign({duplex: 'half'}, options))
  183. // } else throw e
  184. // }
  185. const f = this.fetchFunction // required to first put it in a variable and then call.
  186. const fetched = f(url, signed)
  187. if (i === this._client.retries) {
  188. return fetched // No need to await if we're returning anyway
  189. }
  190. const res = await fetched
  191. if (res.status < 500 && res.status !== 429) {
  192. return res
  193. }
  194. await timeout(Math.random() * this._client.initRetryMs * Math.pow(2, i))
  195. }
  196. throw new Error('An unknown error occurred, ensure retries is not negative')
  197. }
  198. }
  199. export type AwsRequestInit = RequestInit & {
  200. aws?: {
  201. accessKeyId?: string | undefined;
  202. secretAccessKey?: string | undefined;
  203. sessionToken?: string | undefined;
  204. service?: string | undefined;
  205. region?: string | undefined;
  206. cache?: Map<string, ArrayBuffer> | undefined;
  207. datetime?: string | undefined;
  208. signQuery?: boolean | undefined;
  209. appendSessionToken?: boolean | undefined;
  210. allHeaders?: boolean | undefined;
  211. singleEncode?: boolean | undefined;
  212. } | undefined;
  213. }
  214. export async function sign2(client: AwsClient, input: RequestInfo, init?: AwsRequestInit) {
  215. if (input instanceof Request) {
  216. const {method, url, headers, body} = input
  217. init = Object.assign({method, url, headers}, init)
  218. if (init.body == null && headers.has('Content-Type')) {
  219. init.body = body != null && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer()
  220. }
  221. input = url
  222. console.warn('There could be a bug in chrome with cloning Request objects, see https://bugs.chromium.org/p/chromium/issues/detail?id=1360943')
  223. }
  224. const signer = new AwsV4Signer(Object.assign({url: input}, init, client, init && init.aws))
  225. const signed = Object.assign({}, init, await signer.sign())
  226. delete signed.aws
  227. return signed
  228. // try {
  229. // return new Request(signed.url.toString(), signed)
  230. // } catch (e) {
  231. // if (e instanceof TypeError) {
  232. // // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
  233. // return new Request(signed.url.toString(), Object.assign({duplex: 'half'}, signed))
  234. // }
  235. // throw e
  236. // }
  237. }
  238. // https://github.com/sindresorhus/ky/blob/main/source/core/constants.ts
  239. // https://issues.chromium.org/issues/40237822
  240. // todo: right now we are using try catch like in aws4fetch
  241. // export const supportsRequestStreams = (() => {
  242. // let duplexAccessed = false
  243. // let hasContentType = false
  244. // const supportsReadableStream = typeof globalThis.ReadableStream === 'function'
  245. // const supportsRequest = typeof globalThis.Request === 'function'
  246. //
  247. // if (supportsReadableStream && supportsRequest) {
  248. // hasContentType = new globalThis.Request('https://empty.invalid', {
  249. // body: new globalThis.ReadableStream(),
  250. // method: 'POST',
  251. // // @ts-expect-error - Types are outdated.
  252. // get duplex() {
  253. // duplexAccessed = true
  254. // return 'half'
  255. // },
  256. // }).headers.has('Content-Type')
  257. // }
  258. //
  259. // return duplexAccessed && !hasContentType
  260. // })()