Blame Raw
Cohee · 51ad27fb · · 515 lines (22.3 KB)
1 contributor
1import path from 'node:path';
2import fs from 'node:fs';
3
4import express from 'express';
5import sanitize from 'sanitize-filename';
6import { CheckRepoActions, default as simpleGit } from 'simple-git';
7
8import { PUBLIC_DIRECTORIES } from '../constants.js';
9import { getConfigValue, isValidUrl } from '../util.js';
10import { createGitClient } from '../git/client.js';
11
12const gitBackend = getConfigValue('git.backend', 'auto');
13
14/**
15 * @type {Partial<import('simple-git').SimpleGitOptions>}
16 */
17const OPTIONS = Object.freeze({ timeout: { block: 5 * 60 * 1000 } });
18
19/**
20 * This function extracts the extension information from the manifest file.
21 * @param {string} extensionPath - The path of the extension folder
22 * @returns {Promise<Object>} - Returns the manifest data as an object
23 */
24async function getManifest(extensionPath) {
25 const manifestPath = path.join(extensionPath, 'manifest.json');
26
27 // Check if manifest.json exists
28 if (!fs.existsSync(manifestPath)) {
29 throw new Error(`Manifest file not found at ${manifestPath}`);
30 }
31
32 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
33 return manifest;
34}
35
36/**
37 * This function checks if the local repository is up-to-date with the remote repository.
38 * @param {string} extensionPath - The path of the extension folder
39 * @returns {Promise<Object>} - Returns the extension information as an object
40 */
41async function checkIfRepoIsUpToDate(extensionPath) {
42 const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
43 await git.fetch('origin');
44 const currentBranch = await git.branch();
45 const currentCommitHash = await git.revparse(['HEAD']);
46 const log = await git.log({
47 from: currentCommitHash,
48 to: `origin/${currentBranch.current}`,
49 });
50
51 // Fetch remote repository information
52 const remotes = await git.getRemotes(true);
53 if (remotes.length === 0) {
54 return {
55 isUpToDate: true,
56 remoteUrl: '',
57 };
58 }
59
60 return {
61 isUpToDate: log.total === 0,
62 remoteUrl: remotes[0].refs.fetch, // URL of the remote repository
63 };
64}
65
66export const router = express.Router();
67
68/**
69 * Feature flag guard: don't allow calling any of the endpoints if extensions are disabled
70 * @type {import('express').RequestHandler}
71 */
72export const extensionsEnabledFeatureGuard = (_, response, next) => {
73 const enabled = !!getConfigValue('extensions.enabled', true, 'boolean');
74 if (!enabled) {
75 response.sendStatus(404);
76 return;
77 }
78 next();
79};
80
81router.use(extensionsEnabledFeatureGuard);
82
83/**
84 * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
85 * and return extension information and path.
86 *
87 * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.
88 * @param {Object} response - HTTP Response object used to respond to the HTTP request.
89 *
90 * @returns {void}
91 */
92router.post('/install', async (request, response) => {
93 try {
94 const { url, global, branch } = request.body;
95
96 if (global && !request.user.profile.admin) {
97 console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
98 return response.status(403).send('Forbidden: No permission to install global extensions.');
99 }
100
101 if (!isValidUrl(url)) {
102 return response.status(400).send('Bad Request: A valid URL is required in the request body.');
103 }
104
105 const parsedUrl = new URL(url);
106 if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
107 return response.status(400).send('Bad Request: Only HTTP and HTTPS protocols are supported for the Extension URL.');
108 }
109
110 const git = createGitClient({ backend: gitBackend });
111
112 // make sure the third-party directory exists
113 if (!fs.existsSync(path.join(request.user.directories.extensions))) {
114 fs.mkdirSync(path.join(request.user.directories.extensions));
115 }
116
117 if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
118 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
119 }
120
121 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
122 const extensionNameSanitized = sanitize(path.basename(parsedUrl.pathname, '.git'));
123 if (!extensionNameSanitized) {
124 return response.status(400).send('Could not determine the extension name from the URL. Please provide a valid git repository URL.');
125 }
126
127 const extensionPath = path.join(basePath, extensionNameSanitized);
128 const folderName = path.basename(extensionPath);
129
130 if (fs.existsSync(extensionPath)) {
131 return response.status(409).send(`Directory already exists at ${extensionPath}`);
132 }
133
134 const cloneOptions = { depth: 1 };
135 if (branch) {
136 cloneOptions.branch = branch;
137 }
138 await git.clone(parsedUrl.href, extensionPath, cloneOptions);
139 console.info(`Extension has been cloned to ${extensionPath} from ${parsedUrl.href} at ${branch || '(default)'} branch`);
140
141 try {
142 const manifest = await getManifest(extensionPath);
143 if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
144 throw new Error('Manifest is not a valid JSON object.');
145 }
146 const { version, author, display_name } = manifest;
147 return response.send({ version, author, display_name, extensionPath, folderName });
148 } catch (manifestError) {
149 await fs.promises.rm(extensionPath, { recursive: true, force: true });
150 throw manifestError;
151 }
152 } catch (error) {
153 console.error('Importing extension failed', error);
154 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
155 }
156});
157
158/**
159 * HTTP POST handler function to pull the latest updates from a git repository
160 * based on the extension name provided in the request body. It returns the latest commit hash,
161 * the path of the extension, the status of the repository (whether it's up-to-date or not),
162 * and the remote URL of the repository.
163 *
164 * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
165 * @param {Object} response - HTTP Response object used to respond to the HTTP request.
166 *
167 * @returns {void}
168 */
169router.post('/update', async (request, response) => {
170 try {
171 if (typeof request.body.extensionName !== 'string') {
172 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
173 }
174
175 const { extensionName, global } = request.body;
176 const extensionNameSanitized = sanitize(extensionName);
177 if (!extensionNameSanitized) {
178 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
179 }
180
181 if (global && !request.user.profile.admin) {
182 console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
183 return response.status(403).send('Forbidden: No permission to update global extensions.');
184 }
185
186 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
187 const extensionPath = path.join(basePath, extensionNameSanitized);
188
189 if (!fs.existsSync(extensionPath)) {
190 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
191 }
192
193 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
194 const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
195 const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
196 if (!isRepo) {
197 throw new Error(`Directory is not a Git repository at ${extensionPath}`);
198 }
199 const currentBranch = await git.branch();
200 if (!isUpToDate) {
201 await git.pull('origin', currentBranch.current);
202 console.info(`Extension has been updated at ${extensionPath}`);
203 } else {
204 console.info(`Extension is up to date at ${extensionPath}`);
205 }
206 await git.fetch('origin');
207 const fullCommitHash = await git.revparse(['HEAD']);
208 const shortCommitHash = fullCommitHash.slice(0, 7);
209
210 return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
211 } catch (error) {
212 console.error('Updating extension failed', error);
213 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
214 }
215});
216
217router.post('/branches', async (request, response) => {
218 try {
219 if (typeof request.body.extensionName !== 'string') {
220 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
221 }
222
223 const { extensionName, global } = request.body;
224 const extensionNameSanitized = sanitize(extensionName);
225 if (!extensionNameSanitized) {
226 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
227 }
228
229 if (global && !request.user.profile.admin) {
230 console.error(`User ${request.user.profile.handle} does not have permission to list branches of global extensions.`);
231 return response.status(403).send('Forbidden: No permission to list branches of global extensions.');
232 }
233
234 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
235 const extensionPath = path.join(basePath, extensionNameSanitized);
236
237 if (!fs.existsSync(extensionPath)) {
238 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
239 }
240
241 const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
242 // Unshallow the repository if it is shallow
243 const isShallow = await git.revparse(['--is-shallow-repository']) === 'true';
244 if (isShallow) {
245 console.info(`Unshallowing the repository at ${extensionPath}`);
246 await git.fetch('origin', ['--unshallow']);
247 }
248
249 // Fetch all branches
250 await git.remote(['set-branches', 'origin', '*']);
251 await git.fetch('origin');
252 const localBranches = await git.branchLocal();
253 const remoteBranches = await git.branch(['-r', '--list', 'origin/*']);
254 const result = [
255 ...Object.values(localBranches.branches),
256 ...Object.values(remoteBranches.branches),
257 ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label }));
258
259 return response.send(result);
260 } catch (error) {
261 console.error('Getting branches failed', error);
262 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
263 }
264});
265
266router.post('/switch', async (request, response) => {
267 try {
268 if (typeof request.body.extensionName !== 'string') {
269 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
270 }
271
272 const { extensionName, branch, global } = request.body;
273 const extensionNameSanitized = sanitize(extensionName);
274 if (!extensionNameSanitized || !branch) {
275 return response.status(400).send('Bad Request: A valid extensionName and branch are required in the request body.');
276 }
277
278 if (global && !request.user.profile.admin) {
279 console.error(`User ${request.user.profile.handle} does not have permission to switch branches of global extensions.`);
280 return response.status(403).send('Forbidden: No permission to switch branches of global extensions.');
281 }
282
283 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
284 const extensionPath = path.join(basePath, extensionNameSanitized);
285
286 if (!fs.existsSync(extensionPath)) {
287 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
288 }
289
290 const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
291 const branches = await git.branchLocal();
292
293 if (String(branch).startsWith('origin/')) {
294 const localBranch = branch.replace('origin/', '');
295 if (branches.all.includes(localBranch)) {
296 console.info(`Branch ${localBranch} already exists locally, checking it out`);
297 await git.checkout(localBranch);
298 return response.sendStatus(204);
299 }
300
301 console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`);
302 await git.checkoutBranch(localBranch, branch);
303 return response.sendStatus(204);
304 }
305
306 if (!branches.all.includes(branch)) {
307 console.error(`Branch ${branch} does not exist locally`);
308 return response.status(404).send(`Branch ${branch} does not exist locally`);
309 }
310
311 // Check if the branch is already checked out
312 const currentBranch = await git.branch();
313 if (currentBranch.current === branch) {
314 console.info(`Branch ${branch} is already checked out`);
315 return response.sendStatus(204);
316 }
317
318 // Checkout the branch
319 await git.checkout(branch);
320 console.info(`Checked out branch ${branch} at ${extensionPath}`);
321
322 return response.sendStatus(204);
323 } catch (error) {
324 console.error('Switching branches failed', error);
325 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
326 }
327});
328
329router.post('/move', async (request, response) => {
330 try {
331 if (typeof request.body.extensionName !== 'string') {
332 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
333 }
334
335 const { extensionName, source, destination } = request.body;
336 const extensionNameSanitized = sanitize(extensionName);
337 if (!extensionNameSanitized || !source || !destination) {
338 return response.status(400).send('Bad Request: A valid extensionName, source, and destination are required in the request body.');
339 }
340
341 if (!request.user.profile.admin) {
342 console.error(`User ${request.user.profile.handle} does not have permission to move extensions.`);
343 return response.status(403).send('Forbidden: No permission to move extensions.');
344 }
345
346 const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
347 const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
348 const sourcePath = path.join(sourceDirectory, extensionNameSanitized);
349 const destinationPath = path.join(destinationDirectory, extensionNameSanitized);
350
351 if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
352 console.error(`Source directory does not exist at ${sourcePath}`);
353 return response.status(404).send('Source directory does not exist.');
354 }
355
356 if (fs.existsSync(destinationPath)) {
357 console.error(`Destination directory already exists at ${destinationPath}`);
358 return response.status(409).send('Destination directory already exists.');
359 }
360
361 if (source === destination) {
362 console.error('Source and destination directories are the same');
363 return response.status(409).send('Source and destination directories are the same.');
364 }
365
366 fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
367 fs.rmSync(sourcePath, { recursive: true, force: true });
368 console.info(`Extension has been moved from ${sourcePath} to ${destinationPath}`);
369
370 return response.sendStatus(204);
371 } catch (error) {
372 console.error('Moving extension failed', error);
373 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
374 }
375});
376
377/**
378 * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
379 * It checks whether the repository is up-to-date with the remote, and returns the status along with
380 * the remote URL of the repository.
381 *
382 * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property.
383 * @param {Object} response - HTTP Response object used to respond to the HTTP request.
384 *
385 * @returns {void}
386 */
387router.post('/version', async (request, response) => {
388 try {
389 if (typeof request.body.extensionName !== 'string') {
390 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
391 }
392
393 const { extensionName, global } = request.body;
394 const extensionNameSanitized = sanitize(extensionName);
395 if (!extensionNameSanitized) {
396 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
397 }
398
399 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
400 const extensionPath = path.join(basePath, extensionNameSanitized);
401
402 if (!fs.existsSync(extensionPath)) {
403 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
404 }
405
406 const git = simpleGit({ baseDir: extensionPath, ...OPTIONS });
407 let currentCommitHash;
408 try {
409 const isRepo = await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
410 if (!isRepo) {
411 throw new Error(`Directory is not a Git repository at ${extensionPath}`);
412 }
413 currentCommitHash = await git.revparse(['HEAD']);
414 } catch (error) {
415 // it is not a git repo, or has no commits yet, or is a bare repo
416 // not possible to update it, most likely can't get the branch name either
417 return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
418 }
419
420 const currentBranch = await git.branch();
421 // get only the working branch
422 const currentBranchName = currentBranch.current;
423 await git.fetch('origin');
424 console.debug(extensionNameSanitized, currentBranchName, currentCommitHash);
425 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
426
427 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
428 } catch (error) {
429 console.error('Getting extension version failed', error);
430 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
431 }
432});
433
434/**
435 * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
436 *
437 * @param {Object} request - HTTP Request object, expects a JSON body with a 'extensionName' property.
438 * @param {Object} response - HTTP Response object used to respond to the HTTP request.
439 *
440 * @returns {void}
441 */
442router.post('/delete', async (request, response) => {
443 try {
444 if (typeof request.body.extensionName !== 'string') {
445 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
446 }
447
448 const { extensionName, global } = request.body;
449 const extensionNameSanitized = sanitize(extensionName);
450 if (!extensionNameSanitized) {
451 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
452 }
453
454 if (global && !request.user.profile.admin) {
455 console.error(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);
456 return response.status(403).send('Forbidden: No permission to delete global extensions.');
457 }
458
459 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
460 const extensionPath = path.join(basePath, extensionNameSanitized);
461
462 if (!fs.existsSync(extensionPath)) {
463 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
464 }
465
466 await fs.promises.rm(extensionPath, { recursive: true });
467 console.info(`Extension has been deleted at ${extensionPath}`);
468
469 return response.send(`Extension has been deleted at ${extensionPath}`);
470 } catch (error) {
471 console.error('Deleting extension failed', error);
472 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
473 }
474});
475
476/**
477 * Discover the extension folders
478 * If the folder is called third-party, search for subfolders instead
479 */
480router.get('/discover', function (request, response) {
481 if (!fs.existsSync(path.join(request.user.directories.extensions))) {
482 fs.mkdirSync(path.join(request.user.directories.extensions));
483 }
484
485 if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
486 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
487 }
488
489 // Get all folders in system extensions folder, excluding third-party
490 const builtInExtensions = fs
491 .readdirSync(PUBLIC_DIRECTORIES.extensions)
492 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
493 .filter(f => f !== 'third-party')
494 .map(f => ({ type: 'system', name: f }));
495
496 // Get all folders in local extensions folder
497 const userExtensions = fs
498 .readdirSync(path.join(request.user.directories.extensions))
499 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
500 .map(f => ({ type: 'local', name: `third-party/${f}` }));
501
502 // Get all folders in global extensions folder
503 // In case of a conflict, the extension will be loaded from the user folder
504 const globalExtensions = fs
505 .readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
506 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
507 .map(f => ({ type: 'global', name: `third-party/${f}` }))
508 .filter(f => !userExtensions.some(e => e.name === f.name));
509
510 // Combine all extensions
511 const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions];
512 console.debug('Extensions available for', request.user.profile.handle, allExtensions);
513
514 return response.send(allExtensions);
515});