From 2528ccd33fd5009f9e83005b682d6b1f07446f8b Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Fri, 19 May 2023 01:02:37 +0200 Subject: [PATCH] Host a writefreely instance for dtth --- nki-personal-do/configuration.nix | 7 +- nki-personal-do/secrets/secrets.yaml | 5 +- nki-personal-do/writefreely.nix | 79 ++++ nki-personal-do/writefreely/module.nix | 500 +++++++++++++++++++++++++ 4 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 nki-personal-do/writefreely.nix create mode 100644 nki-personal-do/writefreely/module.nix diff --git a/nki-personal-do/configuration.nix b/nki-personal-do/configuration.nix index d3c1375..f341794 100644 --- a/nki-personal-do/configuration.nix +++ b/nki-personal-do/configuration.nix @@ -16,6 +16,7 @@ ./headscale.nix ./gitea.nix ./miniflux.nix + ./writefreely.nix ]; common.linux.enable = false; # Don't enable the "common linux" module, this is a special machine. @@ -156,9 +157,9 @@ envFile = config.sops.secrets.youmubot-env.path; }; - # Writefreely - cloud.writefreely.enable = true; - cloud.writefreely.package = pkgs.unstable.writefreely; + # Writefreely on nki + # cloud.writefreely.enable = true; + # cloud.writefreely.package = pkgs.unstable.writefreely; # Authentik sops.secrets.authentik-env = { }; diff --git a/nki-personal-do/secrets/secrets.yaml b/nki-personal-do/secrets/secrets.yaml index 3ed4654..306ccb0 100644 --- a/nki-personal-do/secrets/secrets.yaml +++ b/nki-personal-do/secrets/secrets.yaml @@ -30,6 +30,7 @@ miniflux: oidc-client-secret: ENC[AES256_GCM,data:lvPFzc1ZXOTArs6VfFzJmKjW7pFpWU1XMCzHfUWC8WRq3CTvd6iQSwnGgBmUtHtDJCF1un/32fOaqBtSeMkgt5nkd/j8fLhTrTZyo8hJV2l1p16RBC2C0+igQh8PMah1Yp+V4zTMd5UKYMPkK9Iwb/tJ5zXeqQ20LvmGk+E7lCCVrnyyJrJNwr310mzYaUmpRz9/1AEM,iv:+9yhwYxnB5zPSRjX9o3PRvBpq4hM45jNF3acqT6lSJc=,tag:yMTWbxHlTZgg4XxNjfc8wQ==,type:str] pocket-consumer-key: ENC[AES256_GCM,data:NXY9Y8rFlzCVVG3ATUL/u7Sj6Im1RU/D16toUOLcIfKvddBjlu+QddKXWfLKppV1BQZ0,iv:nf3gkm098UhpZOgMbOdyG1FYVcl5G0gxoI6RTsZ1r14=,tag:bMOYwtFwUJ4SFornsWo8ig==,type:str] admin-creds: ENC[AES256_GCM,data:cBCwwRZR0B8nH7XLxHVZCThqmnUI6ZHFp3wH9TjdRbBTmySjPqU526ltn3lRQtopgqQ0IOuneTztXJ+wfqmLUABV6xlLBkXD7VX6Mf43RtIDyHL+UC56eIdn3xeawGsIjnta,iv:DOwHUL64ufLS7FbvnJCPxPYwMJF1pMPqjx78vltm9IY=,tag:A2Fpk4rI0/WK0jFtTlGhaA==,type:str] +writefreely-dtth: ENC[AES256_GCM,data:Q2b3eCr5GLLyBMrGlTUSIuMN/vZXmMZV8T56+t7RjcoHQmEVDKGwPGgka4jf/yO9Nf6TdGB7iiXft+XK3t74XdnzTCTYYVFzFsv49eZDKpTeaR6pKcbesfJYyqOcHIuatQz/orQ1X6Ext9Xf9aBStY4GV6ticLpvdW3GtHzchMPuMm8vY8A8DYNH/kLGb96aHpQ53paKkckeDWcbDyCulUU=,iv:G4TNJ4vY6qo4iOrEBmsf6hHJWAqbl3t8JAyDIZ1lUUg=,tag:HEknuS+MjBBFbkpDEIRUfw==,type:str] sops: kms: [] gcp_kms: [] @@ -63,8 +64,8 @@ sops: by9kZFlTRVdCZFkxYTVVb0RIRk8zUlkKCqMw9oL9RaYBV5Hhy3o8Nm5xmGrPH8Sd hv36sxRFFNZT/DCKaHaSRbT3mfpBZSTXJt1dgl4nZe6whH54t/1KmA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2023-05-18T18:34:18Z" - mac: ENC[AES256_GCM,data:9+5M/rTOhL/DXtSYLRjjvF8smhvWQgVE/XLSjwBxv/fCR89ArFNPsf6TC1PCP9eUZb0eThoxr1zkDSBOJD57wRpIbhmDe3/UyNpPTQLnu7Hnj/nvRJYpX49TYhfdT4CH3IzP+m0sopnCvo8DYgNIOKpz7Lhi4x/eCNP8gDzfv4k=,iv:lbXxenPw8EghEvDh8OwMlVfN3gFEa+wT2/Fai/xrzuo=,tag:b+HzElhvF1Rr3BAyLiPQsQ==,type:str] + lastmodified: "2023-05-18T19:31:18Z" + mac: ENC[AES256_GCM,data:yhPCOXDC8MzyKbzWOv/7XTNgeNQEprI8j6Q2u4BFoAmTmWDC8QdGs5dEesqmIaOkTGv5qEUJTfUwsHSZ2tVGW7RuwnCd9LVBkOjP89V0XGmEfb+HBjBWql94utGgRS0qTcDxF/nSUUuyhyL/TIqsvmy6wt75JdDTqSWPcAtVmwY=,iv:xOby+a3Zf+meI8LhomLJkrg5p3SeA+mCRUqCtNLl7ig=,tag:2PwUgrSLVH5JEHdrVs7BFg==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.7.3 diff --git a/nki-personal-do/writefreely.nix b/nki-personal-do/writefreely.nix new file mode 100644 index 0000000..bfca27a --- /dev/null +++ b/nki-personal-do/writefreely.nix @@ -0,0 +1,79 @@ +{ config, pkgs, lib, ... }: +with lib; +let + host = "blog.dtth.ch"; + port = 18074; + + user = "writefreely-dtth"; +in +{ + imports = [ ./writefreely/module.nix ]; + # traefik + cloud.traefik.hosts.writefreely-dtth = { inherit host port; }; + + sops.secrets."writefreely-dtth" = { owner = user; }; + + users.users.${user} = { + isSystemUser = true; + home = "${config.fileSystems.data.mountPoint}/writefreely-dtth"; + createHome = true; + group = user; + }; + users.groups.${user} = { }; + + nki.services.writefreely = { + inherit host user; + enable = true; + package = pkgs.unstable.writefreely; + + group = user; + + stateDir = "${config.fileSystems.data.mountPoint}/writefreely-dtth"; + settings = { + server.port = port; + app = { + host = "https://${host}"; + + site_name = "DTTH Blog"; + site_description = "Blogs from members of DTTH"; + editor = "pad"; + + landing = "/read"; + local_timeline = true; + default_visibility = "public"; + + open_registration = true; + disable_password_auth = true; + max_blogs = 5; + user_invites = "admin"; + min_username_len = 3; + + federation = true; + wf_modesty = true; + public_stats = true; + monetization = false; + }; + + "oauth.generic" = { + client_id = "rpoTTr2Wz0h4EgOSCHe0G85O8DCQDMup7JW9U9fV"; + host = "https://auth.dtth.ch"; + display_name = "DTTH"; + token_endpoint = "/application/o/token/"; + inspect_endpoint = "/application/o/userinfo/"; + auth_endpoint = "/application/o/authorize/"; + scope = "email openid profile"; + map_user_id = "nickname"; + map_username = "preferred_username"; + map_display_name = "name"; + allow_registration = true; + }; + }; + + extraSettingsFile = config.sops.secrets."writefreely-dtth".path; + + database.type = "sqlite3"; + + admin.name = "nki"; + }; +} + diff --git a/nki-personal-do/writefreely/module.nix b/nki-personal-do/writefreely/module.nix new file mode 100644 index 0000000..dd21b36 --- /dev/null +++ b/nki-personal-do/writefreely/module.nix @@ -0,0 +1,500 @@ +{ config, lib, pkgs, ... }: + +let + inherit (builtins) toString; + inherit (lib) types mkIf mkOption mkDefault; + inherit (lib) optional optionals optionalAttrs optionalString; + + inherit (pkgs) sqlite; + + format = pkgs.formats.ini { + mkKeyValue = key: value: + let + value' = lib.optionalString (value != null) + (if builtins.isBool value then + if value == true then "true" else "false" + else + toString value); + in + "${key} = ${value'}"; + }; + + cfg = config.nki.services.writefreely; + + isSqlite = cfg.database.type == "sqlite3"; + isMysql = cfg.database.type == "mysql"; + isMysqlLocal = isMysql && cfg.database.createLocally == true; + + hostProtocol = if cfg.acme.enable then "https" else "http"; + + settings = cfg.settings // { + app = cfg.settings.app or { } // { + host = cfg.settings.app.host or "${hostProtocol}://${cfg.host}"; + }; + + database = + if cfg.database.type == "sqlite3" then { + type = "sqlite3"; + filename = cfg.settings.database.filename or "writefreely.db"; + database = cfg.database.name; + } else { + type = "mysql"; + username = cfg.database.user; + password = "#dbpass#"; + database = cfg.database.name; + host = cfg.database.host; + port = cfg.database.port; + tls = cfg.database.tls; + }; + + server = cfg.settings.server or { } // { + bind = cfg.settings.server.bind or "localhost"; + gopher_port = cfg.settings.server.gopher_port or 0; + autocert = !cfg.nginx.enable && cfg.acme.enable; + templates_parent_dir = + cfg.settings.server.templates_parent_dir or cfg.package.src; + static_parent_dir = cfg.settings.server.static_parent_dir or assets; + pages_parent_dir = + cfg.settings.server.pages_parent_dir or cfg.package.src; + keys_parent_dir = cfg.settings.server.keys_parent_dir or cfg.stateDir; + }; + }; + + configFile = format.generate "config.ini" settings; + + assets = pkgs.stdenvNoCC.mkDerivation { + pname = "writefreely-assets"; + + inherit (cfg.package) version src; + + nativeBuildInputs = with pkgs.nodePackages; [ less ]; + + buildPhase = '' + mkdir -p $out + + cp -r static $out/ + ''; + + installPhase = '' + less_dir=$src/less + css_dir=$out/static/css + + lessc $less_dir/app.less $css_dir/write.css + lessc $less_dir/fonts.less $css_dir/fonts.css + lessc $less_dir/icons.less $css_dir/icons.css + lessc $less_dir/prose.less $css_dir/prose.css + ''; + }; + + withConfigFile = text: '' + db_pass=${ + optionalString (cfg.database.passwordFile != null) + "$(head -n1 ${cfg.database.passwordFile})" + } + + install -m 0660 ${configFile} '${cfg.stateDir}/config.ini' + sed -e "s,#dbpass#,$db_pass,g" -i '${cfg.stateDir}/config.ini' + ${if cfg.extraSettingsFile != null then "cat ${cfg.extraSettingsFile} >> ${cfg.stateDir}/config.ini" else ""} + chmod 440 '${cfg.stateDir}/config.ini' + + ${text} + ''; + + withMysql = text: + withConfigFile '' + query () { + local result=$(${config.services.mysql.package}/bin/mysql \ + --user=${cfg.database.user} \ + --password=$db_pass \ + --database=${cfg.database.name} \ + --silent \ + --raw \ + --skip-column-names \ + --execute "$1" \ + ) + + echo $result + } + + ${text} + ''; + + withSqlite = text: + withConfigFile '' + query () { + local result=$(${sqlite}/bin/sqlite3 \ + '${cfg.stateDir}/${settings.database.filename}' + "$1" \ + ) + + echo $result + } + + ${text} + ''; +in +{ + options.nki.services.writefreely = { + enable = + lib.mkEnableOption (lib.mdDoc "Writefreely, build a digital writing community"); + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.writefreely; + defaultText = lib.literalExpression "pkgs.writefreely"; + description = lib.mdDoc "Writefreely package to use."; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/writefreely"; + description = lib.mdDoc "The state directory where keys and data are stored."; + }; + + user = mkOption { + type = types.str; + default = "writefreely"; + description = lib.mdDoc "User under which Writefreely is ran."; + }; + + group = mkOption { + type = types.str; + default = "writefreely"; + description = lib.mdDoc "Group under which Writefreely is ran."; + }; + + host = mkOption { + type = types.str; + default = ""; + description = lib.mdDoc "The public host name to serve."; + example = "example.com"; + }; + + extraSettingsFile = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc "Additional configs to be appended to the config file"; + }; + + settings = mkOption { + default = { }; + description = lib.mdDoc '' + Writefreely configuration ({file}`config.ini`). Refer to + + for details. + ''; + + type = types.submodule { + freeformType = format.type; + + options = { + app = { + theme = mkOption { + type = types.str; + default = "write"; + description = lib.mdDoc "The theme to apply."; + }; + }; + + server = { + port = mkOption { + type = types.port; + default = if cfg.nginx.enable then 18080 else 80; + defaultText = "80"; + description = lib.mdDoc "The port WriteFreely should listen on."; + }; + }; + }; + }; + }; + + database = { + type = mkOption { + type = types.enum [ "sqlite3" "mysql" ]; + default = "sqlite3"; + description = lib.mdDoc "The database provider to use."; + }; + + name = mkOption { + type = types.str; + default = "writefreely"; + description = lib.mdDoc "The name of the database to store data in."; + }; + + user = mkOption { + type = types.nullOr types.str; + default = if cfg.database.type == "mysql" then "writefreely" else null; + defaultText = "writefreely"; + description = lib.mdDoc "The database user to connect as."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = lib.mdDoc "The file to load the database password from."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "The database host to connect to."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "The port used when connecting to the database host."; + }; + + tls = mkOption { + type = types.bool; + default = false; + description = + lib.mdDoc "Whether or not TLS should be used for the database connection."; + }; + + migrate = mkOption { + type = types.bool; + default = true; + description = + lib.mdDoc "Whether or not to automatically run migrations on startup."; + }; + + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + When {option}`services.writefreely.database.type` is set to + `"mysql"`, this option will enable the MySQL service locally. + ''; + }; + }; + + admin = { + name = mkOption { + type = types.nullOr types.str; + description = lib.mdDoc "The name of the first admin user."; + default = null; + }; + + initialPasswordFile = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to a file containing the initial password for the admin user. + If not provided, the default password will be set to `nixos`. + ''; + default = pkgs.writeText "default-admin-pass" "nixos"; + defaultText = "/nix/store/xxx-default-admin-pass"; + }; + }; + + nginx = { + enable = mkOption { + type = types.bool; + default = false; + description = + lib.mdDoc "Whether or not to enable and configure nginx as a proxy for WriteFreely."; + }; + + forceSSL = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Whether or not to force the use of SSL."; + }; + }; + + acme = { + enable = mkOption { + type = types.bool; + default = false; + description = + lib.mdDoc "Whether or not to automatically fetch and configure SSL certs."; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.host != ""; + message = "services.writefreely.host must be set"; + } + { + assertion = isMysqlLocal -> cfg.database.passwordFile != null; + message = + "services.writefreely.database.passwordFile must be set if services.writefreely.database.createLocally is set to true"; + } + { + assertion = isSqlite -> !cfg.database.createLocally; + message = + "services.writefreely.database.createLocally has no use when services.writefreely.database.type is set to sqlite3"; + } + ]; + + users = { + users = optionalAttrs (cfg.user == "writefreely") { + writefreely = { + group = cfg.group; + home = cfg.stateDir; + isSystemUser = true; + }; + }; + + groups = + optionalAttrs (cfg.group == "writefreely") { writefreely = { }; }; + }; + + systemd.tmpfiles.rules = + [ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" ]; + + systemd.services.writefreely = { + path = with pkgs; [ openssl ]; + after = [ "network.target" ] + ++ optional isSqlite "writefreely-sqlite-init.service" + ++ optional isMysql "writefreely-mysql-init.service" + ++ optional isMysqlLocal "mysql.service"; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + Restart = "always"; + RestartSec = 20; + ExecStart = + "${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' serve"; + AmbientCapabilities = + optionalString (settings.server.port < 1024) "cap_net_bind_service"; + }; + + preStart = '' + if ! test -d "${cfg.stateDir}/keys"; then + mkdir -p ${cfg.stateDir}/keys + + # Key files end up with the wrong permissions by default. + # We need to correct them so that Writefreely can read them. + chmod -R 750 "${cfg.stateDir}/keys" + + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' keys generate + fi + ''; + }; + + systemd.services.writefreely-sqlite-init = mkIf isSqlite { + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ReadOnlyPaths = optional (cfg.admin.initialPasswordFile != null) + cfg.admin.initialPasswordFile; + }; + + script = + let + migrateDatabase = optionalString cfg.database.migrate '' + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate + ''; + + createAdmin = optionalString (cfg.admin.name != null) '' + if [[ $(query "SELECT COUNT(*) FROM users") == 0 ]]; then + admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile}) + + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass + fi + ''; + in + withSqlite '' + if ! test -f '${settings.database.filename}'; then + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init + fi + + ${migrateDatabase} + + ${createAdmin} + ''; + }; + + systemd.services.writefreely-mysql-init = mkIf isMysql { + wantedBy = [ "multi-user.target" ]; + after = optional isMysqlLocal "mysql.service"; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ReadOnlyPaths = optional isMysqlLocal cfg.database.passwordFile + ++ optional (cfg.admin.initialPasswordFile != null) + cfg.admin.initialPasswordFile; + }; + + script = + let + updateUser = optionalString isMysqlLocal '' + # WriteFreely currently *requires* a password for authentication, so we + # need to update the user in MySQL accordingly. By default MySQL users + # authenticate with auth_socket or unix_socket. + # See: https://github.com/writefreely/writefreely/issues/568 + ${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;" + ''; + + migrateDatabase = optionalString cfg.database.migrate '' + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db migrate + ''; + + createAdmin = optionalString (cfg.admin.name != null) '' + if [[ $(query 'SELECT COUNT(*) FROM users') == 0 ]]; then + admin_pass=$(head -n1 ${cfg.admin.initialPasswordFile}) + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' --create-admin ${cfg.admin.name}:$admin_pass + fi + ''; + in + withMysql '' + ${updateUser} + + if [[ $(query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${cfg.database.name}'") == 0 ]]; then + ${cfg.package}/bin/writefreely -c '${cfg.stateDir}/config.ini' db init + fi + + ${migrateDatabase} + + ${createAdmin} + ''; + }; + + services.mysql = mkIf isMysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [{ + name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "ALL PRIVILEGES"; + # WriteFreely requires the use of passwords, so we need permissions + # to `ALTER` the user to add password support and also to reload + # permissions so they can be used. + "*.*" = "CREATE USER, RELOAD"; + }; + }]; + }; + + services.nginx = lib.mkIf cfg.nginx.enable { + enable = true; + recommendedProxySettings = true; + + virtualHosts."${cfg.host}" = { + enableACME = cfg.acme.enable; + forceSSL = cfg.nginx.forceSSL; + + locations."/" = { + proxyPass = "http://127.0.0.1:${toString settings.server.port}"; + }; + }; + }; + }; +} +