feat(web): sélecteur de répertoire (parcourir au lieu de taper le chemin)
Ajoute un navigateur de dossiers réutilisable sur les deux champs qui exigeaient un chemin absolu tapé à la main : « Répertoire de travail » (nouvelle session) et « Chemin du dépôt » (ajout de repo). Un bouton « Parcourir… » déplie un panneau inline ; le champ texte reste pour taper/coller un chemin connu. Pour l'ajout de repo, les sous-dossiers qui sont des dépôts git sont signalés par un badge. Serveur : nouvel endpoint GET /api/v1/fs/list (authentifié par le hook preValidation global), ne renvoie que des noms de sous-dossiers (jamais de contenu de fichier). Valide le chemin via resolve() (clampe à la racine, neutralise « .. »), masque les dotfiles par défaut (showHidden), annote les dépôts git (markRepos), tolère les entrées illisibles. Tests : packages/server/test/fs-routes.test.ts (10 cas — listing, tri, hidden, markRepos, rejets 400/404, auth 401). Suite : 185/185 verts.
This commit is contained in:
@@ -17,6 +17,7 @@ import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerFsRoutes } from './routes/fs.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -92,6 +93,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
registerRepoRoutes(app, worktrees);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
registerFsRoutes(app);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
|
||||
82
packages/server/src/routes/fs.ts
Normal file
82
packages/server/src/routes/fs.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||
|
||||
/**
|
||||
* Navigation du système de fichiers pour le sélecteur de dossier côté web.
|
||||
* Ne renvoie QUE des noms de sous-dossiers (jamais de contenu de fichier) ; authentifié
|
||||
* par le hook preValidation global comme tout /api/**. Un utilisateur authentifié dispose
|
||||
* déjà d'un terminal (RCE par conception) : lister des dossiers n'élargit pas le modèle de menace.
|
||||
*
|
||||
* GET /api/v1/fs/list?path=<abs>&markRepos=1&showHidden=1
|
||||
* - path absent → home de l'utilisateur
|
||||
* - markRepos → annote les sous-dossiers qui sont des dépôts git (présence d'un `.git`)
|
||||
* - showHidden → inclut les dotfiles (masqués par défaut)
|
||||
*/
|
||||
export function registerFsRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/v1/fs/list', async (req, reply) => {
|
||||
const q = req.query as { path?: string; markRepos?: string; showHidden?: string };
|
||||
const raw = q.path && q.path.length > 0 ? q.path : homedir();
|
||||
if (!raw.startsWith('/')) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_PATH', message: 'path must be absolute' } });
|
||||
}
|
||||
// resolve() normalise et clampe à la racine : aucun échappement d'arborescence via `..`.
|
||||
const abs = resolve(raw);
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = await stat(abs);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${abs}` } });
|
||||
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } });
|
||||
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${abs}` } });
|
||||
}
|
||||
|
||||
const markRepos = q.markRepos === '1' || q.markRepos === 'true';
|
||||
const showHidden = q.showHidden === '1' || q.showHidden === 'true';
|
||||
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await readdir(abs, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } });
|
||||
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
||||
}
|
||||
|
||||
const entries: FsEntry[] = [];
|
||||
for (const d of dirents) {
|
||||
if (!showHidden && d.name.startsWith('.')) continue;
|
||||
let isDir = d.isDirectory();
|
||||
// symlink éventuel vers un dossier : résolu par stat (tolérant aux liens cassés).
|
||||
if (!isDir && d.isSymbolicLink()) {
|
||||
try {
|
||||
isDir = (await stat(join(abs, d.name))).isDirectory();
|
||||
} catch {
|
||||
isDir = false;
|
||||
}
|
||||
}
|
||||
if (!isDir) continue;
|
||||
const full = join(abs, d.name);
|
||||
const entry: FsEntry = { name: d.name, path: full };
|
||||
if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true;
|
||||
entries.push(entry);
|
||||
}
|
||||
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
|
||||
const res: FsListResponse = {
|
||||
path: abs,
|
||||
parent: abs === '/' ? null : dirname(abs),
|
||||
home: homedir(),
|
||||
entries,
|
||||
};
|
||||
return reply.send(res);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user