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