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