diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix
index f11209c11b56..c143cd2afcaa 100644
--- a/maintainers/maintainer-list.nix
+++ b/maintainers/maintainer-list.nix
@@ -5781,6 +5781,16 @@
github = "jacg";
githubId = 2570854;
};
+ jakehamilton = {
+ name = "Jake Hamilton";
+ email = "jake.hamilton@hey.com";
+ matrix = "@jakehamilton:matrix.org";
+ github = "jakehamilton";
+ githubId = 7005773;
+ keys = [{
+ fingerprint = "B982 0250 1720 D540 6A18 2DA8 188E 4945 E85B 2D21";
+ }];
+ };
jasoncarr = {
email = "jcarr250@gmail.com";
github = "jasoncarr0";
diff --git a/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml
index a951835a0764..cd2ad54db20f 100644
--- a/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml
+++ b/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml
@@ -258,6 +258,14 @@
services.patroni.
+
+
+ WriteFreely,
+ a simple blogging platform with ActivityPub support. Available
+ as
+ services.writefreely.
+
+
diff --git a/nixos/doc/manual/release-notes/rl-2211.section.md b/nixos/doc/manual/release-notes/rl-2211.section.md
index 73348007cb73..119cd12492aa 100644
--- a/nixos/doc/manual/release-notes/rl-2211.section.md
+++ b/nixos/doc/manual/release-notes/rl-2211.section.md
@@ -92,6 +92,8 @@ In addition to numerous new and upgraded packages, this release has the followin
- [Patroni](https://github.com/zalando/patroni), a template for PostgreSQL HA with ZooKeeper, etcd or Consul.
Available as [services.patroni](options.html#opt-services.patroni.enable).
+- [WriteFreely](https://writefreely.org), a simple blogging platform with ActivityPub support. Available as [services.writefreely](options.html#opt-services.writefreely.enable).
+
## Backward Incompatibilities {#sec-release-22.11-incompatibilities}
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 6e95c45f0d56..308bd8cb717b 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1119,6 +1119,7 @@
./services/web-apps/wiki-js.nix
./services/web-apps/whitebophir.nix
./services/web-apps/wordpress.nix
+ ./services/web-apps/writefreely.nix
./services/web-apps/youtrack.nix
./services/web-apps/zabbix.nix
./services/web-servers/agate.nix
diff --git a/nixos/modules/services/web-apps/writefreely.nix b/nixos/modules/services/web-apps/writefreely.nix
new file mode 100644
index 000000000000..c363760d5c2d
--- /dev/null
+++ b/nixos/modules/services/web-apps/writefreely.nix
@@ -0,0 +1,485 @@
+{ config, lib, pkgs, ... }:
+
+let
+ inherit (builtins) toString;
+ inherit (lib) types mkIf mkOption mkDefault;
+ inherit (lib) optional optionals optionalAttrs optionalString;
+
+ inherit (pkgs) sqlite;
+
+ format = pkgs.formats.ini {
+ mkKeyValue = key: value:
+ let
+ value' = if builtins.isNull value then
+ ""
+ else if builtins.isBool value then
+ if value == true then "true" else "false"
+ else
+ toString value;
+ in "${key} = ${value'}";
+ };
+
+ cfg = config.services.writefreely;
+
+ isSqlite = cfg.database.type == "sqlite3";
+ isMysql = cfg.database.type == "mysql";
+ isMysqlLocal = isMysql && cfg.database.createLocally == true;
+
+ hostProtocol = if cfg.acme.enable then "https" else "http";
+
+ settings = cfg.settings // {
+ app = cfg.settings.app or { } // {
+ host = cfg.settings.app.host or "${hostProtocol}://${cfg.host}";
+ };
+
+ database = if cfg.database.type == "sqlite3" then {
+ type = "sqlite3";
+ filename = cfg.settings.database.filename or "writefreely.db";
+ database = cfg.database.name;
+ } else {
+ type = "mysql";
+ username = cfg.database.user;
+ password = "#dbpass#";
+ database = cfg.database.name;
+ host = cfg.database.host;
+ port = cfg.database.port;
+ tls = cfg.database.tls;
+ };
+
+ server = cfg.settings.server or { } // {
+ bind = cfg.settings.server.bind or "localhost";
+ gopher_port = cfg.settings.server.gopher_port or 0;
+ autocert = !cfg.nginx.enable && cfg.acme.enable;
+ templates_parent_dir =
+ cfg.settings.server.templates_parent_dir or cfg.package.src;
+ static_parent_dir = cfg.settings.server.static_parent_dir or assets;
+ pages_parent_dir =
+ cfg.settings.server.pages_parent_dir or cfg.package.src;
+ keys_parent_dir = cfg.settings.server.keys_parent_dir or cfg.stateDir;
+ };
+ };
+
+ configFile = format.generate "config.ini" settings;
+
+ assets = pkgs.stdenvNoCC.mkDerivation {
+ pname = "writefreely-assets";
+
+ inherit (cfg.package) version src;
+
+ nativeBuildInputs = with pkgs.nodePackages; [ less ];
+
+ buildPhase = ''
+ mkdir -p $out
+
+ cp -r static $out/
+ '';
+
+ installPhase = ''
+ less_dir=$src/less
+ css_dir=$out/static/css
+
+ lessc $less_dir/app.less $css_dir/write.css
+ lessc $less_dir/fonts.less $css_dir/fonts.css
+ lessc $less_dir/icons.less $css_dir/icons.css
+ lessc $less_dir/prose.less $css_dir/prose.css
+ '';
+ };
+
+ withConfigFile = text: ''
+ db_pass=${
+ optionalString (cfg.database.passwordFile != null)
+ "$(head -n1 ${cfg.database.passwordFile})"
+ }
+
+ cp -f ${configFile} '${cfg.stateDir}/config.ini'
+ sed -e "s,#dbpass#,$db_pass,g" -i '${cfg.stateDir}/config.ini'
+ chmod 440 '${cfg.stateDir}/config.ini'
+
+ ${text}
+ '';
+
+ withMysql = text:
+ withConfigFile ''
+ query () {
+ local result=$(${config.services.mysql.package}/bin/mysql \
+ --user=${cfg.database.user} \
+ --password=$db_pass \
+ --database=${cfg.database.name} \
+ --silent \
+ --raw \
+ --skip-column-names \
+ --execute "$1" \
+ )
+
+ echo $result
+ }
+
+ ${text}
+ '';
+
+ withSqlite = text:
+ withConfigFile ''
+ query () {
+ local result=$(${sqlite}/bin/sqlite3 \
+ '${cfg.stateDir}/${settings.database.filename}'
+ "$1" \
+ )
+
+ echo $result
+ }
+
+ ${text}
+ '';
+in {
+ options.services.writefreely = {
+ enable =
+ lib.mkEnableOption "Writefreely, build a digital writing community";
+
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = pkgs.writefreely;
+ defaultText = lib.literalExpression "pkgs.writefreely";
+ description = "Writefreely package to use.";
+ };
+
+ stateDir = mkOption {
+ type = types.path;
+ default = "/var/lib/writefreely";
+ description = "The state directory where keys and data are stored.";
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "writefreely";
+ description = "User under which Writefreely is ran.";
+ };
+
+ group = mkOption {
+ type = types.str;
+ default = "writefreely";
+ description = "Group under which Writefreely is ran.";
+ };
+
+ host = mkOption {
+ type = types.str;
+ default = "";
+ description = "The public host name to serve.";
+ example = "example.com";
+ };
+
+ settings = mkOption {
+ default = { };
+ description = ''
+ Writefreely configuration (config.ini). Refer to
+
+ for details.
+ '';
+
+ type = types.submodule {
+ freeformType = format.type;
+
+ options = {
+ app = {
+ theme = mkOption {
+ type = types.str;
+ default = "write";
+ description = "The theme to apply.";
+ };
+ };
+
+ server = {
+ port = mkOption {
+ type = types.port;
+ default = if cfg.nginx.enable then 18080 else 80;
+ defaultText = "80";
+ description = "The port WriteFreely should listen on.";
+ };
+ };
+ };
+ };
+ };
+
+ database = {
+ type = mkOption {
+ type = types.enum [ "sqlite3" "mysql" ];
+ default = "sqlite3";
+ description = "The database provider to use.";
+ };
+
+ name = mkOption {
+ type = types.str;
+ default = "writefreely";
+ description = "The name of the database to store data in.";
+ };
+
+ user = mkOption {
+ type = types.nullOr types.str;
+ default = if cfg.database.type == "mysql" then "writefreely" else null;
+ defaultText = "writefreely";
+ description = "The database user to connect as.";
+ };
+
+ passwordFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = "The file to load the database password from.";
+ };
+
+ host = mkOption {
+ type = types.str;
+ default = "localhost";
+ description = "The database host to connect to.";
+ };
+
+ port = mkOption {
+ type = types.port;
+ default = 3306;
+ description = "The port used when connecting to the database host.";
+ };
+
+ tls = mkOption {
+ type = types.bool;
+ default = false;
+ description =
+ "Whether or not TLS should be used for the database connection.";
+ };
+
+ migrate = mkOption {
+ type = types.bool;
+ default = true;
+ description =
+ "Whether or not to automatically run migrations on startup.";
+ };
+
+ createLocally = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ When is set to
+ "mysql"
, this option will enable the MySQL service locally.
+ '';
+ };
+ };
+
+ admin = {
+ name = mkOption {
+ type = types.nullOr types.str;
+ description = "The name of the first admin user.";
+ default = null;
+ };
+
+ initialPasswordFile = mkOption {
+ type = types.path;
+ description = ''
+ Path to a file containing the initial password for the admin user.
+ If not provided, the default password will be set to nixos
.
+ '';
+ default = pkgs.writeText "default-admin-pass" "nixos";
+ defaultText = "/nix/store/xxx-default-admin-pass";
+ };
+ };
+
+ nginx = {
+ enable = mkOption {
+ type = types.bool;
+ default = false;
+ description =
+ "Whether or not to enable and configure nginx as a proxy for WriteFreely.";
+ };
+
+ forceSSL = mkOption {
+ type = types.bool;
+ default = false;
+ description = "Whether or not to force the use of SSL.";
+ };
+ };
+
+ acme = {
+ enable = mkOption {
+ type = types.bool;
+ default = false;
+ description =
+ "Whether or not to automatically fetch and configure SSL certs.";
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.host != "";
+ message = "services.writefreely.host must be set";
+ }
+ {
+ assertion = isMysqlLocal -> cfg.database.passwordFile != null;
+ message =
+ "services.writefreely.database.passwordFile must be set if services.writefreely.database.createLocally is set to true";
+ }
+ {
+ assertion = isSqlite -> !cfg.database.createLocally;
+ message =
+ "services.writefreely.database.createLocally has no use when services.writefreely.database.type is set to sqlite3";
+ }
+ ];
+
+ users = {
+ users = optionalAttrs (cfg.user == "writefreely") {
+ writefreely = {
+ group = cfg.group;
+ home = cfg.stateDir;
+ isSystemUser = true;
+ };
+ };
+
+ groups =
+ optionalAttrs (cfg.group == "writefreely") { writefreely = { }; };
+ };
+
+ systemd.tmpfiles.rules =
+ [ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" ];
+
+ systemd.services.writefreely = {
+ after = [ "network.target" ]
+ ++ optional isSqlite "writefreely-sqlite-init.service"
+ ++ optional isMysql "writefreely-mysql-init.service"
+ ++ optional isMysqlLocal "mysql.service";
+ wantedBy = [ "multi-user.target" ];
+
+ serviceConfig = {
+ Type = "simple";
+ User = cfg.user;
+ Group = cfg.group;
+ WorkingDirectory = cfg.stateDir;
+ Restart = "always";
+ RestartSec = 20;
+ ExecStart =
+ "${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' serve";
+ AmbientCapabilities =
+ optionalString (settings.server.port < 1024) "cap_net_bind_service";
+ };
+
+ preStart = ''
+ if ! test -d "${cfg.stateDir}/keys"; then
+ mkdir -p ${cfg.stateDir}/keys
+
+ # Key files end up with the wrong permissions by default.
+ # We need to correct them so that Writefreely can read them.
+ chmod -R 750 "${cfg.stateDir}/keys"
+
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' keys generate
+ fi
+ '';
+ };
+
+ systemd.services.writefreely-sqlite-init = mkIf isSqlite {
+ wantedBy = [ "multi-user.target" ];
+
+ serviceConfig = {
+ Type = "oneshot";
+ User = cfg.user;
+ Group = cfg.group;
+ WorkingDirectory = cfg.stateDir;
+ ReadOnlyPaths = optional (cfg.admin.initialPasswordFile != null)
+ cfg.admin.initialPasswordFile;
+ };
+
+ script = let
+ migrateDatabase = optionalString cfg.database.migrate ''
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate
+ '';
+
+ createAdmin = optionalString (cfg.admin.name != null) ''
+ if [[ $(query "SELECT COUNT(*) FROM users") == 0 ]]; then
+ admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile})
+
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass
+ fi
+ '';
+ in withSqlite ''
+ if ! test -f '${settings.database.filename}'; then
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init
+ fi
+
+ ${migrateDatabase}
+
+ ${createAdmin}
+ '';
+ };
+
+ systemd.services.writefreely-mysql-init = mkIf isMysql {
+ wantedBy = [ "multi-user.target" ];
+ after = optional isMysqlLocal "mysql.service";
+
+ serviceConfig = {
+ Type = "oneshot";
+ User = cfg.user;
+ Group = cfg.group;
+ WorkingDirectory = cfg.stateDir;
+ ReadOnlyPaths = optional isMysqlLocal cfg.database.passwordFile
+ ++ optional (cfg.admin.initialPasswordFile != null)
+ cfg.admin.initialPasswordFile;
+ };
+
+ script = let
+ updateUser = optionalString isMysqlLocal ''
+ # WriteFreely currently *requires* a password for authentication, so we
+ # need to update the user in MySQL accordingly. By default MySQL users
+ # authenticate with auth_socket or unix_socket.
+ # See: https://github.com/writefreely/writefreely/issues/568
+ ${config.services.mysql.package}/bin/mysql --skip-column-names --execute "ALTER USER '${cfg.database.user}'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('$db_pass'); FLUSH PRIVILEGES;"
+ '';
+
+ migrateDatabase = optionalString cfg.database.migrate ''
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate
+ '';
+
+ createAdmin = optionalString (cfg.admin.name != null) ''
+ if [[ $(query 'SELECT COUNT(*) FROM users') == 0 ]]; then
+ admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile})
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass
+ fi
+ '';
+ in withMysql ''
+ ${updateUser}
+
+ if [[ $(query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${cfg.database.name}'") == 0 ]]; then
+ ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init
+ fi
+
+ ${migrateDatabase}
+
+ ${createAdmin}
+ '';
+ };
+
+ services.mysql = mkIf isMysqlLocal {
+ enable = true;
+ package = mkDefault pkgs.mariadb;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [{
+ name = cfg.database.user;
+ ensurePermissions = {
+ "${cfg.database.name}.*" = "ALL PRIVILEGES";
+ # WriteFreely requires the use of passwords, so we need permissions
+ # to `ALTER` the user to add password support and also to reload
+ # permissions so they can be used.
+ "*.*" = "CREATE USER, RELOAD";
+ };
+ }];
+ };
+
+ services.nginx = lib.mkIf cfg.nginx.enable {
+ enable = true;
+ recommendedProxySettings = true;
+
+ virtualHosts."${cfg.host}" = {
+ enableACME = cfg.acme.enable;
+ forceSSL = cfg.nginx.forceSSL;
+
+ locations."/" = {
+ proxyPass = "http://127.0.0.1:${toString settings.server.port}";
+ };
+ };
+ };
+ };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 7e1ba8f5ed91..1cf310cb3321 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -621,6 +621,7 @@ in {
wmderland = handleTest ./wmderland.nix {};
wpa_supplicant = handleTest ./wpa_supplicant.nix {};
wordpress = handleTest ./wordpress.nix {};
+ writefreely = handleTest ./web-apps/writefreely.nix {};
xandikos = handleTest ./xandikos.nix {};
xautolock = handleTest ./xautolock.nix {};
xfce = handleTest ./xfce.nix {};
diff --git a/nixos/tests/web-apps/writefreely.nix b/nixos/tests/web-apps/writefreely.nix
new file mode 100644
index 000000000000..ce614909706b
--- /dev/null
+++ b/nixos/tests/web-apps/writefreely.nix
@@ -0,0 +1,44 @@
+{ system ? builtins.currentSystem, config ? { }
+, pkgs ? import ../../.. { inherit system config; } }:
+
+with import ../../lib/testing-python.nix { inherit system pkgs; };
+with pkgs.lib;
+
+let
+ writefreelyTest = { name, type }:
+ makeTest {
+ name = "writefreely-${name}";
+
+ nodes.machine = { config, pkgs, ... }: {
+ services.writefreely = {
+ enable = true;
+ host = "localhost:3000";
+ admin.name = "nixos";
+
+ database = {
+ inherit type;
+ createLocally = type == "mysql";
+ passwordFile = pkgs.writeText "db-pass" "pass";
+ };
+
+ settings.server.port = 3000;
+ };
+ };
+
+ testScript = ''
+ start_all()
+ machine.wait_for_unit("writefreely.service")
+ machine.wait_for_open_port(3000)
+ machine.succeed("curl --fail http://localhost:3000")
+ '';
+ };
+in {
+ sqlite = writefreelyTest {
+ name = "sqlite";
+ type = "sqlite3";
+ };
+ mysql = writefreelyTest {
+ name = "mysql";
+ type = "mysql";
+ };
+}