diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 85638a0f6d..041c7f13eb 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -322,6 +322,22 @@ }, "port": { "label": "Port" + }, + "https": { + "label": "HTTPS & Certificates", + "submenu": { + "enable-https": { + "label": "Enable HTTPS" + }, + "cert": { + "label": "Certificate file (.crt/.pem)", + "dialogTitle": "Select HTTPS certificate file" + }, + "key": { + "label": "Private key file (.key/.pem)", + "dialogTitle": "Select HTTPS private key file" + } + } } }, "name": "API Server [Beta]", diff --git a/src/plugins/api-server/backend/main.ts b/src/plugins/api-server/backend/main.ts index ae05d9247c..02a99e344d 100644 --- a/src/plugins/api-server/backend/main.ts +++ b/src/plugins/api-server/backend/main.ts @@ -1,3 +1,7 @@ +import { createServer as createHttpServer } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import { readFileSync } from 'node:fs'; + import { jwt } from 'hono/jwt'; import { OpenAPIHono as Hono } from '@hono/zod-openapi'; import { cors } from 'hono/cors'; @@ -48,22 +52,26 @@ export const backend = createBackend({ (newVolumeState: VolumeState) => (this.volumeState = newVolumeState), ); - this.run(config.hostname, config.port); + this.run(config); }, stop() { this.end(); }, onConfigChange(config) { + const old = this.oldConfig; if ( - this.oldConfig?.hostname === config.hostname && - this.oldConfig?.port === config.port + old?.hostname === config.hostname && + old?.port === config.port && + old?.useHttps === config.useHttps && + old?.certPath === config.certPath && + old?.keyPath === config.keyPath ) { this.oldConfig = config; return; } this.end(); - this.run(config.hostname, config.port); + this.run(config); this.oldConfig = config; }, @@ -153,15 +161,30 @@ export const backend = createBackend({ this.injectWebSocket = ws.injectWebSocket.bind(this); }, - run(hostname, port) { + run(config) { if (!this.app) return; try { - this.server = serve({ - fetch: this.app.fetch.bind(this.app), - port, - hostname, - }); + const serveOptions = + config.useHttps && config.certPath && config.keyPath + ? { + fetch: this.app.fetch.bind(this.app), + port: config.port, + hostname: config.hostname, + createServer: createHttpsServer, + serverOptions: { + key: readFileSync(config.keyPath), + cert: readFileSync(config.certPath), + }, + } + : { + fetch: this.app.fetch.bind(this.app), + port: config.port, + hostname: config.hostname, + createServer: createHttpServer, + }; + + this.server = serve(serveOptions); if (this.injectWebSocket && this.server) { this.injectWebSocket(this.server); diff --git a/src/plugins/api-server/backend/types.ts b/src/plugins/api-server/backend/types.ts index 2f20dbe93d..a49e71f93a 100644 --- a/src/plugins/api-server/backend/types.ts +++ b/src/plugins/api-server/backend/types.ts @@ -17,6 +17,6 @@ export type BackendType = { injectWebSocket?: (server: ReturnType) => void; init: (ctx: BackendContext) => void; - run: (hostname: string, port: number) => void; + run: (config: APIServerConfig) => void; end: () => void; }; diff --git a/src/plugins/api-server/config.ts b/src/plugins/api-server/config.ts index 22d8e661fe..2a390decf1 100644 --- a/src/plugins/api-server/config.ts +++ b/src/plugins/api-server/config.ts @@ -11,6 +11,9 @@ export interface APIServerConfig { secret: string; authorizedClients: string[]; + useHttps: boolean; + certPath: string; + keyPath: string; } export const defaultAPIServerConfig: APIServerConfig = { @@ -21,4 +24,7 @@ export const defaultAPIServerConfig: APIServerConfig = { secret: Date.now().toString(36), authorizedClients: [], + useHttps: false, + certPath: '', + keyPath: '', }; diff --git a/src/plugins/api-server/menu.ts b/src/plugins/api-server/menu.ts index 4a28ad9a39..08800e06ac 100644 --- a/src/plugins/api-server/menu.ts +++ b/src/plugins/api-server/menu.ts @@ -1,3 +1,4 @@ +import { dialog } from 'electron'; import prompt from 'custom-electron-prompt'; import { t } from '@/i18n'; @@ -93,5 +94,51 @@ export const onMenu = async ({ }, ], }, + { + label: t('plugins.api-server.menu.https.label'), + type: 'submenu', + submenu: [ + { + label: t('plugins.api-server.menu.https.submenu.enable-https.label'), + type: 'checkbox', + checked: config.useHttps, + click(menuItem) { + setConfig({ ...config, useHttps: menuItem.checked }); + }, + }, + { + label: t('plugins.api-server.menu.https.submenu.cert.label'), + type: 'normal', + async click() { + const config = await getConfig(); + const result = await dialog.showOpenDialog(window, { + title: t( + 'plugins.api-server.menu.https.submenu.cert.dialogTitle', + ), + filters: [{ name: 'Certificate', extensions: ['crt', 'pem'] }], + properties: ['openFile'], + }); + if (!result.canceled && result.filePaths.length > 0) { + setConfig({ ...config, certPath: result.filePaths[0] }); + } + }, + }, + { + label: t('plugins.api-server.menu.https.submenu.key.label'), + type: 'normal', + async click() { + const config = await getConfig(); + const result = await dialog.showOpenDialog(window, { + title: t('plugins.api-server.menu.https.submenu.key.dialogTitle'), + filters: [{ name: 'Private Key', extensions: ['key', 'pem'] }], + properties: ['openFile'], + }); + if (!result.canceled && result.filePaths.length > 0) { + setConfig({ ...config, keyPath: result.filePaths[0] }); + } + }, + }, + ], + }, ]; };