nixpkgs/nixos/modules/services/web-apps/discourse.nix
talyz 125bb7dac1
discourse: Don't patch the public path
Instead of patching the path to /public in Discourse's sources, make
the nginx configuration refer to the symlink in the discourse
package which points to the real path.

When there is a mismatch between the path nginx serves and the path
Discourse thinks it serves, we can run into issues like files not
being served - at least when sendfile requests from the ruby app are
processed by nginx. The issue I ran into most recently is that backup
downloads don't work.

Since Discourse refers to the public directory relative to the Rails
root in many places, it's much easier to just sync this path to the
nginx configuration than trying to patch all occurrences in the
sources. This should hopefully mean less potential for breakage in
future Discourse releases, too.
2021-12-06 14:21:39 +01:00

1085 lines
36 KiB
Nix

{ config, options, lib, pkgs, utils, ... }:
let
json = pkgs.formats.json {};
cfg = config.services.discourse;
# Keep in sync with https://github.com/discourse/discourse_docker/blob/master/image/base/Dockerfile#L5
upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13;
postgresqlPackage = if config.services.postgresql.enable then
config.services.postgresql.package
else
pkgs.postgresql;
postgresqlVersion = lib.getVersion postgresqlPackage;
# We only want to create a database if we're actually going to connect to it.
databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null;
tlsEnabled = (cfg.enableACME
|| cfg.sslCertificate != null
|| cfg.sslCertificateKey != null);
in
{
options = {
services.discourse = {
enable = lib.mkEnableOption "Discourse, an open source discussion platform";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.discourse;
apply = p: p.override {
plugins = lib.unique (p.enabledPlugins ++ cfg.plugins);
};
defaultText = lib.literalExpression "pkgs.discourse";
description = ''
The discourse package to use.
'';
};
hostname = lib.mkOption {
type = lib.types.str;
default = if config.networking.domain != null then
config.networking.fqdn
else
config.networking.hostName;
defaultText = lib.literalExpression "config.networking.fqdn";
example = "discourse.example.com";
description = ''
The hostname to serve Discourse on.
'';
};
secretKeyBaseFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
example = "/run/keys/secret_key_base";
description = ''
The path to a file containing the
<literal>secret_key_base</literal> secret.
Discourse uses <literal>secret_key_base</literal> to encrypt
the cookie store, which contains session data, and to digest
user auth tokens.
Needs to be a 64 byte long string of hexadecimal
characters. You can generate one by running
<screen>
<prompt>$ </prompt>openssl rand -hex 64 >/path/to/secret_key_base_file
</screen>
This should be a string, not a nix path, since nix paths are
copied into the world-readable nix store.
'';
};
sslCertificate = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
example = "/run/keys/ssl.cert";
description = ''
The path to the server SSL certificate. Set this to enable
SSL.
'';
};
sslCertificateKey = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
example = "/run/keys/ssl.key";
description = ''
The path to the server SSL certificate key. Set this to
enable SSL.
'';
};
enableACME = lib.mkOption {
type = lib.types.bool;
default = cfg.sslCertificate == null && cfg.sslCertificateKey == null;
defaultText = lib.literalDocBook ''
<literal>true</literal>, unless <option>services.discourse.sslCertificate</option>
and <option>services.discourse.sslCertificateKey</option> are set.
'';
description = ''
Whether an ACME certificate should be used to secure
connections to the server.
'';
};
backendSettings = lib.mkOption {
type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ]));
default = {};
example = lib.literalExpression ''
{
max_reqs_per_ip_per_minute = 300;
max_reqs_per_ip_per_10_seconds = 60;
max_asset_reqs_per_ip_per_10_seconds = 250;
max_reqs_per_ip_mode = "warn+block";
};
'';
description = ''
Additional settings to put in the
<filename>discourse.conf</filename> file.
Look in the
<link xlink:href="https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf">discourse_defaults.conf</link>
file in the upstream distribution to find available options.
Setting an option to <literal>null</literal> means
<quote>define variable, but leave right-hand side
empty</quote>.
'';
};
siteSettings = lib.mkOption {
type = json.type;
default = {};
example = lib.literalExpression ''
{
required = {
title = "My Cats";
site_description = "Discuss My Cats (and be nice plz)";
};
login = {
enable_github_logins = true;
github_client_id = "a2f6dfe838cb3206ce20";
github_client_secret._secret = /run/keys/discourse_github_client_secret;
};
};
'';
description = ''
Discourse site settings. These are the settings that can be
changed from the UI. This only defines their default values:
they can still be overridden from the UI.
Available settings can be found by looking in the
<link xlink:href="https://github.com/discourse/discourse/blob/master/config/site_settings.yml">site_settings.yml</link>
file of the upstream distribution. To find a setting's path,
you only need to care about the first two levels; i.e. its
category and name. See the example.
Settings containing secret data should be set to an
attribute set containing the attribute
<literal>_secret</literal> - a string pointing to a file
containing the value the option should be set to. See the
example to get a better picture of this: in the resulting
<filename>config/nixos_site_settings.json</filename> file,
the <literal>login.github_client_secret</literal> key will
be set to the contents of the
<filename>/run/keys/discourse_github_client_secret</filename>
file.
'';
};
admin = {
skipCreate = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Do not create the admin account, instead rely on other
existing admin accounts.
'';
};
email = lib.mkOption {
type = lib.types.str;
example = "admin@example.com";
description = ''
The admin user email address.
'';
};
username = lib.mkOption {
type = lib.types.str;
example = "admin";
description = ''
The admin user username.
'';
};
fullName = lib.mkOption {
type = lib.types.str;
description = ''
The admin user's full name.
'';
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = ''
A path to a file containing the admin user's password.
This should be a string, not a nix path, since nix paths are
copied into the world-readable nix store.
'';
};
};
nginx.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether an <literal>nginx</literal> virtual host should be
set up to serve Discourse. Only disable if you're planning
to use a different web server, which is not recommended.
'';
};
database = {
pool = lib.mkOption {
type = lib.types.int;
default = 8;
description = ''
Database connection pool size.
'';
};
host = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
Discourse database hostname. <literal>null</literal> means <quote>prefer
local unix socket connection</quote>.
'';
};
passwordFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
File containing the Discourse database user password.
This should be a string, not a nix path, since nix paths are
copied into the world-readable nix store.
'';
};
createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether a database should be automatically created on the
local host. Set this to <literal>false</literal> if you plan
on provisioning a local database yourself. This has no effect
if <option>services.discourse.database.host</option> is customized.
'';
};
name = lib.mkOption {
type = lib.types.str;
default = "discourse";
description = ''
Discourse database name.
'';
};
username = lib.mkOption {
type = lib.types.str;
default = "discourse";
description = ''
Discourse database user.
'';
};
ignorePostgresqlVersion = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to allow other versions of PostgreSQL than the
recommended one. Only effective when
<option>services.discourse.database.createLocally</option>
is enabled.
'';
};
};
redis = {
host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
Redis server hostname.
'';
};
passwordFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
File containing the Redis password.
This should be a string, not a nix path, since nix paths are
copied into the world-readable nix store.
'';
};
dbNumber = lib.mkOption {
type = lib.types.int;
default = 0;
description = ''
Redis database number.
'';
};
useSSL = lib.mkOption {
type = lib.types.bool;
default = cfg.redis.host != "localhost";
description = ''
Connect to Redis with SSL.
'';
};
};
mail = {
notificationEmailAddress = lib.mkOption {
type = lib.types.str;
default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}";
defaultText = lib.literalExpression ''
"''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}"
'';
description = ''
The <literal>from:</literal> email address used when
sending all essential system emails. The domain specified
here must have SPF, DKIM and reverse PTR records set
correctly for email to arrive.
'';
};
contactEmailAddress = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
Email address of key contact responsible for this
site. Used for critical notifications, as well as on the
<literal>/about</literal> contact form for urgent matters.
'';
};
outgoing = {
serverAddress = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
The address of the SMTP server Discourse should use to
send email.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 25;
description = ''
The port of the SMTP server Discourse should use to
send email.
'';
};
username = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
The username of the SMTP server.
'';
};
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
A file containing the password of the SMTP server account.
This should be a string, not a nix path, since nix paths
are copied into the world-readable nix store.
'';
};
domain = lib.mkOption {
type = lib.types.str;
default = cfg.hostname;
description = ''
HELO domain to use for outgoing mail.
'';
};
authentication = lib.mkOption {
type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]);
default = null;
description = ''
Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html
'';
};
enableStartTLSAuto = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to try to use StartTLS.
'';
};
opensslVerifyMode = lib.mkOption {
type = lib.types.str;
default = "peer";
description = ''
How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html
'';
};
forceTLS = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Force implicit TLS as per RFC 8314 3.3.
'';
};
};
incoming = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to set up Postfix to receive incoming mail.
'';
};
replyEmailAddress = lib.mkOption {
type = lib.types.str;
default = "%{reply_key}@${cfg.hostname}";
defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"'';
description = ''
Template for reply by email incoming email address, for
example: %{reply_key}@reply.example.com or
replies+%{reply_key}@example.com
'';
};
mailReceiverPackage = lib.mkOption {
type = lib.types.package;
default = pkgs.discourse-mail-receiver;
defaultText = lib.literalExpression "pkgs.discourse-mail-receiver";
description = ''
The discourse-mail-receiver package to use.
'';
};
apiKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
A file containing the Discourse API key used to add
posts and messages from mail. If left at its default
value <literal>null</literal>, one will be automatically
generated.
This should be a string, not a nix path, since nix paths
are copied into the world-readable nix store.
'';
};
};
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [];
example = lib.literalExpression ''
with config.services.discourse.package.plugins; [
discourse-canned-replies
discourse-github
];
'';
description = ''
Plugins to install as part of
<productname>Discourse</productname>, expressed as a list of
derivations.
'';
};
sidekiqProcesses = lib.mkOption {
type = lib.types.int;
default = 1;
description = ''
How many Sidekiq processes should be spawned.
'';
};
unicornTimeout = lib.mkOption {
type = lib.types.int;
default = 30;
description = ''
Time in seconds before a request to Unicorn times out.
This can be raised if the system Discourse is running on is
too slow to handle many requests within 30 seconds.
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null);
message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!";
}
{
assertion = cfg.hostname != "";
message = "Could not automatically determine hostname, set service.discourse.hostname manually.";
}
{
assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion);
message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. "
+ "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. "
+ "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL.";
}
];
# Default config values are from `config/discourse_defaults.conf`
# upstream.
services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) {
db_pool = cfg.database.pool;
db_timeout = 5000;
db_connect_timeout = 5;
db_socket = null;
db_host = cfg.database.host;
db_backup_host = null;
db_port = null;
db_backup_port = 5432;
db_name = cfg.database.name;
db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username;
db_password = cfg.database.passwordFile;
db_prepared_statements = false;
db_replica_host = null;
db_replica_port = null;
db_advisory_locks = true;
inherit (cfg) hostname;
backup_hostname = null;
smtp_address = cfg.mail.outgoing.serverAddress;
smtp_port = cfg.mail.outgoing.port;
smtp_domain = cfg.mail.outgoing.domain;
smtp_user_name = cfg.mail.outgoing.username;
smtp_password = cfg.mail.outgoing.passwordFile;
smtp_authentication = cfg.mail.outgoing.authentication;
smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto;
smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode;
smtp_force_tls = cfg.mail.outgoing.forceTLS;
load_mini_profiler = true;
mini_profiler_snapshots_period = 0;
mini_profiler_snapshots_transport_url = null;
mini_profiler_snapshots_transport_auth_key = null;
cdn_url = null;
cdn_origin_hostname = null;
developer_emails = null;
redis_host = cfg.redis.host;
redis_port = 6379;
redis_replica_host = null;
redis_replica_port = 6379;
redis_db = cfg.redis.dbNumber;
redis_password = cfg.redis.passwordFile;
redis_skip_client_commands = false;
redis_use_ssl = cfg.redis.useSSL;
message_bus_redis_enabled = false;
message_bus_redis_host = "localhost";
message_bus_redis_port = 6379;
message_bus_redis_replica_host = null;
message_bus_redis_replica_port = 6379;
message_bus_redis_db = 0;
message_bus_redis_password = null;
message_bus_redis_skip_client_commands = false;
enable_cors = false;
cors_origin = "";
serve_static_assets = false;
sidekiq_workers = 5;
rtl_css = false;
connection_reaper_age = 30;
connection_reaper_interval = 30;
relative_url_root = null;
message_bus_max_backlog_size = 100;
secret_key_base = cfg.secretKeyBaseFile;
fallback_assets_path = null;
s3_bucket = null;
s3_region = null;
s3_access_key_id = null;
s3_secret_access_key = null;
s3_use_iam_profile = null;
s3_cdn_url = null;
s3_endpoint = null;
s3_http_continue_timeout = null;
s3_install_cors_rule = null;
max_user_api_reqs_per_minute = 20;
max_user_api_reqs_per_day = 2880;
max_admin_api_reqs_per_minute = 60;
max_reqs_per_ip_per_minute = 200;
max_reqs_per_ip_per_10_seconds = 50;
max_asset_reqs_per_ip_per_10_seconds = 200;
max_reqs_per_ip_mode = "block";
max_reqs_rate_limit_on_private = false;
skip_per_ip_rate_limit_trust_level = 1;
force_anonymous_min_queue_seconds = 1;
force_anonymous_min_per_10_seconds = 3;
background_requests_max_queue_length = 0.5;
reject_message_bus_queue_seconds = 0.1;
disable_search_queue_threshold = 1;
max_old_rebakes_per_15_minutes = 300;
max_logster_logs = 1000;
refresh_maxmind_db_during_precompile_days = 2;
maxmind_backup_path = null;
maxmind_license_key = null;
enable_performance_http_headers = false;
enable_js_error_reporting = true;
mini_scheduler_workers = 5;
compress_anon_cache = false;
anon_cache_store_threshold = 2;
allowed_theme_repos = null;
enable_email_sync_demon = false;
max_digests_enqueued_per_30_mins_per_site = 10000;
cluster_name = null;
multisite_config_path = "config/multisite.yml";
enable_long_polling = null;
long_polling_interval = null;
};
services.redis.enable = lib.mkDefault (cfg.redis.host == "localhost");
services.postgresql = lib.mkIf databaseActuallyCreateLocally {
enable = true;
ensureUsers = [{ name = "discourse"; }];
};
# The postgresql module doesn't currently support concepts like
# objects owners and extensions; for now we tack on what's needed
# here.
systemd.services.discourse-postgresql =
let
pgsql = config.services.postgresql;
in
lib.mkIf databaseActuallyCreateLocally {
after = [ "postgresql.service" ];
bindsTo = [ "postgresql.service" ];
wantedBy = [ "discourse.service" ];
partOf = [ "discourse.service" ];
path = [
pgsql.package
];
script = ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"'
psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm"
psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore"
'';
serviceConfig = {
User = pgsql.superUser;
Type = "oneshot";
RemainAfterExit = true;
};
};
systemd.services.discourse = {
wantedBy = [ "multi-user.target" ];
after = [
"redis.service"
"postgresql.service"
"discourse-postgresql.service"
];
bindsTo = [
"redis.service"
] ++ lib.optionals (cfg.database.host == null) [
"postgresql.service"
"discourse-postgresql.service"
];
path = cfg.package.runtimeDeps ++ [
postgresqlPackage
pkgs.replace-secret
cfg.package.rake
];
environment = cfg.package.runtimeEnv // {
UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout;
UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses;
MALLOC_ARENA_MAX = "2";
};
preStart =
let
discourseKeyValue = lib.generators.toKeyValue {
mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " {
mkValueString = v: with builtins;
if isInt v then toString v
else if isString v then ''"${v}"''
else if true == v then "true"
else if false == v then "false"
else if null == v then ""
else if isFloat v then lib.strings.floatToString v
else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
};
};
discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings);
mkSecretReplacement = file:
lib.optionalString (file != null) ''
replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf
'';
mkAdmin = ''
export ADMIN_EMAIL="${cfg.admin.email}"
export ADMIN_NAME="${cfg.admin.fullName}"
export ADMIN_USERNAME="${cfg.admin.username}"
ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})"
export ADMIN_PASSWORD
discourse-rake admin:create_noninteractively
'';
in ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
umask u=rwx,g=rx,o=
rm -rf /var/lib/discourse/tmp/*
cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/
cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/
ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads
ln -sf /var/lib/discourse/backups /run/discourse/public/backups
(
umask u=rwx,g=,o=
${utils.genJqSecretsReplacementSnippet
cfg.siteSettings
"/run/discourse/config/nixos_site_settings.json"
}
install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf
${mkSecretReplacement cfg.database.passwordFile}
${mkSecretReplacement cfg.mail.outgoing.passwordFile}
${mkSecretReplacement cfg.redis.passwordFile}
${mkSecretReplacement cfg.secretKeyBaseFile}
chmod 0400 /run/discourse/config/discourse.conf
)
discourse-rake db:migrate >>/var/log/discourse/db_migration.log
chmod -R u+w /var/lib/discourse/tmp/
${lib.optionalString (!cfg.admin.skipCreate) mkAdmin}
discourse-rake themes:update
discourse-rake uploads:regenerate_missing_optimized
'';
serviceConfig = {
Type = "simple";
User = "discourse";
Group = "discourse";
RuntimeDirectory = map (p: "discourse/" + p) [
"config"
"home"
"assets/javascripts/plugins"
"public"
"sockets"
];
RuntimeDirectoryMode = 0750;
StateDirectory = map (p: "discourse/" + p) [
"uploads"
"backups"
"tmp"
];
StateDirectoryMode = 0750;
LogsDirectory = "discourse";
TimeoutSec = "infinity";
Restart = "on-failure";
WorkingDirectory = "${cfg.package}/share/discourse";
RemoveIPC = true;
PrivateTmp = true;
NoNewPrivileges = true;
RestrictSUIDSGID = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb";
};
};
services.nginx = lib.mkIf cfg.nginx.enable {
enable = true;
additionalModules = [ pkgs.nginxModules.brotli ];
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedProxySettings = true;
upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {};
appendHttpConfig = ''
# inactive means we keep stuff around for 1440m minutes regardless of last access (1 week)
# levels means it is a 2 deep hierarchy cause we can have lots of files
# max_size limits the size of the cache
proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m;
# see: https://meta.discourse.org/t/x/74060
proxy_buffer_size 8k;
'';
virtualHosts.${cfg.hostname} = {
inherit (cfg) sslCertificate sslCertificateKey enableACME;
forceSSL = lib.mkDefault tlsEnabled;
root = "${cfg.package}/share/discourse/public";
locations =
let
proxy = { extraConfig ? "" }: {
proxyPass = "http://discourse";
extraConfig = extraConfig + ''
proxy_set_header X-Request-Start "t=''${msec}";
'';
};
cache = time: ''
expires ${time};
add_header Cache-Control public,immutable;
'';
cache_1y = cache "1y";
cache_1d = cache "1d";
in
{
"/".tryFiles = "$uri @discourse";
"@discourse" = proxy {};
"^~ /backups/".extraConfig = ''
internal;
'';
"/favicon.ico" = {
return = "204";
extraConfig = ''
access_log off;
log_not_found off;
'';
};
"~ ^/uploads/short-url/" = proxy {};
"~ ^/secure-media-uploads/" = proxy {};
"~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + ''
add_header Access-Control-Allow-Origin *;
'';
"/srv/status" = proxy {
extraConfig = ''
access_log off;
log_not_found off;
'';
};
"~ ^/javascripts/".extraConfig = cache_1d;
"~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + ''
# asset pipeline enables this
brotli_static on;
gzip_static on;
'';
"~ ^/plugins/".extraConfig = cache_1y;
"~ /images/emoji/".extraConfig = cache_1y;
"~ ^/uploads/" = proxy {
extraConfig = cache_1y + ''
proxy_set_header X-Sendfile-Type X-Accel-Redirect;
proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
# custom CSS
location ~ /stylesheet-cache/ {
try_files $uri =404;
}
# this allows us to bypass rails
location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ {
try_files $uri =404;
}
# SVG needs an extra header attached
location ~* \.(svg)$ {
}
# thumbnails & optimized images
location ~ /_?optimized/ {
try_files $uri =404;
}
'';
};
"~ ^/admin/backups/" = proxy {
extraConfig = ''
proxy_set_header X-Sendfile-Type X-Accel-Redirect;
proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
'';
};
"~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy {
extraConfig = ''
# if Set-Cookie is in the response nothing gets cached
# this is double bad cause we are not passing last modified in
proxy_ignore_headers "Set-Cookie";
proxy_hide_header "Set-Cookie";
proxy_hide_header "X-Discourse-Username";
proxy_hide_header "X-Runtime";
# note x-accel-redirect can not be used with proxy_cache
proxy_cache discourse;
proxy_cache_key "$scheme,$host,$request_uri";
proxy_cache_valid 200 301 302 7d;
proxy_cache_valid any 1m;
'';
};
"/message-bus/" = proxy {
extraConfig = ''
proxy_http_version 1.1;
proxy_buffering off;
'';
};
"/downloads/".extraConfig = ''
internal;
alias ${cfg.package}/share/discourse/public/;
'';
};
};
};
systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable (
let
mail-receiver-environment = {
MAIL_DOMAIN = cfg.hostname;
DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
DISCOURSE_API_KEY = "@api-key@";
DISCOURSE_API_USERNAME = "system";
};
mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment;
in
{
before = [ "postfix.service" ];
after = [ "discourse.service" ];
wantedBy = [ "discourse.service" ];
partOf = [ "discourse.service" ];
path = [
cfg.package.rake
pkgs.jq
];
preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then
discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key
fi
'';
script =
let
apiKeyPath =
if cfg.mail.incoming.apiKeyFile == null then
"/var/lib/discourse-mail-receiver/api_key"
else
cfg.mail.incoming.apiKeyFile;
in ''
set -o errexit -o pipefail -o nounset -o errtrace
shopt -s inherit_errexit
api_key=$(<'${apiKeyPath}')
export api_key
jq <${mail-receiver-json} \
'.DISCOURSE_API_KEY = $ENV.api_key' \
>'/run/discourse-mail-receiver/mail-receiver-environment.json'
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
RuntimeDirectory = "discourse-mail-receiver";
RuntimeDirectoryMode = "0700";
StateDirectory = "discourse-mail-receiver";
User = "discourse";
Group = "discourse";
};
});
services.discourse.siteSettings = {
required = {
notification_email = cfg.mail.notificationEmailAddress;
contact_email = cfg.mail.contactEmailAddress;
};
email = {
manual_polling_enabled = cfg.mail.incoming.enable;
reply_by_email_enabled = cfg.mail.incoming.enable;
reply_by_email_address = cfg.mail.incoming.replyEmailAddress;
};
};
services.postfix = lib.mkIf cfg.mail.incoming.enable {
enable = true;
sslCert = if cfg.sslCertificate != null then cfg.sslCertificate else "";
sslKey = if cfg.sslCertificateKey != null then cfg.sslCertificateKey else "";
origin = cfg.hostname;
relayDomains = [ cfg.hostname ];
config = {
smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy";
append_dot_mydomain = lib.mkDefault false;
compatibility_level = "2";
smtputf8_enable = false;
smtpd_banner = lib.mkDefault "ESMTP server";
myhostname = lib.mkDefault cfg.hostname;
mydestination = lib.mkDefault "localhost";
};
transport = ''
${cfg.hostname} discourse-mail-receiver:
'';
masterConfig = {
"discourse-mail-receiver" = {
type = "unix";
privileged = true;
chroot = false;
command = "pipe";
args = [
"user=discourse"
"argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail"
"\${recipient}"
];
};
"discourse-policy" = {
type = "unix";
privileged = true;
chroot = false;
command = "spawn";
args = [
"user=discourse"
"argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection"
];
};
};
};
users.users = {
discourse = {
group = "discourse";
isSystemUser = true;
};
} // (lib.optionalAttrs cfg.nginx.enable {
${config.services.nginx.user}.extraGroups = [ "discourse" ];
});
users.groups = {
discourse = {};
};
environment.systemPackages = [
cfg.package.rake
];
};
meta.doc = ./discourse.xml;
meta.maintainers = [ lib.maintainers.talyz ];
}