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>
206 lines
6.7 KiB
TypeScript
206 lines
6.7 KiB
TypeScript
import { NestFactory, Reflector } from '@nestjs/core';
|
|
import { AppModule } from './app.module';
|
|
import {
|
|
FastifyAdapter,
|
|
NestFastifyApplication,
|
|
} from '@nestjs/platform-fastify';
|
|
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
|
import { Logger as PinoLogger } from 'nestjs-pino';
|
|
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
|
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
|
import fastifyMultipart from '@fastify/multipart';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyIp from 'fastify-ip';
|
|
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
|
import { EnvironmentService } from './integrations/environment/environment.service';
|
|
import { resolveFrameHeader } from './common/helpers';
|
|
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
|
|
import { GitHttpService } from './integrations/git-sync/http/git-http.service';
|
|
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create<NestFastifyApplication>(
|
|
AppModule,
|
|
new FastifyAdapter({
|
|
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
|
|
routerOptions: {
|
|
maxParamLength: 1000,
|
|
ignoreTrailingSlash: true,
|
|
ignoreDuplicateSlashes: true,
|
|
},
|
|
}),
|
|
{
|
|
rawBody: true,
|
|
// captures NestJS internal errors
|
|
logger: new InternalLogFilter(),
|
|
// bufferLogs must be false else pino will fail
|
|
// to log OnApplicationBootstrap logs
|
|
bufferLogs: false,
|
|
},
|
|
);
|
|
|
|
app.useLogger(app.get(PinoLogger));
|
|
|
|
app.setGlobalPrefix('api', {
|
|
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
|
|
});
|
|
|
|
const reflector = app.get(Reflector);
|
|
const redisIoAdapter = new WsRedisIoAdapter(app);
|
|
await redisIoAdapter.connectToRedis();
|
|
|
|
app.useWebSocketAdapter(redisIoAdapter);
|
|
|
|
await app.register(fastifyIp);
|
|
await app.register(fastifyMultipart);
|
|
await app.register(fastifyCookie);
|
|
|
|
const environmentService = app.get(EnvironmentService);
|
|
const frameHeader = resolveFrameHeader(
|
|
environmentService.isIframeEmbedAllowed(),
|
|
environmentService.getIframeAllowedOrigins(),
|
|
);
|
|
if (frameHeader) {
|
|
// Skipped routes:
|
|
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
|
// /share/ 0 public share pages are safe to embed
|
|
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addHook('onSend', (req, reply, payload, done) => {
|
|
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
|
|
return done(null, payload);
|
|
}
|
|
reply.header(frameHeader.name, frameHeader.value);
|
|
done(null, payload);
|
|
});
|
|
}
|
|
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addHook('onRequest', (request, _reply, done) => {
|
|
(request.raw as any).ip = request.ip;
|
|
done();
|
|
});
|
|
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addContentTypeParser(
|
|
'application/scim+json',
|
|
{ parseAs: 'string' },
|
|
(_, body, done) => {
|
|
try {
|
|
const json = JSON.parse(body.toString());
|
|
done(null, json);
|
|
} catch (err: any) {
|
|
done(err);
|
|
}
|
|
},
|
|
);
|
|
|
|
// git smart-HTTP POST bodies use these media types. Register PASSTHROUGH
|
|
// content-type parsers so Fastify does NOT buffer/parse them (it would
|
|
// otherwise reject the unknown type with 415); the /git handler streams the
|
|
// raw Node request (request.raw) to `git http-backend` stdin instead. A
|
|
// passthrough parser also bypasses the bodyLimit, so large pushes are not
|
|
// truncated (the bytes are never buffered by Fastify).
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.addContentTypeParser(
|
|
[
|
|
'application/x-git-upload-pack-request',
|
|
'application/x-git-receive-pack-request',
|
|
],
|
|
(_req, payload, done) => done(null, payload),
|
|
);
|
|
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.decorateReply('setHeader', function (name: string, value: unknown) {
|
|
this.header(name, value);
|
|
})
|
|
.decorateReply('end', function () {
|
|
this.send('');
|
|
})
|
|
.addHook('preHandler', function (req, reply, done) {
|
|
// don't require workspaceId for the following paths
|
|
const excludedPaths = [
|
|
'/api/auth/setup',
|
|
'/api/health',
|
|
'/api/billing/stripe/webhook',
|
|
'/api/workspace/check-hostname',
|
|
'/api/sso/google',
|
|
'/api/workspace/create',
|
|
'/api/workspace/joined',
|
|
'/api/workspace/find-by-email',
|
|
];
|
|
|
|
if (
|
|
req.originalUrl.startsWith('/api') &&
|
|
!excludedPaths.some((path) => req.originalUrl.startsWith(path))
|
|
) {
|
|
if (!req.raw?.['workspaceId'] && req.originalUrl !== '/api') {
|
|
throw new NotFoundException('Workspace not found');
|
|
}
|
|
done();
|
|
} else {
|
|
done();
|
|
}
|
|
});
|
|
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
stopAtFirstError: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
app.enableCors();
|
|
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
|
|
app.enableShutdownHooks();
|
|
|
|
// git smart-HTTP host (the /git/<spaceId>.git/... subtree). Registered as a
|
|
// RAW Fastify route — NOT a Nest controller under the global '/api' prefix —
|
|
// so it lives at the ROOT and a single wildcard reliably captures the whole
|
|
// multi-segment subtree (avoiding the path-to-regexp v8 wildcard / global-
|
|
// prefix-exclude ambiguity in NestJS v11). The handler is resolved from the
|
|
// Nest container so all auth/authz/gating still runs. NOTE: Nest middleware
|
|
// (DomainMiddleware) does NOT run for this raw root route — it is bound to the
|
|
// Nest router under the global '/api' prefix — so request.raw.workspaceId is
|
|
// NOT populated here; GitHttpService resolves the workspace itself (mirroring
|
|
// DomainMiddleware). The Fastify wildcard '/git/*' captures the multi-segment
|
|
// subpath; the handler re-parses req.url itself.
|
|
const gitHttpService = app.get(GitHttpService);
|
|
app
|
|
.getHttpAdapter()
|
|
.getInstance()
|
|
.all('/git/*', async (request, reply) => {
|
|
await gitHttpService.handle(request as any, reply as any);
|
|
});
|
|
|
|
const logger = new Logger('NestApplication');
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
logger.error(`UnhandledRejection, reason: ${reason}`, promise);
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
logger.error('UncaughtException:', error);
|
|
});
|
|
|
|
const port = process.env.PORT || 3000;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
await app.listen(port, host, () => {
|
|
logger.log(
|
|
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
bootstrap();
|