main
ts 217 lines 9 KB
Raw
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 }