Merge pull request #298938 from Ramblurr/init-davis

davis: init at 4.4.1
This commit is contained in:
superherointj 2024-04-01 11:04:15 -03:00 committed by GitHub
commit d6b259ca25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 11418 additions and 0 deletions

View File

@ -138,6 +138,8 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m
- [Scrutiny](https://github.com/AnalogJ/scrutiny), a S.M.A.R.T monitoring tool for hard disks with a web frontend.
- [davis](https://github.com/tchapi/davis), a simple CardDav and CalDav server inspired by Baïkal. Available as [services.davis]($opt-services-davis.enable).
- [systemd-lock-handler](https://git.sr.ht/~whynothugo/systemd-lock-handler/), a bridge between logind D-Bus events and systemd targets. Available as [services.systemd-lock-handler.enable](#opt-services.systemd-lock-handler.enable).
- [wastebin](https://github.com/matze/wastebin), a pastebin server written in rust. Available as [services.wastebin](#opt-services.wastebin.enable).

View File

@ -1309,6 +1309,7 @@
./services/web-apps/cloudlog.nix
./services/web-apps/code-server.nix
./services/web-apps/convos.nix
./services/web-apps/davis.nix
./services/web-apps/dex.nix
./services/web-apps/discourse.nix
./services/web-apps/documize.nix

View File

@ -0,0 +1,32 @@
# Davis {#module-services-davis}
[Davis](https://github.com/tchapi/davis/) is a caldav and carrddav server. It
has a simple, fully translatable admin interface for sabre/dav based on Symfony
5 and Bootstrap 5, initially inspired by Baïkal.
## Basic Usage {#module-services-davis-basic-usage}
At first, an application secret is needed, this can be generated with:
```ShellSession
$ cat /dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1
```
After that, `davis` can be deployed like this:
```
{
services.davis = {
enable = true;
hostname = "davis.example.com";
mail = {
dsn = "smtp://username@example.com:25";
inviteFromAddress = "davis@example.com";
};
adminLogin = "admin";
adminPasswordFile = "/run/secrets/davis-admin-password";
appSecretFile = "/run/secrets/davis-app-secret";
nginx = {};
};
}
```
This deploys Davis using a sqlite database running out of `/var/lib/davis`.

View File

@ -0,0 +1,554 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.davis;
db = cfg.database;
mail = cfg.mail;
mysqlLocal = db.createLocally && db.driver == "mysql";
pgsqlLocal = db.createLocally && db.driver == "postgresql";
user = cfg.user;
group = cfg.group;
isSecret = v: lib.isAttrs v && v ? _secret && (lib.isString v._secret || builtins.isPath v._secret);
davisEnvVars = lib.generators.toKeyValue {
mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
mkValueString =
v:
if builtins.isInt v then
toString v
else if lib.isString v then
"\"${v}\""
else if true == v then
"true"
else if false == v then
"false"
else if null == v then
""
else if isSecret v then
if (lib.isString v._secret) then
builtins.hashString "sha256" v._secret
else
builtins.hashString "sha256" (builtins.readFile v._secret)
else
throw "unsupported type ${builtins.typeOf v}: ${(lib.generators.toPretty { }) v}";
};
};
secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
mkSecretReplacement = file: ''
replace-secret ${
lib.escapeShellArgs [
(
if (lib.isString file) then
builtins.hashString "sha256" file
else
builtins.hashString "sha256" (builtins.readFile file)
)
file
"${cfg.dataDir}/.env.local"
]
}
'';
secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
filteredConfig = lib.converge (lib.filterAttrsRecursive (
_: v:
!lib.elem v [
{ }
null
]
)) cfg.config;
davisEnv = pkgs.writeText "davis.env" (davisEnvVars filteredConfig);
in
{
options.services.davis = {
enable = lib.mkEnableOption (lib.mdDoc "Davis is a caldav and carddav server");
user = lib.mkOption {
default = "davis";
description = lib.mdDoc "User davis runs as.";
type = lib.types.str;
};
group = lib.mkOption {
default = "davis";
description = lib.mdDoc "Group davis runs as.";
type = lib.types.str;
};
package = lib.mkPackageOption pkgs "davis" { };
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/davis";
description = lib.mdDoc ''
Davis data directory.
'';
};
hostname = lib.mkOption {
type = lib.types.str;
example = "davis.yourdomain.org";
description = lib.mdDoc ''
Domain of the host to serve davis under. You may want to change it if you
run Davis on a different URL than davis.yourdomain.
'';
};
config = lib.mkOption {
type = lib.types.attrsOf (
lib.types.nullOr (
lib.types.either
(lib.types.oneOf [
lib.types.bool
lib.types.int
lib.types.port
lib.types.path
lib.types.str
])
(
lib.types.submodule {
options = {
_secret = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.str
lib.types.path
]
);
description = lib.mdDoc ''
The path to a file containing the value the
option should be set to in the final
configuration file.
'';
};
};
}
)
)
);
default = { };
example = '''';
description = lib.mdDoc '''';
};
adminLogin = lib.mkOption {
type = lib.types.str;
default = "root";
description = lib.mdDoc ''
Username for the admin account.
'';
};
adminPasswordFile = lib.mkOption {
type = lib.types.path;
description = lib.mdDoc ''
The full path to a file that contains the admin's password. Must be
readable by the user.
'';
example = "/run/secrets/davis-admin-pass";
};
appSecretFile = lib.mkOption {
type = lib.types.path;
description = lib.mdDoc ''
A file containing the Symfony APP_SECRET - Its value should be a series
of characters, numbers and symbols chosen randomly and the recommended
length is around 32 characters. Can be generated with <code>cat
/dev/urandom | tr -dc a-zA-Z0-9 | fold -w 48 | head -n 1</code>.
'';
example = "/run/secrets/davis-appsecret";
};
database = {
driver = lib.mkOption {
type = lib.types.enum [
"sqlite"
"postgresql"
"mysql"
];
default = "sqlite";
description = lib.mdDoc "Database type, required in all circumstances.";
};
urlFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/davis-db-url";
description = lib.mdDoc ''
A file containing the database connection url. If set then it
overrides all other database settings (except driver). This is
mandatory if you want to use an external database, that is when
`services.davis.database.createLocally` is `false`.
'';
};
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "davis";
description = lib.mdDoc "Database name, only used when the databse is created locally.";
};
createLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = lib.mdDoc "Create the database and database user locally.";
};
};
mail = {
dsn = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = lib.mdDoc "Mail DSN for sending emails. Mutually exclusive with `services.davis.mail.dsnFile`.";
example = "smtp://username:password@example.com:25";
};
dsnFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/run/secrets/davis-mail-dsn";
description = lib.mdDoc "A file containing the mail DSN for sending emails. Mutually exclusive with `servies.davis.mail.dsn`.";
};
inviteFromAddress = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = lib.mdDoc "Email address to send invitations from.";
example = "no-reply@dav.example.com";
};
};
nginx = lib.mkOption {
type = lib.types.submodule (
lib.recursiveUpdate (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) { }
);
default = null;
example = ''
{
serverAliases = [
"dav.''${config.networking.domain}"
];
# To enable encryption and let let's encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
description = lib.mdDoc ''
With this option, you can customize the nginx virtualHost settings.
'';
};
poolConfig = lib.mkOption {
type = lib.types.attrsOf (
lib.types.oneOf [
lib.types.str
lib.types.int
lib.types.bool
]
);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = lib.mdDoc ''
Options for the davis PHP pool. See the documentation on <literal>php-fpm.conf</literal>
for details on configuration directives.
'';
};
};
config =
let
defaultServiceConfig = {
ReadWritePaths = "${cfg.dataDir}";
User = user;
UMask = 77;
DeviceAllow = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@resources"
"~@privileged"
];
WorkingDirectory = "${cfg.package}/";
};
in
lib.mkIf cfg.enable {
assertions = [
{
assertion = db.createLocally -> db.urlFile == null;
message = "services.davis.database.urlFile must be unset if services.davis.database.createLocally is set true.";
}
{
assertion = db.createLocally || db.urlFile != null;
message = "One of services.davis.database.urlFile or services.davis.database.createLocally must be set.";
}
{
assertion = (mail.dsn != null) != (mail.dsnFile != null);
message = "One of (and only one of) services.davis.mail.dsn or services.davis.mail.dsnFile must be set.";
}
];
services.davis.config =
{
APP_ENV = "prod";
CACHE_DIR = "${cfg.dataDir}/var/cache";
# note: we do not need the log dir (we log to stdout/journald), by davis/symfony will try to create it, and the default value is one in the nix-store
# so we set it to a path under dataDir to avoid something like: Unable to create the "logs" directory (/nix/store/5cfskz0ybbx37s1161gjn5klwb5si1zg-davis-4.4.1/var/log).
LOG_DIR = "${cfg.dataDir}/var/log";
LOG_FILE_PATH = "/dev/stdout";
DATABASE_DRIVER = db.driver;
INVITE_FROM_ADDRESS = mail.inviteFromAddress;
APP_SECRET._secret = cfg.appSecretFile;
ADMIN_LOGIN = cfg.adminLogin;
ADMIN_PASSWORD._secret = cfg.adminPasswordFile;
APP_TIMEZONE = config.time.timeZone;
WEBDAV_ENABLED = false;
CALDAV_ENABLED = true;
CARDDAV_ENABLED = true;
}
// (if mail.dsn != null then { MAILER_DSN = mail.dsn; } else { MAILER_DSN._secret = mail.dsnFile; })
// (
if db.createLocally then
{
DATABASE_URL =
if db.driver == "sqlite" then
"sqlite:///${cfg.dataDir}/davis.db" # note: sqlite needs 4 slashes for an absolute path
else if
pgsqlLocal
# note: davis expects a non-standard postgres uri (due to the underlying doctrine library)
# specifically the charset query parameter, and the dummy hostname which is overriden by the host query parameter
then
"postgres://${user}@localhost/${db.name}?host=/run/postgresql&charset=UTF-8"
else if mysqlLocal then
"mysql://${user}@localhost/${db.name}?socket=/run/mysqld/mysqld.sock"
else
null;
}
else
{ DATABASE_URL._secret = db.urlFile; }
);
users = {
users = lib.mkIf (user == "davis") {
davis = {
description = "Davis service user";
group = cfg.group;
isSystemUser = true;
home = cfg.dataDir;
};
};
groups = lib.mkIf (group == "davis") { davis = { }; };
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0710 ${user} ${group} - -"
"d ${cfg.dataDir}/var 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/var/log 0700 ${user} ${group} - -"
"d ${cfg.dataDir}/var/cache 0700 ${user} ${group} - -"
];
services.phpfpm.pools.davis = {
inherit user group;
phpOptions = ''
log_errors = on
'';
phpEnv = {
ENV_DIR = "${cfg.dataDir}";
CACHE_DIR = "${cfg.dataDir}/var/cache";
#LOG_DIR = "${cfg.dataDir}/var/log";
};
settings =
{
"listen.mode" = "0660";
"pm" = "dynamic";
"pm.max_children" = 256;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
}
// (
if cfg.nginx != null then
{
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
else
{ }
)
// cfg.poolConfig;
};
# Reading the user-provided secret files requires root access
systemd.services.davis-env-setup = {
description = "Setup davis environment";
before = [
"phpfpm-davis.service"
"davis-db-migrate.service"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.replace-secret ];
restartTriggers = [
cfg.package
davisEnv
];
script = ''
# error handling
set -euo pipefail
# create .env file with the upstream values
install -T -m 0600 -o ${user} ${cfg.package}/env-upstream "${cfg.dataDir}/.env"
# create .env.local file with the user-provided values
install -T -m 0600 -o ${user} ${davisEnv} "${cfg.dataDir}/.env.local"
${secretReplacements}
'';
};
systemd.services.davis-db-migrate = {
description = "Migrate davis database";
before = [ "phpfpm-davis.service" ];
after =
lib.optional mysqlLocal "mysql.service"
++ lib.optional pgsqlLocal "postgresql.service"
++ [ "davis-env-setup.service" ];
requires =
lib.optional mysqlLocal "mysql.service"
++ lib.optional pgsqlLocal "postgresql.service"
++ [ "davis-env-setup.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = defaultServiceConfig // {
Type = "oneshot";
RemainAfterExit = true;
Environment = [
"ENV_DIR=${cfg.dataDir}"
"CACHE_DIR=${cfg.dataDir}/var/cache"
"LOG_DIR=${cfg.dataDir}/var/log"
];
EnvironmentFile = "${cfg.dataDir}/.env.local";
};
restartTriggers = [
cfg.package
davisEnv
];
script = ''
set -euo pipefail
${cfg.package}/bin/console cache:clear --no-debug
${cfg.package}/bin/console cache:warmup --no-debug
${cfg.package}/bin/console doctrine:migrations:migrate
'';
};
systemd.services.phpfpm-davis.after = [
"davis-env-setup.service"
"davis-db-migrate.service"
];
systemd.services.phpfpm-davis.requires = [
"davis-env-setup.service"
"davis-db-migrate.service"
] ++ lib.optional mysqlLocal "mysql.service" ++ lib.optional pgsqlLocal "postgresql.service";
systemd.services.phpfpm-davis.serviceConfig.ReadWritePaths = [ cfg.dataDir ];
services.nginx = lib.mkIf (cfg.nginx != null) {
enable = lib.mkDefault true;
virtualHosts = {
"${cfg.hostname}" = lib.mkMerge [
cfg.nginx
{
root = lib.mkForce "${cfg.package}/public";
extraConfig = ''
charset utf-8;
index index.php;
'';
locations = {
"/" = {
extraConfig = ''
try_files $uri $uri/ /index.php$is_args$args;
'';
};
"~* ^/.well-known/(caldav|carddav)$" = {
extraConfig = ''
return 302 $http_x_forwarded_proto://$host/dav/;
'';
};
"~ ^(.+\.php)(.*)$" = {
extraConfig = ''
try_files $fastcgi_script_name =404;
include ${config.services.nginx.package}/conf/fastcgi_params;
include ${config.services.nginx.package}/conf/fastcgi.conf;
fastcgi_pass unix:${config.services.phpfpm.pools.davis.socket};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_split_path_info ^(.+\.php)(.*)$;
fastcgi_param X-Forwarded-Proto $http_x_forwarded_proto;
fastcgi_param X-Forwarded-Port $http_x_forwarded_port;
'';
};
"~ /(\\.ht)" = {
extraConfig = ''
deny all;
return 404;
'';
};
};
}
];
};
};
services.mysql = lib.mkIf mysqlLocal {
enable = true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ db.name ];
ensureUsers = [
{
name = user;
ensurePermissions = {
"${db.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.postgresql = lib.mkIf pgsqlLocal {
enable = true;
ensureDatabases = [ db.name ];
ensureUsers = [
{
name = user;
ensureDBOwnership = true;
}
];
};
};
meta = {
doc = ./davis.md;
maintainers = pkgs.davis.meta.maintainers;
};
}

View File

@ -233,6 +233,7 @@ in {
croc = handleTest ./croc.nix {};
darling = handleTest ./darling.nix {};
dae = handleTest ./dae.nix {};
davis = handleTest ./davis.nix {};
dconf = handleTest ./dconf.nix {};
deconz = handleTest ./deconz.nix {};
deepin = handleTest ./deepin.nix {};

59
nixos/tests/davis.nix Normal file
View File

@ -0,0 +1,59 @@
import ./make-test-python.nix (
{ lib, pkgs, ... }:
{
name = "davis";
meta.maintainers = pkgs.davis.meta.maintainers;
nodes.machine =
{ config, ... }:
{
virtualisation = {
memorySize = 512;
};
services.davis = {
enable = true;
hostname = "davis.example.com";
database = {
driver = "postgresql";
};
mail = {
dsnFile = "${pkgs.writeText "davisMailDns" "smtp://username:password@example.com:25"}";
inviteFromAddress = "dav@example.com";
};
adminLogin = "admin";
appSecretFile = "${pkgs.writeText "davisAppSecret" "52882ef142066e09ab99ce816ba72522e789505caba224"}";
adminPasswordFile = "${pkgs.writeText "davisAdminPass" "nixos"}";
nginx = { };
};
};
testScript = ''
start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_unit("davis-env-setup.service")
machine.wait_for_unit("davis-db-migrate.service")
machine.wait_for_unit("nginx.service")
machine.wait_for_unit("phpfpm-davis.service")
with subtest("welcome screen loads"):
machine.succeed(
"curl -sSfL --resolve davis.example.com:80:127.0.0.1 http://davis.example.com/ | grep '<title>Davis</title>'"
)
with subtest("login works"):
csrf_token = machine.succeed(
"curl -c /tmp/cookies -sSfL --resolve davis.example.com:80:127.0.0.1 http://davis.example.com/login | grep '_csrf_token' | sed -E 's,.*value=\"(.*)\".*,\\1,g'"
)
r = machine.succeed(
f"curl -b /tmp/cookies --resolve davis.example.com:80:127.0.0.1 http://davis.example.com/login -X POST -F username=admin -F password=nixos -F _csrf_token={csrf_token.strip()} -D headers"
)
print(r)
machine.succeed(
"[[ $(grep -i 'location: ' headers | cut -d: -f2- | xargs echo) == /dashboard* ]]"
)
'';
}
)

10650
pkgs/by-name/da/davis/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
diff --git a/bin/console b/bin/console
index 8fe9d49..3af9662 100755
--- a/bin/console
+++ b/bin/console
@@ -1,5 +1,8 @@
#!/usr/bin/env php
<?php
+if (getenv('ENV_DIR') !== false) {
+ $_SERVER['APP_RUNTIME_OPTIONS']['dotenv_path'] = getenv('ENV_DIR').'/.env';
+}
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
@@ -28,7 +31,11 @@ if ($input->hasParameterOption('--no-debug', true)) {
putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0');
}
-(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
+if (getenv('ENV_DIR') !== false) {
+ (new Dotenv())->bootEnv(getenv('ENV_DIR').'/.env');
+} else {
+ (new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
+}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
diff --git a/public/index.php b/public/index.php
index 3f8b90e..c57ec21 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1,5 +1,9 @@
<?php
+if (getenv('ENV_DIR') !== false) {
+ $_SERVER['APP_RUNTIME_OPTIONS']['dotenv_path'] = getenv('ENV_DIR').'/.env';
+}
+
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\Debug;
@@ -7,7 +11,11 @@ use Symfony\Component\HttpFoundation\Request;
require dirname(__DIR__).'/vendor/autoload.php';
-(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
+if (getenv('ENV_DIR') !== false) {
+ (new Dotenv())->bootEnv(getenv('ENV_DIR').'/.env');
+} else {
+ (new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
+}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
diff --git a/src/Kernel.php b/src/Kernel.php
index 0f43d2f..8863f2c 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -49,4 +49,20 @@ class Kernel extends BaseKernel
(require $path)($routes->withPath($path), $this);
}
}
+
+ public function getCacheDir(): string
+ {
+ if (getenv('CACHE_DIR') !== false) {
+ return getenv('CACHE_DIR') . '/' . $this->getEnvironment();
+ }
+ return parent::getCacheDir();
+ }
+
+ public function getLogDir(): string
+ {
+ if (getenv('LOG_DIR') !== false) {
+ return getenv('LOG_DIR') . '/' . $this->getEnvironment();
+ }
+ return parent::getLogDir();
+ }
}

View File

@ -0,0 +1,41 @@
{ lib, fetchFromGitHub, php, }:
php.buildComposerProject (finalAttrs: {
pname = "davis";
version = "4.4.1";
src = fetchFromGitHub {
owner = "tchapi";
repo = "davis";
rev = "v${finalAttrs.version}";
hash = "sha256-UBekmxKs4dveHh866Ix8UzY2NL6ygb8CKor+V3Cblns=";
};
composerLock = ./composer.lock;
vendorHash = "sha256-WGeNwBRzfUXa7kPIwd7/5dPXDjaBxXirAJcm6lNzueY=";
patches = [
# Symfony loads .env files from the same directory as composer.json
# The .env files contain runtime configuration that shouldn't be baked into deriviation for the package
# This patch adds a few extension points exposing three environment variables:
# RUNTIME_DIRECTORY (where to load .env from), CACHE_DIRECTORY and LOG_DIRECTORY (symfony cache and log rw directories)
# Upstream PR https://github.com/tchapi/davis/issues/154
./davis-data.patch
];
postInstall = ''
# Only include the files needed for runtime in the derivation
mv $out/share/php/${finalAttrs.pname}/{migrations,public,src,config,bin,templates,tests,translations,vendor,symfony.lock,composer.json,composer.lock} $out
# Save the upstream .env file for reference, but rename it so it is not loaded
mv $out/share/php/${finalAttrs.pname}/.env $out/env-upstream
rm -rf "$out/share"
'';
meta = {
changelog = "https://github.com/tchapi/davis/releases/tag/v${finalAttrs.version}";
homepage = "https://github.com/tchapi/davis";
description = "A simple CardDav and CalDav server inspired by Baïkal";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ramblurr ];
};
})