{ config, options, lib, pkgs, utils, ... }: let json = pkgs.formats.json {}; cfg = config.services.discourse; opt = options.services.discourse; # Keep in sync with https://github.com/discourse/discourse_docker/blob/main/image/base/slim.Dockerfile#L5 upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13; postgresqlPackage = if config.services.postgresql.enable then config.services.postgresql.package else pkgs.postgresql; postgresqlVersion = lib.getVersion postgresqlPackage; # We only want to create a database if we're actually going to connect to it. databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null; tlsEnabled = cfg.enableACME || cfg.sslCertificate != null || cfg.sslCertificateKey != null; in { options = { services.discourse = { enable = lib.mkEnableOption (lib.mdDoc "Discourse, an open source discussion platform"); package = lib.mkOption { type = lib.types.package; default = pkgs.discourse; apply = p: p.override { plugins = lib.unique (p.enabledPlugins ++ cfg.plugins); }; defaultText = lib.literalExpression "pkgs.discourse"; description = lib.mdDoc '' The discourse package to use. ''; }; hostname = lib.mkOption { type = lib.types.str; default = config.networking.fqdnOrHostName; defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; example = "discourse.example.com"; description = lib.mdDoc '' The hostname to serve Discourse on. ''; }; secretKeyBaseFile = lib.mkOption { type = with lib.types; nullOr path; default = null; example = "/run/keys/secret_key_base"; description = lib.mdDoc '' The path to a file containing the `secret_key_base` secret. Discourse uses `secret_key_base` to encrypt the cookie store, which contains session data, and to digest user auth tokens. Needs to be a 64 byte long string of hexadecimal characters. You can generate one by running ``` openssl rand -hex 64 >/path/to/secret_key_base_file ``` This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. ''; }; sslCertificate = lib.mkOption { type = with lib.types; nullOr path; default = null; example = "/run/keys/ssl.cert"; description = lib.mdDoc '' The path to the server SSL certificate. Set this to enable SSL. ''; }; sslCertificateKey = lib.mkOption { type = with lib.types; nullOr path; default = null; example = "/run/keys/ssl.key"; description = lib.mdDoc '' The path to the server SSL certificate key. Set this to enable SSL. ''; }; enableACME = lib.mkOption { type = lib.types.bool; default = cfg.sslCertificate == null && cfg.sslCertificateKey == null; defaultText = lib.literalMD '' `true`, unless {option}`services.discourse.sslCertificate` and {option}`services.discourse.sslCertificateKey` are set. ''; description = lib.mdDoc '' Whether an ACME certificate should be used to secure connections to the server. ''; }; backendSettings = lib.mkOption { type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ])); default = {}; example = lib.literalExpression '' { max_reqs_per_ip_per_minute = 300; max_reqs_per_ip_per_10_seconds = 60; max_asset_reqs_per_ip_per_10_seconds = 250; max_reqs_per_ip_mode = "warn+block"; }; ''; description = lib.mdDoc '' Additional settings to put in the {file}`discourse.conf` file. Look in the [discourse_defaults.conf](https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf) file in the upstream distribution to find available options. Setting an option to `null` means “define variable, but leave right-hand side empty”. ''; }; siteSettings = lib.mkOption { type = json.type; default = {}; example = lib.literalExpression '' { required = { title = "My Cats"; site_description = "Discuss My Cats (and be nice plz)"; }; login = { enable_github_logins = true; github_client_id = "a2f6dfe838cb3206ce20"; github_client_secret._secret = /run/keys/discourse_github_client_secret; }; }; ''; description = lib.mdDoc '' Discourse site settings. These are the settings that can be changed from the UI. This only defines their default values: they can still be overridden from the UI. Available settings can be found by looking in the [site_settings.yml](https://github.com/discourse/discourse/blob/master/config/site_settings.yml) file of the upstream distribution. To find a setting's path, you only need to care about the first two levels; i.e. its category and name. See the example. Settings containing secret data should be set to an attribute set containing the attribute `_secret` - a string pointing to a file containing the value the option should be set to. See the example to get a better picture of this: in the resulting {file}`config/nixos_site_settings.json` file, the `login.github_client_secret` key will be set to the contents of the {file}`/run/keys/discourse_github_client_secret` file. ''; }; admin = { skipCreate = lib.mkOption { type = lib.types.bool; default = false; description = lib.mdDoc '' Do not create the admin account, instead rely on other existing admin accounts. ''; }; email = lib.mkOption { type = lib.types.str; example = "admin@example.com"; description = lib.mdDoc '' The admin user email address. ''; }; username = lib.mkOption { type = lib.types.str; example = "admin"; description = lib.mdDoc '' The admin user username. ''; }; fullName = lib.mkOption { type = lib.types.str; description = lib.mdDoc '' The admin user's full name. ''; }; passwordFile = lib.mkOption { type = lib.types.path; description = lib.mdDoc '' A path to a file containing the admin user's password. This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. ''; }; }; nginx.enable = lib.mkOption { type = lib.types.bool; default = true; description = lib.mdDoc '' Whether an `nginx` virtual host should be set up to serve Discourse. Only disable if you're planning to use a different web server, which is not recommended. ''; }; database = { pool = lib.mkOption { type = lib.types.int; default = 8; description = lib.mdDoc '' Database connection pool size. ''; }; host = lib.mkOption { type = with lib.types; nullOr str; default = null; description = lib.mdDoc '' Discourse database hostname. `null` means “prefer local unix socket connection”. ''; }; passwordFile = lib.mkOption { type = with lib.types; nullOr path; default = null; description = lib.mdDoc '' File containing the Discourse database user password. This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. ''; }; createLocally = lib.mkOption { type = lib.types.bool; default = true; description = lib.mdDoc '' Whether a database should be automatically created on the local host. Set this to `false` if you plan on provisioning a local database yourself. This has no effect if {option}`services.discourse.database.host` is customized. ''; }; name = lib.mkOption { type = lib.types.str; default = "discourse"; description = lib.mdDoc '' Discourse database name. ''; }; username = lib.mkOption { type = lib.types.str; default = "discourse"; description = lib.mdDoc '' Discourse database user. ''; }; ignorePostgresqlVersion = lib.mkOption { type = lib.types.bool; default = false; description = lib.mdDoc '' Whether to allow other versions of PostgreSQL than the recommended one. Only effective when {option}`services.discourse.database.createLocally` is enabled. ''; }; }; redis = { host = lib.mkOption { type = lib.types.str; default = "localhost"; description = lib.mdDoc '' Redis server hostname. ''; }; passwordFile = lib.mkOption { type = with lib.types; nullOr path; default = null; description = lib.mdDoc '' File containing the Redis password. This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. ''; }; dbNumber = lib.mkOption { type = lib.types.int; default = 0; description = lib.mdDoc '' Redis database number. ''; }; useSSL = lib.mkOption { type = lib.types.bool; default = cfg.redis.host != "localhost"; defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"''; description = lib.mdDoc '' Connect to Redis with SSL. ''; }; }; mail = { notificationEmailAddress = lib.mkOption { type = lib.types.str; default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}"; defaultText = lib.literalExpression '' "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}" ''; description = lib.mdDoc '' The `from:` email address used when sending all essential system emails. The domain specified here must have SPF, DKIM and reverse PTR records set correctly for email to arrive. ''; }; contactEmailAddress = lib.mkOption { type = lib.types.str; default = ""; description = lib.mdDoc '' Email address of key contact responsible for this site. Used for critical notifications, as well as on the `/about` contact form for urgent matters. ''; }; outgoing = { serverAddress = lib.mkOption { type = lib.types.str; default = "localhost"; description = lib.mdDoc '' The address of the SMTP server Discourse should use to send email. ''; }; port = lib.mkOption { type = lib.types.port; default = 25; description = lib.mdDoc '' The port of the SMTP server Discourse should use to send email. ''; }; username = lib.mkOption { type = with lib.types; nullOr str; default = null; description = lib.mdDoc '' The username of the SMTP server. ''; }; passwordFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = lib.mdDoc '' A file containing the password of the SMTP server account. This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. ''; }; domain = lib.mkOption { type = lib.types.str; default = cfg.hostname; defaultText = lib.literalExpression "config.${opt.hostname}"; description = lib.mdDoc '' HELO domain to use for outgoing mail. ''; }; authentication = lib.mkOption { type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]); default = null; description = lib.mdDoc '' Authentication type to use, see https://api.rubyonrails.org/classes/ActionMailer/Base.html ''; }; enableStartTLSAuto = lib.mkOption { type = lib.types.bool; default = true; description = lib.mdDoc '' Whether to try to use StartTLS. ''; }; opensslVerifyMode = lib.mkOption { type = lib.types.str; default = "peer"; description = lib.mdDoc '' How OpenSSL checks the certificate, see https://api.rubyonrails.org/classes/ActionMailer/Base.html ''; }; forceTLS = lib.mkOption { type = lib.types.bool; default = false; description = lib.mdDoc '' Force implicit TLS as per RFC 8314 3.3. ''; }; }; incoming = { enable = lib.mkOption { type = lib.types.bool; default = false; description = lib.mdDoc '' Whether to set up Postfix to receive incoming mail. ''; }; replyEmailAddress = lib.mkOption { type = lib.types.str; default = "%{reply_key}@${cfg.hostname}"; defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"''; description = lib.mdDoc '' Template for reply by email incoming email address, for example: %{reply_key}@reply.example.com or replies+%{reply_key}@example.com ''; }; mailReceiverPackage = lib.mkOption { type = lib.types.package; default = pkgs.discourse-mail-receiver; defaultText = lib.literalExpression "pkgs.discourse-mail-receiver"; description = lib.mdDoc '' The discourse-mail-receiver package to use. ''; }; apiKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = lib.mdDoc '' A file containing the Discourse API key used to add posts and messages from mail. If left at its default value `null`, one will be automatically generated. This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. ''; }; }; }; plugins = lib.mkOption { type = lib.types.listOf lib.types.package; default = []; example = lib.literalExpression '' with config.services.discourse.package.plugins; [ discourse-canned-replies discourse-github ]; ''; description = lib.mdDoc '' Plugins to install as part of Discourse, expressed as a list of derivations. ''; }; sidekiqProcesses = lib.mkOption { type = lib.types.int; default = 1; description = lib.mdDoc '' How many Sidekiq processes should be spawned. ''; }; unicornTimeout = lib.mkOption { type = lib.types.int; default = 30; description = lib.mdDoc '' Time in seconds before a request to Unicorn times out. This can be raised if the system Discourse is running on is too slow to handle many requests within 30 seconds. ''; }; }; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null); message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!"; } { assertion = cfg.hostname != ""; message = "Could not automatically determine hostname, set service.discourse.hostname manually."; } { assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion); message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. " + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. " + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL."; } ]; # Default config values are from `config/discourse_defaults.conf` # upstream. services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) { db_pool = cfg.database.pool; db_timeout = 5000; db_connect_timeout = 5; db_socket = null; db_host = cfg.database.host; db_backup_host = null; db_port = null; db_backup_port = 5432; db_name = cfg.database.name; db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username; db_password = cfg.database.passwordFile; db_prepared_statements = false; db_replica_host = null; db_replica_port = null; db_advisory_locks = true; inherit (cfg) hostname; backup_hostname = null; smtp_address = cfg.mail.outgoing.serverAddress; smtp_port = cfg.mail.outgoing.port; smtp_domain = cfg.mail.outgoing.domain; smtp_user_name = cfg.mail.outgoing.username; smtp_password = cfg.mail.outgoing.passwordFile; smtp_authentication = cfg.mail.outgoing.authentication; smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto; smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode; smtp_force_tls = cfg.mail.outgoing.forceTLS; load_mini_profiler = true; mini_profiler_snapshots_period = 0; mini_profiler_snapshots_transport_url = null; mini_profiler_snapshots_transport_auth_key = null; cdn_url = null; cdn_origin_hostname = null; developer_emails = null; redis_host = cfg.redis.host; redis_port = 6379; redis_replica_host = null; redis_replica_port = 6379; redis_db = cfg.redis.dbNumber; redis_password = cfg.redis.passwordFile; redis_skip_client_commands = false; redis_use_ssl = cfg.redis.useSSL; message_bus_redis_enabled = false; message_bus_redis_host = "localhost"; message_bus_redis_port = 6379; message_bus_redis_replica_host = null; message_bus_redis_replica_port = 6379; message_bus_redis_db = 0; message_bus_redis_password = null; message_bus_redis_skip_client_commands = false; enable_cors = false; cors_origin = ""; serve_static_assets = false; sidekiq_workers = 5; connection_reaper_age = 30; connection_reaper_interval = 30; relative_url_root = null; message_bus_max_backlog_size = 100; message_bus_clear_every = 50; secret_key_base = cfg.secretKeyBaseFile; fallback_assets_path = null; s3_bucket = null; s3_region = null; s3_access_key_id = null; s3_secret_access_key = null; s3_use_iam_profile = null; s3_cdn_url = null; s3_endpoint = null; s3_http_continue_timeout = null; s3_install_cors_rule = null; s3_asset_cdn_url = null; max_user_api_reqs_per_minute = 20; max_user_api_reqs_per_day = 2880; max_admin_api_reqs_per_minute = 60; max_reqs_per_ip_per_minute = 200; max_reqs_per_ip_per_10_seconds = 50; max_asset_reqs_per_ip_per_10_seconds = 200; max_reqs_per_ip_mode = "block"; max_reqs_rate_limit_on_private = false; skip_per_ip_rate_limit_trust_level = 1; force_anonymous_min_queue_seconds = 1; force_anonymous_min_per_10_seconds = 3; background_requests_max_queue_length = 0.5; reject_message_bus_queue_seconds = 0.1; disable_search_queue_threshold = 1; max_old_rebakes_per_15_minutes = 300; max_logster_logs = 1000; refresh_maxmind_db_during_precompile_days = 2; maxmind_backup_path = null; maxmind_license_key = null; enable_performance_http_headers = false; enable_js_error_reporting = true; mini_scheduler_workers = 5; compress_anon_cache = false; anon_cache_store_threshold = 2; allowed_theme_repos = null; enable_email_sync_demon = false; max_digests_enqueued_per_30_mins_per_site = 10000; cluster_name = null; multisite_config_path = "config/multisite.yml"; enable_long_polling = null; long_polling_interval = null; preload_link_header = false; redirect_avatar_requests = false; pg_force_readonly_mode = false; dns_query_timeout_secs = null; regex_timeout_seconds = 2; allow_impersonation = true; }; services.redis.servers.discourse = lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) { enable = true; bind = cfg.redis.host; port = cfg.backendSettings.redis_port; }; services.postgresql = lib.mkIf databaseActuallyCreateLocally { enable = true; ensureUsers = [{ name = "discourse"; }]; }; # The postgresql module doesn't currently support concepts like # objects owners and extensions; for now we tack on what's needed # here. systemd.services.discourse-postgresql = let pgsql = config.services.postgresql; in lib.mkIf databaseActuallyCreateLocally { after = [ "postgresql.service" ]; bindsTo = [ "postgresql.service" ]; wantedBy = [ "discourse.service" ]; partOf = [ "discourse.service" ]; path = [ pgsql.package ]; script = '' set -o errexit -o pipefail -o nounset -o errtrace shopt -s inherit_errexit psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"' psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm" psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore" ''; serviceConfig = { User = pgsql.superUser; Type = "oneshot"; RemainAfterExit = true; }; }; systemd.services.discourse = { wantedBy = [ "multi-user.target" ]; after = [ "redis-discourse.service" "postgresql.service" "discourse-postgresql.service" ]; bindsTo = [ "redis-discourse.service" ] ++ lib.optionals (cfg.database.host == null) [ "postgresql.service" "discourse-postgresql.service" ]; path = cfg.package.runtimeDeps ++ [ postgresqlPackage pkgs.replace-secret cfg.package.rake ]; environment = cfg.package.runtimeEnv // { UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout; UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses; MALLOC_ARENA_MAX = "2"; }; preStart = let discourseKeyValue = lib.generators.toKeyValue { mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " { mkValueString = v: with builtins; if isInt v then toString v else if isString v then ''"${v}"'' else if true == v then "true" else if false == v then "false" else if null == v then "" else if isFloat v then lib.strings.floatToString v else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; }; }; discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings); mkSecretReplacement = file: lib.optionalString (file != null) '' replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf ''; mkAdmin = '' export ADMIN_EMAIL="${cfg.admin.email}" export ADMIN_NAME="${cfg.admin.fullName}" export ADMIN_USERNAME="${cfg.admin.username}" ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})" export ADMIN_PASSWORD discourse-rake admin:create_noninteractively ''; in '' set -o errexit -o pipefail -o nounset -o errtrace shopt -s inherit_errexit umask u=rwx,g=rx,o= rm -rf /var/lib/discourse/tmp/* cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/ cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/ ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads ln -sf /var/lib/discourse/backups /run/discourse/public/backups ( umask u=rwx,g=,o= ${utils.genJqSecretsReplacementSnippet cfg.siteSettings "/run/discourse/config/nixos_site_settings.json" } install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf ${mkSecretReplacement cfg.database.passwordFile} ${mkSecretReplacement cfg.mail.outgoing.passwordFile} ${mkSecretReplacement cfg.redis.passwordFile} ${mkSecretReplacement cfg.secretKeyBaseFile} chmod 0400 /run/discourse/config/discourse.conf ) discourse-rake db:migrate >>/var/log/discourse/db_migration.log chmod -R u+w /var/lib/discourse/tmp/ ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin} discourse-rake themes:update discourse-rake uploads:regenerate_missing_optimized ''; serviceConfig = { Type = "simple"; User = "discourse"; Group = "discourse"; RuntimeDirectory = map (p: "discourse/" + p) [ "config" "home" "assets/javascripts/plugins" "public" "sockets" ]; RuntimeDirectoryMode = "0750"; StateDirectory = map (p: "discourse/" + p) [ "uploads" "backups" "tmp" ]; StateDirectoryMode = "0750"; LogsDirectory = "discourse"; TimeoutSec = "infinity"; Restart = "on-failure"; WorkingDirectory = "${cfg.package}/share/discourse"; RemoveIPC = true; PrivateTmp = true; NoNewPrivileges = true; RestrictSUIDSGID = true; ProtectSystem = "strict"; ProtectHome = "read-only"; ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb"; }; }; services.nginx = lib.mkIf cfg.nginx.enable { enable = true; recommendedTlsSettings = true; recommendedOptimisation = true; recommendedBrotliSettings = true; recommendedGzipSettings = true; recommendedProxySettings = true; upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {}; appendHttpConfig = '' # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week) # levels means it is a 2 deep hierarchy cause we can have lots of files # max_size limits the size of the cache proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m; # see: https://meta.discourse.org/t/x/74060 proxy_buffer_size 8k; ''; virtualHosts.${cfg.hostname} = { inherit (cfg) sslCertificate sslCertificateKey enableACME; forceSSL = lib.mkDefault tlsEnabled; root = "${cfg.package}/share/discourse/public"; locations = let proxy = { extraConfig ? "" }: { proxyPass = "http://discourse"; extraConfig = extraConfig + '' proxy_set_header X-Request-Start "t=''${msec}"; ''; }; cache = time: '' expires ${time}; add_header Cache-Control public,immutable; ''; cache_1y = cache "1y"; cache_1d = cache "1d"; in { "/".tryFiles = "$uri @discourse"; "@discourse" = proxy {}; "^~ /backups/".extraConfig = '' internal; ''; "/favicon.ico" = { return = "204"; extraConfig = '' access_log off; log_not_found off; ''; }; "~ ^/uploads/short-url/" = proxy {}; "~ ^/secure-media-uploads/" = proxy {}; "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + '' add_header Access-Control-Allow-Origin *; ''; "/srv/status" = proxy { extraConfig = '' access_log off; log_not_found off; ''; }; "~ ^/javascripts/".extraConfig = cache_1d; "~ ^/assets/(?.+)$".extraConfig = cache_1y + '' # asset pipeline enables this brotli_static on; gzip_static on; ''; "~ ^/plugins/".extraConfig = cache_1y; "~ /images/emoji/".extraConfig = cache_1y; "~ ^/uploads/" = proxy { extraConfig = cache_1y + '' proxy_set_header X-Sendfile-Type X-Accel-Redirect; proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; # custom CSS location ~ /stylesheet-cache/ { try_files $uri =404; } # this allows us to bypass rails location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ { try_files $uri =404; } # SVG needs an extra header attached location ~* \.(svg)$ { } # thumbnails & optimized images location ~ /_?optimized/ { try_files $uri =404; } ''; }; "~ ^/admin/backups/" = proxy { extraConfig = '' proxy_set_header X-Sendfile-Type X-Accel-Redirect; proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; ''; }; "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy { extraConfig = '' # if Set-Cookie is in the response nothing gets cached # this is double bad cause we are not passing last modified in proxy_ignore_headers "Set-Cookie"; proxy_hide_header "Set-Cookie"; proxy_hide_header "X-Discourse-Username"; proxy_hide_header "X-Runtime"; # note x-accel-redirect can not be used with proxy_cache proxy_cache discourse; proxy_cache_key "$scheme,$host,$request_uri"; proxy_cache_valid 200 301 302 7d; ''; }; "/message-bus/" = proxy { extraConfig = '' proxy_http_version 1.1; proxy_buffering off; ''; }; "/downloads/".extraConfig = '' internal; alias ${cfg.package}/share/discourse/public/; ''; }; }; }; systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable ( let mail-receiver-environment = { MAIL_DOMAIN = cfg.hostname; DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}"; DISCOURSE_API_KEY = "@api-key@"; DISCOURSE_API_USERNAME = "system"; }; mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment; in { before = [ "postfix.service" ]; after = [ "discourse.service" ]; wantedBy = [ "discourse.service" ]; partOf = [ "discourse.service" ]; path = [ cfg.package.rake pkgs.jq ]; preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) '' set -o errexit -o pipefail -o nounset -o errtrace shopt -s inherit_errexit if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key fi ''; script = let apiKeyPath = if cfg.mail.incoming.apiKeyFile == null then "/var/lib/discourse-mail-receiver/api_key" else cfg.mail.incoming.apiKeyFile; in '' set -o errexit -o pipefail -o nounset -o errtrace shopt -s inherit_errexit api_key=$(<'${apiKeyPath}') export api_key jq <${mail-receiver-json} \ '.DISCOURSE_API_KEY = $ENV.api_key' \ >'/run/discourse-mail-receiver/mail-receiver-environment.json' ''; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; RuntimeDirectory = "discourse-mail-receiver"; RuntimeDirectoryMode = "0700"; StateDirectory = "discourse-mail-receiver"; User = "discourse"; Group = "discourse"; }; }); services.discourse.siteSettings = { required = { notification_email = cfg.mail.notificationEmailAddress; contact_email = cfg.mail.contactEmailAddress; }; security.force_https = tlsEnabled; email = { manual_polling_enabled = cfg.mail.incoming.enable; reply_by_email_enabled = cfg.mail.incoming.enable; reply_by_email_address = cfg.mail.incoming.replyEmailAddress; }; }; services.postfix = lib.mkIf cfg.mail.incoming.enable { enable = true; sslCert = lib.optionalString (cfg.sslCertificate != null) cfg.sslCertificate; sslKey = lib.optionalString (cfg.sslCertificateKey != null) cfg.sslCertificateKey; origin = cfg.hostname; relayDomains = [ cfg.hostname ]; config = { smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy"; append_dot_mydomain = lib.mkDefault false; compatibility_level = "2"; smtputf8_enable = false; smtpd_banner = lib.mkDefault "ESMTP server"; myhostname = lib.mkDefault cfg.hostname; mydestination = lib.mkDefault "localhost"; }; transport = '' ${cfg.hostname} discourse-mail-receiver: ''; masterConfig = { "discourse-mail-receiver" = { type = "unix"; privileged = true; chroot = false; command = "pipe"; args = [ "user=discourse" "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail" "\${recipient}" ]; }; "discourse-policy" = { type = "unix"; privileged = true; chroot = false; command = "spawn"; args = [ "user=discourse" "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection" ]; }; }; }; users.users = { discourse = { group = "discourse"; isSystemUser = true; }; } // (lib.optionalAttrs cfg.nginx.enable { ${config.services.nginx.user}.extraGroups = [ "discourse" ]; }); users.groups = { discourse = {}; }; environment.systemPackages = [ cfg.package.rake ]; }; meta.doc = ./discourse.md; meta.maintainers = [ lib.maintainers.talyz ]; }