| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import { |
| 4 | InactivePlugin, enablePlugins, getInactivePlugins, getPluginConfigFields, mapPlugins, Plugin, pluginsConfig, |
| 5 | PATH as PLUGINS_PATH, enablePlugin, getPluginInfo, setPluginConfig, isPluginRunning, pluginsScanned, |
| 6 | stopPlugin, startPlugin, CommonPluginInterface, getMissingDependencies, findPluginByRepo, suspendPlugins, |
| 7 | } from './plugins' |
| 8 | import _ from 'lodash' |
| 9 | import assert from 'assert' |
| 10 | import { apiAssertTypes, HTTP_CONFLICT, HTTP_PRECONDITION_FAILED, newObj, waitFor } from './misc' |
| 11 | import { ApiError, ApiHandlers } from './apiMiddleware' |
| 12 | import { rm } from 'fs/promises' |
| 13 | import { |
| 14 | downloadPlugin, getFolder2repo, readOnlineCompatiblePlugin, readOnlinePlugin, searchPlugins, downloading |
| 15 | } from './github' |
| 16 | import { HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const' |
| 17 | import { SendListReadable } from './SendList' |
| 18 | |
| 19 | const apis: ApiHandlers = { |
| 20 | |
| 21 | get_plugins({}, ctx) { |
| 22 | const list = new SendListReadable({ addAtStart: [ ...mapPlugins(serialize, false), ...getInactivePlugins().map(serialize) ] }) |
| 23 | return list.events(ctx, { |
| 24 | pluginInstalled: p => list.add(serialize(p)), |
| 25 | 'pluginStarted pluginStopped pluginUpdated': p => { |
| 26 | const { id, ...rest } = serialize(p) |
| 27 | list.update({ id }, rest) |
| 28 | }, |
| 29 | pluginUninstalled: id => list.remove({ id }), |
| 30 | pluginLog: id => list.update({ id }, { log: true }) // SendList is already capping frequency |
| 31 | }) |
| 32 | }, |
| 33 | |
| 34 | async get_plugin_updates({}, ctx) { |
| 35 | return new SendListReadable({ |
| 36 | async doAtStart(list) { |
| 37 | const errs: any = {} |
| 38 | list.events(ctx, { |
| 39 | pluginDownload({ repo, status }) { |
| 40 | list.update({ id: findPluginByRepo(repo)?.id }, { downloading: status ?? null }) |
| 41 | }, |
| 42 | pluginDownloaded({ id }) { |
| 43 | list.update({ id }, { updated: true }) |
| 44 | } |
| 45 | }) |
| 46 | await pluginsScanned |
| 47 | await Promise.allSettled(_.map(getFolder2repo(), async (repo, folder) => { |
| 48 | try { |
| 49 | if (!repo) return |
| 50 | const online = await readOnlineCompatiblePlugin(repo) |
| 51 | if (!online) return |
| 52 | const disk = getPluginInfo(folder) |
| 53 | if (!disk) return // plugin removed in the meantime? |
| 54 | if (online.version === disk.version) return // different, not just newer ones, in case a version was retired |
| 55 | list.add(Object.assign(online, { |
| 56 | id: disk.id, // id is installation-dependant, and online cannot know |
| 57 | installedVersion: disk.version, |
| 58 | repo: serialize(disk).repo, // show the user the current repo we are getting this update from, not a possibly-changed future one |
| 59 | downgrade: online.version! < disk.version, |
| 60 | downloading: _.isString(online.repo) && downloading[online.repo], |
| 61 | })) |
| 62 | } catch (err: any) { |
| 63 | if (err.message !== '404') // the plugin is declaring a wrong repo |
| 64 | (errs[err.code || err.message] ||= []).push(repo) |
| 65 | } |
| 66 | })) |
| 67 | if (!_.isEmpty(errs)) |
| 68 | list.error(errs) |
| 69 | list.ready() |
| 70 | } |
| 71 | }) |
| 72 | }, |
| 73 | |
| 74 | async start_plugin({ id }) { |
| 75 | assertPluginId(id) |
| 76 | if (isPluginRunning(id)) |
| 77 | return { msg: 'already running' } |
| 78 | if (suspendPlugins.get()) |
| 79 | return new ApiError(HTTP_PRECONDITION_FAILED, 'all plugins suspended') |
| 80 | await stopPlugin(id) |
| 81 | return startPlugin(id).then(() => ({}), e => new ApiError(HTTP_SERVER_ERROR, e.message)) |
| 82 | }, |
| 83 | |
| 84 | async stop_plugin({ id }) { |
| 85 | assertPluginId(id) |
| 86 | if (!isPluginRunning(id)) |
| 87 | return { msg: 'already stopped' } |
| 88 | await stopPlugin(id) |
| 89 | return {} |
| 90 | }, |
| 91 | |
| 92 | async set_plugin({ id, enabled, config }) { |
| 93 | assertPluginId(id) |
| 94 | if (config) |
| 95 | setPluginConfig(id, config) |
| 96 | if (enabled !== undefined) |
| 97 | enablePlugin(id, enabled) |
| 98 | return {} |
| 99 | }, |
| 100 | |
| 101 | async get_plugin({ id }) { |
| 102 | assertPluginId(id) |
| 103 | return { |
| 104 | enabled: enablePlugins.get().includes(id), |
| 105 | config: { |
| 106 | ...newObj(getPluginConfigFields(id), v => v?.defaultValue), |
| 107 | ...pluginsConfig.get()[id] |
| 108 | } |
| 109 | } |
| 110 | }, |
| 111 | |
| 112 | get_online_plugins({ text }, ctx) { |
| 113 | if (text !== undefined && !_.isString(text)) |
| 114 | return new ApiError(HTTP_BAD_REQUEST, 'bad text') |
| 115 | return new SendListReadable({ |
| 116 | async doAtStart(list) { |
| 117 | const repos = [] as string[] |
| 118 | list.events(ctx, { |
| 119 | pluginInstalled: p => { |
| 120 | if (repos.includes(p.repo)) |
| 121 | list.update({ id: p.repo }, { installed: true }) |
| 122 | }, |
| 123 | pluginUninstalled: (_folder, repo) => { |
| 124 | if (typeof repo !== 'string') return // custom repo |
| 125 | if (repos.includes(repo)) |
| 126 | list.update({ id: repo }, { installed: false }) |
| 127 | }, |
| 128 | pluginDownload({ repo, status }) { |
| 129 | if (repos.includes(repo)) |
| 130 | list.update({ id: repo }, { downloading: status ?? null }) |
| 131 | } |
| 132 | }) |
| 133 | try { |
| 134 | const already = Object.values(getFolder2repo()).filter(Boolean).map(String) |
| 135 | for await (const pl of await searchPlugins(text, { skipRepos: already })) { |
| 136 | if (ctx.isAborted()) return |
| 137 | const repo = pl.repo || pl.id // .repo property can be more trustworthy in case github user renamed and left the previous link in 'repo' |
| 138 | const missing = getMissingDependencies(pl) |
| 139 | if (missing.length) pl.missing = missing |
| 140 | list.add(pl) |
| 141 | repos.push(repo) |
| 142 | } |
| 143 | } catch (err: any) { |
| 144 | list.error(err.code || err.message) |
| 145 | } |
| 146 | list.ready() |
| 147 | } |
| 148 | }) |
| 149 | }, |
| 150 | |
| 151 | async download_plugin({ id, branch, stop }) { |
| 152 | assertPluginId(id) |
| 153 | await checkDependencies(await readOnlinePlugin(id, branch)) |
| 154 | const folder = await downloadPlugin(id, { branch }) |
| 155 | if (stop) // be sure this is not automatically started |
| 156 | await stopPlugin(folder) |
| 157 | return (await waitFor(() => getPluginInfo(folder), { timeout: 5000 })) |
| 158 | || new ApiError(HTTP_SERVER_ERROR) |
| 159 | }, |
| 160 | |
| 161 | async update_plugin({ id }) { |
| 162 | assertPluginId(id) |
| 163 | const found = getPluginInfo(id) |
| 164 | if (!found) |
| 165 | return new ApiError(HTTP_NOT_FOUND) |
| 166 | const online = await readOnlineCompatiblePlugin(found.repo) // branch returned by readOnlineCompatiblePlugin is possibly fresher, so we use that |
| 167 | if (!online) |
| 168 | return new ApiError(HTTP_CONFLICT) |
| 169 | await checkDependencies(online) |
| 170 | await downloadPlugin(found.repo, { branch: online.branch, overwrite: true }) |
| 171 | return {} |
| 172 | }, |
| 173 | |
| 174 | async uninstall_plugin({ id, deleteConfig }) { |
| 175 | assertPluginId(id) |
| 176 | await stopPlugin(id) |
| 177 | await rm(PLUGINS_PATH + '/' + id, { recursive: true, force: true }) |
| 178 | if (deleteConfig) |
| 179 | setPluginConfig(id, null) |
| 180 | return {} |
| 181 | }, |
| 182 | |
| 183 | get_plugin_log({ id }, ctx) { |
| 184 | assertPluginId(id) |
| 185 | const p = getPluginInfo(id) |
| 186 | if (!p) |
| 187 | return new ApiError(HTTP_NOT_FOUND) |
| 188 | const list = new SendListReadable({ addAtStart: p.log }) |
| 189 | return list.events(ctx, { |
| 190 | ['pluginLog:' + id]: x => list.add(x) |
| 191 | }) |
| 192 | }, |
| 193 | |
| 194 | } |
| 195 | |
| 196 | export default apis |
| 197 | |
| 198 | function assertPluginId(id: unknown) { |
| 199 | apiAssertTypes({ string: { id } }) |
| 200 | } |
| 201 | |
| 202 | function serialize(p: Readonly<Plugin> | InactivePlugin) { |
| 203 | let o = 'getData' in p ? Object.assign(_.pick(p, ['id','started']), p.getData()) |
| 204 | : { ...p } // _.defaults mutates object, and we don't want that |
| 205 | if (typeof o.repo === 'object') // custom repo |
| 206 | o.repo = o.repo.web |
| 207 | o.log = 'log' in p && p.log?.length > 0 |
| 208 | o.config &&= _.isFunction(o.config) ? String(o.config) |
| 209 | : JSON.stringify(o.config, (_k, v) => _.isFunction(v) ? String(v) : v) // allow simple functions |
| 210 | return _.defaults(o, { started: null, badApi: null }) // nulls should be used to be sure to overwrite previous values, |
| 211 | } |
| 212 | |
| 213 | export async function checkDependencies(plugin: CommonPluginInterface) { |
| 214 | const miss = await getMissingDependencies(plugin) |
| 215 | if (miss.length) |
| 216 | throw new ApiError(HTTP_FAILED_DEPENDENCY, miss) |
| 217 | } |