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