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