envision-unwrapped: 0-unstable-2024-10-20 -> 1.1.1 (#360652)
[NixPkgs.git] / nixos / modules / services / web-apps / lemmy.nix
blob3185f9a4263c9d6176546ddaa22c133bb23620ce
1 { lib, pkgs, config, utils, ... }:
2 with lib;
3 let
4   cfg = config.services.lemmy;
5   settingsFormat = pkgs.formats.json { };
6 in
8   meta.maintainers = with maintainers; [ happysalada ];
9   meta.doc = ./lemmy.md;
11   imports = [
12     (mkRemovedOptionModule [ "services" "lemmy" "jwtSecretPath" ] "As of v0.13.0, Lemmy auto-generates the JWT secret.")
13   ];
15   options.services.lemmy = {
17     enable = mkEnableOption "lemmy a federated alternative to reddit in rust";
19     server = {
20       package = mkPackageOption pkgs "lemmy-server" {};
21     };
23     ui = {
24       package = mkPackageOption pkgs "lemmy-ui" {};
26       port = mkOption {
27         type = types.port;
28         default = 1234;
29         description = "Port where lemmy-ui should listen for incoming requests.";
30       };
31     };
33     caddy.enable = mkEnableOption "exposing lemmy with the caddy reverse proxy";
34     nginx.enable = mkEnableOption "exposing lemmy with the nginx reverse proxy";
36     database = {
37       createLocally = mkEnableOption "creation of database on the instance";
39       uri = mkOption {
40         type = with types; nullOr str;
41         default = null;
42         description = "The connection URI to use. Takes priority over the configuration file if set.";
43       };
45       uriFile = mkOption {
46         type = with types; nullOr path;
47         default = null;
48         description = "File which contains the database uri.";
49       };
50     };
52     pictrsApiKeyFile = mkOption {
53       type = with types; nullOr path;
54       default = null;
55       description = "File which contains the value of `pictrs.api_key`.";
56     };
58     smtpPasswordFile = mkOption {
59       type = with types; nullOr path;
60       default = null;
61       description = "File which contains the value of `email.smtp_password`.";
62     };
64     adminPasswordFile = mkOption {
65       type = with types; nullOr path;
66       default = null;
67       description = "File which contains the value of `setup.admin_password`.";
68     };
70     settings = mkOption {
71       default = { };
72       description = "Lemmy configuration";
74       type = types.submodule {
75         freeformType = settingsFormat.type;
77         options.hostname = mkOption {
78           type = types.str;
79           default = null;
80           description = "The domain name of your instance (eg 'lemmy.ml').";
81         };
83         options.port = mkOption {
84           type = types.port;
85           default = 8536;
86           description = "Port where lemmy should listen for incoming requests.";
87         };
89         options.captcha = {
90           enabled = mkOption {
91             type = types.bool;
92             default = true;
93             description = "Enable Captcha.";
94           };
95           difficulty = mkOption {
96             type = types.enum [ "easy" "medium" "hard" ];
97             default = "medium";
98             description = "The difficultly of the captcha to solve.";
99           };
100         };
101       };
102     };
103   };
105   config =
106     let
107       secretOptions = {
108         pictrsApiKeyFile = { setting = [ "pictrs" "api_key" ]; path = cfg.pictrsApiKeyFile; };
109         smtpPasswordFile = { setting = [ "email" "smtp_password" ]; path = cfg.smtpPasswordFile; };
110         adminPasswordFile = { setting = [ "setup" "admin_password" ]; path = cfg.adminPasswordFile; };
111         uriFile = { setting = [ "database" "uri" ]; path = cfg.database.uriFile; };
112       };
113       secrets = lib.filterAttrs (option: data: data.path != null) secretOptions;
114     in
115     lib.mkIf cfg.enable {
116       services.lemmy.settings = lib.attrsets.recursiveUpdate (mapAttrs (name: mkDefault)
117         {
118           bind = "127.0.0.1";
119           tls_enabled = true;
120           pictrs = {
121             url = with config.services.pict-rs; "http://${address}:${toString port}";
122           };
123           actor_name_max_length = 20;
125           rate_limit.message = 180;
126           rate_limit.message_per_second = 60;
127           rate_limit.post = 6;
128           rate_limit.post_per_second = 600;
129           rate_limit.register = 3;
130           rate_limit.register_per_second = 3600;
131           rate_limit.image = 6;
132           rate_limit.image_per_second = 3600;
133         } // {
134           database = mapAttrs (name: mkDefault) {
135             user = "lemmy";
136             host = "/run/postgresql";
137             port = 5432;
138             database = "lemmy";
139             pool_size = 5;
140           };
141         }) (lib.foldlAttrs (acc: option: data: acc // lib.setAttrByPath data.setting { _secret = option; }) {} secrets);
142         # the option name is the id of the credential loaded by LoadCredential
144       services.postgresql = mkIf cfg.database.createLocally {
145         enable = true;
146         ensureDatabases = [ cfg.settings.database.database ];
147         ensureUsers = [{
148           name = cfg.settings.database.user;
149           ensureDBOwnership = true;
150         }];
151       };
153       services.pict-rs.enable = true;
155       services.caddy = mkIf cfg.caddy.enable {
156         enable = mkDefault true;
157         virtualHosts."${cfg.settings.hostname}" = {
158           extraConfig = ''
159             handle_path /static/* {
160               root * ${cfg.ui.package}/dist
161               file_server
162             }
163             handle_path /static/${cfg.ui.package.passthru.commit_sha}/* {
164               root * ${cfg.ui.package}/dist
165               file_server
166             }
167             @for_backend {
168               path /api/* /pictrs/* /feeds/* /nodeinfo/*
169             }
170             handle @for_backend {
171               reverse_proxy 127.0.0.1:${toString cfg.settings.port}
172             }
173             @post {
174               method POST
175             }
176             handle @post {
177               reverse_proxy 127.0.0.1:${toString cfg.settings.port}
178             }
179             @jsonld {
180               header Accept "application/activity+json"
181               header Accept "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
182             }
183             handle @jsonld {
184               reverse_proxy 127.0.0.1:${toString cfg.settings.port}
185             }
186             handle {
187               reverse_proxy 127.0.0.1:${toString cfg.ui.port}
188             }
189           '';
190         };
191       };
193       services.nginx = mkIf cfg.nginx.enable {
194         enable = mkDefault true;
195         virtualHosts."${cfg.settings.hostname}".locations = let
196           ui = "http://127.0.0.1:${toString cfg.ui.port}";
197           backend = "http://127.0.0.1:${toString cfg.settings.port}";
198         in {
199           "~ ^/(api|pictrs|feeds|nodeinfo|.well-known)" = {
200             # backend requests
201             proxyPass = backend;
202             proxyWebsockets = true;
203             recommendedProxySettings = true;
204           };
205           "/" = {
206             # mixed frontend and backend requests, based on the request headers
207             recommendedProxySettings = true;
208             extraConfig = ''
209               set $proxpass "${ui}";
210               if ($http_accept = "application/activity+json") {
211                 set $proxpass "${backend}";
212               }
213               if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") {
214                 set $proxpass "${backend}";
215               }
216               if ($request_method = POST) {
217                 set $proxpass "${backend}";
218               }
220               # Cuts off the trailing slash on URLs to make them valid
221               rewrite ^(.+)/+$ $1 permanent;
223               proxy_pass $proxpass;
224             '';
225           };
226         };
227       };
229       assertions = [
230         {
231           assertion = cfg.database.createLocally -> cfg.settings.database.host == "localhost" || cfg.settings.database.host == "/run/postgresql";
232           message = "if you want to create the database locally, you need to use a local database";
233         }
234         {
235           assertion = (!(hasAttrByPath ["federation"] cfg.settings)) && (!(hasAttrByPath ["federation" "enabled"] cfg.settings));
236           message = "`services.lemmy.settings.federation` was removed in 0.17.0 and no longer has any effect";
237         }
238         {
239           assertion = cfg.database.uriFile != null -> cfg.database.uri == null && !cfg.database.createLocally;
240           message = "specifying a database uri while also specifying a database uri file is not allowed";
241         }
242       ];
244       systemd.services.lemmy = let
245         substitutedConfig = "/run/lemmy/config.hjson";
246       in {
247         description = "Lemmy server";
249         environment = {
250           LEMMY_CONFIG_LOCATION = if secrets == {} then settingsFormat.generate "config.hjson" cfg.settings else substitutedConfig;
251           LEMMY_DATABASE_URL = if cfg.database.uri != null then cfg.database.uri else (mkIf (cfg.database.createLocally) "postgres:///lemmy?host=/run/postgresql&user=lemmy");
252         };
254         documentation = [
255           "https://join-lemmy.org/docs/en/admins/from_scratch.html"
256           "https://join-lemmy.org/docs/en/"
257         ];
259         wantedBy = [ "multi-user.target" ];
261         after = [ "pict-rs.service" ] ++ lib.optionals cfg.database.createLocally [ "postgresql.service" ];
263         requires = lib.optionals cfg.database.createLocally [ "postgresql.service" ];
265         # substitute secrets and prevent others from reading the result
266         # if somehow $CREDENTIALS_DIRECTORY is not set we fail
267         preStart = mkIf (secrets != {}) ''
268           set -u
269           umask u=rw,g=,o=
270           cd "$CREDENTIALS_DIRECTORY"
271           ${utils.genJqSecretsReplacementSnippet cfg.settings substitutedConfig}
272         '';
274         serviceConfig = {
275           DynamicUser = true;
276           RuntimeDirectory = "lemmy";
277           ExecStart = "${cfg.server.package}/bin/lemmy_server";
278           LoadCredential = lib.foldlAttrs (acc: option: data: acc ++ [ "${option}:${toString data.path}" ]) [] secrets;
279           PrivateTmp = true;
280           MemoryDenyWriteExecute = true;
281           NoNewPrivileges = true;
282         };
283       };
285       systemd.services.lemmy-ui = {
286         description = "Lemmy ui";
288         environment = {
289           LEMMY_UI_HOST = "127.0.0.1:${toString cfg.ui.port}";
290           LEMMY_UI_LEMMY_INTERNAL_HOST = "127.0.0.1:${toString cfg.settings.port}";
291           LEMMY_UI_LEMMY_EXTERNAL_HOST = cfg.settings.hostname;
292           LEMMY_UI_HTTPS = "false";
293           NODE_ENV = "production";
294         };
296         documentation = [
297           "https://join-lemmy.org/docs/en/admins/from_scratch.html"
298           "https://join-lemmy.org/docs/en/"
299         ];
301         wantedBy = [ "multi-user.target" ];
303         after = [ "lemmy.service" ];
305         requires = [ "lemmy.service" ];
307         serviceConfig = {
308           DynamicUser = true;
309           WorkingDirectory = "${cfg.ui.package}";
310           ExecStart = "${pkgs.nodejs}/bin/node ${cfg.ui.package}/dist/js/server.js";
311         };
312       };
313     };