import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import ms, { StringValue } from 'ms'; @Injectable() export class EnvironmentService { private readonly logger = new Logger(EnvironmentService.name); // Env keys already warned about for an invalid value (one-shot per key, so a // bad SANDBOX_* value is not logged on every blob put). Mirrors the original // sandboxTtlWarned guard, generalized across the TTL + the three byte caps. private readonly invalidPositiveIntWarned = new Set(); constructor(private configService: ConfigService) {} getNodeEnv(): string { return this.configService.get('NODE_ENV', 'development'); } isDevelopment(): boolean { return this.getNodeEnv() === 'development'; } getAppUrl(): string { const rawUrl = this.configService.get('APP_URL') || `http://localhost:${this.getPort()}`; const { origin } = new URL(rawUrl); return origin; } isHttps(): boolean { const appUrl = this.configService.get('APP_URL'); try { const url = new URL(appUrl); return url.protocol === 'https:'; } catch (error) { return false; } } getSubdomainHost(): string { return this.configService.get('SUBDOMAIN_HOST'); } getPort(): number { return parseInt(this.configService.get('PORT', '3000')); } getAppSecret(): string { return this.configService.get('APP_SECRET'); } getDatabaseURL(): string { return this.configService.get('DATABASE_URL'); } getDatabaseMaxPool(): number { return parseInt(this.configService.get('DATABASE_MAX_POOL', '10')); } getRedisUrl(): string { return this.configService.get( 'REDIS_URL', 'redis://localhost:6379', ); } getJwtTokenExpiresIn(): string { return this.configService.get('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('GOTENBERG_URL'); } getStorageDriver(): string { return this.configService.get('STORAGE_DRIVER', 'local'); } getFileUploadSizeLimit(): string { return this.configService.get('FILE_UPLOAD_SIZE_LIMIT', '50mb'); } getFileImportSizeLimit(): string { return this.configService.get('FILE_IMPORT_SIZE_LIMIT', '200mb'); } getAwsS3AccessKeyId(): string { return this.configService.get('AWS_S3_ACCESS_KEY_ID'); } getAwsS3SecretAccessKey(): string { return this.configService.get('AWS_S3_SECRET_ACCESS_KEY'); } getAwsS3Region(): string { return this.configService.get('AWS_S3_REGION'); } getAwsS3Bucket(): string { return this.configService.get('AWS_S3_BUCKET'); } getAwsS3Endpoint(): string { return this.configService.get('AWS_S3_ENDPOINT'); } getAwsS3ForcePathStyle(): boolean { const forcePathStyle = this.configService .get('AWS_S3_FORCE_PATH_STYLE', 'false') .toLowerCase(); return forcePathStyle === 'true'; } getAwsS3Url(): string { return this.configService.get('AWS_S3_URL'); } getAzureStorageAccountName(): string { return this.configService.get('AZURE_STORAGE_ACCOUNT_NAME'); } getAzureStorageContainer(): string { return this.configService.get('AZURE_STORAGE_CONTAINER'); } getAzureStorageAccountKey(): string { return this.configService.get('AZURE_STORAGE_ACCOUNT_KEY'); } getAzureStorageEndpoint(): string { return this.configService.get('AZURE_STORAGE_ENDPOINT'); } getAzureStorageUrl(): string { return this.configService.get('AZURE_STORAGE_URL'); } getMailDriver(): string { return this.configService.get('MAIL_DRIVER', 'log'); } getMailFromAddress(): string { return this.configService.get('MAIL_FROM_ADDRESS'); } getMailFromName(): string { return this.configService.get('MAIL_FROM_NAME', 'Docmost'); } getMailBlockedRecipientDomains(): string[] { const raw = this.configService.get( 'MAIL_BLOCKED_RECIPIENT_DOMAINS', '', ); return raw .split(',') .map((d) => d.trim().toLowerCase()) .filter(Boolean); } getSmtpHost(): string { return this.configService.get('SMTP_HOST'); } getSmtpPort(): number { return parseInt(this.configService.get('SMTP_PORT')); } getSmtpSecure(): boolean { const secure = this.configService .get('SMTP_SECURE', 'false') .toLowerCase(); return secure === 'true'; } getSmtpIgnoreTLS(): boolean { const ignoretls = this.configService .get('SMTP_IGNORETLS', 'false') .toLowerCase(); return ignoretls === 'true'; } getSmtpUsername(): string { return this.configService.get('SMTP_USERNAME'); } getSmtpPassword(): string { return this.configService.get('SMTP_PASSWORD'); } getPostmarkToken(): string { return this.configService.get('POSTMARK_TOKEN'); } getDrawioUrl(): string { return this.configService.get('DRAWIO_URL'); } isCloud(): boolean { const cloudConfig = this.configService .get('CLOUD', 'false') .toLowerCase(); return cloudConfig === 'true'; } isSelfHosted(): boolean { return !this.isCloud(); } isCompactPageTreeEnabled(): boolean { const compactTree = this.configService .get('COMPACT_PAGE_TREE', 'true') .toLowerCase(); return compactTree === 'true'; } getStripePublishableKey(): string { return this.configService.get('STRIPE_PUBLISHABLE_KEY'); } getStripeSecretKey(): string { return this.configService.get('STRIPE_SECRET_KEY'); } getStripeWebhookSecret(): string { return this.configService.get('STRIPE_WEBHOOK_SECRET'); } getBillingTrialDays(): number { return parseInt(this.configService.get('BILLING_TRIAL_DAYS', '14')); } getCollabUrl(): string { return this.configService.get('COLLAB_URL'); } isCollabDisableRedis(): boolean { const isStandalone = this.configService .get('COLLAB_DISABLE_REDIS', 'false') .toLowerCase(); return isStandalone === 'true'; } isDisableTelemetry(): boolean { const disable = this.configService .get('DISABLE_TELEMETRY', 'false') .toLowerCase(); return disable === 'true'; } getPostHogHost(): string { return this.configService.get('POSTHOG_HOST'); } getPostHogKey(): string { return this.configService.get('POSTHOG_KEY'); } getSearchDriver(): string { return this.configService .get('SEARCH_DRIVER', 'database') .toLowerCase(); } getTypesenseUrl(): string { return this.configService .get('TYPESENSE_URL', 'http://localhost:8108') .toLowerCase(); } getTypesenseApiKey(): string { return this.configService.get('TYPESENSE_API_KEY'); } getTypesenseLocale(): string { return this.configService .get('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). getAiAgentRolesCatalogSource(): string { // Catalog location: an http(s):// base URL the catalog is fetched from. // The image ships a per-branch default for this baked in at build time // (Dockerfile ARG AI_AGENT_ROLES_CATALOG_URL, set per-branch in CI), but it // is overridable at runtime via the env var (this getter returns that // runtime value). Local-filesystem sources are no longer supported. // Empty/unset => the catalog is unavailable (the provider returns 502). // This is INFRA config (where the catalog lives), not provider/model // config, so an env var is appropriate. return this.configService.get('AI_AGENT_ROLES_CATALOG_URL', ''); } getEventStoreDriver(): string { return this.configService .get('EVENT_STORE_DRIVER', 'postgres') .toLowerCase(); } getClickHouseUrl(): string { return this.configService.get('CLICKHOUSE_URL'); } getSamlDisableRequestedAuthnContext(): boolean { const disabled = this.configService .get('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false') .toLowerCase(); return disabled === 'true'; } isIframeEmbedAllowed(): boolean { const allowed = this.configService .get('IFRAME_EMBED_ALLOWED', 'false') .toLowerCase(); return allowed === 'true'; } getIframeAllowedOrigins(): string[] { const raw = this.configService.get('IFRAME_ALLOWED_ORIGINS', ''); return raw .split(',') .map((o) => o.trim()) .filter(Boolean); } // --- git-sync (issue #194 §7.2) ------------------------------------------------- /** Global master switch for the git-sync control plane (default false). */ isGitSyncEnabled(): boolean { return ( this.configService.get('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('GIT_SYNC_HTTP_ENABLED'); if (raw === undefined) return this.isGitSyncEnabled(); return raw.toLowerCase() === 'true'; } /** * Root directory holding the per-space vault repos. Defaults to * `/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('GIT_SYNC_DATA_DIR'); if (explicit) return explicit; const dataDir = this.configService.get('DATA_DIR') || './data'; return `${dataDir.replace(/\/+$/, '')}/git-sync`; } /** * Optional remote template, e.g. `git@host:vault-{spaceId}.git` (`{spaceId}` is * substituted per-space in the orchestrator). SCAFFOLDING for the deferred * remote-push feature: the vendored engine has no remote-push path yet (SPEC * §7), so this value is currently inert — kept so the wiring is ready when the * engine grows a push path. */ getGitSyncRemoteTemplate(): string | undefined { return this.configService.get('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('GIT_SYNC_POLL_INTERVAL_MS', '15000'), 10, ); return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000; } /** * Spawned `git http-backend` watchdog timeout in ms (default 120000). Bounds a * single smart-HTTP request so a stalled `git-receive-pack` cannot hold the * per-space lock forever (the child is killed and a 500 sent on expiry). A NaN / * non-positive value falls back to the default so a bad override can never * disable the watchdog. */ getGitSyncBackendTimeoutMs(): number { const v = parseInt( this.configService.get('GIT_SYNC_BACKEND_TIMEOUT_MS', '120000'), 10, ); return Number.isFinite(v) && v > 0 ? v : 120000; } /** * 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('GIT_SYNC_DEBOUNCE_MS', '2000'), 10, ); return Number.isFinite(parsed) && parsed > 0 ? parsed : 2000; } /** * 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('GIT_SYNC_SERVICE_USER_ID'); } // --- Blob sandbox (in-RAM ephemeral blob transfer; see SandboxModule) --- // Base URL the sandbox `uri` is built from. It MUST be reachable over the // network by the external consumer that fetches the blobs (not a loopback // address if that consumer is remote). Falls back to APP_URL when unset so a // single-host deployment works out of the box; set it explicitly when the // consumer lives on another host. getSandboxPublicUrl(): string { const raw = this.configService.get('SANDBOX_PUBLIC_URL') || this.getAppUrl(); // Drop any trailing slash so `${base}/api/sb/${id}` never doubles up. return raw.replace(/\/+$/, ''); } // Parse a REQUIRED positive-integer env (TTL in ms or a byte cap). A // non-integer or <= 0 value would break the sandbox silently (instant expiry, // or every put failing against a 0-byte cap), so warn once and fall back to // the default instead. Blob bodies are never logged. private getPositiveIntEnv(key: string, def: number): number { const parsed = parseInt( this.configService.get(key, String(def)), 10, ); if (!Number.isInteger(parsed) || parsed <= 0) { if (!this.invalidPositiveIntWarned.has(key)) { this.invalidPositiveIntWarned.add(key); this.logger.warn( `Invalid ${key} (must be a positive integer); falling back to the ${def} default`, ); } return def; } return parsed; } // Blob time-to-live. Default 1h. The unguessable UUID + this short TTL + TLS // are the whole capability model (no tokens). A non-positive or non-integer // value would make every blob expire instantly (silent 404s), so reject it and // fall back to the 1h default (warned about once to avoid per-put log spam). getSandboxTtlMs(): number { return this.getPositiveIntEnv('SANDBOX_TTL_MS', 3_600_000); } // Per-blob cap for non-image blobs (the serialized document). Default 8 MiB. getSandboxMaxBytes(): number { return this.getPositiveIntEnv('SANDBOX_MAX_BYTES', 8_388_608); } // Per-blob cap for mirrored image blobs. Default 20 MiB. getSandboxMaxImageBytes(): number { return this.getPositiveIntEnv('SANDBOX_MAX_IMAGE_BYTES', 20_971_520); } // RAM guard: total bytes the whole store may hold. Default 128 MiB. On // overflow the store evicts oldest entries to make room. getSandboxMaxTotalBytes(): number { return this.getPositiveIntEnv('SANDBOX_MAX_TOTAL_BYTES', 134_217_728); } }