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