1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.castopod;
9 fpm = config.services.phpfpm.pools.castopod;
10
11 user = "castopod";
12
13 # https://docs.castopod.org/getting-started/install.html#requirements
14 phpPackage = pkgs.php82.withExtensions (
15 { enabled, all }:
16 with all;
17 [
18 intl
19 curl
20 mbstring
21 gd
22 exif
23 mysqlnd
24 ]
25 ++ enabled
26 );
27in
28{
29 meta.doc = ./castopod.md;
30 meta.maintainers = with lib.maintainers; [ alexoundos ];
31
32 options.services = {
33 castopod = {
34 enable = lib.mkEnableOption "Castopod, a hosting platform for podcasters";
35 package = lib.mkPackageOption pkgs "castopod" { };
36 dataDir = lib.mkOption {
37 type = lib.types.path;
38 default = "/var/lib/castopod";
39 description = ''
40 The path where castopod stores all data. This path must be in sync
41 with the castopod package (where it is hardcoded during the build in
42 accordance with its own `dataDir` argument).
43 '';
44 };
45 database = {
46 createLocally = lib.mkOption {
47 type = lib.types.bool;
48 default = true;
49 description = ''
50 Create the database and database user locally.
51 '';
52 };
53 hostname = lib.mkOption {
54 type = lib.types.str;
55 default = "localhost";
56 description = "Database hostname.";
57 };
58 name = lib.mkOption {
59 type = lib.types.str;
60 default = "castopod";
61 description = "Database name.";
62 };
63 user = lib.mkOption {
64 type = lib.types.str;
65 default = user;
66 description = "Database user.";
67 };
68 passwordFile = lib.mkOption {
69 type = lib.types.nullOr lib.types.path;
70 default = null;
71 example = "/run/keys/castopod-dbpassword";
72 description = ''
73 A file containing the password corresponding to
74 [](#opt-services.castopod.database.user).
75
76 This file is loaded using systemd LoadCredentials.
77 '';
78 };
79 };
80 settings = lib.mkOption {
81 type =
82 with lib.types;
83 attrsOf (oneOf [
84 str
85 int
86 bool
87 ]);
88 default = { };
89 example = {
90 "email.protocol" = "smtp";
91 "email.SMTPHost" = "localhost";
92 "email.SMTPUser" = "myuser";
93 "email.fromEmail" = "castopod@example.com";
94 };
95 description = ''
96 Environment variables used for Castopod.
97 See [](https://code.castopod.org/adaures/castopod/-/blob/main/.env.example)
98 for available environment variables.
99 '';
100 };
101 environmentFile = lib.mkOption {
102 type = lib.types.nullOr lib.types.path;
103 default = null;
104 example = "/run/keys/castopod-env";
105 description = ''
106 Environment file to inject e.g. secrets into the configuration.
107 See [](https://code.castopod.org/adaures/castopod/-/blob/main/.env.example)
108 for available environment variables.
109
110 This file is loaded using systemd LoadCredentials.
111 '';
112 };
113 configureNginx = lib.mkOption {
114 type = lib.types.bool;
115 default = true;
116 description = "Configure nginx as a reverse proxy for CastoPod.";
117 };
118 localDomain = lib.mkOption {
119 type = lib.types.str;
120 example = "castopod.example.org";
121 description = "The domain serving your CastoPod instance.";
122 };
123 poolSettings = lib.mkOption {
124 type =
125 with lib.types;
126 attrsOf (oneOf [
127 str
128 int
129 bool
130 ]);
131 default = {
132 "pm" = "dynamic";
133 "pm.max_children" = "32";
134 "pm.start_servers" = "2";
135 "pm.min_spare_servers" = "2";
136 "pm.max_spare_servers" = "4";
137 "pm.max_requests" = "500";
138 };
139 description = ''
140 Options for Castopod's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives.
141 '';
142 };
143 maxUploadSize = lib.mkOption {
144 type = lib.types.str;
145 default = "512M";
146 description = ''
147 Maximum supported size for a file upload in. Maximum HTTP body
148 size is set to this value for nginx and PHP (because castopod doesn't
149 support chunked uploads yet:
150 https://code.castopod.org/adaures/castopod/-/issues/330).
151
152 Note, that practical upload size limit is smaller. For example, with
153 512 MiB setting - around 500 MiB is possible.
154 '';
155 };
156 };
157 };
158
159 config = lib.mkIf cfg.enable {
160 services.castopod.settings =
161 let
162 sslEnabled =
163 with config.services.nginx.virtualHosts.${cfg.localDomain};
164 addSSL || forceSSL || onlySSL || enableACME || useACMEHost != null;
165 baseURL = "http${lib.optionalString sslEnabled "s"}://${cfg.localDomain}";
166 in
167 lib.mapAttrs (_: lib.mkDefault) {
168 "app.forceGlobalSecureRequests" = sslEnabled;
169 "app.baseURL" = baseURL;
170
171 "media.baseURL" = baseURL;
172 "media.root" = "media";
173 "media.storage" = cfg.dataDir;
174
175 "admin.gateway" = "admin";
176 "auth.gateway" = "auth";
177
178 "database.default.hostname" = cfg.database.hostname;
179 "database.default.database" = cfg.database.name;
180 "database.default.username" = cfg.database.user;
181 "database.default.DBPrefix" = "cp_";
182
183 "cache.handler" = "file";
184 };
185
186 services.phpfpm.pools.castopod = {
187 inherit user;
188 group = config.services.nginx.group;
189 inherit phpPackage;
190 phpOptions = ''
191 # https://code.castopod.org/adaures/castopod/-/blob/develop/docker/production/common/uploads.template.ini
192 file_uploads = On
193 memory_limit = 512M
194 upload_max_filesize = ${cfg.maxUploadSize}
195 post_max_size = ${cfg.maxUploadSize}
196 max_execution_time = 300
197 max_input_time = 300
198 '';
199 settings = {
200 "listen.owner" = config.services.nginx.user;
201 "listen.group" = config.services.nginx.group;
202 }
203 // cfg.poolSettings;
204 };
205
206 systemd.services.castopod-setup = {
207 after = lib.optional config.services.mysql.enable "mysql.service";
208 requires = lib.optional config.services.mysql.enable "mysql.service";
209 wantedBy = [ "multi-user.target" ];
210 path = [
211 pkgs.openssl
212 phpPackage
213 ];
214 script =
215 let
216 envFile = "${cfg.dataDir}/.env";
217 media = "${cfg.settings."media.storage"}/${cfg.settings."media.root"}";
218 in
219 ''
220 mkdir -p ${cfg.dataDir}/writable/{cache,logs,session,temp,uploads}
221
222 if [ ! -d ${lib.escapeShellArg media} ]; then
223 cp --no-preserve=mode,ownership -r ${cfg.package}/share/castopod/public/media ${lib.escapeShellArg media}
224 fi
225
226 if [ ! -f ${cfg.dataDir}/salt ]; then
227 openssl rand -base64 33 > ${cfg.dataDir}/salt
228 fi
229
230 cat <<'EOF' > ${envFile}
231 ${lib.generators.toKeyValue { } cfg.settings}
232 EOF
233
234 echo "analytics.salt=$(cat ${cfg.dataDir}/salt)" >> ${envFile}
235
236 ${
237 if (cfg.database.passwordFile != null) then
238 ''
239 echo "database.default.password=$(cat "$CREDENTIALS_DIRECTORY/dbpasswordfile)" >> ${envFile}
240 ''
241 else
242 ''
243 echo "database.default.password=" >> ${envFile}
244 ''
245 }
246
247 ${lib.optionalString (cfg.environmentFile != null) ''
248 cat "$CREDENTIALS_DIRECTORY/envfile" >> ${envFile}
249 ''}
250
251 php ${cfg.package}/share/castopod/spark castopod:database-update
252 '';
253 serviceConfig = {
254 StateDirectory = "castopod";
255 LoadCredential =
256 lib.optional (cfg.environmentFile != null) "envfile:${cfg.environmentFile}"
257 ++ (lib.optional (cfg.database.passwordFile != null) "dbpasswordfile:${cfg.database.passwordFile}");
258 WorkingDirectory = "${cfg.package}/share/castopod";
259 Type = "oneshot";
260 RemainAfterExit = true;
261 User = user;
262 Group = config.services.nginx.group;
263 ReadWritePaths = cfg.dataDir;
264 };
265 };
266
267 systemd.services.castopod-scheduled = {
268 after = [ "castopod-setup.service" ];
269 wantedBy = [ "multi-user.target" ];
270 path = [ phpPackage ];
271 script = ''
272 php ${cfg.package}/share/castopod/spark tasks:run
273 '';
274 serviceConfig = {
275 StateDirectory = "castopod";
276 WorkingDirectory = "${cfg.package}/share/castopod";
277 Type = "oneshot";
278 User = user;
279 Group = config.services.nginx.group;
280 ReadWritePaths = cfg.dataDir;
281 LogLevelMax = "notice"; # otherwise periodic tasks flood the journal
282 };
283 };
284
285 systemd.timers.castopod-scheduled = {
286 wantedBy = [ "timers.target" ];
287 timerConfig = {
288 OnCalendar = "*-*-* *:*:00";
289 Unit = "castopod-scheduled.service";
290 };
291 };
292
293 services.mysql = lib.mkIf cfg.database.createLocally {
294 enable = true;
295 package = lib.mkDefault pkgs.mariadb;
296 ensureDatabases = [ cfg.database.name ];
297 ensureUsers = [
298 {
299 name = cfg.database.user;
300 ensurePermissions = {
301 "${cfg.database.name}.*" = "ALL PRIVILEGES";
302 };
303 }
304 ];
305 };
306
307 services.nginx = lib.mkIf cfg.configureNginx {
308 enable = true;
309 virtualHosts."${cfg.localDomain}" = {
310 root = lib.mkForce "${cfg.package}/share/castopod/public";
311
312 extraConfig = ''
313 try_files $uri $uri/ /index.php?$args;
314 index index.php index.html;
315 client_max_body_size ${cfg.maxUploadSize};
316 '';
317
318 locations."^~ /${cfg.settings."media.root"}/" = {
319 root = cfg.settings."media.storage";
320 extraConfig = ''
321 add_header Access-Control-Allow-Origin "*";
322 expires max;
323 access_log off;
324 '';
325 };
326
327 locations."~ \\.php$" = {
328 fastcgiParams = {
329 SERVER_NAME = "$host";
330 };
331 extraConfig = ''
332 fastcgi_intercept_errors on;
333 fastcgi_index index.php;
334 fastcgi_pass unix:${fpm.socket};
335 try_files $uri =404;
336 fastcgi_read_timeout 3600;
337 fastcgi_send_timeout 3600;
338 '';
339 };
340 };
341 };
342
343 users.users.${user} = lib.mapAttrs (_: lib.mkDefault) {
344 description = "Castopod user";
345 isSystemUser = true;
346 group = config.services.nginx.group;
347 };
348 };
349}