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