From 416e5577cb3ae2f352d2051441ba9c9af2b13261 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 17 Jun 2026 13:04:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20s=C3=A9lecteur=20de=20r=C3=A9perto?= =?UTF-8?q?ire=20(parcourir=20au=20lieu=20de=20taper=20le=20chemin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server/src/app.ts | 2 + packages/server/src/routes/fs.ts | 82 ++++++++++ packages/server/test/fs-routes.test.ts | 143 ++++++++++++++++++ packages/shared/src/api.ts | 19 +++ .../web/src/components/DirectoryPicker.vue | 84 ++++++++++ packages/web/src/i18n/en.ts | 9 ++ packages/web/src/i18n/fr.ts | 9 ++ packages/web/src/views/DashboardView.vue | 15 ++ packages/web/src/views/SessionsListView.vue | 15 ++ 9 files changed, 378 insertions(+) create mode 100644 packages/server/src/routes/fs.ts create mode 100644 packages/server/test/fs-routes.test.ts create mode 100644 packages/web/src/components/DirectoryPicker.vue diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index a436a0d..168f185 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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) => { diff --git a/packages/server/src/routes/fs.ts b/packages/server/src/routes/fs.ts new file mode 100644 index 0000000..e6992fd --- /dev/null +++ b/packages/server/src/routes/fs.ts @@ -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=&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); + }); +} diff --git a/packages/server/test/fs-routes.test.ts b/packages/server/test/fs-routes.test.ts new file mode 100644 index 0000000..3f33795 --- /dev/null +++ b/packages/server/test/fs-routes.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildApp, type AppBundle } from '../src/app.js'; +import { openDb, type Db } from '../src/db/index.js'; +import type { Config } from '../src/config.js'; +import type { FsListResponse } from '@arboretum/shared'; + +// Mêmes stubs que app.e2e : pas de vrai claude ni de vrai PTY en CI. +vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); +vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { + class FakePty { + pid = 424242; + write = vi.fn(); + resize = vi.fn(); + pause = vi.fn(); + resume = vi.fn(); + kill = vi.fn(); + onData(): { dispose: () => void } { + return { dispose: () => {} }; + } + onExit(): { dispose: () => void } { + return { dispose: () => {} }; + } + } + return { default: { spawn: (): FakePty => new FakePty() } }; +}); + +process.env.ARBORETUM_LOG = 'silent'; + +let dir: string; +let root: string; +let bundle: AppBundle; +let db: Db; +let token: string; + +const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'arboretum-fs-')); + // Arbo de test : root/{alpha, Beta, .hidden, a-repo/.git, file.txt} + root = join(dir, 'root'); + mkdirSync(join(root, 'alpha'), { recursive: true }); + mkdirSync(join(root, 'Beta'), { recursive: true }); + mkdirSync(join(root, '.hidden'), { recursive: true }); + mkdirSync(join(root, 'a-repo', '.git'), { recursive: true }); + writeFileSync(join(root, 'file.txt'), 'x'); + + const dbPath = join(dir, 'fs.db'); + db = openDb(dbPath); + const config: Config = { + port: 7317, + bind: '127.0.0.1', + dbPath, + dataDir: dir, + allowedOrigins: [], + printToken: false, + claudeProjectsDir: join(dir, 'claude', 'projects'), + claudeSessionsDir: join(dir, 'claude', 'sessions'), + vapidContact: 'mailto:test@localhost', + }; + bundle = buildApp(config, db, '0.0.0-test'); + const t = bundle.auth.ensureBootstrapToken(); + if (!t) throw new Error('bootstrap token attendu sur une base vierge'); + token = t; +}); + +afterAll(async () => { + await bundle.app.close(); + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('GET /api/v1/fs/list', () => { + it('liste les sous-dossiers triés, masque les dotfiles et les fichiers', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() }); + expect(res.statusCode).toBe(200); + const body = res.json() as FsListResponse; + const names = body.entries.map((e) => e.name); + expect(names).toEqual(['a-repo', 'alpha', 'Beta']); // tri insensible à la casse, fichiers/dotfiles exclus + expect(body.path).toBe(root); + expect(body.parent).toBe(dir); + expect(typeof body.home).toBe('string'); + }); + + it('showHidden=1 inclut les dossiers cachés', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&showHidden=1`, headers: auth() }); + const body = res.json() as FsListResponse; + expect(body.entries.map((e) => e.name)).toContain('.hidden'); + }); + + it('markRepos=1 annote le dossier contenant un .git', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&markRepos=1`, headers: auth() }); + const body = res.json() as FsListResponse; + expect(body.entries.find((e) => e.name === 'a-repo')?.isRepo).toBe(true); + expect(body.entries.find((e) => e.name === 'alpha')?.isRepo).toBeUndefined(); + }); + + it('sans markRepos, aucune annotation isRepo', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() }); + const body = res.json() as FsListResponse; + expect(body.entries.every((e) => e.isRepo === undefined)).toBe(true); + }); + + it('path absent → liste le home (réponse 200 cohérente)', async () => { + const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list', headers: auth() }); + expect(res.statusCode).toBe(200); + const body = res.json() as FsListResponse; + expect(body.path).toBe(body.home); + }); + + it('chemin relatif → 400 BAD_PATH', async () => { + const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list?path=relatif/x', headers: auth() }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: 'BAD_PATH' } }); + }); + + it('échappement par `..` neutralisé : resolve clampe, pas de fuite (200 sur un dossier réel)', async () => { + // `/../..` résout vers un ancêtre réel : la requête réussit mais ne contient jamais de `..`. + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, '..', '..'))}`, headers: auth() }); + expect(res.statusCode).toBe(200); + expect((res.json() as FsListResponse).path).not.toContain('..'); + }); + + it('chemin inexistant → 404 NOT_FOUND', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'nope-xyz'))}`, headers: auth() }); + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }); + }); + + it('cible non-dossier → 400 NOT_A_DIRECTORY', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'file.txt'))}`, headers: auth() }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: 'NOT_A_DIRECTORY' } }); + }); + + it('sans authentification → 401', async () => { + const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}` }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } }); + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 40f5260..da82040 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -91,6 +91,25 @@ export interface AdoptWorktreeRequest { preTrust?: boolean; } +// ---- Navigateur de répertoires (sélecteur de dossier côté web) ---- +export interface FsEntry { + name: string; + /** chemin absolu du dossier */ + path: string; + /** présent (true) uniquement en mode markRepos quand le dossier est un dépôt git */ + isRepo?: boolean; +} +export interface FsListResponse { + /** chemin absolu listé (normalisé) */ + path: string; + /** parent (null à la racine `/`) — pour le bouton « remonter » */ + parent: string | null; + /** home de l'utilisateur côté serveur — point de départ par défaut */ + home: string; + /** sous-dossiers uniquement, triés sans tenir compte de la casse */ + entries: FsEntry[]; +} + // ---- Web Push (P4) ---- export interface VapidKeyResponse { /** clé publique VAPID (applicationServerKey côté navigateur). */ diff --git a/packages/web/src/components/DirectoryPicker.vue b/packages/web/src/components/DirectoryPicker.vue new file mode 100644 index 0000000..b7030e9 --- /dev/null +++ b/packages/web/src/components/DirectoryPicker.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 8565fbc..903247f 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -103,6 +103,15 @@ export default { denied: 'Notification permission denied — enable it in your browser settings.', unsupported: 'Push needs HTTPS (e.g. Tailscale Serve); on iOS, install the app to your home screen first.', }, + fs: { + browse: 'Browse…', + up: 'Up', + useFolder: 'Use this folder', + showHidden: 'Show hidden', + loading: 'Loading…', + empty: 'No subfolders', + repoBadge: 'repo', + }, ws: { reconnecting: 'Connection lost — reconnecting…', }, diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 1665809..bfe2bb2 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -106,6 +106,15 @@ const fr: typeof en = { denied: 'Permission de notification refusée — activez-la dans les réglages du navigateur.', unsupported: 'Le push nécessite HTTPS (ex. Tailscale Serve) ; sur iOS, installez d’abord l’app sur l’écran d’accueil.', }, + fs: { + browse: 'Parcourir…', + up: 'Remonter', + useFolder: 'Utiliser ce dossier', + showHidden: 'Afficher les cachés', + loading: 'Chargement…', + empty: 'Aucun sous-dossier', + repoBadge: 'dépôt', + }, ws: { reconnecting: 'Connexion perdue — reconnexion…', }, diff --git a/packages/web/src/views/DashboardView.vue b/packages/web/src/views/DashboardView.vue index 2705429..d19dbc8 100644 --- a/packages/web/src/views/DashboardView.vue +++ b/packages/web/src/views/DashboardView.vue @@ -28,10 +28,18 @@ {{ t('repos.pathLabel') }} + +

{{ addError }}

{{ store.loadError }}

@@ -53,6 +61,7 @@ import { useSessionsStore } from '../stores/sessions'; import { usePushStore } from '../stores/push'; import LanguageSwitcher from '../components/LanguageSwitcher.vue'; import RepoSection from '../components/RepoSection.vue'; +import DirectoryPicker from '../components/DirectoryPicker.vue'; const { t } = useI18n(); const router = useRouter(); @@ -62,9 +71,15 @@ const sessions = useSessionsStore(); const push = usePushStore(); const newPath = ref(''); +const showPicker = ref(false); const adding = ref(false); const addError = ref(null); +function onPickPath(path: string): void { + newPath.value = path; + showPicker.value = false; +} + // 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel. const pushErrorText = computed(() => { const e = push.error; diff --git a/packages/web/src/views/SessionsListView.vue b/packages/web/src/views/SessionsListView.vue index e091b77..3c20813 100644 --- a/packages/web/src/views/SessionsListView.vue +++ b/packages/web/src/views/SessionsListView.vue @@ -35,10 +35,18 @@ + +

{{ createError }}

{{ actionError }}

@@ -112,6 +120,7 @@ import { useSessionsStore } from '../stores/sessions'; import LanguageSwitcher from '../components/LanguageSwitcher.vue'; import SessionStateBadge from '../components/SessionStateBadge.vue'; import DialogPrompt from '../components/DialogPrompt.vue'; +import DirectoryPicker from '../components/DirectoryPicker.vue'; const { t, locale } = useI18n(); const router = useRouter(); @@ -120,6 +129,7 @@ const store = useSessionsStore(); const newCwd = ref(''); const newCommand = ref<'claude' | 'bash'>('claude'); +const showPicker = ref(false); const creating = ref(false); const createError = ref(null); const killArmedId = ref(null); @@ -136,6 +146,11 @@ function formatDate(iso: string): string { return new Date(iso).toLocaleString(locale.value); } +function onPickCwd(path: string): void { + newCwd.value = path; + showPicker.value = false; +} + async function onCreate(): Promise { creating.value = true; createError.value = null;