Files
gitmost/apps/server/src/integrations/environment/environment.service.ts
claude_code 6debcb354a 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 16:49:59 +03:00

415 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
constructor(private configService: ConfigService) {}
getNodeEnv(): string {
return this.configService.get<string>('NODE_ENV', 'development');
}
isDevelopment(): boolean {
return this.getNodeEnv() === 'development';
}
getAppUrl(): string {
const rawUrl =
this.configService.get<string>('APP_URL') ||
`http://localhost:${this.getPort()}`;
const { origin } = new URL(rawUrl);
return origin;
}
isHttps(): boolean {
const appUrl = this.configService.get<string>('APP_URL');
try {
const url = new URL(appUrl);
return url.protocol === 'https:';
} catch (error) {
return false;
}
}
getSubdomainHost(): string {
return this.configService.get<string>('SUBDOMAIN_HOST');
}
getPort(): number {
return parseInt(this.configService.get<string>('PORT', '3000'));
}
getAppSecret(): string {
return this.configService.get<string>('APP_SECRET');
}
getDatabaseURL(): string {
return this.configService.get<string>('DATABASE_URL');
}
getDatabaseMaxPool(): number {
return parseInt(this.configService.get<string>('DATABASE_MAX_POOL', '10'));
}
getRedisUrl(): string {
return this.configService.get<string>(
'REDIS_URL',
'redis://localhost:6379',
);
}
getJwtTokenExpiresIn(): string {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
try {
msUntilExpiry = ms(expiresInStr as StringValue);
} catch (err) {
msUntilExpiry = ms('90d');
}
return new Date(Date.now() + msUntilExpiry);
}
getGotenbergUrl(): string | undefined {
return this.configService.get<string>('GOTENBERG_URL');
}
getStorageDriver(): string {
return this.configService.get<string>('STORAGE_DRIVER', 'local');
}
getFileUploadSizeLimit(): string {
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
}
getFileImportSizeLimit(): string {
return this.configService.get<string>('FILE_IMPORT_SIZE_LIMIT', '200mb');
}
getAwsS3AccessKeyId(): string {
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
}
getAwsS3SecretAccessKey(): string {
return this.configService.get<string>('AWS_S3_SECRET_ACCESS_KEY');
}
getAwsS3Region(): string {
return this.configService.get<string>('AWS_S3_REGION');
}
getAwsS3Bucket(): string {
return this.configService.get<string>('AWS_S3_BUCKET');
}
getAwsS3Endpoint(): string {
return this.configService.get<string>('AWS_S3_ENDPOINT');
}
getAwsS3ForcePathStyle(): boolean {
const forcePathStyle = this.configService
.get<string>('AWS_S3_FORCE_PATH_STYLE', 'false')
.toLowerCase();
return forcePathStyle === 'true';
}
getAwsS3Url(): string {
return this.configService.get<string>('AWS_S3_URL');
}
getAzureStorageAccountName(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
}
getAzureStorageContainer(): string {
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
}
getAzureStorageAccountKey(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
}
getAzureStorageEndpoint(): string {
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
}
getAzureStorageUrl(): string {
return this.configService.get<string>('AZURE_STORAGE_URL');
}
getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log');
}
getMailFromAddress(): string {
return this.configService.get<string>('MAIL_FROM_ADDRESS');
}
getMailFromName(): string {
return this.configService.get<string>('MAIL_FROM_NAME', 'Docmost');
}
getMailBlockedRecipientDomains(): string[] {
const raw = this.configService.get<string>(
'MAIL_BLOCKED_RECIPIENT_DOMAINS',
'',
);
return raw
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
}
getSmtpHost(): string {
return this.configService.get<string>('SMTP_HOST');
}
getSmtpPort(): number {
return parseInt(this.configService.get<string>('SMTP_PORT'));
}
getSmtpSecure(): boolean {
const secure = this.configService
.get<string>('SMTP_SECURE', 'false')
.toLowerCase();
return secure === 'true';
}
getSmtpIgnoreTLS(): boolean {
const ignoretls = this.configService
.get<string>('SMTP_IGNORETLS', 'false')
.toLowerCase();
return ignoretls === 'true';
}
getSmtpUsername(): string {
return this.configService.get<string>('SMTP_USERNAME');
}
getSmtpPassword(): string {
return this.configService.get<string>('SMTP_PASSWORD');
}
getPostmarkToken(): string {
return this.configService.get<string>('POSTMARK_TOKEN');
}
getDrawioUrl(): string {
return this.configService.get<string>('DRAWIO_URL');
}
isCloud(): boolean {
const cloudConfig = this.configService
.get<string>('CLOUD', 'false')
.toLowerCase();
return cloudConfig === 'true';
}
isSelfHosted(): boolean {
return !this.isCloud();
}
isCompactPageTreeEnabled(): boolean {
const compactTree = this.configService
.get<string>('COMPACT_PAGE_TREE', 'true')
.toLowerCase();
return compactTree === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}
getStripeSecretKey(): string {
return this.configService.get<string>('STRIPE_SECRET_KEY');
}
getStripeWebhookSecret(): string {
return this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
}
getBillingTrialDays(): number {
return parseInt(this.configService.get<string>('BILLING_TRIAL_DAYS', '14'));
}
getCollabUrl(): string {
return this.configService.get<string>('COLLAB_URL');
}
isCollabDisableRedis(): boolean {
const isStandalone = this.configService
.get<string>('COLLAB_DISABLE_REDIS', 'false')
.toLowerCase();
return isStandalone === 'true';
}
isDisableTelemetry(): boolean {
const disable = this.configService
.get<string>('DISABLE_TELEMETRY', 'false')
.toLowerCase();
return disable === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
getSearchDriver(): string {
return this.configService
.get<string>('SEARCH_DRIVER', 'database')
.toLowerCase();
}
getTypesenseUrl(): string {
return this.configService
.get<string>('TYPESENSE_URL', 'http://localhost:8108')
.toLowerCase();
}
getTypesenseApiKey(): string {
return this.configService.get<string>('TYPESENSE_API_KEY');
}
getTypesenseLocale(): string {
return this.configService
.get<string>('TYPESENSE_LOCALE', 'en')
.toLowerCase();
}
// NOTE: AI_*/OPENAI_*/GEMINI_*/OLLAMA_* env getters were removed (D8/§14[M3]):
// provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getEventStoreDriver(): string {
return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres')
.toLowerCase();
}
getClickHouseUrl(): string {
return this.configService.get<string>('CLICKHOUSE_URL');
}
getSamlDisableRequestedAuthnContext(): boolean {
const disabled = this.configService
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
.toLowerCase();
return disabled === 'true';
}
isIframeEmbedAllowed(): boolean {
const allowed = this.configService
.get<string>('IFRAME_EMBED_ALLOWED', 'false')
.toLowerCase();
return allowed === 'true';
}
getIframeAllowedOrigins(): string[] {
const raw = this.configService.get<string>('IFRAME_ALLOWED_ORIGINS', '');
return raw
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}
// --- git-sync (plan §7.2) -------------------------------------------------
/** Global master switch for the git-sync control plane (default false). */
isGitSyncEnabled(): boolean {
return (
this.configService.get<string>('GIT_SYNC_ENABLED', 'false').toLowerCase() ===
'true'
);
}
/**
* Whether gitmost serves the per-space vaults over smart-HTTP (the /git host).
* When GIT_SYNC_HTTP_ENABLED is UNSET it DEFAULTS to isGitSyncEnabled() — so
* enabling sync also enables the host unless explicitly disabled. When set, it
* is honored verbatim ('true' -> on, anything else -> off).
*/
isGitSyncHttpEnabled(): boolean {
const raw = this.configService.get<string>('GIT_SYNC_HTTP_ENABLED');
if (raw === undefined) return this.isGitSyncEnabled();
return raw.toLowerCase() === 'true';
}
/**
* Root directory holding the per-space vault repos. Defaults to
* `<DATA_DIR or ./data>/git-sync`. `DATA_DIR` is read directly (no dedicated
* getter exists in this codebase) so the vault root tracks the data volume.
*/
getGitSyncDataDir(): string {
const explicit = this.configService.get<string>('GIT_SYNC_DATA_DIR');
if (explicit) return explicit;
const dataDir = this.configService.get<string>('DATA_DIR') || './data';
return `${dataDir.replace(/\/+$/, '')}/git-sync`;
}
/** Optional remote template, e.g. `git@host:vault-{spaceId}.git`. */
getGitSyncRemoteTemplate(): string | undefined {
return this.configService.get<string>('GIT_SYNC_REMOTE_TEMPLATE');
}
/**
* Poll-safety interval in ms (default 15000). A NaN / non-positive value falls
* back to the default so a bad override can never disable or zero the poll loop.
*/
getGitSyncPollIntervalMs(): number {
const parsed = parseInt(
this.configService.get<string>('GIT_SYNC_POLL_INTERVAL_MS', '15000'),
10,
);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000;
}
/**
* Event debounce window in ms (default 2000). A NaN / non-positive value falls
* back to the default so a bad override can never disable the debounce.
*/
getGitSyncDebounceMs(): number {
const parsed = parseInt(
this.configService.get<string>('GIT_SYNC_DEBOUNCE_MS', '2000'),
10,
);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2000;
}
/**
* Defense-in-depth absolute cap on how many pages a single push cycle may
* soft-delete (default 5). A non-convergent / phantom-absence cycle whose push
* plan would delete more than this is forced to skip deletions that cycle (the
* orchestrator logs a WARNING). A non-positive or unparseable value falls back
* to the default 5 so the cap can never be silently disabled.
*/
getGitSyncMaxDeletesPerCycle(): number {
const parsed = parseInt(
this.configService.get<string>('GIT_SYNC_MAX_DELETES_PER_CYCLE', '5'),
10,
);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
}
/**
* The service user id git-sync writes are attributed to. Required when sync is
* enabled (validated in environment.validation.ts); optional otherwise.
*/
getGitSyncServiceUserId(): string | undefined {
return this.configService.get<string>('GIT_SYNC_SERVICE_USER_ID');
}
/** Optional path to the SSH key used for git remote access. */
getGitSyncSshKeyPath(): string | undefined {
return this.configService.get<string>('GIT_SYNC_SSH_KEY_PATH');
}
}