1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.weblate;
10
11 dataDir = "/var/lib/weblate";
12 settingsDir = "${dataDir}/settings";
13
14 finalPackage = cfg.package.overridePythonAttrs (old: {
15 # We only support the PostgreSQL backend in this module
16 dependencies = old.dependencies ++ cfg.package.optional-dependencies.postgres;
17 # Use a settings module in dataDir, to avoid having to rebuild the package
18 # when user changes settings.
19 makeWrapperArgs = (old.makeWrapperArgs or [ ]) ++ [
20 "--set PYTHONPATH \"${settingsDir}\""
21 "--set DJANGO_SETTINGS_MODULE \"settings\""
22 ];
23 });
24 inherit (finalPackage) python;
25
26 pythonEnv = python.buildEnv.override {
27 extraLibs = with python.pkgs; [
28 (toPythonModule finalPackage)
29 celery
30 ];
31 };
32
33 # This extends and overrides the weblate/settings_example.py code found in upstream.
34 weblateConfig =
35 ''
36 # This was autogenerated by the NixOS module.
37
38 SITE_TITLE = "Weblate"
39 SITE_DOMAIN = "${cfg.localDomain}"
40 # TLS terminates at the reverse proxy, but this setting controls how links to weblate are generated.
41 ENABLE_HTTPS = True
42 SESSION_COOKIE_SECURE = ENABLE_HTTPS
43 DATA_DIR = "${dataDir}"
44 CACHE_DIR = f"{DATA_DIR}/cache"
45 STATIC_ROOT = "${finalPackage.static}"
46 MEDIA_ROOT = "/var/lib/weblate/media"
47 COMPRESS_ROOT = "${finalPackage.static}"
48 COMPRESS_OFFLINE = True
49 DEBUG = False
50
51 with open("${cfg.djangoSecretKeyFile}") as f:
52 SECRET_KEY = f.read().rstrip("\n")
53
54 CACHES = {
55 "default": {
56 "BACKEND": "django_redis.cache.RedisCache",
57 "LOCATION": "unix://${config.services.redis.servers.weblate.unixSocket}",
58 "OPTIONS": {
59 "CLIENT_CLASS": "django_redis.client.DefaultClient",
60 "PASSWORD": None,
61 "CONNECTION_POOL_KWARGS": {},
62 },
63 "KEY_PREFIX": "weblate",
64 "TIMEOUT": 3600,
65 },
66 "avatar": {
67 "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
68 "LOCATION": "/var/lib/weblate/avatar-cache",
69 "TIMEOUT": 86400,
70 "OPTIONS": {"MAX_ENTRIES": 1000},
71 }
72 }
73
74 CELERY_TASK_ALWAYS_EAGER = False
75 CELERY_BROKER_URL = "redis+socket://${config.services.redis.servers.weblate.unixSocket}"
76 CELERY_RESULT_BACKEND = CELERY_BROKER_URL
77
78 VCS_BACKENDS = ("weblate.vcs.git.GitRepository",)
79
80 SITE_URL = "https://{}".format(SITE_DOMAIN)
81
82 # WebAuthn
83 OTP_WEBAUTHN_RP_NAME = SITE_TITLE
84 OTP_WEBAUTHN_RP_ID = SITE_DOMAIN.split(":")[0]
85 OTP_WEBAUTHN_ALLOWED_ORIGINS = [SITE_URL]
86 ''
87 + lib.optionalString cfg.configurePostgresql ''
88 DATABASES = {
89 "default": {
90 "ENGINE": "django.db.backends.postgresql",
91 "HOST": "/run/postgresql",
92 "NAME": "weblate",
93 "USER": "weblate",
94 }
95 }
96 ''
97 + lib.optionalString cfg.smtp.enable ''
98 EMAIL_HOST = "${cfg.smtp.host}"
99 EMAIL_USE_TLS = True
100 EMAIL_PORT = ${builtins.toString cfg.smtp.port}
101 SERVER_EMAIL = "${cfg.smtp.from}"
102 DEFAULT_FROM_EMAIL = "${cfg.smtp.from}"
103 ''
104 + lib.optionalString (cfg.smtp.enable && cfg.smtp.user != null) ''
105 ADMINS = (("Weblate Admin", "${cfg.smtp.user}"),)
106 EMAIL_HOST_USER = "${cfg.smtp.user}"
107 ''
108 + lib.optionalString (cfg.smtp.enable && cfg.smtp.passwordFile != null) ''
109 with open("${cfg.smtp.passwordFile}") as f:
110 EMAIL_HOST_PASSWORD = f.read().rstrip("\n")
111 ''
112 + cfg.extraConfig;
113 settings_py =
114 pkgs.runCommand "weblate_settings.py"
115 {
116 inherit weblateConfig;
117 passAsFile = [ "weblateConfig" ];
118 }
119 ''
120 mkdir -p $out
121 cat \
122 ${finalPackage}/${python.sitePackages}/weblate/settings_example.py \
123 $weblateConfigPath \
124 > $out/settings.py
125 '';
126
127 environment = {
128 PYTHONPATH = "${settingsDir}:${pythonEnv}/${python.sitePackages}/";
129 DJANGO_SETTINGS_MODULE = "settings";
130 # We run Weblate through gunicorn, so we can't utilise the env var set in the wrapper.
131 inherit (finalPackage) GI_TYPELIB_PATH;
132 };
133
134 weblatePath = with pkgs; [
135 gitSVN
136 borgbackup
137
138 #optional
139 git-review
140 tesseract
141 licensee
142 mercurial
143 openssh
144 ];
145in
146{
147
148 options = {
149 services.weblate = {
150 enable = lib.mkEnableOption "Weblate service";
151
152 package = lib.mkPackageOption pkgs "weblate" { };
153
154 localDomain = lib.mkOption {
155 description = "The domain name serving your Weblate instance.";
156 example = "weblate.example.org";
157 type = lib.types.str;
158 };
159
160 djangoSecretKeyFile = lib.mkOption {
161 description = ''
162 Location of the Django secret key.
163
164 This should be a path pointing to a file with secure permissions (not /nix/store).
165
166 Can be generated with `weblate-generate-secret-key` which is available as the `weblate` user.
167 '';
168 type = lib.types.path;
169 };
170
171 configurePostgresql = lib.mkOption {
172 type = lib.types.bool;
173 default = true;
174 description = ''
175 Whether to enable and configure a local PostgreSQL server by creating a user and database for weblate.
176 The default `settings` reference this database, if you disable this option you must provide a database URL in `extraConfig`.
177 '';
178 };
179
180 extraConfig = lib.mkOption {
181 type = lib.types.lines;
182 default = "";
183 description = ''
184 Text to append to `settings.py` Weblate configuration file.
185 '';
186 };
187
188 smtp = {
189 enable = lib.mkEnableOption "Weblate SMTP support";
190
191 from = lib.mkOption {
192 description = "The from address being used in sent emails.";
193 example = "weblate@example.com";
194 default = config.services.weblate.smtp.user;
195 defaultText = "config.services.weblate.smtp.user";
196 type = lib.types.str;
197 };
198
199 user = lib.mkOption {
200 description = "SMTP login name.";
201 example = "weblate@example.org";
202 type = lib.types.nullOr lib.types.str;
203 default = null;
204 };
205
206 host = lib.mkOption {
207 description = "SMTP host used when sending emails to users.";
208 type = lib.types.str;
209 example = "127.0.0.1";
210 };
211
212 port = lib.mkOption {
213 description = "SMTP port used when sending emails to users.";
214 type = lib.types.port;
215 default = 587;
216 example = 25;
217 };
218
219 passwordFile = lib.mkOption {
220 description = ''
221 Location of a file containing the SMTP password.
222
223 This should be a path pointing to a file with secure permissions (not /nix/store).
224 '';
225 type = lib.types.nullOr lib.types.path;
226 default = null;
227 };
228 };
229 };
230 };
231
232 config = lib.mkIf cfg.enable {
233
234 systemd.tmpfiles.rules = [ "L+ ${settingsDir} - - - - ${settings_py}" ];
235
236 services.nginx = {
237 enable = true;
238 virtualHosts."${cfg.localDomain}" = {
239
240 forceSSL = true;
241 enableACME = true;
242
243 locations = {
244 "= /favicon.ico".alias = "${finalPackage}/${python.sitePackages}/weblate/static/favicon.ico";
245 "/static/".alias = "${finalPackage.static}/";
246 "/media/".alias = "/var/lib/weblate/media/";
247 "/".proxyPass = "http://unix:///run/weblate.socket";
248 };
249 };
250 };
251
252 systemd.services.weblate-postgresql-setup = {
253 description = "Weblate PostgreSQL setup";
254 after = [ "postgresql.service" ];
255 serviceConfig = {
256 Type = "oneshot";
257 User = "postgres";
258 Group = "postgres";
259 ExecStart = ''
260 ${config.services.postgresql.package}/bin/psql weblate -c "CREATE EXTENSION IF NOT EXISTS pg_trgm"
261 '';
262 };
263 };
264
265 systemd.services.weblate-migrate = {
266 description = "Weblate migration";
267 after = [
268 "weblate-postgresql-setup.service"
269 "redis-weblate.service"
270 ];
271 requires = [
272 "weblate-postgresql-setup.service"
273 "redis-weblate.service"
274 ];
275 # We want this to be active on boot, not just on socket activation
276 wantedBy = [ "multi-user.target" ];
277 inherit environment;
278 path = weblatePath;
279 serviceConfig = {
280 Type = "oneshot";
281 StateDirectory = "weblate";
282 User = "weblate";
283 Group = "weblate";
284 ExecStart = "${finalPackage}/bin/weblate migrate --noinput";
285 };
286 };
287
288 systemd.services.weblate-celery = {
289 description = "Weblate Celery";
290 after = [
291 "network.target"
292 "redis-weblate.service"
293 "postgresql.service"
294 ];
295 # We want this to be active on boot, not just on socket activation
296 wantedBy = [ "multi-user.target" ];
297 environment = environment // {
298 CELERY_WORKER_RUNNING = "1";
299 };
300 path = weblatePath;
301 # Recommendations from:
302 # https://github.com/WeblateOrg/weblate/blob/main/weblate/examples/celery-weblate.service
303 serviceConfig =
304 let
305 # We have to push %n through systemd's replacement, therefore %%n.
306 pidFile = "/run/celery/weblate-%%n.pid";
307 nodes = "celery notify memory backup translate";
308 cmd = verb: ''
309 ${pythonEnv}/bin/celery multi ${verb} \
310 ${nodes} \
311 -A "weblate.utils" \
312 --pidfile=${pidFile} \
313 --logfile=/var/log/celery/weblate-%%n%%I.log \
314 --loglevel=DEBUG \
315 --beat:celery \
316 --queues:celery=celery \
317 --prefetch-multiplier:celery=4 \
318 --queues:notify=notify \
319 --prefetch-multiplier:notify=10 \
320 --queues:memory=memory \
321 --prefetch-multiplier:memory=10 \
322 --queues:translate=translate \
323 --prefetch-multiplier:translate=4 \
324 --concurrency:backup=1 \
325 --queues:backup=backup \
326 --prefetch-multiplier:backup=2
327 '';
328 in
329 {
330 Type = "forking";
331 User = "weblate";
332 Group = "weblate";
333 WorkingDirectory = "${finalPackage}/${python.sitePackages}/weblate/";
334 RuntimeDirectory = "celery";
335 RuntimeDirectoryPreserve = "restart";
336 LogsDirectory = "celery";
337 ExecStart = cmd "start";
338 ExecReload = cmd "restart";
339 ExecStop = ''
340 ${pythonEnv}/bin/celery multi stopwait \
341 ${nodes} \
342 --pidfile=${pidFile}
343 '';
344 Restart = "always";
345 };
346 };
347
348 systemd.services.weblate = {
349 description = "Weblate Gunicorn app";
350 after = [
351 "network.target"
352 "weblate-migrate.service"
353 "weblate-celery.service"
354 ];
355 requires = [
356 "weblate-migrate.service"
357 "weblate-celery.service"
358 "weblate.socket"
359 ];
360 inherit environment;
361 path = weblatePath;
362 serviceConfig = {
363 Type = "notify";
364 NotifyAccess = "all";
365 ExecStart =
366 let
367 gunicorn = python.pkgs.gunicorn.overridePythonAttrs (old: {
368 # Allows Gunicorn to set a meaningful process name
369 dependencies = (old.dependencies or [ ]) ++ old.optional-dependencies.setproctitle;
370 });
371 in
372 ''
373 ${gunicorn}/bin/gunicorn \
374 --name=weblate \
375 --bind='unix:///run/weblate.socket' \
376 weblate.wsgi
377 '';
378 ExecReload = "kill -s HUP $MAINPID";
379 KillMode = "mixed";
380 PrivateTmp = true;
381 WorkingDirectory = dataDir;
382 StateDirectory = "weblate";
383 RuntimeDirectory = "weblate";
384 User = "weblate";
385 Group = "weblate";
386 };
387 };
388
389 systemd.sockets.weblate = {
390 before = [ "nginx.service" ];
391 wantedBy = [ "sockets.target" ];
392 socketConfig = {
393 ListenStream = "/run/weblate.socket";
394 SocketUser = "weblate";
395 SocketGroup = "weblate";
396 SocketMode = "770";
397 };
398 };
399
400 services.redis.servers.weblate = {
401 enable = true;
402 user = "weblate";
403 unixSocket = "/run/redis-weblate/redis.sock";
404 unixSocketPerm = 770;
405 };
406
407 services.postgresql = lib.mkIf cfg.configurePostgresql {
408 enable = true;
409 ensureUsers = [
410 {
411 name = "weblate";
412 ensureDBOwnership = true;
413 }
414 ];
415 ensureDatabases = [ "weblate" ];
416 };
417
418 users.users.weblate = {
419 isSystemUser = true;
420 group = "weblate";
421 packages = [ finalPackage ] ++ weblatePath;
422 };
423
424 users.groups.weblate.members = [ config.services.nginx.user ];
425 };
426
427 meta.maintainers = with lib.maintainers; [ erictapen ];
428
429}