Ctrl K
ComunidadGuías…

server/env.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* oxlint-disable no-console */
import environment from "./utils/environment";
import os from "node:os";
import wellKnownServices from "nodemailer/lib/well-known/services.json";
import {
  validate,
  IsNotEmpty,
  IsUrl,
  IsOptional,
  IsHexadecimal,
  Length,
  IsNumber,
  IsIn,
  IsBoolean,
  Min,
} from "class-validator";
import { uniq } from "es-toolkit/compat";
import { languages } from "@shared/i18n";
import { Day, Hour } from "@shared/utils/time";
import {
  CannotUseWith,
  CannotUseWithout,
  CannotUseWithAny,
  IsInCaseInsensitive,
  IsDatabaseUrl,
  IsMailboxAddress,
} from "@server/utils/validators";
import Deprecated from "./models/decorators/Deprecated";
import { getArg } from "./utils/args";
import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public";

export class Environment {
  constructor() {
    process.nextTick(() => {
      void validate(this).then((errors) => {
        if (errors.length > 0) {
          let output =
            "Environment configuration is invalid, please check the following:\n\n";
          output += errors.map(
            (error) => "- " + Object.values(error.constraints ?? {}).join(", ")
          );
          console.warn(output);
          process.exit(1);
        }
      });
    });

    PublicEnvironmentRegister.registerEnv(this);
  }

  /**
   * Returns an object consisting of env vars annotated with `@Public` decorator
   */
  get public() {
    return PublicEnvironmentRegister.getEnv();
  }

  /**
   * The current environment name.
   */
  @Public
  @IsIn(["development", "production", "staging", "test"])
  public ENVIRONMENT = environment.NODE_ENV ?? "production";

  /**
   * The secret key is used for encrypting data. Do not change this value once
   * set or your users will be unable to login.
   */
  @IsHexadecimal()
  @Length(64, 64, {
    message: `The SECRET_KEY environment variable must be exactly 64 hexadecimal characters (Use \`openssl rand -hex 32\` to generate a value).`,
  })
  public SECRET_KEY = environment.SECRET_KEY ?? "";

  /**
   * The secret that should be passed to the cron utility endpoint to enable
   * triggering of scheduled tasks.
   */
  @IsNotEmpty()
  public UTILS_SECRET = environment.UTILS_SECRET ?? "";

  /**
   * The url of the database.
   */
  @IsOptional()
  @IsDatabaseUrl()
  @CannotUseWithAny([
    "DATABASE_HOST",
    "DATABASE_PORT",
    "DATABASE_NAME",
    "DATABASE_USER",
    "DATABASE_PASSWORD",
  ])
  public DATABASE_URL = this.toOptionalString(environment.DATABASE_URL);

  /**
   * Optional database URL for read replica to distribute read queries
   * and reduce load on primary database.
   */
  @IsOptional()
  @IsDatabaseUrl()
  public DATABASE_READ_ONLY_URL = this.toOptionalString(
    // Support deprecated variable name for backwards compatibility
    environment.DATABASE_READ_ONLY_URL ?? environment.DATABASE_URL_READ_ONLY
  );

  /**
   * Database host for individual component configuration.
   */
  @IsOptional()
  @CannotUseWith("DATABASE_URL")
  public DATABASE_HOST = this.toOptionalString(environment.DATABASE_HOST);

  /**
   * Database port for individual component configuration.
   */
  @IsOptional()
  @IsNumber()
  @CannotUseWith("DATABASE_URL")
  public DATABASE_PORT = this.toOptionalNumber(environment.DATABASE_PORT);

  /**
   * Database name for individual component configuration.
   */
  @IsOptional()
  @CannotUseWith("DATABASE_URL")
  public DATABASE_NAME = this.toOptionalString(environment.DATABASE_NAME);

  /**
   * Database user for individual component configuration.
   */
  @IsOptional()
  @CannotUseWith("DATABASE_URL")
  public DATABASE_USER = this.toOptionalString(environment.DATABASE_USER);

  /**
   * Database password for individual component configuration.
   */
  @IsOptional()
  @CannotUseWith("DATABASE_URL")
  public DATABASE_PASSWORD = this.toOptionalString(
    environment.DATABASE_PASSWORD
  );

  /**
   * An optional database schema.
   */
  @IsOptional()
  public DATABASE_SCHEMA = this.toOptionalString(environment.DATABASE_SCHEMA);

  /**
   * The url of the database pool.
   */
  @IsOptional()
  @IsDatabaseUrl()
  public DATABASE_CONNECTION_POOL_URL = this.toOptionalString(
    environment.DATABASE_CONNECTION_POOL_URL
  );

  /**
   * Database connection pool configuration.
   */
  @IsNumber()
  @IsOptional()
  public DATABASE_CONNECTION_POOL_MIN = this.toOptionalNumber(
    environment.DATABASE_CONNECTION_POOL_MIN
  );

  /**
   * Database connection pool configuration. At least two connections are
   * needed, as some operations query outside of a transaction they hold open.
   */
  @IsNumber()
  @IsOptional()
  @Min(2)
  public DATABASE_CONNECTION_POOL_MAX = this.toOptionalNumber(
    environment.DATABASE_CONNECTION_POOL_MAX
  );

  /**
   * Set to "disable" to disable SSL connection to the database. This option is
   * passed through to Postgres. See:
   *
   * https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE
   */
  @IsIn(["disable", "allow", "require", "prefer", "verify-ca", "verify-full"])
  @IsOptional()
  public PGSSLMODE = environment.PGSSLMODE;

  /**
   * The url of redis. Note that redis does not have a database after the port.
   * Note: More extensive validation isn't included here due to our support for
   * base64-encoded configuration.
   */
  @IsNotEmpty()
  public REDIS_URL = environment.REDIS_URL;

  /**
   * The url of redis for horizontally scaling the collaboration service. If not
   * set then the collaboration service must be ran as a singleton.
   */
  public REDIS_COLLABORATION_URL = environment.REDIS_COLLABORATION_URL;

  /**
   * The interval in milliseconds between redis connection healthchecks. Each
   * healthcheck issues a PING and forces a reconnect if it fails.
   */
  @IsNumber()
  @Min(1000)
  public REDIS_HEALTHCHECK_INTERVAL = parseInt(
    environment.REDIS_HEALTHCHECK_INTERVAL || "30000",
    10
  );

  /**
   * The timeout in milliseconds for a redis healthcheck PING before the
   * connection is considered stuck and forcibly reconnected.
   */
  @IsNumber()
  @Min(100)
  public REDIS_HEALTHCHECK_TIMEOUT = parseInt(
    environment.REDIS_HEALTHCHECK_TIMEOUT || "5000",
    10
  );

  /**
   * The time in milliseconds that a redis connection can stay unavailable
   * before the process exits, so that the process manager can restart it.
   */
  @IsNumber()
  @Min(1000)
  public REDIS_UNAVAILABLE_TIMEOUT = parseInt(
    environment.REDIS_UNAVAILABLE_TIMEOUT || "300000",
    10
  );

  /**
   * The timeout in milliseconds for a command on the redis connection used for
   * ephemeral data such as caches, locks and rate limits, after which the
   * command fails.
   */
  @IsNumber()
  @Min(100)
  public REDIS_EPHEMERAL_COMMAND_TIMEOUT = parseInt(
    environment.REDIS_EPHEMERAL_COMMAND_TIMEOUT || "500",
    10
  );

  /**
   * The fully qualified, external facing domain name of the server.
   * If not set, will be derived from HEROKU_APP_NAME for Heroku deployments.
   */
  @Public
  @IsNotEmpty()
  @IsUrl({
    protocols: ["http", "https"],
    require_protocol: true,
    require_tld: false,
  })
  public URL = (
    environment.URL ??
    (environment.HEROKU_APP_NAME
      ? `https://${environment.HEROKU_APP_NAME}.herokuapp.com`
      : "")
  ).replace(/\/$/, "");

  /**
   * If using a Cloudfront/Cloudflare distribution or similar it can be set below.
   * This will cause paths to javascript, stylesheets, and images to be updated to
   * the hostname defined in CDN_URL. In your CDN configuration the origin server
   * should be set to the same as URL.
   */
  @Public
  @IsOptional()
  @IsUrl({
    protocols: ["http", "https"],
    require_protocol: true,
    require_tld: false,
  })
  public CDN_URL = this.toOptionalString(
    environment.CDN_URL ? environment.CDN_URL.replace(/\/$/, "") : undefined
  );

  /**
   * The fully qualified, external facing domain name of the collaboration
   * service, if different (unlikely)
   */
  @Public
  @IsUrl({
    require_tld: false,
    require_protocol: true,
    protocols: ["http", "https", "ws", "wss"],
  })
  @IsOptional()
  public COLLABORATION_URL = (environment.COLLABORATION_URL || this.URL)
    .replace(/\/$/, "")
    .replace(/^http/, "ws");

  /**
   * The maximum number of network clients that can be connected to a single
   * document at once. Defaults to 100.
   */
  @IsOptional()
  @IsNumber()
  public COLLABORATION_MAX_CLIENTS_PER_DOCUMENT = parseInt(
    environment.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT || "100",
    10
  );

  /**
   * The port that the server will listen on, defaults to 3000.
   */
  @IsNumber()
  @IsOptional()
  public PORT = this.toOptionalNumber(environment.PORT) ?? 3000;

  /**
   * Optional extra debugging. Comma separated
   */
  public DEBUG = environment.DEBUG || "";

  /**
   * Configure lowest severity level for server logs
   */
  @IsIn(["error", "warn", "info", "http", "verbose", "debug", "silly"])
  public LOG_LEVEL = environment.LOG_LEVEL || "info";

  /**
   * How many processes should be spawned. As a reasonable rule divide your
   * server's available memory by 512 for a rough estimate
   */
  @IsNumber()
  @IsOptional()
  public WEB_CONCURRENCY = this.toOptionalNumber(environment.WEB_CONCURRENCY);

  /**
   * How long a request should be processed before giving up and returning an
   * error response to the client, defaults to 10s
   */
  @IsNumber()
  @IsOptional()
  public REQUEST_TIMEOUT =
    this.toOptionalNumber(environment.REQUEST_TIMEOUT) ?? 10 * 1000;

  /**
   * Base64 encoded protected key if Outline is to perform SSL termination.
   */
  @IsOptional()
  @CannotUseWithout("SSL_CERT")
  public SSL_KEY = this.toOptionalString(environment.SSL_KEY);

  /**
   * Base64 encoded public certificate if Outline is to perform SSL termination.
   */
  @IsOptional()
  @CannotUseWithout("SSL_KEY")
  public SSL_CERT = this.toOptionalString(environment.SSL_CERT);

  /**
   * The default interface language. See translate.getoutline.com for a list of
   * available language codes and their percentage translated.
   */
  @Public
  @IsIn(languages)
  public DEFAULT_LANGUAGE = environment.DEFAULT_LANGUAGE ?? "en_US";

  /**
   * A comma list of which services should be enabled on this instance – defaults to all.
   * The "admin" service is only available in development.
   *
   * If a services flag is passed it takes priority over the environment variable
   * for example: --services=web,worker
   */
  public SERVICES = uniq(
    (
      getArg("services") ??
      environment.SERVICES ??
      "collaboration,websockets,worker,web"
    )
      .split(",")
      .map((service) => service.toLowerCase().trim())
  );

  /**
   * Auto-redirect to https in production. The default is true but you may set
   * to false if you can be sure that SSL is terminated at an external
   * loadbalancer.
   */
  @IsBoolean()
  public FORCE_HTTPS = this.toBoolean(environment.FORCE_HTTPS ?? "true");

  /**
   * When the app is behind a proxy, sets the HTTP header used for the client IP.
   * The default value is "X-Forwarded-For", common values are "X-Real-IP"
   * and "X-Client-IP".
   */
  @IsOptional()
  public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);

  /**
   * Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
   * X-Forwarded-Proto) set by an upstream proxy or load balancer. Defaults to
   * true for backwards compat. Set to false if not running behind a proxy in production.
   */
  @IsBoolean()
  public PROXY_HEADERS_TRUSTED = this.toBoolean(
    environment.PROXY_HEADERS_TRUSTED ?? "true"
  );

  /**
   * Should the installation send anonymized statistics to the maintainers.
   * Defaults to true.
   */
  @IsBoolean()
  public TELEMETRY = this.toBoolean(
    environment.ENABLE_UPDATES ?? environment.TELEMETRY ?? "true"
  );

  // Third-party services

  /**
   * The host of your SMTP server for enabling emails.
   */
  @CannotUseWith("SMTP_SERVICE")
  public SMTP_HOST = this.toOptionalString(environment.SMTP_HOST);

  /**
   * The service name of a well-known SMTP service for nodemailer.
   * See https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
   */
  @CannotUseWith("SMTP_HOST")
  @IsInCaseInsensitive(Object.keys(wellKnownServices))
  public SMTP_SERVICE = this.toOptionalString(environment.SMTP_SERVICE);

  @Public
  public EMAIL_ENABLED =
    !!(this.SMTP_HOST || this.SMTP_SERVICE) || this.isDevelopment;

  /**
   * Optional hostname of the client, used for identifying to the server
   * defaults to hostname of the machine.
   */
  public SMTP_NAME = environment.SMTP_NAME;

  /**
   * The port of your SMTP server.
   */
  @IsNumber()
  @IsOptional()
  @CannotUseWith("SMTP_SERVICE")
  public SMTP_PORT = this.toOptionalNumber(environment.SMTP_PORT);

  /**
   * The username of your SMTP server, if any.
   */
  public SMTP_USERNAME = environment.SMTP_USERNAME;

  /**
   * The password for the SMTP username, if any.
   */
  public SMTP_PASSWORD = environment.SMTP_PASSWORD;

  /**
   * The email address from which emails are sent.
   */
  @IsMailboxAddress()
  @IsOptional()
  public SMTP_FROM_EMAIL = this.toOptionalString(environment.SMTP_FROM_EMAIL);

  /**
   * The reply-to address for emails sent from Outline. If unset the from
   * address is used by default.
   */
  @IsMailboxAddress()
  @IsOptional()
  public SMTP_REPLY_EMAIL = this.toOptionalString(environment.SMTP_REPLY_EMAIL);

  /**
   * Override the cipher used for SMTP SSL connections.
   */
  public SMTP_TLS_CIPHERS = this.toOptionalString(environment.SMTP_TLS_CIPHERS);

  /**
   * If true (the default) the connection will use TLS when connecting to server.
   * If false then TLS is used only if server supports the STARTTLS extension.
   *
   * Setting secure to false therefore does not mean that you would not use an
   * encrypted connection.
   */
  public SMTP_SECURE = this.toBoolean(environment.SMTP_SECURE ?? "true");

  /**
   * If true then STARTTLS is disabled even if the server supports it.
   * If false (the default) then STARTTLS is used if server supports it.
   *
   * Setting secure to false therefore does not mean that you would not use an
   * encrypted connection.
   */
  public SMTP_DISABLE_STARTTLS = this.toBoolean(
    environment.SMTP_DISABLE_STARTTLS ?? "false"
  );

  /**
   * Dropbox app key for embedding Dropbox files
   */
  @Public
  @IsOptional()
  public DROPBOX_APP_KEY = this.toOptionalString(environment.DROPBOX_APP_KEY);

  /**
   * Sentry DSN for capturing errors and frontend performance.
   */
  @Public
  @IsUrl()
  @IsOptional()
  public SENTRY_DSN = this.toOptionalString(environment.SENTRY_DSN);

  /**
   * Sentry tunnel URL for bypassing ad blockers
   */
  @Public
  @IsUrl()
  @IsOptional()
  public SENTRY_TUNNEL = this.toOptionalString(environment.SENTRY_TUNNEL);

  /**
   * A release SHA or other identifier for Sentry.
   */
  public RELEASE = this.toOptionalString(environment.RELEASE);

  /**
   * A Google Analytics tracking ID, supports v3 or v4 properties.
   */
  @Public
  @IsOptional()
  public GOOGLE_ANALYTICS_ID = this.toOptionalString(
    environment.GOOGLE_ANALYTICS_ID
  );

  /**
   * A DataDog API key for tracking server metrics.
   */
  public DD_API_KEY = environment.DD_API_KEY;

  /**
   * The name of the service to use in DataDog.
   */
  public DD_SERVICE = environment.DD_SERVICE ?? "outline";

  /**
   * A string representing the version of the software.
   *
   * SOURCE_COMMIT is used by Docker Hub
   * SOURCE_VERSION is used by Heroku
   */
  @Public
  public VERSION = this.toOptionalString(
    environment.SOURCE_COMMIT || environment.SOURCE_VERSION
  );

  /**
   * The maximum number of concurrent events processed per-worker. To get total
   * concurrency you should multiply this by the number of workers.
   */
  @IsOptional()
  @IsNumber()
  public WORKER_CONCURRENCY_EVENTS =
    this.toOptionalNumber(environment.WORKER_CONCURRENCY_EVENTS) ?? 10;

  /**
   * The maximum number of concurrent tasks processed per-worker. To get total
   * concurrency you should multiply this by the number of workers.
   */
  @IsOptional()
  @IsNumber()
  public WORKER_CONCURRENCY_TASKS =
    this.toOptionalNumber(environment.WORKER_CONCURRENCY_TASKS) ?? 10;

  /**
   * A boolean switch to toggle the rate limiter at application web server.
   */
  @IsOptional()
  @IsBoolean()
  public RATE_LIMITER_ENABLED = this.toBoolean(
    environment.RATE_LIMITER_ENABLED ?? "true"
  );

  /**
   * Set max allowed requests in a given duration for default rate limiter to
   * trigger throttling, per IP address.
   */
  @IsOptional()
  @IsNumber()
  @CannotUseWithout("RATE_LIMITER_ENABLED")
  public RATE_LIMITER_REQUESTS =
    this.toOptionalNumber(environment.RATE_LIMITER_REQUESTS) ?? 1000;

  /**
   * Set max allowed realtime connections before throttling. Defaults to 50
   * requests/ip/duration window.
   */
  @IsOptional()
  @IsNumber()
  public RATE_LIMITER_COLLABORATION_REQUESTS =
    this.toOptionalNumber(environment.RATE_LIMITER_COLLABORATION_REQUESTS) ??
    50;

  /**
   * Set fixed duration window(in secs) for default rate limiter, elapsing which
   * the request quota is reset (the bucket is refilled with tokens).
   */
  @IsOptional()
  @IsNumber()
  @CannotUseWithout("RATE_LIMITER_ENABLED")
  public RATE_LIMITER_DURATION_WINDOW =
    this.toOptionalNumber(environment.RATE_LIMITER_DURATION_WINDOW) ?? 60;

  /**
   * Multiplier applied to the per-endpoint API rate limits. Allows operators to
   * uniformly scale the hard-coded route-level limits up or down without
   * touching code. A value of 1 (the default) preserves the built-in limits.
   * Effective per-endpoint limits are scaled by this value, rounded to the
   * nearest integer, and clamped to a minimum of 1.
   */
  @IsOptional()
  @IsNumber()
  @Min(0)
  @CannotUseWithout("RATE_LIMITER_ENABLED")
  public RATE_LIMITER_MULTIPLIER =
    this.toOptionalFloat(environment.RATE_LIMITER_MULTIPLIER) ?? 1;

  /**
   * Set max allowed upload size for file attachments.
   * @deprecated Use FILE_STORAGE_UPLOAD_MAX_SIZE instead
   */
  @IsOptional()
  @IsNumber()
  @Deprecated("Use FILE_STORAGE_UPLOAD_MAX_SIZE instead")
  public AWS_S3_UPLOAD_MAX_SIZE = this.toOptionalNumber(
    environment.AWS_S3_UPLOAD_MAX_SIZE
  );

  /**
   * Access key ID for AWS S3.
   */
  @IsOptional()
  public AWS_ACCESS_KEY_ID = this.toOptionalString(
    environment.AWS_ACCESS_KEY_ID
  );

  /**
   * Secret key for AWS S3.
   */
  @IsOptional()
  @CannotUseWithout("AWS_ACCESS_KEY_ID")
  public AWS_SECRET_ACCESS_KEY = this.toOptionalString(
    environment.AWS_SECRET_ACCESS_KEY
  );

  /**
   * The name of the AWS S3 region to use.
   */
  @IsOptional()
  public AWS_REGION = this.toOptionalString(environment.AWS_REGION);

  /**
   * Optional AWS S3 endpoint URL for file attachments.
   */
  @Public
  @IsOptional()
  public AWS_S3_ACCELERATE_URL = environment.AWS_S3_ACCELERATE_URL ?? "";

  /**
   * Optional CloudFront distribution URL for serving attachment downloads.
   * Uploads continue to use the S3 endpoint directly. When set together with
   * AWS_CLOUDFRONT_KEY_PAIR_ID and a private key, signed CloudFront URLs are
   * used for downloads. If signing credentials are missing, S3 presigned URLs
   * are used instead.
   * Example: https://d1a2b3c4d5e6f.cloudfront.net (no trailing slash)
   */
  @IsOptional()
  public AWS_CLOUDFRONT_URL = this.toOptionalString(
    environment.AWS_CLOUDFRONT_URL
  );

  /**
   * CloudFront key pair ID for signed download URLs. Required together with a
   * private key when AWS_CLOUDFRONT_URL is set.
   */
  @IsOptional()
  public AWS_CLOUDFRONT_KEY_PAIR_ID = this.toOptionalString(
    environment.AWS_CLOUDFRONT_KEY_PAIR_ID
  );

  /**
   * PEM-encoded RSA private key for CloudFront signed URLs, or a base64-encoded
   * PEM string on a single line. Use a YAML block scalar in docker-compose for
   * multi-line PEM values.
   */
  @IsOptional()
  public AWS_CLOUDFRONT_PRIVATE_KEY = this.toOptionalString(
    environment.AWS_CLOUDFRONT_PRIVATE_KEY
  );

  /**
   * Optional AWS S3 endpoint URL for file attachments.
   */
  @Public
  @IsOptional()
  public AWS_S3_UPLOAD_BUCKET_URL = environment.AWS_S3_UPLOAD_BUCKET_URL ?? "";

  /**
   * The bucket name to store file attachments in.
   */
  @IsOptional()
  public AWS_S3_UPLOAD_BUCKET_NAME = this.toOptionalString(
    environment.AWS_S3_UPLOAD_BUCKET_NAME
  );

  /**
   * Whether to force path style URLs for S3 objects, this is required for some
   * S3-compatible storage providers.
   */
  @IsOptional()
  public AWS_S3_FORCE_PATH_STYLE = this.toBoolean(
    environment.AWS_S3_FORCE_PATH_STYLE ?? "true"
  );

  /**
   * Set default AWS S3 ACL for file attachments.
   */
  @IsOptional()
  public AWS_S3_ACL = environment.AWS_S3_ACL ?? "private";

  /**
   * Which HTTP method to use for presigned uploads to S3-compatible storage.
   * "post" uses multipart form upload (traditional S3 presigned POST).
   * "put" uses a single PUT request with a presigned URL (required for
   * providers like Cloudflare R2 that do not support presigned POST).
   */
  @IsIn(["put", "post"])
  public AWS_S3_UPLOAD_METHOD =
    this.toOptionalString(environment.AWS_S3_UPLOAD_METHOD) ?? "post";

  /**
   * Which file storage system to use
   */
  @IsIn(["local", "s3"])
  public FILE_STORAGE = this.toOptionalString(environment.FILE_STORAGE) ?? "s3";

  /**
   * Set default root dir path for local file storage
   */
  public FILE_STORAGE_LOCAL_ROOT_DIR =
    this.toOptionalString(environment.FILE_STORAGE_LOCAL_ROOT_DIR) ??
    "/var/lib/outline/data";

  /**
   * Set max allowed upload size for file attachments.
   */
  @IsNumber()
  public FILE_STORAGE_UPLOAD_MAX_SIZE =
    this.toOptionalNumber(environment.FILE_STORAGE_UPLOAD_MAX_SIZE) ??
    this.toOptionalNumber(environment.AWS_S3_UPLOAD_MAX_SIZE) ??
    1000000;

  /**
   * Set max allowed upload size for document imports.
   */
  @Public
  @IsNumber()
  public FILE_STORAGE_IMPORT_MAX_SIZE =
    this.toOptionalNumber(environment.FILE_STORAGE_IMPORT_MAX_SIZE) ??
    this.toOptionalNumber(environment.MAXIMUM_IMPORT_SIZE) ??
    this.toOptionalNumber(environment.FILE_STORAGE_UPLOAD_MAX_SIZE) ??
    1000000;

  /**
   * Timeout in milliseconds for downloading files from remote locations to file storage.
   */
  @IsNumber()
  public FILE_STORAGE_IMPORT_TIMEOUT =
    this.toOptionalNumber(environment.FILE_STORAGE_IMPORT_TIMEOUT) ?? 60000;

  /**
   * Set max allowed upload size for imports at workspace level.
   */
  @IsNumber()
  public FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE =
    this.toOptionalNumber(environment.FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE) ??
    this.toOptionalNumber(environment.MAXIMUM_IMPORT_SIZE) ??
    this.toOptionalNumber(environment.FILE_STORAGE_UPLOAD_MAX_SIZE) ??
    1000000;

  /**
   * Because imports can be much larger than regular file attachments and are
   * deleted automatically we allow an optional separate limit on the size of
   * imports.
   *
   * @deprecated Use `FILE_STORAGE_IMPORT_MAX_SIZE` or `FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE` instead
   */
  @IsOptional()
  @IsNumber()
  @Deprecated("Use FILE_STORAGE_IMPORT_MAX_SIZE instead")
  public MAXIMUM_IMPORT_SIZE = this.toOptionalNumber(
    environment.MAXIMUM_IMPORT_SIZE
  );

  /**
   * Limit on export size in bytes. Defaults to the total memory available to
   * the container.
   */
  @IsNumber()
  public MAXIMUM_EXPORT_SIZE =
    this.toOptionalNumber(environment.MAXIMUM_EXPORT_SIZE) ?? os.totalmem();

  /**
   * The number of seconds access tokens issue by the OAuth provider are valid.
   */
  @IsNumber()
  public OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME =
    this.toOptionalNumber(environment.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME) ??
    Hour.seconds;

  /**
   * The number of seconds refresh tokens issue by the OAuth provider are valid.
   */
  @IsNumber()
  public OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME =
    this.toOptionalNumber(environment.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME) ??
    30 * Day.seconds;

  /**
   * The number of seconds authorization codes issue by the OAuth provider are valid.
   */
  @IsNumber()
  public OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME =
    this.toOptionalNumber(
      environment.OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME
    ) ?? 300;

  /**
   * Whether to disable OAuth Dynamic Client Registration (DCR). When set to
   * true, the POST /oauth/register endpoint will be unavailable.
   */
  @IsBoolean()
  public OAUTH_DISABLE_DCR = this.toBoolean(
    environment.OAUTH_DISABLE_DCR ?? "false"
  );

  /**
   * Whether to disable OAuth Client ID Metadata Documents (CIMD). When set to
   * true, clients must use Dynamic Client Registration instead. Use this when
   * the server cannot fetch metadata documents from client hosts.
   */
  @IsBoolean()
  public OAUTH_DISABLE_CIMD = this.toBoolean(
    environment.OAUTH_DISABLE_CIMD ?? "false"
  );

  /**
   * Enable unsafe-inline in script-src CSP directive
   */
  @IsBoolean()
  public DEVELOPMENT_UNSAFE_INLINE_CSP = this.toBoolean(
    environment.DEVELOPMENT_UNSAFE_INLINE_CSP ?? "false"
  );

  /**
   * Time window in seconds to analyze webhook failures for disabling decision.
   * Defaults to 86400 seconds (24 hours).
   */
  @IsNumber()
  @IsOptional()
  public WEBHOOK_FAILURE_TIME_WINDOW =
    this.toOptionalNumber(environment.WEBHOOK_FAILURE_TIME_WINDOW) ?? 86400;

  /**
   * Percentage threshold of failures within the time window that triggers
   * webhook disabling. Defaults to 80%.
   */
  @IsNumber()
  @IsOptional()
  public WEBHOOK_FAILURE_RATE_THRESHOLD =
    this.toOptionalNumber(environment.WEBHOOK_FAILURE_RATE_THRESHOLD) ?? 80;

  /**
   * Comma-separated list of IP addresses or CIDR ranges that are allowed to be accessed
   * even if they are private IP addresses. This is useful for allowing
   * connections to OIDC providers or webhooks on private networks.
   * Supports both individual IP addresses and CIDR notation.
   * Example: "10.0.0.1,192.168.1.0/24,172.16.0.1"
   */
  @IsOptional()
  public ALLOWED_PRIVATE_IP_ADDRESSES = this.toOptionalCommaList(
    environment.ALLOWED_PRIVATE_IP_ADDRESSES
  );

  /**
   * The search provider to use. Defaults to "postgres" which uses PostgreSQL
   * full-text search. Alternative providers can be registered via plugins.
   */
  @IsOptional()
  public SEARCH_PROVIDER =
    this.toOptionalString(environment.SEARCH_PROVIDER) ?? "postgres";

  /**
   * The product name
   */
  @Public
  public APP_NAME = "Outline";

  /**
   * Gravity constant for time decay in popularity scoring. Higher values cause
   * faster decay of older content. Default is 0.7.
   */
  @IsOptional()
  @IsNumber()
  public POPULARITY_GRAVITY =
    this.toOptionalNumber(environment.POPULARITY_GRAVITY) ?? 0.7;

  /**
   * Number of weeks of activity to consider when calculating popularity scores.
   * Default is 2 weeks.
   */
  @IsOptional()
  @IsNumber()
  public POPULARITY_ACTIVITY_THRESHOLD_WEEKS =
    this.toOptionalNumber(environment.POPULARITY_ACTIVITY_THRESHOLD_WEEKS) ?? 2;

  /**
   * Interval in hours at which popularity scores are recalculated.
   * Default is 12 hours.
   */
  @IsOptional()
  @IsNumber()
  public POPULARITY_UPDATE_INTERVAL_HOURS =
    this.toOptionalNumber(environment.POPULARITY_UPDATE_INTERVAL_HOURS) ?? 12;

  /**
   * Returns true if the current installation is the cloud hosted version at
   * getoutline.com
   */
  public get isCloudHosted() {
    return [
      "https://app.getoutline.com",
      "https://app.outline.dev",
      "https://app.outline.dev:3000",
    ].includes(this.URL);
  }

  /**
   * Returns true if the current installation is running in production.
   */
  public get isProduction() {
    return this.ENVIRONMENT === "production";
  }

  /**
   * Returns true if the current installation is running in the development environment.
   */
  public get isDevelopment() {
    return this.ENVIRONMENT === "development";
  }

  /**
   * Returns true if the current installation is running in a test environment.
   */
  public get isTest() {
    return this.ENVIRONMENT === "test";
  }

  /**
   * Returns the port that the Vite development server listens on.
   */
  public get vitePort() {
    return this.PORT + 1;
  }

  protected toOptionalString(value: string | undefined) {
    return value ? value : undefined;
  }

  protected toOptionalCommaList(value: string | undefined) {
    return value ? value.split(",").map((item) => item.trim()) : undefined;
  }

  protected toOptionalNumber(value: string | undefined) {
    return value ? parseInt(value, 10) : undefined;
  }

  protected toOptionalFloat(value: string | undefined) {
    return value ? parseFloat(value) : undefined;
  }

  /**
   * Convert a string to a boolean. Supports the following:
   *
   * 0 = false
   * 1 = true
   * "true" = true
   * "false" = false
   * "" = false
   *
   * @param value The string to convert
   * @returns A boolean
   */
  protected toBoolean(value: string) {
    try {
      return value ? !!JSON.parse(value) : false;
    } catch (_err) {
      throw new Error(
        `"${value}" could not be parsed as a boolean, must be "true" or "false"`
      );
    }
  }

  /**
   * Convert a string to an optional boolean. Supports the following:
   *
   * 0 = false
   * 1 = true
   * "true" = true
   * "false" = false
   * "" = undefined
   *
   * @param value The string to convert
   * @returns A boolean or undefined
   */
  protected toOptionalBoolean(value: string | undefined) {
    try {
      return value ? !!JSON.parse(value) : undefined;
    } catch (_err) {
      return undefined;
    }
  }
}

export default new Environment();