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>
415 lines
12 KiB
TypeScript
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');
|
|
}
|
|
}
|