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