grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / web-apps / writefreely.nix
blob4bb5d8a579fd2becec41394c3f6cec6c9eb6912f
1 { config, lib, pkgs, ... }:
3 let
4   inherit (builtins) toString;
5   inherit (lib) types mkIf mkOption mkDefault;
6   inherit (lib) optional optionals optionalAttrs optionalString;
8   inherit (pkgs) sqlite;
10   format = pkgs.formats.ini {
11     mkKeyValue = key: value:
12       let
13         value' = lib.optionalString (value != null)
14           (if builtins.isBool value then
15             if value == true then "true" else "false"
16           else
17             toString value);
18       in "${key} = ${value'}";
19   };
21   cfg = config.services.writefreely;
23   isSqlite = cfg.database.type == "sqlite3";
24   isMysql = cfg.database.type == "mysql";
25   isMysqlLocal = isMysql && cfg.database.createLocally == true;
27   hostProtocol = if cfg.acme.enable then "https" else "http";
29   settings = cfg.settings // {
30     app = cfg.settings.app or { } // {
31       host = cfg.settings.app.host or "${hostProtocol}://${cfg.host}";
32     };
34     database = if cfg.database.type == "sqlite3" then {
35       type = "sqlite3";
36       filename = cfg.settings.database.filename or "writefreely.db";
37       database = cfg.database.name;
38     } else {
39       type = "mysql";
40       username = cfg.database.user;
41       password = "#dbpass#";
42       database = cfg.database.name;
43       host = cfg.database.host;
44       port = cfg.database.port;
45       tls = cfg.database.tls;
46     };
48     server = cfg.settings.server or { } // {
49       bind = cfg.settings.server.bind or "localhost";
50       gopher_port = cfg.settings.server.gopher_port or 0;
51       autocert = !cfg.nginx.enable && cfg.acme.enable;
52       templates_parent_dir =
53         cfg.settings.server.templates_parent_dir or cfg.package.src;
54       static_parent_dir = cfg.settings.server.static_parent_dir or assets;
55       pages_parent_dir =
56         cfg.settings.server.pages_parent_dir or cfg.package.src;
57       keys_parent_dir = cfg.settings.server.keys_parent_dir or cfg.stateDir;
58     };
59   };
61   configFile = format.generate "config.ini" settings;
63   assets = pkgs.stdenvNoCC.mkDerivation {
64     pname = "writefreely-assets";
66     inherit (cfg.package) version src;
68     nativeBuildInputs = with pkgs.nodePackages; [ less ];
70     buildPhase = ''
71       mkdir -p $out
73       cp -r static $out/
74     '';
76     installPhase = ''
77       less_dir=$src/less
78       css_dir=$out/static/css
80       lessc $less_dir/app.less $css_dir/write.css
81       lessc $less_dir/fonts.less $css_dir/fonts.css
82       lessc $less_dir/icons.less $css_dir/icons.css
83       lessc $less_dir/prose.less $css_dir/prose.css
84     '';
85   };
87   withConfigFile = text: ''
88     db_pass=${
89       optionalString (cfg.database.passwordFile != null)
90       "$(head -n1 ${cfg.database.passwordFile})"
91     }
93     cp -f ${configFile} '${cfg.stateDir}/config.ini'
94     sed -e "s,#dbpass#,$db_pass,g" -i '${cfg.stateDir}/config.ini'
95     chmod 440 '${cfg.stateDir}/config.ini'
97     ${text}
98   '';
100   withMysql = text:
101     withConfigFile ''
102       query () {
103         local result=$(${config.services.mysql.package}/bin/mysql \
104           --user=${cfg.database.user} \
105           --password=$db_pass \
106           --database=${cfg.database.name} \
107           --silent \
108           --raw \
109           --skip-column-names \
110           --execute "$1" \
111         )
113         echo $result
114       }
116       ${text}
117     '';
119   withSqlite = text:
120     withConfigFile ''
121       query () {
122         local result=$(${sqlite}/bin/sqlite3 \
123           '${cfg.stateDir}/${settings.database.filename}' \
124           "$1" \
125         )
127         echo $result
128       }
130       ${text}
131     '';
132 in {
133   options.services.writefreely = {
134     enable =
135       lib.mkEnableOption "Writefreely, build a digital writing community";
137     package = lib.mkOption {
138       type = lib.types.package;
139       default = pkgs.writefreely;
140       defaultText = lib.literalExpression "pkgs.writefreely";
141       description = "Writefreely package to use.";
142     };
144     stateDir = mkOption {
145       type = types.path;
146       default = "/var/lib/writefreely";
147       description = "The state directory where keys and data are stored.";
148     };
150     user = mkOption {
151       type = types.str;
152       default = "writefreely";
153       description = "User under which Writefreely is ran.";
154     };
156     group = mkOption {
157       type = types.str;
158       default = "writefreely";
159       description = "Group under which Writefreely is ran.";
160     };
162     host = mkOption {
163       type = types.str;
164       default = "";
165       description = "The public host name to serve.";
166       example = "example.com";
167     };
169     settings = mkOption {
170       default = { };
171       description = ''
172         Writefreely configuration ({file}`config.ini`). Refer to
173         <https://writefreely.org/docs/latest/admin/config>
174         for details.
175       '';
177       type = types.submodule {
178         freeformType = format.type;
180         options = {
181           app = {
182             theme = mkOption {
183               type = types.str;
184               default = "write";
185               description = "The theme to apply.";
186             };
187           };
189           server = {
190             port = mkOption {
191               type = types.port;
192               default = if cfg.nginx.enable then 18080 else 80;
193               defaultText = "80";
194               description = "The port WriteFreely should listen on.";
195             };
196           };
197         };
198       };
199     };
201     database = {
202       type = mkOption {
203         type = types.enum [ "sqlite3" "mysql" ];
204         default = "sqlite3";
205         description = "The database provider to use.";
206       };
208       name = mkOption {
209         type = types.str;
210         default = "writefreely";
211         description = "The name of the database to store data in.";
212       };
214       user = mkOption {
215         type = types.nullOr types.str;
216         default = if cfg.database.type == "mysql" then "writefreely" else null;
217         defaultText = "writefreely";
218         description = "The database user to connect as.";
219       };
221       passwordFile = mkOption {
222         type = types.nullOr types.path;
223         default = null;
224         description = "The file to load the database password from.";
225       };
227       host = mkOption {
228         type = types.str;
229         default = "localhost";
230         description = "The database host to connect to.";
231       };
233       port = mkOption {
234         type = types.port;
235         default = 3306;
236         description = "The port used when connecting to the database host.";
237       };
239       tls = mkOption {
240         type = types.bool;
241         default = false;
242         description = "Whether or not TLS should be used for the database connection.";
243       };
245       migrate = mkOption {
246         type = types.bool;
247         default = true;
248         description = "Whether or not to automatically run migrations on startup.";
249       };
251       createLocally = mkOption {
252         type = types.bool;
253         default = false;
254         description = ''
255           When {option}`services.writefreely.database.type` is set to
256           `"mysql"`, this option will enable the MySQL service locally.
257         '';
258       };
259     };
261     admin = {
262       name = mkOption {
263         type = types.nullOr types.str;
264         description = "The name of the first admin user.";
265         default = null;
266       };
268       initialPasswordFile = mkOption {
269         type = types.path;
270         description = ''
271           Path to a file containing the initial password for the admin user.
272           If not provided, the default password will be set to `nixos`.
273         '';
274         default = pkgs.writeText "default-admin-pass" "nixos";
275         defaultText = "/nix/store/xxx-default-admin-pass";
276       };
277     };
279     nginx = {
280       enable = mkOption {
281         type = types.bool;
282         default = false;
283         description = "Whether or not to enable and configure nginx as a proxy for WriteFreely.";
284       };
286       forceSSL = mkOption {
287         type = types.bool;
288         default = false;
289         description = "Whether or not to force the use of SSL.";
290       };
291     };
293     acme = {
294       enable = mkOption {
295         type = types.bool;
296         default = false;
297         description = "Whether or not to automatically fetch and configure SSL certs.";
298       };
299     };
300   };
302   config = mkIf cfg.enable {
303     assertions = [
304       {
305         assertion = cfg.host != "";
306         message = "services.writefreely.host must be set";
307       }
308       {
309         assertion = isMysqlLocal -> cfg.database.passwordFile != null;
310         message =
311           "services.writefreely.database.passwordFile must be set if services.writefreely.database.createLocally is set to true";
312       }
313       {
314         assertion = isSqlite -> !cfg.database.createLocally;
315         message =
316           "services.writefreely.database.createLocally has no use when services.writefreely.database.type is set to sqlite3";
317       }
318     ];
320     users = {
321       users = optionalAttrs (cfg.user == "writefreely") {
322         writefreely = {
323           group = cfg.group;
324           home = cfg.stateDir;
325           isSystemUser = true;
326         };
327       };
329       groups =
330         optionalAttrs (cfg.group == "writefreely") { writefreely = { }; };
331     };
333     systemd.tmpfiles.settings."10-writefreely".${cfg.stateDir}.d = {
334       inherit (cfg) user group;
335       mode = "0750";
336     };
338     systemd.services.writefreely = {
339       after = [ "network.target" ]
340         ++ optional isSqlite "writefreely-sqlite-init.service"
341         ++ optional isMysql "writefreely-mysql-init.service"
342         ++ optional isMysqlLocal "mysql.service";
343       wantedBy = [ "multi-user.target" ];
345       serviceConfig = {
346         Type = "simple";
347         User = cfg.user;
348         Group = cfg.group;
349         WorkingDirectory = cfg.stateDir;
350         Restart = "always";
351         RestartSec = 20;
352         ExecStart =
353           "${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' serve";
354         AmbientCapabilities =
355           optionalString (settings.server.port < 1024) "cap_net_bind_service";
356       };
358       preStart = ''
359         if ! test -d "${cfg.stateDir}/keys"; then
360           mkdir -p ${cfg.stateDir}/keys
362           # Key files end up with the wrong permissions by default.
363           # We need to correct them so that Writefreely can read them.
364           chmod -R 750 "${cfg.stateDir}/keys"
366           ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' keys generate
367         fi
368       '';
369     };
371     systemd.services.writefreely-sqlite-init = mkIf isSqlite {
372       wantedBy = [ "multi-user.target" ];
374       serviceConfig = {
375         Type = "oneshot";
376         User = cfg.user;
377         Group = cfg.group;
378         WorkingDirectory = cfg.stateDir;
379         ReadOnlyPaths = optional (cfg.admin.initialPasswordFile != null)
380           cfg.admin.initialPasswordFile;
381       };
383       script = let
384         migrateDatabase = optionalString cfg.database.migrate ''
385           ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate
386         '';
388         createAdmin = optionalString (cfg.admin.name != null) ''
389           if [[ $(query "SELECT COUNT(*) FROM users") == 0 ]]; then
390             admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile})
392             ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass
393           fi
394         '';
395       in withSqlite ''
396         if ! test -f '${settings.database.filename}'; then
397           ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init
398         fi
400         ${migrateDatabase}
402         ${createAdmin}
403       '';
404     };
406     systemd.services.writefreely-mysql-init = mkIf isMysql {
407       wantedBy = [ "multi-user.target" ];
408       after = optional isMysqlLocal "mysql.service";
410       serviceConfig = {
411         Type = "oneshot";
412         User = cfg.user;
413         Group = cfg.group;
414         WorkingDirectory = cfg.stateDir;
415         ReadOnlyPaths = optional isMysqlLocal cfg.database.passwordFile
416           ++ optional (cfg.admin.initialPasswordFile != null)
417           cfg.admin.initialPasswordFile;
418       };
420       script = let
421         updateUser = optionalString isMysqlLocal ''
422           # WriteFreely currently *requires* a password for authentication, so we
423           # need to update the user in MySQL accordingly. By default MySQL users
424           # authenticate with auth_socket or unix_socket.
425           # See: https://github.com/writefreely/writefreely/issues/568
426           ${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;"
427         '';
429         migrateDatabase = optionalString cfg.database.migrate ''
430           ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate
431         '';
433         createAdmin = optionalString (cfg.admin.name != null) ''
434           if [[ $(query 'SELECT COUNT(*) FROM users') == 0 ]]; then
435             admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile})
436             ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass
437           fi
438         '';
439       in withMysql ''
440         ${updateUser}
442         if [[ $(query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${cfg.database.name}'") == 0 ]]; then
443           ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init
444         fi
446         ${migrateDatabase}
448         ${createAdmin}
449       '';
450     };
452     services.mysql = mkIf isMysqlLocal {
453       enable = true;
454       package = mkDefault pkgs.mariadb;
455       ensureDatabases = [ cfg.database.name ];
456       ensureUsers = [{
457         name = cfg.database.user;
458         ensurePermissions = {
459           "${cfg.database.name}.*" = "ALL PRIVILEGES";
460           # WriteFreely requires the use of passwords, so we need permissions
461           # to `ALTER` the user to add password support and also to reload
462           # permissions so they can be used.
463           "*.*" = "CREATE USER, RELOAD";
464         };
465       }];
466     };
468     services.nginx = lib.mkIf cfg.nginx.enable {
469       enable = true;
470       recommendedProxySettings = true;
472       virtualHosts."${cfg.host}" = {
473         enableACME = cfg.acme.enable;
474         forceSSL = cfg.nginx.forceSSL;
476         locations."/" = {
477           proxyPass = "http://127.0.0.1:${toString settings.server.port}";
478         };
479       };
480     };
481   };