diff --git a/README.md b/README.md index 7b0e8880..3faa25b1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ In later releases, the release configuration will only be backward compatible to ## Recent critical updates and bug fixes +- All platforms migrated to official forward proxy - Support replacing picture bed image links with Picgo plugin - Fixed the issue that the release preview of the authorization code mode was invalidated - Support publishing to Zhihu @@ -30,21 +31,14 @@ In later releases, the release configuration will only be backward compatible to This plugin supports almost all devices and platforms of Siyuan Note, and the specific compatibility is as follows: - [X] Siyuan Note Client (zero configuration) is highly recommended -- [X] Servo environment (cross-domain request proxy needs to be set) +- [X] Servo environment (Zero configuration, cross-domain request proxy built-in) - [X] CentSource Note Browser Servo - [X] Siyuan Note Client Servo - [X] Siyuan Notemaker mobile servo -- [X] Siyuan Note docker version (need to set up cross-domain request proxy) +- [X] Siyuan Note docker version (Zero configuration, cross-domain request proxy built-in) -**Note: If it is a LAN servo, you need to deploy the cross-domain proxy on the LAN.** +🎉 **All platforms have been migrated to the official forward proxy, achieving zero user configuration and supporting cross-domain request proxies by default 🎉** -**Set up the method, clone https://github.com/terwer/node-metaweblog-api-adaptor then `pnpm install & pnpm dev`, after startup the proxy address is https://:3000/api/middleware .** - -**If the Internet needs to be deployed on the Internet, the cross-domain request proxy of the Internet can also be used directly: https://api.terwer.space/api/middleware** - -**It may be migrated to the official forward proxy to achieve zero configuration in the future, but for now you must set it yourself, you can follow the progress here.** - -- Progress 1: The Yuque and Notion platforms have used the built-in forward proxy and do not need to be configured. ## Platform List diff --git a/README_zh_CN.md b/README_zh_CN.md index 22a843b7..7e96562f 100644 --- a/README_zh_CN.md +++ b/README_zh_CN.md @@ -14,6 +14,7 @@ ## 最近的关键更新与 Bug 修复 +- 所有平台迁移到官方的正向代理 - 支持使用Picgo插件的情况下替换图床图片链接 - 修复授权码模式发布预览失效问题 - 支持发布到知乎 @@ -28,21 +29,13 @@ 本插件支持思源笔记几乎所有设备和平台,具体兼容情况如下: - [X] 思源笔记客户端(零配置)强烈推荐 -- [X] 伺服环境(需要设置跨域请求代理) +- [X] 伺服环境(零配置,跨域请求代理已内置) - [X] 思源笔记浏览器伺服 - [X] 思源笔记客户端伺服 - [X] 思源笔记客移动端伺服 -- [X] 思源笔记docker版(需要设置跨域请求代理) +- [X] 思源笔记docker版(零配置,跨域请求代理已内置) -**注意:如果是局域网伺服,需要在局域网自行部署跨域代理。** - -**设置方法,clone https://github.com/terwer/node-metaweblog-api-adaptor 然后 `pnpm install && pnpm dev`,启动之后代理地址为:https://<局域网IP>:3000/api/middleware 。** - -**如果是外网需要部署在外网,外网的跨域请求代理也可以直接使用:https://api.terwer.space/api/middleware** - -**后续可能会迁移到官方的正向代理实现零配置,但是目前还是必须要自己设置,可在这里关注进展。** - -- 进度1:语雀、Notion平台已使用内置正向代理,无需配置。 +**🎉 所有平台均已迁移到官方的正向代理,实现了用户零配置,默认支持跨域请求代理 🎉** ## 平台列表 diff --git a/src/adaptors/api/base/baseBlogApi.ts b/src/adaptors/api/base/baseBlogApi.ts index 77916782..8ca21711 100644 --- a/src/adaptors/api/base/baseBlogApi.ts +++ b/src/adaptors/api/base/baseBlogApi.ts @@ -24,13 +24,9 @@ */ import { BlogApi, BlogConfig, Post } from "zhi-blog-api" -import { SiyuanKernelApi } from "zhi-siyuan-api" -import { CommonFetchClient } from "zhi-fetch-middleware" import { AppInstance } from "~/src/appInstance.ts" import { createAppLogger, ILogger } from "~/src/utils/appLogger.ts" -import { useSiyuanApi } from "~/src/composables/useSiyuanApi.ts" -import { JsonUtil, ObjectUtil, StrUtil } from "zhi-common" -import { isDev } from "~/src/utils/constants.ts" +import { useProxy } from "~/src/composables/useProxy.ts" /** * API授权统一封装基类 @@ -42,9 +38,7 @@ import { isDev } from "~/src/utils/constants.ts" export class BaseBlogApi extends BlogApi { protected logger: ILogger protected cfg: BlogConfig - private readonly kernelApi: SiyuanKernelApi - private readonly commonFetchClient: CommonFetchClient - private readonly useSiyuanProxy: boolean + protected readonly proxyFetch: any /** * 初始化API授权适配器 @@ -57,11 +51,9 @@ export class BaseBlogApi extends BlogApi { this.cfg = cfg this.logger = createAppLogger("base-blog-api") - this.commonFetchClient = new CommonFetchClient(appInstance, cfg.apiUrl, cfg.middlewareUrl, isDev) - const { kernelApi, isUseSiyuanProxy } = useSiyuanApi() - this.kernelApi = kernelApi - this.useSiyuanProxy = isUseSiyuanProxy() + const { proxyFetch } = useProxy(cfg.middlewareUrl) + this.proxyFetch = proxyFetch } public async preEditPost(post: Post, id?: string, publishCfg?: any): Promise { @@ -72,78 +64,11 @@ export class BaseBlogApi extends BlogApi { // ================ // private methods // ================ - protected async readFileToBlob(url: string) { + public async readFileToBlob(url: string) { const response = await this.proxyFetch(url, [], {}, "GET", "image/jpeg") const body = response.body const blobData = new Blob([body], { type: response.contentType }) this.logger.debug("blobData =>", blobData) return blobData } - - /** - * 网页授权通用的请求代理 - * - * @param url - url - * @param headers - headers,默认是[] - * @param params - 参数,默认是 {} - * @param method - 方法,默认是GET - * @param contentType - 类型,默认是 application/json - */ - protected async proxyFetch( - url: string, - headers: any[] = [], - params: any = {}, - method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" = "GET", - contentType: string = "application/json" - ): Promise { - let body: any - if (typeof params === "string" && !StrUtil.isEmptyString(params)) { - body = params - } else if (typeof params === "object" && !ObjectUtil.isEmptyObject(params)) { - body = params - } - - if (this.useSiyuanProxy) { - this.logger.info("using siyuan forwardProxy") - const apiUrl = `${this.cfg.apiUrl}${url}` - this.logger.info("siyuan forwardProxy url =>", apiUrl) - this.logger.info("siyuan forwardProxy fetchOptions =>", { - headers, - body, - method, - contentType, - }) - const fetchResult = await this.kernelApi.forwardProxy(apiUrl, headers, body, method, contentType, 7000) - this.logger.debug("siyuan forwardProxy result=>", fetchResult) - // 后续调试可打开这个日志 - // this.logger.debug("proxyFetch resText=>", resText) - if (contentType === "application/json") { - const resText = fetchResult?.body - const res = JsonUtil.safeParse(resText, {} as any) - return res - } else if (contentType === "text/html") { - const resText = fetchResult?.body - return resText - } else { - return fetchResult - } - } else { - this.logger.info("using commonFetchClient") - const header = headers.length > 0 ? headers[0] : {} - let fetchOptions: any = { - method, - headers: { - ...header, - }, - } - if (body) { - fetchOptions.body = body - } - this.logger.info("commonFetchClient url =>", url) - this.logger.info("commonFetchClient fetchOptions =>", fetchOptions) - const res = await this.commonFetchClient.fetchCall(url, fetchOptions) - this.logger.debug("commonFetchClient res =>", res) - return res - } - } } diff --git a/src/adaptors/api/base/metaweblog/metaweblogBlogApiAdaptor.ts b/src/adaptors/api/base/metaweblog/metaweblogBlogApiAdaptor.ts index eeecb4ac..fbe642c2 100644 --- a/src/adaptors/api/base/metaweblog/metaweblogBlogApiAdaptor.ts +++ b/src/adaptors/api/base/metaweblog/metaweblogBlogApiAdaptor.ts @@ -25,14 +25,12 @@ import { Attachment, CategoryInfo, MediaObject, Post, PostStatusEnum, UserBlog } from "zhi-blog-api" import { createAppLogger } from "~/src/utils/appLogger.ts" -import { CommonXmlrpcClient } from "zhi-xmlrpc-middleware" import { MetaweblogConstants } from "~/src/adaptors/api/base/metaweblog/metaweblogConstants.ts" import { StrUtil } from "zhi-common" import { BrowserUtil } from "zhi-device" import { BaseBlogApi } from "~/src/adaptors/api/base/baseBlogApi.ts" import { MetaweblogConfig } from "~/src/adaptors/api/base/metaweblog/metaweblogConfig.ts" -import { result } from "lodash-es" -import { data } from "cheerio/lib/api/attributes" +import { useProxy } from "~/src/composables/useProxy.ts" /** * MetaweblogBlogApi 类继承自 BaseBlogApi 类,并为 Metaweblog API 提供了额外的功能 @@ -42,7 +40,7 @@ import { data } from "cheerio/lib/api/attributes" * @since 0.9.0 */ class MetaweblogBlogApiAdaptor extends BaseBlogApi { - private readonly commonXmlrpcClient: CommonXmlrpcClient + private readonly proxyXmlrpc: any /** * 初始化 metaweblog API 适配器 @@ -55,7 +53,8 @@ class MetaweblogBlogApiAdaptor extends BaseBlogApi { this.cfg.blogid = "metaweblog" this.logger = createAppLogger("metaweblog-api-adaptor") - this.commonXmlrpcClient = new CommonXmlrpcClient(appInstance, cfg.apiUrl) + const { proxyXmlrpc } = useProxy(cfg.middlewareUrl) + this.proxyXmlrpc = proxyXmlrpc } public override async getUsersBlogs(): Promise> { @@ -230,7 +229,7 @@ class MetaweblogBlogApiAdaptor extends BaseBlogApi { } protected async metaweblogCall(method: string, params: any[]) { - return await this.commonXmlrpcClient.methodCall(method, params, this.cfg.middlewareUrl) + return await this.proxyXmlrpc(this.cfg.apiUrl, method, params) } /** diff --git a/src/adaptors/api/yuque/yuqueApiAdaptor.ts b/src/adaptors/api/yuque/yuqueApiAdaptor.ts index 59fd4735..19281889 100644 --- a/src/adaptors/api/yuque/yuqueApiAdaptor.ts +++ b/src/adaptors/api/yuque/yuqueApiAdaptor.ts @@ -297,12 +297,13 @@ class YuqueApiAdaptor extends BaseBlogApi { } // 打印日志 - this.logger.debug("向语雀请求数据,url =>", url) + const apiUrl = `${this.cfg.apiUrl}${url}` + this.logger.debug("向语雀请求数据,apiUrl =>", apiUrl) this.logger.debug("向语雀请求数据,params =>", params) // 使用兼容的fetch调用并返回统一的JSON数据 const body = ObjectUtil.isEmptyObject(params) ? "" : JSON.stringify(params) - const resJson = await this.proxyFetch(url, [headers], body, method, contentType) + const resJson = await this.proxyFetch(apiUrl, [headers], body, method, contentType) this.logger.debug("向语雀请求数据,resJson =>", resJson) if (resJson?.status === 401) { diff --git a/src/adaptors/web/base/baseWebApi.ts b/src/adaptors/web/base/baseWebApi.ts index 35464965..94b5ae97 100644 --- a/src/adaptors/web/base/baseWebApi.ts +++ b/src/adaptors/web/base/baseWebApi.ts @@ -23,13 +23,9 @@ * questions. */ import { Attachment, ElectronCookie, MediaObject, Post, WebApi, WebConfig } from "zhi-blog-api" -import { SiyuanKernelApi } from "zhi-siyuan-api" -import { CommonFetchClient } from "zhi-fetch-middleware" import { AppInstance } from "~/src/appInstance.ts" import { createAppLogger, ILogger } from "~/src/utils/appLogger.ts" -import { useSiyuanApi } from "~/src/composables/useSiyuanApi.ts" -import { JsonUtil } from "zhi-common" -import { isDev } from "~/src/utils/constants.ts" +import { useProxy } from "~/src/composables/useProxy.ts" /** * 网页授权统一封装基类 @@ -41,9 +37,7 @@ import { isDev } from "~/src/utils/constants.ts" class BaseWebApi extends WebApi { protected logger: ILogger protected cfg: WebConfig - private readonly kernelApi: SiyuanKernelApi - private readonly commonFetchClient: CommonFetchClient - private readonly useSiyuanProxy: boolean + protected readonly proxyFetch: any /** * 初始化网页授权 API 适配器 @@ -56,11 +50,9 @@ class BaseWebApi extends WebApi { this.cfg = cfg this.logger = createAppLogger("base-web-api") - this.commonFetchClient = new CommonFetchClient(appInstance, cfg.apiUrl, cfg.middlewareUrl, isDev) - const { kernelApi, isUseSiyuanProxy } = useSiyuanApi() - this.kernelApi = kernelApi - this.useSiyuanProxy = isUseSiyuanProxy() + const { proxyFetch } = useProxy(cfg.middlewareUrl) + this.proxyFetch = proxyFetch } // web 适配器专有 @@ -116,77 +108,13 @@ class BaseWebApi extends WebApi { // ================ // private methods // ================ - protected async readFileToBlob(url: string) { + public async readFileToBlob(url: string) { const response = await this.proxyFetch(url, [], {}, "GET", "image/jpeg") const body = response.body const blobData = new Blob([body], { type: response.contentType }) this.logger.debug("blobData =>", blobData) return blobData } - - /** - * 网页授权通用的请求代理 - * - * @param url - url - * @param headers - 请求头,默认取 password 作为 cookie - * @param params - 参数,默认是 {} - * @param method - 方法,默认是GET - * @param contentType - 类型,默认是 application/json - */ - protected async proxyFetch( - url: string, - headers: any[] = [], - params: any = {}, - method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" = "GET", - contentType: string = "application/json" - ): Promise { - if (this.useSiyuanProxy) { - this.logger.info("using siyuan forwardProxy") - const fetchResult = await this.kernelApi.forwardProxy( - url, - headers.length > 0 - ? headers - : [ - { - Cookie: this.cfg.password, - }, - ], - params, - method, - contentType, - 7000 - ) - this.logger.debug("proxyFetch result=>", fetchResult) - // 后续调试可打开这个日志 - // this.logger.debug("proxyFetch resText=>", resText) - if (contentType === "application/json") { - const resText = fetchResult?.body - const res = JsonUtil.safeParse(resText, {} as any) - return res - } else if (contentType === "text/html") { - const resText = fetchResult?.body - return resText - } else { - return fetchResult - } - } else { - this.logger.info("using middleware proxy") - const header = headers.length > 0 ? headers[0] : {} - const fetchOptions = { - method: method, - headers: { - "Content-Type": "application/json", - Cookie: this.cfg.password, - ...header, - }, - } - this.logger.info("commonFetchClient from proxyFetch url =>", url) - this.logger.info("commonFetchClient from proxyFetch fetchOptions =>", fetchOptions) - const res = await this.commonFetchClient.fetchCall(url, fetchOptions) - this.logger.debug("commonFetchClient res from proxyFetch =>", res) - return res - } - } } export { BaseWebApi } diff --git a/src/components/test/CnblogsTest.vue b/src/components/test/CnblogsTest.vue index b37e501c..184be340 100644 --- a/src/components/test/CnblogsTest.vue +++ b/src/components/test/CnblogsTest.vue @@ -28,7 +28,6 @@ import { AppInstance } from "~/src/appInstance.ts" import { Utils } from "~/src/utils/utils.ts" import { reactive, ref } from "vue" import { fileToBuffer } from "~/src/utils/polyfillUtils.ts" -import { SimpleXmlRpcClient } from "simple-xmlrpc" import { MediaObject, Post } from "zhi-blog-api" import { createAppLogger } from "~/src/utils/appLogger.ts" import Adaptors from "~/src/adaptors" @@ -200,6 +199,14 @@ const cnblogsHandleApi = async () => { const result = await cnblogsApi.getUsersBlogs() logMessage.value = JSON.stringify(result) logger.info("cnblogs users blogs=>", result) + + // const key = "metaweblog_Cnblogs" + // const cfg = await Adaptors.getCfg(key) + // const { proxyXmlrpc } = useProxy(cfg.middlewareUrl) + // + // const result = await proxyXmlrpc(cfg.apiUrl, "blogger.getUsersBlogs", ["", cfg.username, cfg.password]) + // logMessage.value = JSON.stringify(result) + // logger.info("cnblogs users blogs=>", result) break } case METHOD_GET_RECENT_POSTS_COUNT: { @@ -307,17 +314,58 @@ const cnblogsHandleApi = async () => { logger.info("mediaObject=>", mediaObject) // 设置文件的元数据 - const metadata = { - name: mediaObject.name, - type: mediaObject.type, - bits: mediaObject.bits, - overwrite: true, - } - const cfg = await Adaptors.getCfg(key) - const client = new SimpleXmlRpcClient(appInstance, cfg.apiUrl, {}) - const result = await client.methodCall("metaWeblog.newMediaObject", ["", cfg.username, cfg.password, metadata]) + const cnblogsApiAdaptor = await Adaptors.getAdaptor(key) + const cnblogsApi = Utils.blogApi(appInstance, cnblogsApiAdaptor) + logger.info("cnblogsApi=>", cnblogsApi) + const result = await cnblogsApi.newMediaObject(mediaObject) logMessage.value = JSON.stringify(result) logger.info("cnblogs new mediaObject result=>", result) + + // const key = "metaweblog_Cnblogs" + // + // const file = paramFile.value + // const bits = await fileToBuffer(file) + // const mediaObject = new MediaObject(file.name, file.type, bits) + // logger.info("mediaObject=>", mediaObject) + // + // // 设置文件的元数据 + // const metadata = { + // name: mediaObject.name, + // type: mediaObject.type, + // bits: mediaObject.bits, + // overwrite: true, + // } + // const cfg = await Adaptors.getCfg(key) + // const client = new SimpleXmlRpcClient(appInstance, cfg.apiUrl, {}) + // const result = await client.methodCall("metaWeblog.newMediaObject", ["", cfg.username, cfg.password, metadata]) + // logMessage.value = JSON.stringify(result) + // logger.info("cnblogs new mediaObject result=>", result) + + // proxy + // const key = "metaweblog_Cnblogs" + // const cfg = await Adaptors.getCfg(key) + // const { proxyXmlrpc } = useProxy(cfg.middlewareUrl) + // + // const file = paramFile.value + // const bits = await fileToBuffer(file) + // const mediaObject = new MediaObject(file.name, file.type, bits) + // logger.info("mediaObject=>", mediaObject) + // + // // 设置文件的元数据 + // const metadata = { + // name: mediaObject.name, + // type: mediaObject.type, + // bits: mediaObject.bits, + // overwrite: true, + // } + // const result = await proxyXmlrpc(cfg.apiUrl, "metaWeblog.newMediaObject", [ + // "", + // cfg.username, + // cfg.password, + // metadata, + // ]) + // logMessage.value = JSON.stringify(result) + // logger.info("cnblogs new mediaObject result=>", result) break } default: diff --git a/src/composables/useProxy.ts b/src/composables/useProxy.ts new file mode 100644 index 00000000..22ee1bd1 --- /dev/null +++ b/src/composables/useProxy.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2023, Terwer . All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Terwer designates this + * particular file as subject to the "Classpath" exception as provided + * by Terwer in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Terwer, Shenzhen, Guangdong, China, youweics@163.com + * or visit www.terwer.space if you need additional information or have any + * questions. + */ + +import { useSiyuanApi } from "~/src/composables/useSiyuanApi.ts" +import { JsonUtil, ObjectUtil, StrUtil } from "zhi-common" +import { CommonFetchClient } from "zhi-fetch-middleware" +import { isDev } from "~/src/utils/constants.ts" +import { AppInstance } from "~/src/appInstance.ts" +import { createAppLogger } from "~/src/utils/appLogger.ts" +import { Deserializer, Serializer, XmlrpcUtil } from "simple-xmlrpc" + +/** + * 用于处理代理请求的自定义 hook + * + * @param middlewareUrl - 可选,如果使用 CommonFetchClient 需要传递,否则可留空 + * @author terwer + * @version 1.7.0 + * @since 1.7.0 + */ +const useProxy = (middlewareUrl?: string) => { + const logger = createAppLogger("use-proxy") + const { kernelApi, isUseSiyuanProxy } = useSiyuanApi() + + /** + * 创建应用程序实例和通用的 fetch 客户端实例 + */ + const appInstance = new AppInstance() + const apiUrl = "" + middlewareUrl = middlewareUrl ?? "https://api.terwer.space/api/middleware" + const commonFetchClient = new CommonFetchClient(appInstance, apiUrl, middlewareUrl, isDev) + const serializer = new Serializer(appInstance) + + /** + * 执行代理 fetch 请求 + * + * @param url - 请求的 URL + * @param headers - 请求的头部信息 + * @param params - 请求的参数 + * @param method - 请求的 HTTP 方法 + * @param contentType - 请求的内容类型 + * @returns 返回一个 Promise,解析为响应结果 + */ + const proxyFetch = async ( + url: string, + headers: any[] = [], + params: any = {}, + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" = "GET", + contentType: string = "application/json" + ) => { + if (isUseSiyuanProxy) { + logger.info("Using Siyuan forwardProxy") + let body: any + if (typeof params === "string" && !StrUtil.isEmptyString(params)) { + body = params + } else if (typeof params === "object" && !ObjectUtil.isEmptyObject(params)) { + body = params + } + const reqUrl = `${apiUrl}${url}` + logger.info("siyuan forwardProxy url =>", apiUrl) + logger.info("siyuan forwardProxy fetchOptions =>", { + headers, + body, + method, + contentType, + }) + const fetchResult = await kernelApi.forwardProxy(reqUrl, headers, body, method, contentType, 7000) + logger.debug("proxyFetch result =>", fetchResult) + + if (contentType === "application/json") { + const resText = fetchResult?.body + const res = JsonUtil.safeParse(resText, {} as any) + return res + } else if (contentType === "text/html") { + const resText = fetchResult?.body + return resText + } else { + return fetchResult + } + } else { + logger.info("Using middleware proxy") + const header = headers.length > 0 ? headers[0] : {} + const fetchOptions = { + method: method, + headers: { + "Content-Type": "application/json", + ...header, + }, + } + logger.info("commonFetchClient url in proxyFetch =>", url) + logger.info("commonFetchClient fetchOptions in proxyFetch =>", fetchOptions) + const res = await commonFetchClient.fetchCall(url, fetchOptions) + logger.debug("Result of proxyFetch in commonFetchClient =>", res) + return res + } + } + + /** + * 通过代理调用 XML-RPC 方法 + * + * @param url - xmlrpc 端点地址 + * @param reqMethod - 请求的方法名 + * @param reqParams - 请求的参数 + */ + const proxyXmlrpc = async (url: string, reqMethod: string, reqParams: any[]) => { + const body = serializer.serializeMethodCall(reqMethod, reqParams) + const res = await proxyFetch(url, [], body, "POST", "text/xml") + let resText = res.body + resText = XmlrpcUtil.removeXmlHeader(resText) + const deserializer = new Deserializer() + const resJson = await deserializer.deserializeMethodResponse(resText) + logger.debug("xmlrpc fetch result, resJson =>", resJson) + return resJson + } + + return { proxyFetch, proxyXmlrpc } +} + +export { useProxy }