125bb7dac1
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.
1085 lines
36 KiB
Nix
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 ];
|
|
}
|