at 23.11-pre 14 kB view raw
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}