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