diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index ca7898687b8b..aa118b66367b 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 000000000000..6f4c50006d3e
--- /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
+ ];
+ };
+}