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 10.0KB

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