From 8dddb70bb97ff76c291d582295fba0791ed1c82a Mon Sep 17 00:00:00 2001 From: talyz Date: Sun, 4 Apr 2021 13:42:18 +0200 Subject: [PATCH] nixos/discourse: Init --- nixos/modules/module-list.nix | 1 + nixos/modules/services/web-apps/discourse.nix | 1032 +++++++++++++++++ 2 files changed, 1033 insertions(+) create mode 100644 nixos/modules/services/web-apps/discourse.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index ca7898687b8..aa118b66367 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -890,6 +890,7 @@ ./services/web-apps/bookstack.nix ./services/web-apps/convos.nix ./services/web-apps/cryptpad.nix + ./services/web-apps/discourse.nix ./services/web-apps/documize.nix ./services/web-apps/dokuwiki.nix ./services/web-apps/engelsystem.nix diff --git a/nixos/modules/services/web-apps/discourse.nix b/nixos/modules/services/web-apps/discourse.nix new file mode 100644 index 00000000000..6f4c50006d3 --- /dev/null +++ b/nixos/modules/services/web-apps/discourse.nix @@ -0,0 +1,1032 @@ +{ config, options, lib, pkgs, utils, ... }: + +let + json = pkgs.formats.json {}; + + cfg = config.services.discourse; + + postgresqlPackage = if config.services.postgresql.enable then + config.services.postgresql.package + else + pkgs.postgresql; + + # 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 "Discourse, an open source discussion platform"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.discourse; + defaultText = "pkgs.discourse"; + description = '' + The discourse package to use. + ''; + }; + + hostname = lib.mkOption { + type = lib.types.str; + default = if config.networking.domain != null then + config.networking.fqdn + else + config.networking.hostName; + defaultText = "config.networking.fqdn"; + example = "discourse.example.com"; + description = '' + The hostname to serve Discourse on. + ''; + }; + + secretKeyBaseFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/secret_key_base"; + description = '' + 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 = '' + 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 = '' + 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 = "true, unless services.discourse.sslCertificate and services.discourse.sslCertificateKey are set."; + description = '' + 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.literalExample '' + { + 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 = '' + Additional settings to put in the + discourse.conf file. + + Look in the + 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.literalExample '' + { + 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 = '' + 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 + 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 + config/nixos_site_settings.json file, + the login.github_client_secret key will + be set to the contents of the + /run/keys/discourse_github_client_secret + file. + ''; + }; + + admin = { + email = lib.mkOption { + type = lib.types.str; + example = "admin@example.com"; + description = '' + The admin user email address. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + example = "admin"; + description = '' + The admin user username. + ''; + }; + + fullName = lib.mkOption { + type = lib.types.str; + description = '' + The admin user's full name. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.path; + description = '' + 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 = '' + 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 = '' + Database connection pool size. + ''; + }; + + host = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + Discourse database hostname. null means prefer + local unix socket connection. + ''; + }; + + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = '' + 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 = '' + 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 is customized. + ''; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "discourse"; + description = '' + Discourse database name. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + default = "discourse"; + description = '' + Discourse database user. + ''; + }; + }; + + redis = { + host = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = '' + Redis server hostname. + ''; + }; + + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = '' + 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 = '' + Redis database number. + ''; + }; + + useSSL = lib.mkOption { + type = lib.types.bool; + default = cfg.redis.host != "localhost"; + description = '' + 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 = '' + "notifications@`config.services.discourse.hostname`" if + config.services.discourse.mail.incoming.enable is "true", + otherwise "noreply`config.services.discourse.hostname`" + ''; + description = '' + 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 = '' + 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 = '' + The address of the SMTP server Discourse should use to + send email. + ''; + }; + + port = lib.mkOption { + type = lib.types.int; + default = 25; + description = '' + The port of the SMTP server Discourse should use to + send email. + ''; + }; + + username = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + The username of the SMTP server. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + 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; + description = '' + HELO domain to use for outgoing mail. + ''; + }; + + authentication = lib.mkOption { + type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]); + default = null; + description = '' + Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html + ''; + }; + + enableStartTLSAuto = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to try to use StartTLS. + ''; + }; + + opensslVerifyMode = lib.mkOption { + type = lib.types.str; + default = "peer"; + description = '' + How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html + ''; + }; + }; + + incoming = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to set up Postfix to receive incoming mail. + ''; + }; + + replyEmailAddress = lib.mkOption { + type = lib.types.str; + default = "%{reply_key}@${cfg.hostname}"; + defaultText = "%{reply_key}@`config.services.discourse.hostname`"; + description = '' + 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 = "pkgs.discourse-mail-receiver"; + description = '' + The discourse-mail-receiver package to use. + ''; + }; + + apiKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + 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 = '' + [ + (pkgs.fetchFromGitHub { + owner = "discourse"; + repo = "discourse-spoiler-alert"; + rev = "e200cfa571d252cab63f3d30d619b370986e4cee"; + sha256 = "0ya69ix5g77wz4c9x9gmng6l25ghb5xxlx3icr6jam16q14dzc33"; + }) + ]; + ''; + description = '' + Discourse plugins to install as a + list of derivations. As long as a plugin supports the + standard install method, packaging it should only require + fetching its source with an appropriate fetcher. + ''; + }; + + sidekiqProcesses = lib.mkOption { + type = lib.types.int; + default = 1; + description = '' + How many Sidekiq processes should be spawned. + ''; + }; + + unicornTimeout = lib.mkOption { + type = lib.types.int; + default = 30; + description = '' + 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."; + } + ]; + + + # 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; + + 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_slave_host = null; + redis_slave_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_slave_host = null; + message_bus_redis_slave_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; + rtl_css = false; + connection_reaper_age = 30; + connection_reaper_interval = 30; + relative_url_root = null; + message_bus_max_backlog_size = 100; + 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; + + max_user_api_reqs_per_minute = 20; + max_user_api_reqs_per_day = 2880; + max_admin_api_reqs_per_key_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; + 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; + }; + + services.redis.enable = lib.mkDefault (cfg.redis.host == "localhost"); + + 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.service" + "postgresql.service" + "discourse-postgresql.service" + ]; + bindsTo = [ + "redis.service" + ] ++ lib.optionals (cfg.database.host == null) [ + "postgresql.service" + "discourse-postgresql.service" + ]; + path = cfg.package.runtimeDeps ++ [ + postgresqlPackage + pkgs.replace + cfg.package.rake + ]; + environment = cfg.package.runtimeEnv // { + UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout; + UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses; + }; + + 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) '' + ( + password=$(<'${file}') + replace-literal -fe '${file}' "$password" /run/discourse/config/discourse.conf + ) + ''; + in '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + umask u=rwx,g=rx,o= + + cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/ + cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/ + cp -r ${cfg.package}/share/discourse/plugins.dist/* /run/discourse/plugins/ + ${lib.concatMapStrings (p: "ln -sf ${p} /run/discourse/plugins/") cfg.plugins} + 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 0400 -o discourse ${discourseConf} /run/discourse/config/discourse.conf + ${mkSecretReplacement cfg.database.passwordFile} + ${mkSecretReplacement cfg.mail.outgoing.passwordFile} + ${mkSecretReplacement cfg.redis.passwordFile} + ${mkSecretReplacement cfg.secretKeyBaseFile} + ) + + discourse-rake db:migrate >>/var/log/discourse/db_migration.log + chmod -R u+w /run/discourse/tmp/ + + export ADMIN_EMAIL="${cfg.admin.email}" + export ADMIN_NAME="${cfg.admin.fullName}" + export ADMIN_USERNAME="${cfg.admin.username}" + export ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})" + discourse-rake admin:create_noninteractively + + discourse-rake themes:update + discourse-rake uploads:regenerate_missing_optimized + ''; + + serviceConfig = { + Type = "simple"; + User = "discourse"; + Group = "discourse"; + RuntimeDirectory = map (p: "discourse/" + p) [ + "config" + "home" + "tmp" + "assets/javascripts/plugins" + "public" + "plugins" + "sockets" + ]; + RuntimeDirectoryMode = 0750; + StateDirectory = map (p: "discourse/" + p) [ + "uploads" + "backups" + ]; + 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; + additionalModules = [ pkgs.nginxModules.brotli ]; + + recommendedTlsSettings = true; + recommendedOptimisation = 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 heirarchy 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 = "/run/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 /run/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 /run/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; + proxy_cache_valid any 1m; + ''; + }; + "/message-bus/" = proxy { + extraConfig = '' + proxy_http_version 1.1; + proxy_buffering off; + ''; + }; + "/downloads/".extraConfig = '' + internal; + alias /run/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 + + export api_key=$(<'${apiKeyPath}') + + 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; + }; + 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 = if cfg.sslCertificate != null then cfg.sslCertificate else ""; + sslKey = if cfg.sslCertificateKey != null then cfg.sslCertificateKey else ""; + + 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 + ]; + }; +}