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