vuls: init at 0.27.0 (#348530)
[NixPkgs.git] / nixos / modules / services / web-apps / discourse.nix
blob849a03be8bc8789464bc116a3bcd72cd15c2fdbd
1 { config, options, lib, pkgs, utils, ... }:
3 let
4   json = pkgs.formats.json {};
6   cfg = config.services.discourse;
7   opt = options.services.discourse;
9   # Keep in sync with https://github.com/discourse/discourse_docker/blob/main/image/base/slim.Dockerfile#L5
10   upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13;
12   postgresqlPackage = if config.services.postgresql.enable then
13                         config.services.postgresql.package
14                       else
15                         pkgs.postgresql;
17   postgresqlVersion = lib.getVersion postgresqlPackage;
19   # We only want to create a database if we're actually going to connect to it.
20   databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null;
22   tlsEnabled = cfg.enableACME
23                 || cfg.sslCertificate != null
24                 || cfg.sslCertificateKey != null;
27   options = {
28     services.discourse = {
29       enable = lib.mkEnableOption "Discourse, an open source discussion platform";
31       package = lib.mkOption {
32         type = lib.types.package;
33         default = pkgs.discourse;
34         apply = p: p.override {
35           plugins = lib.unique (p.enabledPlugins ++ cfg.plugins);
36         };
37         defaultText = lib.literalExpression "pkgs.discourse";
38         description = ''
39           The discourse package to use.
40         '';
41       };
43       hostname = lib.mkOption {
44         type = lib.types.str;
45         default = config.networking.fqdnOrHostName;
46         defaultText = lib.literalExpression "config.networking.fqdnOrHostName";
47         example = "discourse.example.com";
48         description = ''
49           The hostname to serve Discourse on.
50         '';
51       };
53       secretKeyBaseFile = lib.mkOption {
54         type = with lib.types; nullOr path;
55         default = null;
56         example = "/run/keys/secret_key_base";
57         description = ''
58           The path to a file containing the
59           `secret_key_base` secret.
61           Discourse uses `secret_key_base` to encrypt
62           the cookie store, which contains session data, and to digest
63           user auth tokens.
65           Needs to be a 64 byte long string of hexadecimal
66           characters. You can generate one by running
68           ```
69           openssl rand -hex 64 >/path/to/secret_key_base_file
70           ```
72           This should be a string, not a nix path, since nix paths are
73           copied into the world-readable nix store.
74         '';
75       };
77       sslCertificate = lib.mkOption {
78         type = with lib.types; nullOr path;
79         default = null;
80         example = "/run/keys/ssl.cert";
81         description = ''
82           The path to the server SSL certificate. Set this to enable
83           SSL.
84         '';
85       };
87       sslCertificateKey = lib.mkOption {
88         type = with lib.types; nullOr path;
89         default = null;
90         example = "/run/keys/ssl.key";
91         description = ''
92           The path to the server SSL certificate key. Set this to
93           enable SSL.
94         '';
95       };
97       enableACME = lib.mkOption {
98         type = lib.types.bool;
99         default = cfg.sslCertificate == null && cfg.sslCertificateKey == null;
100         defaultText = lib.literalMD ''
101           `true`, unless {option}`services.discourse.sslCertificate`
102           and {option}`services.discourse.sslCertificateKey` are set.
103         '';
104         description = ''
105           Whether an ACME certificate should be used to secure
106           connections to the server.
107         '';
108       };
110       backendSettings = lib.mkOption {
111         type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ]));
112         default = {};
113         example = lib.literalExpression ''
114           {
115             max_reqs_per_ip_per_minute = 300;
116             max_reqs_per_ip_per_10_seconds = 60;
117             max_asset_reqs_per_ip_per_10_seconds = 250;
118             max_reqs_per_ip_mode = "warn+block";
119           };
120         '';
121         description = ''
122           Additional settings to put in the
123           {file}`discourse.conf` file.
125           Look in the
126           [discourse_defaults.conf](https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf)
127           file in the upstream distribution to find available options.
129           Setting an option to `null` means
130           “define variable, but leave right-hand side empty”.
131         '';
132       };
134       siteSettings = lib.mkOption {
135         type = json.type;
136         default = {};
137         example = lib.literalExpression ''
138           {
139             required = {
140               title = "My Cats";
141               site_description = "Discuss My Cats (and be nice plz)";
142             };
143             login = {
144               enable_github_logins = true;
145               github_client_id = "a2f6dfe838cb3206ce20";
146               github_client_secret._secret = /run/keys/discourse_github_client_secret;
147             };
148           };
149         '';
150         description = ''
151           Discourse site settings. These are the settings that can be
152           changed from the UI. This only defines their default values:
153           they can still be overridden from the UI.
155           Available settings can be found by looking in the
156           [site_settings.yml](https://github.com/discourse/discourse/blob/master/config/site_settings.yml)
157           file of the upstream distribution. To find a setting's path,
158           you only need to care about the first two levels; i.e. its
159           category and name. See the example.
161           Settings containing secret data should be set to an
162           attribute set containing the attribute
163           `_secret` - a string pointing to a file
164           containing the value the option should be set to. See the
165           example to get a better picture of this: in the resulting
166           {file}`config/nixos_site_settings.json` file,
167           the `login.github_client_secret` key will
168           be set to the contents of the
169           {file}`/run/keys/discourse_github_client_secret`
170           file.
171         '';
172       };
174       admin = {
175         skipCreate = lib.mkOption {
176           type = lib.types.bool;
177           default = false;
178           description = ''
179             Do not create the admin account, instead rely on other
180             existing admin accounts.
181           '';
182         };
184         email = lib.mkOption {
185           type = lib.types.str;
186           example = "admin@example.com";
187           description = ''
188             The admin user email address.
189           '';
190         };
192         username = lib.mkOption {
193           type = lib.types.str;
194           example = "admin";
195           description = ''
196             The admin user username.
197           '';
198         };
200         fullName = lib.mkOption {
201           type = lib.types.str;
202           description = ''
203             The admin user's full name.
204           '';
205         };
207         passwordFile = lib.mkOption {
208           type = lib.types.path;
209           description = ''
210             A path to a file containing the admin user's password.
212             This should be a string, not a nix path, since nix paths are
213             copied into the world-readable nix store.
214           '';
215         };
216       };
218       nginx.enable = lib.mkOption {
219         type = lib.types.bool;
220         default = true;
221         description = ''
222           Whether an `nginx` virtual host should be
223           set up to serve Discourse. Only disable if you're planning
224           to use a different web server, which is not recommended.
225         '';
226       };
228       database = {
229         pool = lib.mkOption {
230           type = lib.types.int;
231           default = 8;
232           description = ''
233             Database connection pool size.
234           '';
235         };
237         host = lib.mkOption {
238           type = with lib.types; nullOr str;
239           default = null;
240           description = ''
241             Discourse database hostname. `null` means
242             “prefer local unix socket connection”.
243           '';
244         };
246         passwordFile = lib.mkOption {
247           type = with lib.types; nullOr path;
248           default = null;
249           description = ''
250             File containing the Discourse database user password.
252             This should be a string, not a nix path, since nix paths are
253             copied into the world-readable nix store.
254           '';
255         };
257         createLocally = lib.mkOption {
258           type = lib.types.bool;
259           default = true;
260           description = ''
261             Whether a database should be automatically created on the
262             local host. Set this to `false` if you plan
263             on provisioning a local database yourself. This has no effect
264             if {option}`services.discourse.database.host` is customized.
265           '';
266         };
268         name = lib.mkOption {
269           type = lib.types.str;
270           default = "discourse";
271           description = ''
272             Discourse database name.
273           '';
274         };
276         username = lib.mkOption {
277           type = lib.types.str;
278           default = "discourse";
279           description = ''
280             Discourse database user.
281           '';
282         };
284         ignorePostgresqlVersion = lib.mkOption {
285           type = lib.types.bool;
286           default = false;
287           description = ''
288             Whether to allow other versions of PostgreSQL than the
289             recommended one. Only effective when
290             {option}`services.discourse.database.createLocally`
291             is enabled.
292           '';
293         };
294       };
296       redis = {
297         host = lib.mkOption {
298           type = lib.types.str;
299           default = "localhost";
300           description = ''
301             Redis server hostname.
302           '';
303         };
305         passwordFile = lib.mkOption {
306           type = with lib.types; nullOr path;
307           default = null;
308           description = ''
309             File containing the Redis password.
311             This should be a string, not a nix path, since nix paths are
312             copied into the world-readable nix store.
313           '';
314         };
316         dbNumber = lib.mkOption {
317           type = lib.types.int;
318           default = 0;
319           description = ''
320             Redis database number.
321           '';
322         };
324         useSSL = lib.mkOption {
325           type = lib.types.bool;
326           default = cfg.redis.host != "localhost";
327           defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"'';
328           description = ''
329             Connect to Redis with SSL.
330           '';
331         };
332       };
334       mail = {
335         notificationEmailAddress = lib.mkOption {
336           type = lib.types.str;
337           default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}";
338           defaultText = lib.literalExpression ''
339             "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}"
340           '';
341           description = ''
342             The `from:` email address used when
343             sending all essential system emails. The domain specified
344             here must have SPF, DKIM and reverse PTR records set
345             correctly for email to arrive.
346           '';
347         };
349         contactEmailAddress = lib.mkOption {
350           type = lib.types.str;
351           default = "";
352           description = ''
353             Email address of key contact responsible for this
354             site. Used for critical notifications, as well as on the
355             `/about` contact form for urgent matters.
356           '';
357         };
359         outgoing = {
360           serverAddress = lib.mkOption {
361             type = lib.types.str;
362             default = "localhost";
363             description = ''
364               The address of the SMTP server Discourse should use to
365               send email.
366             '';
367           };
369           port = lib.mkOption {
370             type = lib.types.port;
371             default = 25;
372             description = ''
373               The port of the SMTP server Discourse should use to
374               send email.
375             '';
376           };
378           username = lib.mkOption {
379             type = with lib.types; nullOr str;
380             default = null;
381             description = ''
382               The username of the SMTP server.
383             '';
384           };
386           passwordFile = lib.mkOption {
387             type = lib.types.nullOr lib.types.path;
388             default = null;
389             description = ''
390               A file containing the password of the SMTP server account.
392               This should be a string, not a nix path, since nix paths
393               are copied into the world-readable nix store.
394             '';
395           };
397           domain = lib.mkOption {
398             type = lib.types.str;
399             default = cfg.hostname;
400             defaultText = lib.literalExpression "config.${opt.hostname}";
401             description = ''
402               HELO domain to use for outgoing mail.
403             '';
404           };
406           authentication = lib.mkOption {
407             type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]);
408             default = null;
409             description = ''
410               Authentication type to use, see https://api.rubyonrails.org/classes/ActionMailer/Base.html
411             '';
412           };
414           enableStartTLSAuto = lib.mkOption {
415             type = lib.types.bool;
416             default = true;
417             description = ''
418               Whether to try to use StartTLS.
419             '';
420           };
422           opensslVerifyMode = lib.mkOption {
423             type = lib.types.str;
424             default = "peer";
425             description = ''
426               How OpenSSL checks the certificate, see https://api.rubyonrails.org/classes/ActionMailer/Base.html
427             '';
428           };
430           forceTLS = lib.mkOption {
431             type = lib.types.bool;
432             default = false;
433             description = ''
434               Force implicit TLS as per RFC 8314 3.3.
435             '';
436           };
437         };
439         incoming = {
440           enable = lib.mkOption {
441             type = lib.types.bool;
442             default = false;
443             description = ''
444               Whether to set up Postfix to receive incoming mail.
445             '';
446           };
448           replyEmailAddress = lib.mkOption {
449             type = lib.types.str;
450             default = "%{reply_key}@${cfg.hostname}";
451             defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"'';
452             description = ''
453               Template for reply by email incoming email address, for
454               example: %{reply_key}@reply.example.com or
455               replies+%{reply_key}@example.com
456             '';
457           };
459           mailReceiverPackage = lib.mkOption {
460             type = lib.types.package;
461             default = pkgs.discourse-mail-receiver;
462             defaultText = lib.literalExpression "pkgs.discourse-mail-receiver";
463             description = ''
464               The discourse-mail-receiver package to use.
465             '';
466           };
468           apiKeyFile = lib.mkOption {
469             type = lib.types.nullOr lib.types.path;
470             default = null;
471             description = ''
472               A file containing the Discourse API key used to add
473               posts and messages from mail. If left at its default
474               value `null`, one will be automatically
475               generated.
477               This should be a string, not a nix path, since nix paths
478               are copied into the world-readable nix store.
479             '';
480           };
481         };
482       };
484       plugins = lib.mkOption {
485         type = lib.types.listOf lib.types.package;
486         default = [];
487         example = lib.literalExpression ''
488           with config.services.discourse.package.plugins; [
489             discourse-canned-replies
490             discourse-github
491           ];
492         '';
493         description = ''
494           Plugins to install as part of Discourse, expressed as a list of derivations.
495         '';
496       };
498       sidekiqProcesses = lib.mkOption {
499         type = lib.types.int;
500         default = 1;
501         description = ''
502           How many Sidekiq processes should be spawned.
503         '';
504       };
506       unicornTimeout = lib.mkOption {
507         type = lib.types.int;
508         default = 30;
509         description = ''
510           Time in seconds before a request to Unicorn times out.
512           This can be raised if the system Discourse is running on is
513           too slow to handle many requests within 30 seconds.
514         '';
515       };
516     };
517   };
519   config = lib.mkIf cfg.enable {
520     assertions = [
521       {
522         assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null);
523         message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!";
524       }
525       {
526         assertion = cfg.hostname != "";
527         message = "Could not automatically determine hostname, set service.discourse.hostname manually.";
528       }
529       {
530         assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion);
531         message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. "
532                   + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. "
533                   + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL.";
534       }
535     ];
538     # Default config values are from `config/discourse_defaults.conf`
539     # upstream.
540     services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) {
541       db_pool = cfg.database.pool;
542       db_timeout = 5000;
543       db_connect_timeout = 5;
544       db_socket = null;
545       db_host = cfg.database.host;
546       db_backup_host = null;
547       db_port = null;
548       db_backup_port = 5432;
549       db_name = cfg.database.name;
550       db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username;
551       db_password = cfg.database.passwordFile;
552       db_prepared_statements = false;
553       db_replica_host = null;
554       db_replica_port = null;
555       db_advisory_locks = true;
557       inherit (cfg) hostname;
558       backup_hostname = null;
560       smtp_address = cfg.mail.outgoing.serverAddress;
561       smtp_port = cfg.mail.outgoing.port;
562       smtp_domain = cfg.mail.outgoing.domain;
563       smtp_user_name = cfg.mail.outgoing.username;
564       smtp_password = cfg.mail.outgoing.passwordFile;
565       smtp_authentication = cfg.mail.outgoing.authentication;
566       smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto;
567       smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode;
568       smtp_force_tls = cfg.mail.outgoing.forceTLS;
570       load_mini_profiler = true;
571       mini_profiler_snapshots_period = 0;
572       mini_profiler_snapshots_transport_url = null;
573       mini_profiler_snapshots_transport_auth_key = null;
575       cdn_url = null;
576       cdn_origin_hostname = null;
577       developer_emails = null;
579       redis_host = cfg.redis.host;
580       redis_port = 6379;
581       redis_replica_host = null;
582       redis_replica_port = 6379;
583       redis_db = cfg.redis.dbNumber;
584       redis_password = cfg.redis.passwordFile;
585       redis_skip_client_commands = false;
586       redis_use_ssl = cfg.redis.useSSL;
588       message_bus_redis_enabled = false;
589       message_bus_redis_host = "localhost";
590       message_bus_redis_port = 6379;
591       message_bus_redis_replica_host = null;
592       message_bus_redis_replica_port = 6379;
593       message_bus_redis_db = 0;
594       message_bus_redis_password = null;
595       message_bus_redis_skip_client_commands = false;
597       enable_cors = false;
598       cors_origin = "";
599       serve_static_assets = false;
600       sidekiq_workers = 5;
601       connection_reaper_age = 30;
602       connection_reaper_interval = 30;
603       relative_url_root = null;
604       message_bus_max_backlog_size = 100;
605       message_bus_clear_every = 50;
606       secret_key_base = cfg.secretKeyBaseFile;
607       fallback_assets_path = null;
609       s3_bucket = null;
610       s3_region = null;
611       s3_access_key_id = null;
612       s3_secret_access_key = null;
613       s3_use_iam_profile = null;
614       s3_cdn_url = null;
615       s3_endpoint = null;
616       s3_http_continue_timeout = null;
617       s3_install_cors_rule = null;
618       s3_asset_cdn_url = null;
620       max_user_api_reqs_per_minute = 20;
621       max_user_api_reqs_per_day = 2880;
622       max_admin_api_reqs_per_minute = 60;
623       max_reqs_per_ip_per_minute = 200;
624       max_reqs_per_ip_per_10_seconds = 50;
625       max_asset_reqs_per_ip_per_10_seconds = 200;
626       max_reqs_per_ip_mode = "block";
627       max_reqs_rate_limit_on_private = false;
628       skip_per_ip_rate_limit_trust_level = 1;
629       force_anonymous_min_queue_seconds = 1;
630       force_anonymous_min_per_10_seconds = 3;
631       background_requests_max_queue_length = 0.5;
632       reject_message_bus_queue_seconds = 0.1;
633       disable_search_queue_threshold = 1;
634       max_old_rebakes_per_15_minutes = 300;
635       max_logster_logs = 1000;
636       refresh_maxmind_db_during_precompile_days = 2;
637       maxmind_backup_path = null;
638       maxmind_license_key = null;
639       enable_performance_http_headers = false;
640       enable_js_error_reporting = true;
641       mini_scheduler_workers = 5;
642       compress_anon_cache = false;
643       anon_cache_store_threshold = 2;
644       allowed_theme_repos = null;
645       enable_email_sync_demon = false;
646       max_digests_enqueued_per_30_mins_per_site = 10000;
647       cluster_name = null;
648       multisite_config_path = "config/multisite.yml";
649       enable_long_polling = null;
650       long_polling_interval = null;
651       preload_link_header = false;
652       redirect_avatar_requests = false;
653       pg_force_readonly_mode = false;
654       dns_query_timeout_secs = null;
655       regex_timeout_seconds = 2;
656       allow_impersonation = true;
657     };
659     services.redis.servers.discourse =
660       lib.mkIf (lib.elem cfg.redis.host [ "localhost" "127.0.0.1" ]) {
661         enable = true;
662         bind = cfg.redis.host;
663         port = cfg.backendSettings.redis_port;
664       };
666     services.postgresql = lib.mkIf databaseActuallyCreateLocally {
667       enable = true;
668       ensureUsers = [{ name = "discourse"; }];
669     };
671     # The postgresql module doesn't currently support concepts like
672     # objects owners and extensions; for now we tack on what's needed
673     # here.
674     systemd.services.discourse-postgresql =
675       let
676         pgsql = config.services.postgresql;
677       in
678         lib.mkIf databaseActuallyCreateLocally {
679           after = [ "postgresql.service" ];
680           bindsTo = [ "postgresql.service" ];
681           wantedBy = [ "discourse.service" ];
682           partOf = [ "discourse.service" ];
683           path = [
684             pgsql.package
685           ];
686           script = ''
687             set -o errexit -o pipefail -o nounset -o errtrace
688             shopt -s inherit_errexit
690             psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"'
691             psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm"
692             psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore"
693           '';
695           serviceConfig = {
696             User = pgsql.superUser;
697             Type = "oneshot";
698             RemainAfterExit = true;
699           };
700         };
702     systemd.services.discourse = {
703       wantedBy = [ "multi-user.target" ];
704       after = [
705         "redis-discourse.service"
706         "postgresql.service"
707         "discourse-postgresql.service"
708       ];
709       bindsTo = [
710         "redis-discourse.service"
711       ] ++ lib.optionals (cfg.database.host == null) [
712         "postgresql.service"
713         "discourse-postgresql.service"
714       ];
715       path = cfg.package.runtimeDeps ++ [
716         postgresqlPackage
717         pkgs.replace-secret
718         cfg.package.rake
719       ];
720       environment = cfg.package.runtimeEnv // {
721         UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout;
722         UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses;
723         MALLOC_ARENA_MAX = "2";
724       };
726       preStart =
727         let
728           discourseKeyValue = lib.generators.toKeyValue {
729             mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " {
730               mkValueString = v: with builtins;
731                 if isInt           v then toString v
732                 else if isString   v then ''"${v}"''
733                 else if true  ==   v then "true"
734                 else if false ==   v then "false"
735                 else if null  ==   v then ""
736                 else if isFloat    v then lib.strings.floatToString v
737                 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
738             };
739           };
741           discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings);
743           mkSecretReplacement = file:
744             lib.optionalString (file != null) ''
745               replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf
746             '';
748           mkAdmin = ''
749             export ADMIN_EMAIL="${cfg.admin.email}"
750             export ADMIN_NAME="${cfg.admin.fullName}"
751             export ADMIN_USERNAME="${cfg.admin.username}"
752             ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})"
753             export ADMIN_PASSWORD
754             discourse-rake admin:create_noninteractively
755           '';
757         in ''
758           set -o errexit -o pipefail -o nounset -o errtrace
759           shopt -s inherit_errexit
761           umask u=rwx,g=rx,o=
763           rm -rf /var/lib/discourse/tmp/*
765           cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/
766           cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/
767           ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads
768           ln -sf /var/lib/discourse/backups /run/discourse/public/backups
770           (
771               umask u=rwx,g=,o=
773               ${utils.genJqSecretsReplacementSnippet
774                   cfg.siteSettings
775                   "/run/discourse/config/nixos_site_settings.json"
776               }
777               install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf
778               ${mkSecretReplacement cfg.database.passwordFile}
779               ${mkSecretReplacement cfg.mail.outgoing.passwordFile}
780               ${mkSecretReplacement cfg.redis.passwordFile}
781               ${mkSecretReplacement cfg.secretKeyBaseFile}
782               chmod 0400 /run/discourse/config/discourse.conf
783           )
785           discourse-rake db:migrate >>/var/log/discourse/db_migration.log
786           chmod -R u+w /var/lib/discourse/tmp/
788           ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin}
790           discourse-rake themes:update
791           discourse-rake uploads:regenerate_missing_optimized
792         '';
794       serviceConfig = {
795         Type = "simple";
796         User = "discourse";
797         Group = "discourse";
798         RuntimeDirectory = map (p: "discourse/" + p) [
799           "config"
800           "home"
801           "assets/javascripts/plugins"
802           "public"
803           "sockets"
804         ];
805         RuntimeDirectoryMode = "0750";
806         StateDirectory = map (p: "discourse/" + p) [
807           "uploads"
808           "backups"
809           "tmp"
810         ];
811         StateDirectoryMode = "0750";
812         LogsDirectory = "discourse";
813         TimeoutSec = "infinity";
814         Restart = "on-failure";
815         WorkingDirectory = "${cfg.package}/share/discourse";
817         RemoveIPC = true;
818         PrivateTmp = true;
819         NoNewPrivileges = true;
820         RestrictSUIDSGID = true;
821         ProtectSystem = "strict";
822         ProtectHome = "read-only";
824         ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb";
825       };
826     };
828     services.nginx = lib.mkIf cfg.nginx.enable {
829       enable = true;
831       recommendedTlsSettings = true;
832       recommendedOptimisation = true;
833       recommendedBrotliSettings = true;
834       recommendedGzipSettings = true;
835       recommendedProxySettings = true;
837       upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {};
839       appendHttpConfig = ''
840         # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week)
841         # levels means it is a 2 deep hierarchy cause we can have lots of files
842         # max_size limits the size of the cache
843         proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m;
845         # see: https://meta.discourse.org/t/x/74060
846         proxy_buffer_size 8k;
847       '';
849       virtualHosts.${cfg.hostname} = {
850         inherit (cfg) sslCertificate sslCertificateKey enableACME;
851         forceSSL = lib.mkDefault tlsEnabled;
853         root = "${cfg.package}/share/discourse/public";
855         locations =
856           let
857             proxy = { extraConfig ? "" }: {
858               proxyPass = "http://discourse";
859               extraConfig = extraConfig + ''
860                 proxy_set_header X-Request-Start "t=''${msec}";
861               '';
862             };
863             cache = time: ''
864               expires ${time};
865               add_header Cache-Control public,immutable;
866             '';
867             cache_1y = cache "1y";
868             cache_1d = cache "1d";
869           in
870             {
871               "/".tryFiles = "$uri @discourse";
872               "@discourse" = proxy {};
873               "^~ /backups/".extraConfig = ''
874                 internal;
875               '';
876               "/favicon.ico" = {
877                 return = "204";
878                 extraConfig = ''
879                   access_log off;
880                   log_not_found off;
881                 '';
882               };
883               "~ ^/uploads/short-url/" = proxy {};
884               "~ ^/secure-media-uploads/" = proxy {};
885               "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + ''
886                 add_header Access-Control-Allow-Origin *;
887               '';
888               "/srv/status" = proxy {
889                 extraConfig = ''
890                   access_log off;
891                   log_not_found off;
892                 '';
893               };
894               "~ ^/javascripts/".extraConfig = cache_1d;
895               "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + ''
896                 # asset pipeline enables this
897                 brotli_static on;
898                 gzip_static on;
899               '';
900               "~ ^/plugins/".extraConfig = cache_1y;
901               "~ /images/emoji/".extraConfig = cache_1y;
902               "~ ^/uploads/" = proxy {
903                 extraConfig = cache_1y + ''
904                   proxy_set_header X-Sendfile-Type X-Accel-Redirect;
905                   proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
907                   # custom CSS
908                   location ~ /stylesheet-cache/ {
909                       try_files $uri =404;
910                   }
911                   # this allows us to bypass rails
912                   location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ {
913                       try_files $uri =404;
914                   }
915                   # SVG needs an extra header attached
916                   location ~* \.(svg)$ {
917                   }
918                   # thumbnails & optimized images
919                   location ~ /_?optimized/ {
920                       try_files $uri =404;
921                   }
922                 '';
923               };
924               "~ ^/admin/backups/" = proxy {
925                 extraConfig = ''
926                   proxy_set_header X-Sendfile-Type X-Accel-Redirect;
927                   proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
928                 '';
929               };
930               "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy {
931                 extraConfig = ''
932                   # if Set-Cookie is in the response nothing gets cached
933                   # this is double bad cause we are not passing last modified in
934                   proxy_ignore_headers "Set-Cookie";
935                   proxy_hide_header "Set-Cookie";
936                   proxy_hide_header "X-Discourse-Username";
937                   proxy_hide_header "X-Runtime";
939                   # note x-accel-redirect can not be used with proxy_cache
940                   proxy_cache discourse;
941                   proxy_cache_key "$scheme,$host,$request_uri";
942                   proxy_cache_valid 200 301 302 7d;
943                 '';
944               };
945               "/message-bus/" = proxy {
946                 extraConfig = ''
947                   proxy_http_version 1.1;
948                   proxy_buffering off;
949                 '';
950               };
951               "/downloads/".extraConfig = ''
952                 internal;
953                 alias ${cfg.package}/share/discourse/public/;
954               '';
955             };
956       };
957     };
959     systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable (
960       let
961         mail-receiver-environment = {
962           MAIL_DOMAIN = cfg.hostname;
963           DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
964           DISCOURSE_API_KEY = "@api-key@";
965           DISCOURSE_API_USERNAME = "system";
966         };
967         mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment;
968       in
969         {
970           before = [ "postfix.service" ];
971           after = [ "discourse.service" ];
972           wantedBy = [ "discourse.service" ];
973           partOf = [ "discourse.service" ];
974           path = [
975             cfg.package.rake
976             pkgs.jq
977           ];
978           preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) ''
979             set -o errexit -o pipefail -o nounset -o errtrace
980             shopt -s inherit_errexit
982             if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then
983                 discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key
984             fi
985           '';
986           script =
987             let
988               apiKeyPath =
989                 if cfg.mail.incoming.apiKeyFile == null then
990                   "/var/lib/discourse-mail-receiver/api_key"
991                 else
992                   cfg.mail.incoming.apiKeyFile;
993             in ''
994               set -o errexit -o pipefail -o nounset -o errtrace
995               shopt -s inherit_errexit
997               api_key=$(<'${apiKeyPath}')
998               export api_key
1000               jq <${mail-receiver-json} \
1001                  '.DISCOURSE_API_KEY = $ENV.api_key' \
1002                  >'/run/discourse-mail-receiver/mail-receiver-environment.json'
1003             '';
1005           serviceConfig = {
1006             Type = "oneshot";
1007             RemainAfterExit = true;
1008             RuntimeDirectory = "discourse-mail-receiver";
1009             RuntimeDirectoryMode = "0700";
1010             StateDirectory = "discourse-mail-receiver";
1011             User = "discourse";
1012             Group = "discourse";
1013           };
1014         });
1016     services.discourse.siteSettings = {
1017       required = {
1018         notification_email = cfg.mail.notificationEmailAddress;
1019         contact_email = cfg.mail.contactEmailAddress;
1020       };
1021       security.force_https = tlsEnabled;
1022       email = {
1023         manual_polling_enabled = cfg.mail.incoming.enable;
1024         reply_by_email_enabled = cfg.mail.incoming.enable;
1025         reply_by_email_address = cfg.mail.incoming.replyEmailAddress;
1026       };
1027     };
1029     services.postfix = lib.mkIf cfg.mail.incoming.enable {
1030       enable = true;
1031       sslCert = lib.optionalString (cfg.sslCertificate != null) cfg.sslCertificate;
1032       sslKey = lib.optionalString (cfg.sslCertificateKey != null) cfg.sslCertificateKey;
1034       origin = cfg.hostname;
1035       relayDomains = [ cfg.hostname ];
1036       config = {
1037         smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy";
1038         append_dot_mydomain = lib.mkDefault false;
1039         compatibility_level = "2";
1040         smtputf8_enable = false;
1041         smtpd_banner = lib.mkDefault "ESMTP server";
1042         myhostname = lib.mkDefault cfg.hostname;
1043         mydestination = lib.mkDefault "localhost";
1044       };
1045       transport = ''
1046         ${cfg.hostname} discourse-mail-receiver:
1047       '';
1048       masterConfig = {
1049         "discourse-mail-receiver" = {
1050           type = "unix";
1051           privileged = true;
1052           chroot = false;
1053           command = "pipe";
1054           args = [
1055             "user=discourse"
1056             "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail"
1057             "\${recipient}"
1058           ];
1059         };
1060         "discourse-policy" = {
1061           type = "unix";
1062           privileged = true;
1063           chroot = false;
1064           command = "spawn";
1065           args = [
1066             "user=discourse"
1067             "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection"
1068           ];
1069         };
1070       };
1071     };
1073     users.users = {
1074       discourse = {
1075         group = "discourse";
1076         isSystemUser = true;
1077       };
1078     } // (lib.optionalAttrs cfg.nginx.enable {
1079       ${config.services.nginx.user}.extraGroups = [ "discourse" ];
1080     });
1082     users.groups = {
1083       discourse = {};
1084     };
1086     environment.systemPackages = [
1087       cfg.package.rake
1088     ];
1089   };
1091   meta.doc = ./discourse.md;
1092   meta.maintainers = [ lib.maintainers.talyz ];