grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / web-apps / pretix.nix
blobf521c49668c876a296df1af8b0abbac910360778
1 { config
2 , lib
3 , pkgs
4 , utils
5 , ...
6 }:
8 let
9   inherit (lib)
10     concatMapStringsSep
11     escapeShellArgs
12     filter
13     filterAttrs
14     getExe
15     getExe'
16     isAttrs
17     isList
18     literalExpression
19     mapAttrs
20     mkDefault
21     mkEnableOption
22     mkIf
23     mkOption
24     mkPackageOption
25     optionals
26     optionalString
27     recursiveUpdate
28     types
29   ;
31   filterRecursiveNull = o:
32     if isAttrs o then
33       mapAttrs (_: v: filterRecursiveNull v) (filterAttrs (_: v: v != null) o)
34     else if isList o then
35       map filterRecursiveNull (filter (v: v != null) o)
36     else
37       o;
39   cfg = config.services.pretix;
40   format = pkgs.formats.ini { };
42   configFile = format.generate "pretix.cfg" (filterRecursiveNull cfg.settings);
44   finalPackage = cfg.package.override {
45     inherit (cfg) plugins;
46   };
48   pythonEnv = cfg.package.python.buildEnv.override {
49     extraLibs = with cfg.package.python.pkgs; [
50       (toPythonModule finalPackage)
51       gunicorn
52     ]
53     ++ lib.optionals (cfg.settings.memcached.location != null)
54       cfg.package.optional-dependencies.memcached
55     ;
56   };
58   withRedis = cfg.settings.redis.location != null;
61   meta = with lib; {
62     maintainers = with maintainers; [ hexa ];
63   };
65   options.services.pretix = {
66     enable = mkEnableOption "Pretix, a ticket shop application for conferences, festivals, concerts, etc";
68     package = mkPackageOption pkgs "pretix" { };
70     group = mkOption {
71       type = types.str;
72       default = "pretix";
73       description = ''
74         Group under which pretix should run.
75       '';
76     };
78     user = mkOption {
79       type = types.str;
80       default = "pretix";
81       description = ''
82         User under which pretix should run.
83       '';
84     };
86     environmentFile = mkOption {
87       type = types.nullOr types.path;
88       default = null;
89       example = "/run/keys/pretix-secrets.env";
90       description = ''
91         Environment file to pass secret configuration values.
93         Each line must follow the `PRETIX_SECTION_KEY=value` pattern.
94       '';
95     };
97     plugins = mkOption {
98       type = types.listOf types.package;
99       default = [];
100       example = literalExpression ''
101         with config.services.pretix.package.plugins; [
102           passbook
103           pages
104         ];
105       '';
106       description = ''
107         Pretix plugins to install into the Python environment.
108       '';
109     };
111     gunicorn.extraArgs = mkOption {
112       type = with types; listOf str;
113       default = [
114         "--name=pretix"
115       ];
116       example = [
117         "--name=pretix"
118         "--workers=4"
119         "--max-requests=1200"
120         "--max-requests-jitter=50"
121         "--log-level=info"
122       ];
123       description = ''
124         Extra arguments to pass to gunicorn.
125         See <https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html#start-pretix-as-a-service> for details.
126       '';
127       apply = escapeShellArgs;
128     };
130     celery = {
131       extraArgs = mkOption {
132         type = with types; listOf str;
133         default = [ ];
134         description = ''
135           Extra arguments to pass to celery.
137           See <https://docs.celeryq.dev/en/stable/reference/cli.html#celery-worker> for more info.
138         '';
139         apply = utils.escapeSystemdExecArgs;
140       };
141     };
143     nginx = {
144       enable = mkOption {
145         type = types.bool;
146         default = true;
147         example = false;
148         description = ''
149           Whether to set up an nginx virtual host.
150         '';
151       };
153       domain = mkOption {
154         type = types.str;
155         example = "talks.example.com";
156         description = ''
157           The domain name under which to set up the virtual host.
158         '';
159       };
160     };
162     database.createLocally = mkOption {
163       type = types.bool;
164       default = true;
165       example = false;
166       description = ''
167         Whether to automatically set up the database on the local DBMS instance.
169         Only supported for PostgreSQL. Not required for sqlite.
170       '';
171     };
173     settings = mkOption {
174       type = types.submodule {
175         freeformType = format.type;
176         options = {
177           pretix = {
178             instance_name = mkOption {
179               type = types.str;
180               example = "tickets.example.com";
181               description = ''
182                 The name of this installation.
183               '';
184             };
186             url = mkOption {
187               type = types.str;
188               example = "https://tickets.example.com";
189               description = ''
190                 The installation’s full URL, without a trailing slash.
191               '';
192             };
194             cachedir = mkOption {
195               type = types.path;
196               default = "/var/cache/pretix";
197               description = ''
198                 Directory for storing temporary files.
199               '';
200             };
202             datadir = mkOption {
203               type = types.path;
204               default = "/var/lib/pretix";
205               description = ''
206                 Directory for storing user uploads and similar data.
207               '';
208             };
210             logdir = mkOption {
211               type = types.path;
212               default = "/var/log/pretix";
213               description = ''
214                 Directory for storing log files.
215               '';
216             };
218             currency = mkOption {
219               type = types.str;
220               default = "EUR";
221               example = "USD";
222               description = ''
223                 Default currency for events in its ISO 4217 three-letter code.
224               '';
225             };
227             registration = mkOption {
228               type = types.bool;
229               default = false;
230               example = true;
231               description = ''
232                 Whether to allow registration of new admin users.
233               '';
234             };
235           };
237           database = {
238             backend = mkOption {
239               type = types.enum [
240                 "sqlite3"
241                 "postgresql"
242               ];
243               default = "postgresql";
244               description = ''
245                 Database backend to use.
247                 Only postgresql is recommended for production setups.
248               '';
249             };
251             host = mkOption {
252               type = with types; nullOr str;
253               default = if cfg.settings.database.backend == "postgresql" then "/run/postgresql" else null;
254               defaultText = literalExpression ''
255                 if config.services.pretix.settings..database.backend == "postgresql" then "/run/postgresql"
256                 else null
257               '';
258               description = ''
259                 Database host or socket path.
260               '';
261             };
263             name = mkOption {
264               type = types.str;
265               default = "pretix";
266               description = ''
267                 Database name.
268               '';
269             };
271             user = mkOption {
272               type = types.str;
273               default = "pretix";
274               description = ''
275                 Database username.
276               '';
277             };
278           };
280           mail = {
281             from = mkOption {
282               type = types.str;
283               example = "tickets@example.com";
284               description = ''
285                 E-Mail address used in the `FROM` header of outgoing mails.
286               '';
287             };
289             host = mkOption {
290               type = types.str;
291               default = "localhost";
292               example = "mail.example.com";
293               description = ''
294                 Hostname of the SMTP server use for mail delivery.
295               '';
296             };
298             port = mkOption {
299               type = types.port;
300               default = 25;
301               example = 587;
302               description = ''
303                 Port of the SMTP server to use for mail delivery.
304               '';
305             };
306           };
308           celery = {
309             backend = mkOption {
310               type = types.str;
311               default = "redis+socket://${config.services.redis.servers.pretix.unixSocket}?virtual_host=1";
312               defaultText = literalExpression ''
313                 redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=1
314               '';
315               description = ''
316                 URI to the celery backend used for the asynchronous job queue.
317               '';
318             };
320             broker = mkOption {
321               type = types.str;
322               default = "redis+socket://${config.services.redis.servers.pretix.unixSocket}?virtual_host=2";
323               defaultText = literalExpression ''
324                 redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=2
325               '';
326               description = ''
327                 URI to the celery broker used for the asynchronous job queue.
328               '';
329             };
330           };
332           redis = {
333             location = mkOption {
334               type = with types; nullOr str;
335               default = "unix://${config.services.redis.servers.pretix.unixSocket}?db=0";
336               defaultText = literalExpression ''
337                 "unix://''${config.services.redis.servers.pretix.unixSocket}?db=0"
338               '';
339               description = ''
340                 URI to the redis server, used to speed up locking, caching and session storage.
341               '';
342             };
344             sessions = mkOption {
345               type = types.bool;
346               default = true;
347               example = false;
348               description = ''
349                 Whether to use redis as the session storage.
350               '';
351             };
352           };
354           memcached = {
355             location = mkOption {
356               type = with types; nullOr str;
357               default = null;
358               example = "127.0.0.1:11211";
359               description = ''
360                 The `host:port` combination or the path to the UNIX socket of a memcached instance.
362                 Can be used instead of Redis for caching.
363               '';
364             };
365           };
367           tools = {
368             pdftk = mkOption {
369               type = types.path;
370               default = getExe pkgs.pdftk;
371               defaultText = literalExpression ''
372                 lib.getExe pkgs.pdftk
373               '';
374               description = ''
375                 Path to the pdftk executable.
376               '';
377             };
378           };
379         };
380       };
381       default = { };
382       description = ''
383         pretix configuration as a Nix attribute set. All settings can also be passed
384         from the environment.
386         See <https://docs.pretix.eu/en/latest/admin/config.html> for possible options.
387       '';
388     };
389   };
391   config = mkIf cfg.enable {
392     # https://docs.pretix.eu/en/latest/admin/installation/index.html
394     environment.systemPackages = [
395       (pkgs.writeScriptBin "pretix-manage" ''
396         cd ${cfg.settings.pretix.datadir}
397         sudo=exec
398         if [[ "$USER" != ${cfg.user} ]]; then
399           sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} ${optionalString withRedis "-g redis-pretix"} --preserve-env=PRETIX_CONFIG_FILE'
400         fi
401         export PRETIX_CONFIG_FILE=${configFile}
402         $sudo ${getExe' pythonEnv "pretix-manage"} "$@"
403       '')
404     ];
406     services.logrotate.settings.pretix = {
407       files = "${cfg.settings.pretix.logdir}/*.log";
408       su = "${cfg.user} ${cfg.group}";
409       frequency = "weekly";
410       rotate = "12";
411       copytruncate = true;
412       compress = true;
413     };
415     services = {
416       nginx = mkIf cfg.nginx.enable {
417         enable = true;
418         recommendedGzipSettings = mkDefault true;
419         recommendedOptimisation = mkDefault true;
420         recommendedProxySettings = mkDefault true;
421         recommendedTlsSettings = mkDefault true;
422         upstreams.pretix.servers."unix:/run/pretix/pretix.sock" = { };
423         virtualHosts.${cfg.nginx.domain} = {
424           # https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html#ssl
425           extraConfig = ''
426             more_set_headers Referrer-Policy same-origin;
427             more_set_headers X-Content-Type-Options nosniff;
428           '';
429           locations = {
430             "/".proxyPass = "http://pretix";
431             "/media/" = {
432               alias = "${cfg.settings.pretix.datadir}/media/";
433               extraConfig = ''
434                 access_log off;
435                 expires 7d;
436               '';
437             };
438             "^~ /media/(cachedfiles|invoices)" = {
439               extraConfig = ''
440                 deny all;
441                 return 404;
442               '';
443             };
444             "/static/" = {
445               alias = "${finalPackage}/${cfg.package.python.sitePackages}/pretix/static.dist/";
446               extraConfig = ''
447                 access_log off;
448                 more_set_headers Cache-Control "public";
449                 expires 365d;
450               '';
451             };
452           };
453         };
454       };
456       postgresql = mkIf (cfg.database.createLocally && cfg.settings.database.backend == "postgresql") {
457         enable = true;
458         ensureUsers = [ {
459           name = cfg.settings.database.user;
460           ensureDBOwnership = true;
461         } ];
462         ensureDatabases = [ cfg.settings.database.name ];
463       };
465       redis.servers.pretix.enable = withRedis;
466     };
468     systemd.services = let
469       commonUnitConfig = {
470         environment.PRETIX_CONFIG_FILE = configFile;
471         serviceConfig = {
472           User = "pretix";
473           Group = "pretix";
474           EnvironmentFile = optionals (cfg.environmentFile != null) [
475             cfg.environmentFile
476           ];
477           StateDirectory = [
478             "pretix"
479           ];
480           StateDirectoryMode = "0750";
481           CacheDirectory = "pretix";
482           LogsDirectory = "pretix";
483           WorkingDirectory = cfg.settings.pretix.datadir;
484           SupplementaryGroups = optionals withRedis [
485             "redis-pretix"
486           ];
487           AmbientCapabilities = "";
488           CapabilityBoundingSet = [ "" ];
489           DevicePolicy = "closed";
490           LockPersonality = true;
491           MemoryDenyWriteExecute = false; # required by pdftk
492           NoNewPrivileges = true;
493           PrivateDevices = true;
494           PrivateTmp = true;
495           ProcSubset = "pid";
496           ProtectControlGroups = true;
497           ProtectHome = true;
498           ProtectHostname = true;
499           ProtectKernelLogs = true;
500           ProtectKernelModules = true;
501           ProtectKernelTunables = true;
502           ProtectProc = "invisible";
503           ProtectSystem = "strict";
504           RemoveIPC = true;
505           RestrictAddressFamilies = [
506             "AF_INET"
507             "AF_INET6"
508             "AF_UNIX"
509           ];
510           RestrictNamespaces = true;
511           RestrictRealtime = true;
512           RestrictSUIDSGID = true;
513           SystemCallArchitectures = "native";
514           SystemCallFilter = [
515             "@system-service"
516             "~@privileged"
517             "@chown"
518           ];
519           UMask = "0027";
520         };
521       };
522     in {
523       pretix-web = recursiveUpdate commonUnitConfig {
524         description = "pretix web service";
525         after = [
526           "network.target"
527           "redis-pretix.service"
528           "postgresql.service"
529         ];
530         wantedBy = [ "multi-user.target" ];
531         preStart = ''
532           versionFile="${cfg.settings.pretix.datadir}/.version"
533           version=$(cat "$versionFile" 2>/dev/null || echo 0)
535           pluginsFile="${cfg.settings.pretix.datadir}/.plugins"
536           plugins=$(cat "$pluginsFile" 2>/dev/null || echo "")
537           configuredPlugins="${concatMapStringsSep "|" (package: package.name) cfg.plugins}"
539           if [[ $version != ${cfg.package.version} || $plugins != $configuredPlugins ]]; then
540             ${getExe' pythonEnv "pretix-manage"} migrate
542             echo "${cfg.package.version}" > "$versionFile"
543             echo "$configuredPlugins" > "$pluginsFile"
544           fi
545         '';
546         serviceConfig = {
547           TimeoutStartSec = "15min";
548           ExecStart = "${getExe' pythonEnv "gunicorn"} --bind unix:/run/pretix/pretix.sock ${cfg.gunicorn.extraArgs} pretix.wsgi";
549           RuntimeDirectory = "pretix";
550           Restart = "on-failure";
551         };
552       };
554       pretix-periodic = recursiveUpdate commonUnitConfig {
555         description = "pretix periodic task runner";
556         # every 15 minutes
557         startAt = [ "*:3,18,33,48" ];
558         serviceConfig = {
559           Type = "oneshot";
560           ExecStart = "${getExe' pythonEnv "pretix-manage"} runperiodic";
561         };
562       };
564       pretix-worker = recursiveUpdate commonUnitConfig {
565         description = "pretix asynchronous job runner";
566         after = [
567           "network.target"
568           "redis-pretix.service"
569           "postgresql.service"
570         ];
571         wantedBy = [ "multi-user.target" ];
572         serviceConfig = {
573           ExecStart = "${getExe' pythonEnv "celery"} -A pretix.celery_app worker ${cfg.celery.extraArgs}";
574           Restart = "on-failure";
575         };
576       };
578       nginx.serviceConfig.SupplementaryGroups = mkIf cfg.nginx.enable [ "pretix" ];
579     };
581     systemd.sockets.pretix-web.socketConfig = {
582       ListenStream = "/run/pretix/pretix.sock";
583       SocketUser = "nginx";
584     };
586     users = {
587       groups.${cfg.group} = {};
588       users.${cfg.user} = {
589         isSystemUser = true;
590         inherit (cfg) group;
591       };
592     };
593   };