Modèle de données : - migration #11 git_credentials (secrets chiffrés SecretBox : secret_encrypted ; colonnes ssh/oauth posées pour P12b/P12c) ; #12 repos ALTER remote_url/git_service/credential_id (pas de FK) - types partagés api.ts (GitCredentialSummary sans secret + hasSecret/secretLast4, CRUD, RemoteRepoSummary, Clone*) ; protocole additif : topic 'clones' + message clone_update (CloneOperation) Backend : - core/git-credentials.ts (GitCredentialsManager(db, box)) : CRUD chiffré, test() (GET /user), getSecret()/authContext() internes, NULLification de repos.credential_id à la suppression - core/git-clients/ (github/gitlab/gitea) via fetch, sans dépendance : verify()+listRepos() paginés, erreurs typées AUTH_FAILED/RATE_LIMITED/UNREACHABLE, SSRF base_url http(s) - core/git-auth.ts : withGitAuth (GIT_ASKPASS éphémère 0o700, secret par env, GIT_TERMINAL_PROMPT=0, jamais dans l'URL/.git/config, nettoyage finally) - core/git.ts cloneRepo (spawn git clone --progress, parse progression, timeout) - core/clone-manager.ts (EventEmitter) : clone async, dest confiné sous scanRoots + non existant, auto-enregistrement via addRepo + métadonnées de provenance, nettoyage du clone partiel, events topic 'clones' - routes/git-connections.ts (CRUD + /test + /:id/repos + POST /repos/clone 202 + GET /repos/clone/:id) ; app.ts câble box→GitCredentialsManager + CloneManager→gateway ; gateway relaie 'clones' Frontend : - ws-client subscribeClones ; stores git-connections + clone (suivi WS) - components/settings/GitConnectionsSection (liste + formulaire pat/app_password, secret jamais ré-affiché) inséré dans SettingsView ; CloneRepoModal (connexion → repos distants paginés → dest scanRoots[0] → barre de progression WS → redirection) ; bouton « Cloner » dans DashboardView ; i18n EN+FR Tests : git-credentials (round-trip SecretBox, résumé sans secret, NULLification) ; acceptance-p12.mjs (clone bare local file:// → clone_update done + repo enregistré + secret ABSENT de l'API, de la DB et du .git/config) Sous-phases restantes : P12b (SSH), P12c (OAuth device flow).
120 lines
5.4 KiB
TypeScript
120 lines
5.4 KiB
TypeScript
// Routes des services git distants (P12) : CRUD des credentials (secrets JAMAIS renvoyés), test de
|
|
// connexion, listing des dépôts distants, et lancement/suivi d'un clone. Authentifiées par le hook
|
|
// preValidation global. Les secrets sont chiffrés par SecretBox dans le manager.
|
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
import type {
|
|
CloneRequest,
|
|
CloneStartResponse,
|
|
CloneStatusResponse,
|
|
CreateGitCredentialRequest,
|
|
GitCredentialResponse,
|
|
GitCredentialsListResponse,
|
|
RemoteReposResponse,
|
|
UpdateGitCredentialRequest,
|
|
} from '@arboretum/shared';
|
|
import type { Db } from '../db/index.js';
|
|
import type { GitCredentialsManager } from '../core/git-credentials.js';
|
|
import type { CloneManager } from '../core/clone-manager.js';
|
|
import { getGitClient, GitServiceError } from '../core/git-clients/index.js';
|
|
import { recordAudit } from '../core/audit-log.js';
|
|
|
|
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
|
const e = err as { statusCode?: number; code?: string; message?: string };
|
|
const status = typeof e.statusCode === 'number' ? e.statusCode : 500;
|
|
return reply.status(status).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
|
}
|
|
|
|
const GIT_SERVICE_ERROR_STATUS: Record<string, number> = { AUTH_FAILED: 401, RATE_LIMITED: 429, UNREACHABLE: 502, BAD_BASE_URL: 400 };
|
|
|
|
export function registerGitConnectionRoutes(
|
|
app: FastifyInstance,
|
|
credentials: GitCredentialsManager,
|
|
clones: CloneManager,
|
|
db: Db,
|
|
): void {
|
|
const actor = (req: { authContext?: { tokenId: string } | null }): string => req.authContext?.tokenId ?? 'unknown';
|
|
|
|
app.get('/api/v1/git-connections', async (): Promise<GitCredentialsListResponse> => ({ credentials: credentials.list() }));
|
|
|
|
app.post('/api/v1/git-connections', async (req, reply) => {
|
|
try {
|
|
const cred = credentials.create((req.body ?? {}) as CreateGitCredentialRequest);
|
|
recordAudit(db, { actor: actor(req), action: 'git_credential.create', resourceId: cred.id, details: { service: cred.service, authType: cred.authType } });
|
|
const res: GitCredentialResponse = { credential: cred };
|
|
return reply.status(201).send(res);
|
|
} catch (err) {
|
|
return sendError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.patch('/api/v1/git-connections/:id', async (req, reply) => {
|
|
try {
|
|
const cred = credentials.update((req.params as { id: string }).id, (req.body ?? {}) as UpdateGitCredentialRequest);
|
|
recordAudit(db, { actor: actor(req), action: 'git_credential.update', resourceId: cred.id });
|
|
const res: GitCredentialResponse = { credential: cred };
|
|
return reply.send(res);
|
|
} catch (err) {
|
|
return sendError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/v1/git-connections/:id', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
if (!credentials.remove(id)) {
|
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No credential with this id' } });
|
|
}
|
|
recordAudit(db, { actor: actor(req), action: 'git_credential.delete', resourceId: id });
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
app.post('/api/v1/git-connections/:id/test', async (req, reply) => {
|
|
try {
|
|
const result = await credentials.test((req.params as { id: string }).id);
|
|
recordAudit(db, { actor: actor(req), action: 'git_credential.test', resourceId: (req.params as { id: string }).id, details: { ok: result.ok } });
|
|
return reply.send(result);
|
|
} catch (err) {
|
|
return sendError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get('/api/v1/git-connections/:id/repos', async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const q = req.query as { page?: string; search?: string };
|
|
const ctx = credentials.authContext(id);
|
|
if (!ctx) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No usable credential with this id' } });
|
|
const page = Math.max(1, Number(q.page) || 1);
|
|
try {
|
|
const { repos, nextPage } = await getGitClient(ctx.service, ctx.baseUrl).listRepos(ctx.auth, page, q.search);
|
|
const res: RemoteReposResponse = { repos, nextPage };
|
|
return reply.send(res);
|
|
} catch (err) {
|
|
if (err instanceof GitServiceError) {
|
|
return reply.status(GIT_SERVICE_ERROR_STATUS[err.errorCode] ?? 502).send({ error: { code: err.errorCode, message: err.message } });
|
|
}
|
|
return sendError(reply, err);
|
|
}
|
|
});
|
|
|
|
// ---- Clone (asynchrone : 202 + suivi WS/REST) ----
|
|
app.post('/api/v1/repos/clone', async (req, reply) => {
|
|
const body = (req.body ?? {}) as Partial<CloneRequest>;
|
|
if (typeof body.credentialId !== 'string' || typeof body.remoteUrl !== 'string' || typeof body.dest !== 'string') {
|
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'credentialId, remoteUrl and dest are required' } });
|
|
}
|
|
try {
|
|
const operationId = await clones.start({ credentialId: body.credentialId, remoteUrl: body.remoteUrl, dest: body.dest }, actor(req));
|
|
const res: CloneStartResponse = { operationId };
|
|
return reply.status(202).send(res);
|
|
} catch (err) {
|
|
return sendError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get('/api/v1/repos/clone/:id', async (req, reply) => {
|
|
const op = clones.get((req.params as { id: string }).id);
|
|
if (!op) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No clone operation with this id' } });
|
|
const res: CloneStatusResponse = { operation: op };
|
|
return reply.send(res);
|
|
});
|
|
}
|