From 34d0e6deeec1b1bde6135d6abd26e6ee70ecc5e0 Mon Sep 17 00:00:00 2001 From: markmehere <52765023+markmehere@users.noreply.github.com> Date: Fri, 19 Jan 2024 14:25:18 +1100 Subject: [PATCH] feat: HTTP proxy support (#1562) * feat: allow self-signed certificates * fix: Extra information in self signed error * chore: simplified PR * feat: allow https proxies * fix: trim() may save one or two user headaches * Update web/context/FeatureToggle.tsx --------- Co-authored-by: Louis Co-authored-by: hiento09 <136591877+hiento09@users.noreply.github.com> --- .gitignore | 4 +- core/README.md | 4 +- core/src/core.ts | 6 +- core/src/extensions/model.ts | 2 +- core/src/node/api/common/builder.ts | 6 +- core/src/node/api/routes/common.ts | 2 +- core/src/node/api/routes/download.ts | 4 +- core/src/types/model/modelInterface.ts | 3 +- electron/handlers/download.ts | 6 +- extensions/model-extension/src/index.ts | 5 +- web/context/FeatureToggle.tsx | 56 +++++++++++--- web/hooks/useDownloadModel.ts | 5 +- web/hooks/useDownloadState.ts | 3 + web/screens/Settings/Advanced/index.tsx | 73 +++++++++++++++++-- .../ExtensionsCatalog/index.tsx | 6 +- 15 files changed, 148 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index dbf94335a..e3e4635fc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ extensions/inference-nitro-extension/bin/*/*.metal extensions/inference-nitro-extension/bin/*/*.exe extensions/inference-nitro-extension/bin/*/*.dll extensions/inference-nitro-extension/bin/*/*.exp -extensions/inference-nitro-extension/bin/*/*.lib \ No newline at end of file +extensions/inference-nitro-extension/bin/*/*.lib +extensions/inference-nitro-extension/bin/saved-* +extensions/inference-nitro-extension/bin/*.tar.gz diff --git a/core/README.md b/core/README.md index 5a7dd3993..62988ef76 100644 --- a/core/README.md +++ b/core/README.md @@ -198,8 +198,8 @@ The Core API also provides functions to perform file operations. Here are a coup You can download a file from a specified URL and save it with a given file name using the core.downloadFile function. ```js -function downloadModel(url: string, fileName: string) { - core.downloadFile(url, fileName); +function downloadModel(url: string, fileName: string, network?: { proxy?: string, ignoreSSL?: boolean }) { + core.downloadFile(url, fileName, network); } ``` diff --git a/core/src/core.ts b/core/src/core.ts index 4480697f6..7a2c84278 100644 --- a/core/src/core.ts +++ b/core/src/core.ts @@ -19,10 +19,12 @@ const executeOnMain: (extension: string, method: string, ...args: any[]) => Prom * Downloads a file from a URL and saves it to the local file system. * @param {string} url - The URL of the file to download. * @param {string} fileName - The name to use for the downloaded file. + * @param {object} network - Optional object to specify proxy/whether to ignore SSL certificates. * @returns {Promise} A promise that resolves when the file is downloaded. */ -const downloadFile: (url: string, fileName: string) => Promise = (url, fileName) => - global.core?.api?.downloadFile(url, fileName) +const downloadFile: (url: string, fileName: string, network?: { proxy?: string, ignoreSSL?: boolean }) => Promise = (url, fileName, network) => { + return global.core?.api?.downloadFile(url, fileName, network) +} /** * Aborts the download of a specific file. diff --git a/core/src/extensions/model.ts b/core/src/extensions/model.ts index cac9d9d89..00fe2cf4d 100644 --- a/core/src/extensions/model.ts +++ b/core/src/extensions/model.ts @@ -5,7 +5,7 @@ import { Model, ModelInterface } from '../index' * Model extension for managing models. */ export abstract class ModelExtension extends BaseExtension implements ModelInterface { - abstract downloadModel(model: Model): Promise + abstract downloadModel(model: Model, network?: { proxy: string, ignoreSSL?: boolean }): Promise abstract cancelModelDownload(modelId: string): Promise abstract deleteModel(modelId: string): Promise abstract saveModel(model: Model): Promise diff --git a/core/src/node/api/common/builder.ts b/core/src/node/api/common/builder.ts index 3291e4217..eccdcc7ca 100644 --- a/core/src/node/api/common/builder.ts +++ b/core/src/node/api/common/builder.ts @@ -246,7 +246,9 @@ export const createMessage = async (threadId: string, message: any) => { } } -export const downloadModel = async (modelId: string) => { +export const downloadModel = async (modelId: string, network?: { proxy?: string, ignoreSSL?: boolean }) => { + const strictSSL = !network?.ignoreSSL; + const proxy = network?.proxy?.startsWith('http') ? network.proxy : undefined; const model = await retrieveBuilder(JanApiRouteConfiguration.models, modelId) if (!model || model.object !== 'model') { return { @@ -263,7 +265,7 @@ export const downloadModel = async (modelId: string) => { const modelBinaryPath = join(directoryPath, modelId) const request = require('request') - const rq = request(model.source_url) + const rq = request({url: model.source_url, strictSSL, proxy }) const progress = require('request-progress') progress(rq, {}) .on('progress', function (state: any) { diff --git a/core/src/node/api/routes/common.ts b/core/src/node/api/routes/common.ts index 184ca131d..a6c65a382 100644 --- a/core/src/node/api/routes/common.ts +++ b/core/src/node/api/routes/common.ts @@ -27,7 +27,7 @@ export const commonRouter = async (app: HttpServer) => { // Download Model Routes app.get(`/models/download/:modelId`, async (request: any) => - downloadModel(request.params.modelId), + downloadModel(request.params.modelId, { ignoreSSL: request.query.ignoreSSL === 'true', proxy: request.query.proxy }), ) // Chat Completion Routes diff --git a/core/src/node/api/routes/download.ts b/core/src/node/api/routes/download.ts index f62ee0258..99c990f76 100644 --- a/core/src/node/api/routes/download.ts +++ b/core/src/node/api/routes/download.ts @@ -7,6 +7,8 @@ import { createWriteStream } from 'fs' export const downloadRouter = async (app: HttpServer) => { app.post(`/${DownloadRoute.downloadFile}`, async (req, res) => { + const strictSSL = !(req.query.ignoreSSL === 'true'); + const proxy = req.query.proxy?.startsWith('http') ? req.query.proxy : undefined; const body = JSON.parse(req.body as any) const normalizedArgs = body.map((arg: any) => { if (typeof arg === 'string' && arg.includes('file:/')) { @@ -21,7 +23,7 @@ export const downloadRouter = async (app: HttpServer) => { const request = require('request') const progress = require('request-progress') - const rq = request(normalizedArgs[0]) + const rq = request({ url: normalizedArgs[0], strictSSL, proxy }) progress(rq, {}) .on('progress', function (state: any) { console.log('download onProgress', state) diff --git a/core/src/types/model/modelInterface.ts b/core/src/types/model/modelInterface.ts index 19b5e6051..74a479f3c 100644 --- a/core/src/types/model/modelInterface.ts +++ b/core/src/types/model/modelInterface.ts @@ -7,9 +7,10 @@ export interface ModelInterface { /** * Downloads a model. * @param model - The model to download. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. * @returns A Promise that resolves when the model has been downloaded. */ - downloadModel(model: Model): Promise + downloadModel(model: Model, network?: { ignoreSSL?: boolean, proxy?: string }): Promise /** * Cancels the download of a specific model. diff --git a/electron/handlers/download.ts b/electron/handlers/download.ts index e8867b055..832c46892 100644 --- a/electron/handlers/download.ts +++ b/electron/handlers/download.ts @@ -54,7 +54,9 @@ export function handleDownloaderIPCs() { * @param url - The URL to download the file from. * @param fileName - The name to give the downloaded file. */ - ipcMain.handle(DownloadRoute.downloadFile, async (_event, url, fileName) => { + ipcMain.handle(DownloadRoute.downloadFile, async (_event, url, fileName, network) => { + const strictSSL = !network?.ignoreSSL; + const proxy = network?.proxy?.startsWith('http') ? network.proxy : undefined; const userDataPath = join(app.getPath('home'), 'jan') if ( typeof fileName === 'string' && @@ -63,7 +65,7 @@ export function handleDownloaderIPCs() { fileName = fileName.replace('file:/', '').replace('file:\\', '') } const destination = resolve(userDataPath, fileName) - const rq = request(url) + const rq = request({ url, strictSSL, proxy }) // Put request to download manager instance DownloadManager.instance.setRequest(fileName, rq) diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index b26036b89..f1573dd46 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -80,9 +80,10 @@ export default class JanModelExtension implements ModelExtension { /** * Downloads a machine learning model. * @param model - The model to download. + * @param network - Optional object to specify proxy/whether to ignore SSL certificates. * @returns A Promise that resolves when the model is downloaded. */ - async downloadModel(model: Model): Promise { + async downloadModel(model: Model, network?: { ignoreSSL?: boolean; proxy?: string }): Promise { // create corresponding directory const modelDirPath = await joinPath([JanModelExtension._homeDir, model.id]) if (!(await fs.existsSync(modelDirPath))) await fs.mkdirSync(modelDirPath) @@ -96,7 +97,7 @@ export default class JanModelExtension implements ModelExtension { ? extractedFileName : model.id const path = await joinPath([modelDirPath, fileName]) - downloadFile(model.source_url, path) + downloadFile(model.source_url, path, network) } /** diff --git a/web/context/FeatureToggle.tsx b/web/context/FeatureToggle.tsx index 405cef904..54dd6a943 100644 --- a/web/context/FeatureToggle.tsx +++ b/web/context/FeatureToggle.tsx @@ -1,13 +1,21 @@ import { createContext, ReactNode, useEffect, useState } from 'react' interface FeatureToggleContextType { - experimentalFeatureEnabed: boolean - setExperimentalFeatureEnabled: (on: boolean) => void + experimentalFeature: boolean + ignoreSSL: boolean + proxy: string + setExperimentalFeature: (on: boolean) => void + setIgnoreSSL: (on: boolean) => void + setProxy: (value: string) => void } const initialContext: FeatureToggleContextType = { - experimentalFeatureEnabed: false, - setExperimentalFeatureEnabled: () => {}, + experimentalFeature: false, + ignoreSSL: false, + proxy: '', + setExperimentalFeature: () => {}, + setIgnoreSSL: () => {}, + setProxy: () => {}, } export const FeatureToggleContext = @@ -18,25 +26,49 @@ export default function FeatureToggleWrapper({ }: { children: ReactNode }) { - const EXPERIMENTAL_FEATURE_ENABLED = 'expermientalFeatureEnabled' - const [experimentalEnabed, setExperimentalEnabled] = useState(false) + const EXPERIMENTAL_FEATURE = 'experimentalFeature' + const IGNORE_SSL = 'ignoreSSLFeature' + const HTTPS_PROXY_FEATURE = 'httpsProxyFeature' + const [experimentalFeature, directSetExperimentalFeature] = useState(false) + const [ignoreSSL, directSetIgnoreSSL] = useState(false) + const [proxy, directSetProxy] = useState('') useEffect(() => { - setExperimentalEnabled( - localStorage.getItem(EXPERIMENTAL_FEATURE_ENABLED) === 'true' + directSetExperimentalFeature( + localStorage.getItem(EXPERIMENTAL_FEATURE) === 'true' + ) + directSetIgnoreSSL( + localStorage.getItem(IGNORE_SSL) === 'true' + ) + directSetProxy( + localStorage.getItem(HTTPS_PROXY_FEATURE) ?? "" ) }, []) const setExperimentalFeature = (on: boolean) => { - localStorage.setItem(EXPERIMENTAL_FEATURE_ENABLED, on ? 'true' : 'false') - setExperimentalEnabled(on) + localStorage.setItem(EXPERIMENTAL_FEATURE, on ? 'true' : 'false') + directSetExperimentalFeature(on) + } + + const setIgnoreSSL = (on: boolean) => { + localStorage.setItem(IGNORE_SSL, on ? 'true' : 'false') + directSetIgnoreSSL(on) + } + + const setProxy = (proxy: string) => { + localStorage.setItem(HTTPS_PROXY_FEATURE, proxy) + directSetProxy(proxy) } return ( {children} diff --git a/web/hooks/useDownloadModel.ts b/web/hooks/useDownloadModel.ts index bd587981c..ee32ff606 100644 --- a/web/hooks/useDownloadModel.ts +++ b/web/hooks/useDownloadModel.ts @@ -14,8 +14,11 @@ import { useDownloadState } from './useDownloadState' import { extensionManager } from '@/extension/ExtensionManager' import { addNewDownloadingModelAtom } from '@/helpers/atoms/Model.atom' +import { useContext } from 'react' +import { FeatureToggleContext } from '@/context/FeatureToggle' export default function useDownloadModel() { + const { ignoreSSL, proxy } = useContext(FeatureToggleContext) const { setDownloadState } = useDownloadState() const addNewDownloadingModel = useSetAtom(addNewDownloadingModelAtom) @@ -39,7 +42,7 @@ export default function useDownloadModel() { await extensionManager .get(ExtensionType.Model) - ?.downloadModel(model) + ?.downloadModel(model, { ignoreSSL, proxy }) } const abortModelDownload = async (model: Model) => { await abortDownload( diff --git a/web/hooks/useDownloadState.ts b/web/hooks/useDownloadState.ts index 811b6b53e..d39ab5e58 100644 --- a/web/hooks/useDownloadState.ts +++ b/web/hooks/useDownloadState.ts @@ -38,6 +38,9 @@ const setDownloadStateFailedAtom = atom( console.debug(`Cannot find download state for ${modelId}`) return } + if (error.includes('certificate')) { + error += '. To fix enable "Ignore SSL Certificates" in Advanced settings.' + } toaster({ title: 'Download Failed', description: `Model ${modelId} download failed: ${error}`, diff --git a/web/screens/Settings/Advanced/index.tsx b/web/screens/Settings/Advanced/index.tsx index 085fd445c..f07d34d6d 100644 --- a/web/screens/Settings/Advanced/index.tsx +++ b/web/screens/Settings/Advanced/index.tsx @@ -1,10 +1,19 @@ /* eslint-disable react-hooks/exhaustive-deps */ 'use client' -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useState, useCallback, ChangeEvent } from 'react' import { fs } from '@janhq/core' -import { Switch, Button } from '@janhq/uikit' +import { + Switch, + Button, + Input, + Modal, + ModalContent, + ModalHeader, + ModalTitle, + ModalTrigger, +} from '@janhq/uikit' import ShortcutModal from '@/containers/ShortcutModal' @@ -15,11 +24,22 @@ import { FeatureToggleContext } from '@/context/FeatureToggle' import { useSettings } from '@/hooks/useSettings' const Advanced = () => { - const { experimentalFeatureEnabed, setExperimentalFeatureEnabled } = + const { experimentalFeature, setExperimentalFeature, ignoreSSL, setIgnoreSSL, proxy, setProxy } = useContext(FeatureToggleContext) + const [partialProxy, setPartialProxy] = useState(proxy) const [gpuEnabled, setGpuEnabled] = useState(false) const { readSettings, saveSettings, validateSettings, setShowNotification } = useSettings() + const onProxyChange = useCallback((event: ChangeEvent) => { + const value = event.target.value || '' + setPartialProxy(value) + if (value.trim().startsWith('http')) { + setProxy(value.trim()) + } + else { + setProxy('') + } + }, [setPartialProxy, setProxy]) useEffect(() => { readSettings().then((settings) => { @@ -81,12 +101,53 @@ const Advanced = () => {

{ if (e === true) { - setExperimentalFeatureEnabled(true) + setExperimentalFeature(true) } else { - setExperimentalFeatureEnabled(false) + setExperimentalFeature(false) + } + }} + /> + + {/* Proxy */} +
+
+
+
+ HTTPS Proxy +
+
+

+ Specify the HTTPS proxy or leave blank (proxy auto-configuration and SOCKS not supported). +

+ :@:"} + value={partialProxy} + onChange={onProxyChange} + /> +
+
+ {/* Ignore SSL certificates */} +
+
+
+
+ Ignore SSL certificates +
+
+

+ Allow self-signed or unverified certificates - may be required for certain proxies. +

+
+ { + if (e === true) { + setIgnoreSSL(true) + } else { + setIgnoreSSL(false) } }} /> diff --git a/web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx b/web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx index 650ed318c..85d5ab5e8 100644 --- a/web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx +++ b/web/screens/Settings/CoreExtensions/ExtensionsCatalog/index.tsx @@ -15,7 +15,7 @@ const ExtensionCatalog = () => { const [activeExtensions, setActiveExtensions] = useState([]) const [extensionCatalog, setExtensionCatalog] = useState([]) const fileInputRef = useRef(null) - const { experimentalFeatureEnabed } = useContext(FeatureToggleContext) + const { experimentalFeature } = useContext(FeatureToggleContext) /** * Loads the extension catalog module from a CDN and sets it as the extension catalog state. */ @@ -27,11 +27,11 @@ const ExtensionCatalog = () => { // Get extension manifest import(/* webpackIgnore: true */ PLUGIN_CATALOG + `?t=${Date.now()}`).then( (data) => { - if (Array.isArray(data.default) && experimentalFeatureEnabed) + if (Array.isArray(data.default) && experimentalFeature) setExtensionCatalog(data.default) } ) - }, [experimentalFeatureEnabed]) + }, [experimentalFeature]) /** * Fetches the active extensions and their preferences from the `extensions` and `preferences` modules.