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