Files
gitmost/apps/server/src/integrations/git-sync/http/git-http.helpers.ts
claude_code 07b3962811 feat(git-sync): serve spaces over smart-HTTP (gitmost as a two-way git host)
Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:43:40 +03:00

148 lines
5.6 KiB
TypeScript

// Pure, framework-free helpers for the /git smart-HTTP host. They carry no Nest
// / DI / concrete-service imports so the request parsing and the auth/authz
// gating DECISION can be unit-tested in isolation, and nothing here ever logs a
// password or the Authorization header.
/** The git operation a request maps to: a read (fetch/clone) or a write (push). */
export type GitHttpServiceKind = 'read' | 'write';
/** A parsed `/git/<spaceId>.git/<subpath>` URL. */
export interface ParsedGitPath {
spaceId: string;
/** The subpath after `<spaceId>.git/` (no leading slash), e.g. `info/refs`. */
subpath: string;
}
/**
* Parse the `<rest>` of a `/git/<rest>` URL path (no query string) into the
* space id and the repo-relative subpath. The space id is the first path
* segment with its trailing `.git` stripped. Returns null when the shape does
* not match (missing `.git`, empty space id, traversal attempt).
*
* `rest` MUST already be URL-path-decoded of its query string by the caller
* (pass the pathname only). We reject `..` segments defensively even though
* http-backend resolves PATH_INFO against GIT_PROJECT_ROOT.
*/
export function parseGitPath(rest: string): ParsedGitPath | null {
// Strip a leading slash, then take the first segment as `<spaceId>.git`.
const clean = rest.replace(/^\/+/, '');
const slash = clean.indexOf('/');
const first = slash === -1 ? clean : clean.slice(0, slash);
const subpath = slash === -1 ? '' : clean.slice(slash + 1);
if (!first.endsWith('.git')) return null;
const spaceId = first.slice(0, -'.git'.length);
if (!spaceId) return null;
// Reject path traversal / degenerate ids in either component.
if (
spaceId === '.' ||
spaceId.includes('..') ||
spaceId.includes('/') ||
subpath.split('/').some((seg) => seg === '..')
) {
return null;
}
// Defense-in-depth: reject percent-encoded dot/slash traversal (`%2e`, `%2f`,
// case-insensitive) in the subpath BEFORE it is used to build PATH_INFO — a
// decoder downstream could otherwise turn `%2e%2e%2f` back into `../`.
if (/%2e|%2f/i.test(subpath)) {
return null;
}
return { spaceId, subpath };
}
/**
* Map a parsed git request (method + subpath + query) to the required operation
* kind. The smart-HTTP shapes:
* - GET info/refs?service=git-upload-pack -> read (fetch)
* - GET info/refs?service=git-receive-pack -> write (push)
* - POST git-upload-pack -> read (fetch)
* - POST git-receive-pack -> write (push)
* - any other dumb-protocol GET (HEAD, objects/…) -> read
* Returns null for an unsupported shape (e.g. a POST that is neither pack
* endpoint) so the caller can 403/404 rather than guess.
*/
export function resolveServiceKind(input: {
method: string;
subpath: string;
service?: string;
}): GitHttpServiceKind | null {
const method = input.method.toUpperCase();
const subpath = input.subpath;
if (method === 'GET') {
if (subpath === 'info/refs') {
if (input.service === 'git-receive-pack') return 'write';
if (input.service === 'git-upload-pack') return 'read';
// info/refs without a known service: dumb-protocol discovery — read.
return 'read';
}
// Dumb-protocol object/ref fetches (HEAD, objects/…) are reads.
return 'read';
}
if (method === 'POST') {
if (subpath === 'git-receive-pack') return 'write';
if (subpath === 'git-upload-pack') return 'read';
return null; // unknown POST endpoint
}
return null; // unsupported method
}
/** The outcome of the gating/auth decision the request handler must enforce. */
export type GitHttpGateDecision =
| { kind: 'unauthorized' } // 401 + WWW-Authenticate (missing/invalid creds)
| { kind: 'not-found' } // 404 (space hidden / sync or http disabled)
| { kind: 'forbidden' } // 403 (authenticated but lacks the permission)
| { kind: 'bad-request' } // 400 (unparseable git request shape)
| { kind: 'proceed' }; // run http-backend
/**
* Pure gating decision, mirroring the handler precedence so it can be unit
* tested without the DB / CASL graph. Inputs are the already-resolved booleans
* the handler computes from EnvironmentService / SpaceRepo / SpaceAbilityFactory.
*
* Precedence (matches the spec):
* 1. no/invalid Basic credentials -> 401 (regardless of space).
* 2. credentials present but invalid -> 401.
* 3. unparseable git request shape -> 400.
* 4. git-sync globally disabled, or git-http disabled, or the space is missing
* / not git-sync-enabled -> 404 (never reveal existence).
* 5. authenticated but lacking the required perm -> 403.
* 6. otherwise -> proceed.
*
* Note (4) is checked AFTER (1)/(2): an anonymous probe always gets 401 first;
* an authenticated user hitting a hidden/disabled space gets 404 (not 403).
*/
export function decideGitHttpGate(input: {
hasCredentials: boolean;
credentialsValid: boolean;
serviceKind: GitHttpServiceKind | null;
gitSyncEnabled: boolean;
gitHttpEnabled: boolean;
spaceExists: boolean;
spaceGitSyncEnabled: boolean;
permissionGranted: boolean;
}): GitHttpGateDecision {
if (!input.hasCredentials) return { kind: 'unauthorized' };
if (!input.credentialsValid) return { kind: 'unauthorized' };
if (input.serviceKind === null) return { kind: 'bad-request' };
if (
!input.gitSyncEnabled ||
!input.gitHttpEnabled ||
!input.spaceExists ||
!input.spaceGitSyncEnabled
) {
return { kind: 'not-found' };
}
if (!input.permissionGranted) return { kind: 'forbidden' };
return { kind: 'proceed' };
}