main
ts 329 lines 15.3 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 events from './events'
4 import {
5 httpString, httpStream, unzip, AsapStream, debounceAsync, retry, popKey, onlyTruthy, waitFor, HOUR, DAY, tryJson
6 } from './misc'
7 import {
8 DISABLING_SUFFIX, enablePlugin, findPluginByRepo, getInactivePlugins, getPluginInfo, isPluginRunning, mapPlugins,
9 parsePluginSource, PATH as PLUGINS_PATH, Repo, startPlugin, stopPlugin, STORAGE_FOLDER, DELETE_ME_SUFFIX,
10 PLUGIN_MAIN_FILE
11 } from './plugins'
12 import { ApiError } from './apiMiddleware'
13 import _ from 'lodash'
14 import {
15 HFS_REPO, HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR, VERSION,
16 RUNNING_BETA,
17 } from './const'
18 import { access, mkdir, rmdir, readFile, rename, rm, writeFile } from 'fs/promises'
19 import { join } from 'path'
20 import fs from 'fs'
21 import { storedMap } from './persistence'
22 import { argv } from './argv'
23 import { expiringCache } from './expiringCache'
24 import { configReady } from './config'
25 import { checkForUpdates } from './update'
26
27 const DIST_ROOT = 'dist'
28
29 type DownloadStatus = true | undefined
30 export const downloading: { [repo:string]: DownloadStatus } = {}
31
32 function downloadProgress(repo: string, status: DownloadStatus) {
33 if (status === undefined)
34 delete downloading[repo]
35 else
36 downloading[repo] = status
37 events.emit('pluginDownload', { repo, status })
38 }
39
40 const branchCache = expiringCache<Promise<string>>(DAY)
41 function getGithubDefaultBranch(repo: string) {
42 if (!repo.includes('/'))
43 throw 'malformed repo'
44 return branchCache.try(repo, async () => {
45 for (const b of ['main', 'master']) // try to not consume api quota
46 if (await httpString(`https://github.com/${repo}/raw/refs/heads/${b}/dist/plugin.js`, { method: 'HEAD', noRedirect: true }).then(() => 1, () => 0))
47 return b
48 return (await getRepoInfo(repo))?.default_branch as string
49 })
50 }
51
52 export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }={}) {
53 if (typeof repo !== 'string')
54 repo = repo.main
55 if (downloading[repo])
56 throw new ApiError(HTTP_CONFLICT, "already downloading")
57 const msg = await isPluginBlacklisted(repo) // check before downloading, in case other filters were passed somehow
58 if (msg)
59 throw new ApiError(HTTP_FORBIDDEN, "blacklisted: " + msg)
60 console.log('Downloading plugin', repo)
61 downloadProgress(repo, true)
62 try {
63 const pl = findPluginByRepo(repo)
64 const customRepo = repo.includes('//')
65 if (customRepo) { // custom repo
66 if (!pl)
67 throw new ApiError(HTTP_BAD_REQUEST, "bad repo")
68 const customRepo = ((pl as any).getData?.() || pl).repo
69 let url = customRepo?.zip
70 if (!url)
71 throw new ApiError(HTTP_SERVER_ERROR, "bad plugin")
72 if (!url.includes('//'))
73 url = customRepo.web + url
74 return await go(url, pl?.id, customRepo.zipRoot ?? DIST_ROOT)
75 }
76 branch ||= await getGithubDefaultBranch(repo)
77 const short = repo.split('/')[1] // second part, repo without the owner
78 if (!short)
79 throw new ApiError(HTTP_BAD_REQUEST, "bad repo")
80 const shortFolder = short.replace(/^hfs-/, '') || short
81 const folder = overwrite && pl?.id // use existing folder
82 || (getFolder2repo().hasOwnProperty(shortFolder) ? repo.replace('/','-') // longer form only if another plugin is using short form, to avoid overwriting
83 : shortFolder)
84 const GITHUB_ZIP_ROOT = short + '-' + branch // GitHub puts everything within this folder
85 return await go(`https://github.com/${repo}/archive/refs/heads/${branch}.zip`, folder, GITHUB_ZIP_ROOT + '/' + DIST_ROOT)
86
87 async function go(url: string, folder: string, zipRoot: string) {
88 const installPath = PLUGINS_PATH + '/' + folder
89 await access(installPath, fs.constants.W_OK) // early check for permission: access if it exists, mkdir+rmdir if it doesn't
90 .catch(() => mkdir(installPath, { recursive: true }).then(() => rmdir(installPath)))
91 const tempInstallPath = installPath + '-installing' + DISABLING_SUFFIX
92 const foldersToCopy = [ // from longer to shorter, so we first test the longer
93 zipRoot + '-' + process.platform + '-' + process.arch,
94 zipRoot + '-' + process.platform,
95 zipRoot,
96 ].map(x => x + '/')
97 // github zip doesn't have content-length, so we cannot produce progress event
98 const stream = await httpStream(url)
99 await unzip(stream, async path => {
100 const folder = foldersToCopy.find(x => path.startsWith(x))
101 if (!folder || path.endsWith('/')) return false
102 let dest = path.slice(folder.length)
103 dest = join(tempInstallPath, dest)
104 return rm(dest, { force: true }).then(() => dest, () => false)
105 })
106 if (!customRepo)
107 try {
108 const mainFile = join(tempInstallPath, PLUGIN_MAIN_FILE)
109 const content = await readFile(mainFile, 'utf8')
110 // force github plugins to have correct repo, in case it is missing, wrong, or just outdated after a rename
111 if (repo !== parsePluginSource('', content).repo) {
112 const correct = `exports.repo = ${JSON.stringify(repo)}\n`
113 let newContent = content.replace(/exports.repo\s*=\s*\S*/g, correct)
114 if (newContent === content) // no change = the line is missing
115 newContent = correct + content
116 await writeFile(mainFile, newContent) // first, as our parsing will consider that (unlike javascript)
117 }
118 }
119 catch (e) { // don't abort the whole procedure just because of the check above. It should never fail, but a user reported a mysterious ENOENT on the readFile()
120 console.warn("Plugin's repo check failed", e)
121 }
122 // ready to replace
123 const wasRunning = isPluginRunning(folder)
124 if (wasRunning)
125 await stopPlugin(folder) // stop old
126 // move data, and consider late release of the resource, up to a few seconds
127 await retry(() => rename(join(installPath, STORAGE_FOLDER), join(tempInstallPath, STORAGE_FOLDER))
128 .then(() => 1, e => e.code === 'ENOENT'))
129 // delete old folder (if any), but it may fail in the presence of .node files, so we rename it first as a precaution (clearing require.cache doesn't help). Especially on Windows, it may be impossible to delete dll files until our process is terminated (in which case, retrying is useless).
130 const deleteMe = installPath + DELETE_ME_SUFFIX
131 await retry(() => rename(installPath, deleteMe).then(() => 1, (e: any) => {
132 if (e.code === 'ENOENT') return 1 // nothing to do
133 console.warn("Error renaming old plugin folder:", String(e))
134 }))
135 await retry(() => rm(deleteMe, { recursive: true, force: true /*ignore ENOENT*/ }).then(() => 1, e => {
136 console.warn("Error deleting old plugin folder:", String(e))
137 }))
138 // final replace
139 await rename(tempInstallPath, installPath)
140 .catch(e => { throw e.code !== 'ENOENT' ? e : new ApiError(HTTP_NOT_ACCEPTABLE, "missing main file") })
141 if (wasRunning)
142 if (await waitFor(() => getPluginInfo(folder), { timeout: 10_000 }))
143 void startPlugin(folder) // don't wait, in case it fails to start. We still use startPlugin instead of enablePlugin, as it will take care of disabling other themes.
144 .catch(e => console.warn(String(e)))
145 events.emit('pluginDownloaded', { id: folder, repo })
146 return folder
147 }
148 }
149 finally {
150 downloadProgress(repo, undefined)
151 }
152 }
153
154 export function getRepoInfo(id: string) {
155 return apiGithub('repos/'+id)
156 }
157
158 export function readGithubFile(uri: string) {
159 return httpString('https://raw.githubusercontent.com/' + uri)
160 }
161
162 export async function readOnlinePlugin(repo: Repo, branch='') {
163 if (typeof repo !== 'string') { // non-github plugin
164 const folder = _.findKey(getFolder2repo(), x => x === repo)
165 if (!folder) throw Error()
166 const pl = getPluginInfo(folder)
167 let { main } = pl.repo
168 if (!main) throw Error("missing repo.main")
169 if (!main.includes('//'))
170 main = pl.repo.web + main
171 return parsePluginSource(main, await httpString(main)) // use 'repo' as 'id' client-side
172 }
173 branch ||= await getGithubDefaultBranch(repo)
174 const res = await readGithubFile(`${repo}/${branch}/${DIST_ROOT}/${PLUGIN_MAIN_FILE}`)
175 const pl = parsePluginSource(repo, res) // use 'repo' as 'id' client-side
176 pl.branch = branch
177 return pl
178 }
179
180 export async function readOnlineCompatiblePlugin(repo: Repo, branch='') {
181 const pl = await readOnlinePlugin(repo, branch)
182 if (!pl?.apiRequired) return // mandatory field
183 if (!pl.badApi) return pl
184 // we try other branches (starting with 'api')
185 const res = await apiGithub('repos/' + repo + '/branches')
186 const branches: string[] = res.map((x: any) => x?.name)
187 .filter((x: any) => typeof x === 'string' && x.startsWith('api'))
188 .sort().reverse()
189 for (const branch of branches) {
190 const pl = await readOnlinePlugin(repo, branch)
191 if (!pl) continue
192 if (!pl.apiRequired)
193 pl.badApi = '-'
194 if (!pl.badApi)
195 return pl
196 }
197 }
198
199 export function getFolder2repo() {
200 const ret = Object.fromEntries(getInactivePlugins().map(x => [x.id, x.repo]))
201 Object.assign(ret, Object.fromEntries(mapPlugins(x => [x.id, x.getData().repo])))
202 return ret
203 }
204
205 async function apiGithub(uri: string) {
206 return httpString('https://api.github.com/' + uri, {
207 headers: {
208 'User-Agent': 'HFS',
209 Accept: 'application/vnd.github.v3+json',
210 }
211 }).then(JSON.parse, e => {
212 // https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limiting
213 throw e.message === '403' ? Error('github_quota')
214 : e
215 })
216 }
217
218 export async function *apiGithubPaginated<T=any>(uri: string) {
219 uri += uri.includes('?') ? '&' : '?'
220 const PAGE_SIZE = 100
221 let page = 1
222 let n = 0
223 try {
224 while (1) {
225 const res = await apiGithub(uri + `page=${page++}&per_page=${PAGE_SIZE}`)
226 const a = res.items || res // "search/repositories" returns an object, while "releases" returns simply an array
227 for (const x of a)
228 yield x as T
229 const now = a.length
230 n += now
231 if (!now || n >= res.total_count) break
232 }
233 }
234 catch(e: any) {
235 if (e.message !== '422') // for some strange reason github api is returning this error if we search repos for a missing user, instead of empty set
236 throw e
237 }
238 }
239
240 async function isPluginBlacklisted(repo: string) {
241 return getProjectInfo().then(x => x?.repo_blacklist?.[repo]?.message as string || '', () => undefined)
242 }
243
244 export async function searchPlugins(text='', { skipRepos=[''] }={}) {
245 const seen = new Set<string>()
246 return new AsapStream(pluginPromises())
247
248 async function *pluginPromises() {
249 // github doesn't allow complex search, so we have to do it multiple times and merge the results
250 const searches = [
251 ...text.split(' ').filter(Boolean).slice(0, 2).map(x => 'user:' + encodeURI(x)), // first 2 words can be the author of the plugin
252 encodeURI(text), // search elsewhere, and results after the author search
253 ]
254 for (const term of searches) {
255 for await (const it of apiGithubPaginated(`search/repositories?q=topic:hfs-plugin+${term}`)) {
256 const repo = it.full_name as string
257 if (!repo || seen.has(repo)) // avoid duplicates, as we search multiple times
258 continue
259 seen.add(repo)
260 if (skipRepos.includes(repo))
261 continue
262 yield (async () => {
263 if (await isPluginBlacklisted(repo))
264 return
265 const pl = await readOnlineCompatiblePlugin(repo, it.default_branch).catch(() => undefined)
266 if (!pl)
267 return
268 Object.assign(pl, {
269 repo,
270 downloading: downloading[repo],
271 license: it.license?.spdx_id,
272 }, _.pick(it, ['pushed_at', 'stargazers_count', 'default_branch']))
273 return pl
274 })()
275 }
276 }
277 }
278 }
279
280 export let alerts: string[] | undefined
281 storedMap.ready().then(() => storedMap.del('alerts')) // remove legacy
282 const cachedCentralInfo = storedMap.singleSync('cachedCentralInfo', '') // persisting it could also be useful for no-internet instances, so that you can provide a fresher copy
283 export let blacklistedInstalledPlugins: string[] = []
284 // centralized hosted information, to be used as little as possible
285 const FN = 'central.json'
286 const builtInJson = fs.readFileSync(join(__dirname, '..', FN), 'utf8')
287 const branch = RUNNING_BETA ? VERSION.split('.')[1] : 'main'
288 export const getProjectInfo = debounceAsync(async () => {
289 const txt = argv.central === false ? builtInJson
290 : await readGithubFile(`${HFS_REPO}/${branch}/${FN}`)
291 .catch(() => RUNNING_BETA ? readGithubFile(`${HFS_REPO}/main/${FN}`) : '') // for beta versions, try again with 'main'
292 .catch(() => '')
293 let obj = tryJson(txt)
294 await cachedCentralInfo.ready()
295 if (obj) {
296 cachedCentralInfo.set(obj)
297 obj = { ...obj } // so that later modifications are not done on the cached object
298 }
299 else
300 obj = { ...cachedCentralInfo.get() || JSON.parse(builtInJson) } // fall back to built-in
301 // merge byVersions info in the main object but collect alerts separately to preserve multiple instances
302 const newAlerts: string[] = [obj.alert]
303 for (const [ver, more] of Object.entries(popKey(obj, 'byVersion') || {}))
304 if (VERSION.match(new RegExp(ver))) {
305 newAlerts.push((more as any).alert)
306 Object.assign(obj, more)
307 }
308 _.remove(newAlerts, x => !x)
309 if (!_.isEqual(alerts, newAlerts)) {
310 alerts = newAlerts
311 if (newAlerts.length)
312 void checkForUpdates() // with new alerts, is best to have fresh updates info
313 for (const a of newAlerts) {
314 console.log("ALERT:", a)
315 events.emit('alert', { message: a })
316 }
317 }
318 const black = onlyTruthy(Object.keys(obj.repo_blacklist || {}).map(findPluginByRepo))
319 blacklistedInstalledPlugins = onlyTruthy(black.map(x => _.isString(x.repo) && x.repo))
320 if (black.length) {
321 console.log("Blacklisted plugins found:", black.join(', '))
322 for (const p of black)
323 enablePlugin(p.id, false)
324 }
325 return obj
326 }, { retain: HOUR, retainFailure: 60_000 })
327
328 // refresh of alerts and blacklist happens early without stalling startup
329 configReady.then(getProjectInfo)