diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 1c5fd30ad64e..7166945d0b1e 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -109,6 +109,8 @@ - [Ente Auth](https://ente.io/auth/), an open source 2FA authenticator, with end-to-end encrypted backups. Available as [programs.ente-auth](#opt-programs.ente-auth.enable). +- [linkding](https://linkding.link/), a self-hosted bookmark manager designed to be minimal, fast, and easy to set up. Available as [services.linkding](#opt-services.linkding.enable). + - [Tinyauth](https://tinyauth.app/), a simple authentication middleware for web apps, with OAuth and LDAP support. Available as [services.tinyauth](#opt-services.tinyauth.enable). - [Strichliste](https://www.strichliste.org), a digital self-service tallysheet used in hackerspaces, clubs and offices. Available as [services.strichliste](#opt-services.strichliste.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 774140f86d92..4a9d5b7695bb 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1698,6 +1698,7 @@ ./services/web-apps/librespeed.nix ./services/web-apps/libretranslate.nix ./services/web-apps/limesurvey.nix + ./services/web-apps/linkding.nix ./services/web-apps/linkwarden.nix ./services/web-apps/lubelogger.nix ./services/web-apps/mainsail.nix diff --git a/nixos/modules/services/web-apps/linkding.nix b/nixos/modules/services/web-apps/linkding.nix new file mode 100644 index 000000000000..fae7fb89c4d9 --- /dev/null +++ b/nixos/modules/services/web-apps/linkding.nix @@ -0,0 +1,403 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + mkEnableOption + mkIf + mkOption + mkPackageOption + optionalAttrs + types + ; + + cfg = config.services.linkding; +in +{ + options.services.linkding = { + enable = mkEnableOption "linkding, a self-hosted bookmark manager"; + + package = mkPackageOption pkgs "linkding" { }; + + user = mkOption { + type = types.str; + default = "linkding"; + description = '' + User account under which linkding runs. + + ::: {.note} + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for ensuring the + user exists before the linkding service starts. + ::: + ''; + }; + + group = mkOption { + type = types.str; + default = "linkding"; + description = '' + Group under which linkding runs. + + ::: {.note} + If left as the default value this group will automatically be created + on system activation, otherwise you are responsible for ensuring the + group exists before the linkding service starts. + ::: + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/linkding"; + description = "Directory used for all mutable state: SQLite database, secret key, favicons, previews, and assets."; + }; + + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address on which linkding listens."; + }; + + port = mkOption { + type = types.port; + default = 9090; + description = "Port on which linkding listens."; + }; + + contextPath = mkOption { + type = types.str; + default = ""; + example = "linkding/"; + description = '' + Configures a URL context path under which linkding is accessible. + When set, linkding is available at `http://host:/`. + Must end with a `/` when non-empty. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/linkding.env"; + description = '' + Path to an environment file loaded by all linkding services. + Useful for injecting secrets that should not appear in the Nix store, + such as `LD_DB_PASSWORD` or `LD_SUPERUSER_PASSWORD`. + ''; + }; + + settings = mkOption { + type = types.attrsOf types.str; + default = { }; + example = { + LD_DISABLE_BACKGROUND_TASKS = "True"; + LD_DISABLE_URL_VALIDATION = "True"; + LD_ENABLE_OIDC = "True"; + }; + description = '' + Additional environment variables passed to linkding. + Refer to the [linkding documentation](https://linkding.link/options/) + for the full list of supported `LD_*` options. + ''; + }; + + database = { + type = mkOption { + type = types.enum [ + "sqlite" + "postgres" + ]; + default = "sqlite"; + description = "Database engine to use. Defaults to SQLite."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "PostgreSQL server host."; + }; + + port = mkOption { + type = types.port; + default = 5432; + description = "PostgreSQL server port."; + }; + + name = mkOption { + type = types.str; + default = "linkding"; + description = "PostgreSQL database name."; + }; + + user = mkOption { + type = types.str; + default = "linkding"; + description = "PostgreSQL user name."; + }; + + createLocally = mkOption { + type = types.bool; + default = false; + description = "Whether to automatically create a local PostgreSQL database and user."; + }; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the linkding port in the firewall."; + }; + }; + + config = mkIf cfg.enable ( + let + pkg = cfg.package; + + usePostgres = cfg.database.type == "postgres"; + + pythonPath = + "${pkg.passthru.python.pkgs.makePythonPath pkg.passthru.dependencies}:${lib.getBin pkg}/${pkg.passthru.python.sitePackages}" + + lib.strings.optionalString usePostgres ":${pkg.passthru.python.pkgs.makePythonPath pkg.optional-dependencies.postgres}"; + + # Build the environment passed to every linkding process. + environment = { + DJANGO_SETTINGS_MODULE = "bookmarks.settings.prod"; + _NIXOS_LINKDING_DATA_DIR = cfg.dataDir; + LD_SERVER_PORT = toString cfg.port; + } + // optionalAttrs (cfg.contextPath != "") { + LD_CONTEXT_PATH = cfg.contextPath; + } + // optionalAttrs usePostgres { + LD_DB_ENGINE = "postgres"; + LD_DB_DATABASE = cfg.database.name; + LD_DB_USER = cfg.database.user; + LD_DB_HOST = "/run/postgresql"; + } + // optionalAttrs (usePostgres && !cfg.database.createLocally) { + LD_DB_HOST = cfg.database.host; + LD_DB_PORT = toString cfg.database.port; + } + // cfg.settings; + + environmentFile = pkgs.writeText "linkding-environment" (lib.generators.toKeyValue { } environment); + + # Generate a uwsgi.ini for the linkding instance, adapted for NixOS from + # the upstream uwsgi.ini. The static-map entries serve pre-generated + # static files from the Nix store as well as the mutable user-data + # directories (favicons, previews) from the data directory. + uwsgiIni = pkgs.writeText "linkding-uwsgi.ini" '' + [uwsgi] + plugins-dir = ${pkg.passthru.uwsgiWithPython}/lib/uwsgi + plugin = python3 + module = bookmarks.wsgi:application + env = DJANGO_SETTINGS_MODULE=bookmarks.settings.prod + processes = 2 + threads = 2 + buffer-size = 8192 + die-on-term = true + mime-file = ${pkgs.mailcap}/etc/mime.types + http = ${cfg.address}:${toString cfg.port} + static-map = /${cfg.contextPath}static=${pkg}/${pkg.passthru.python.sitePackages}/bookmarks/static + static-map = /${cfg.contextPath}static=${cfg.dataDir}/favicons + static-map = /${cfg.contextPath}static=${cfg.dataDir}/previews + static-map = /${cfg.contextPath}robots.txt=${pkg}/${pkg.passthru.python.sitePackages}/bookmarks/static/robots.txt + + if-env = LD_REQUEST_TIMEOUT + http-timeout = %(_) + socket-timeout = %(_) + harakiri = %(_) + endif = + + if-env = LD_REQUEST_MAX_CONTENT_LENGTH + limit-post = %(_) + endif = + + if-env = LD_LOG_X_FORWARDED_FOR + log-x-forwarded-for = %(_) + endif = + + if-env = LD_DISABLE_REQUEST_LOGS=true + disable-logging = true + log-4xx = true + log-5xx = true + endif = + ''; + + # Manage wrapper script installed into the system PATH so administrators can + # run Django management commands as the linkding service user. + linkdingManageScript = + let + args = lib.escapeShellArgs ( + [ + "--uid=${cfg.user}" + "--gid=${cfg.group}" + "--working-directory=${cfg.dataDir}" + "--property=EnvironmentFile=${environmentFile}" + ] + ++ lib.optional (cfg.environmentFile != null) "--property=EnvironmentFile=${cfg.environmentFile}" + ++ [ + "--property=ReadWritePaths=${cfg.dataDir}" + "--setenv=PYTHONPATH=${pythonPath}" + "--pty" + "--wait" + "--collect" + "--service-type=exec" + "--quiet" + "--" + "${lib.getExe' pkg "linkding"}" + ] + ); + in + pkgs.writeShellScriptBin "linkding-manage" '' + exec ${lib.getExe' config.systemd.package "systemd-run"} ${args} "$@" + ''; + + commonServiceConfig = { + Slice = "system-linkding.slice"; + User = cfg.user; + Group = cfg.group; + EnvironmentFile = [ + environmentFile + ] + ++ lib.optional (cfg.environmentFile != null) cfg.environmentFile; + Environment = "PYTHONPATH=${pythonPath}"; + WorkingDirectory = cfg.dataDir; + StateDirectory = [ + "linkding" + "linkding/favicons" + "linkding/previews" + "linkding/assets" + ]; + StateDirectoryMode = "0750"; + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ReadWritePaths = [ cfg.dataDir ]; + PrivateMounts = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RemoveIPC = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + }; + in + { + assertions = [ + { + assertion = cfg.database.createLocally -> usePostgres; + message = "services.linkding.database.createLocally requires services.linkding.database.type = \"postgres\""; + } + { + assertion = + cfg.database.createLocally -> cfg.database.host == "localhost" || cfg.database.host == ""; + message = "services.linkding.database.host should be empty or \"localhost\" when createLocally is enabled"; + } + { + assertion = + cfg.database.createLocally + -> cfg.database.user == cfg.user && cfg.database.user == cfg.database.name; + message = "services.linkding.database.user must match services.linkding.user and services.linkding.database.name when createLocally is enabled"; + } + { + assertion = cfg.contextPath == "" || lib.hasSuffix "/" cfg.contextPath; + message = "services.linkding.contextPath must end with \"/\" when non-empty"; + } + ]; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + + environment.systemPackages = [ linkdingManageScript ]; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + }; + + users.groups.${cfg.group} = { }; + + systemd.slices.system-linkding = { + description = "linkding bookmark manager System Slice"; + documentation = [ "https://linkding.link/" ]; + }; + + # One-shot setup service: run database migrations and first-time + # initialization steps taken from the upstream bootstrap.sh. + systemd.services.linkding-setup = { + description = "linkding database migrations and initialization"; + after = [ + "network.target" + ] + ++ lib.optionals (usePostgres && cfg.database.createLocally) [ "postgresql.target" ]; + requires = lib.optionals (usePostgres && cfg.database.createLocally) [ "postgresql.target" ]; + + serviceConfig = commonServiceConfig // { + Type = "oneshot"; + ExecStart = "${lib.getExe' pkg "linkding-bootstrap"}"; + }; + }; + + # Main WSGI service — starts after setup completes. + systemd.services.linkding = { + description = "linkding bookmark manager"; + wantedBy = [ "multi-user.target" ]; + after = [ "linkding-setup.service" ]; + requires = [ "linkding-setup.service" ]; + startLimitBurst = 5; + startLimitIntervalSec = 60; + serviceConfig = commonServiceConfig // { + Type = "exec"; + ExecStart = "${lib.getExe pkgs.uwsgi} --ini ${uwsgiIni}"; + Restart = "on-failure"; + }; + }; + + # Background task processor (Huey). Can be disabled via + # services.linkding.settings.LD_DISABLE_BACKGROUND_TASKS = "True". + systemd.services.linkding-background-tasks = + mkIf ((cfg.settings.LD_DISABLE_BACKGROUND_TASKS or "False") != "True") + { + description = "linkding background task processor"; + wantedBy = [ "multi-user.target" ]; + after = [ "linkding-setup.service" ]; + requires = [ "linkding-setup.service" ]; + + serviceConfig = commonServiceConfig // { + Type = "exec"; + ExecStart = "${lib.getExe' pkg "linkding"} run_huey -f"; + Restart = "on-failure"; + RestartSec = "5s"; + }; + }; + + # Automatically provision a local PostgreSQL database when requested. + services.postgresql = mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.database.user; + ensureDBOwnership = true; + } + ]; + }; + } + ); + + meta.maintainers = with lib.maintainers; [ squat ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 3fdcc943b0ae..8a1173f52cd5 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -891,6 +891,8 @@ in lighttpd = runTest ./lighttpd.nix; limesurvey = runTest ./limesurvey.nix; limine = import ./limine { inherit runTest; }; + linkding = runTest ./web-apps/linkding.nix; + linkding-postgres = runTest ./web-apps/linkding-postgres.nix; linkwarden = runTest ./web-apps/linkwarden.nix; listmonk = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./listmonk.nix { }; litellm = runTest ./litellm.nix; diff --git a/nixos/tests/web-apps/linkding-postgres.nix b/nixos/tests/web-apps/linkding-postgres.nix new file mode 100644 index 000000000000..486e8de653b3 --- /dev/null +++ b/nixos/tests/web-apps/linkding-postgres.nix @@ -0,0 +1,42 @@ +{ lib, ... }: +{ + name = "linkding-postgres"; + + meta = { + maintainers = with lib.maintainers; [ squat ]; + }; + + nodes.machine = + { ... }: + { + services.linkding = { + enable = true; + port = 9090; + database = { + createLocally = true; + type = "postgres"; + }; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("linkding.service") + machine.wait_for_open_port(9090) + + with subtest("Login page loads"): + machine.succeed( + "curl -sSfL http://127.0.0.1:9090 | grep -i 'linkding'" + ) + + with subtest("Health endpoint responds"): + machine.succeed( + "curl -sSf http://127.0.0.1:9090/health" + ) + + with subtest("linkding-manage works"): + machine.succeed( + "linkding-manage version" + ) + ''; +} diff --git a/nixos/tests/web-apps/linkding.nix b/nixos/tests/web-apps/linkding.nix new file mode 100644 index 000000000000..074e5c947a33 --- /dev/null +++ b/nixos/tests/web-apps/linkding.nix @@ -0,0 +1,38 @@ +{ lib, pkgs, ... }: +{ + name = "linkding"; + + meta = { + maintainers = with lib.maintainers; [ squat ]; + }; + + nodes.machine = + { ... }: + { + services.linkding = { + enable = true; + port = 9090; + }; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("linkding.service") + machine.wait_for_open_port(9090) + + with subtest("Login page loads"): + machine.succeed( + "curl -sSfL http://127.0.0.1:9090 | grep -i 'linkding'" + ) + + with subtest("Health endpoint responds"): + machine.succeed( + "curl -sSf http://127.0.0.1:9090/health" + ) + + with subtest("linkding-manage works"): + machine.succeed( + "linkding-manage version" + ) + ''; +} diff --git a/pkgs/by-name/li/linkding/package.nix b/pkgs/by-name/li/linkding/package.nix new file mode 100644 index 000000000000..920942e394b6 --- /dev/null +++ b/pkgs/by-name/li/linkding/package.nix @@ -0,0 +1,301 @@ +{ + lib, + buildNpmPackage, + fetchFromGitHub, + fetchurl, + gcc, + icu, + nix-update-script, + nixosTests, + pkg-config, + python3, + sqlite, + stdenv, + uwsgi, +}: +let + version = "1.45.0"; + + python = python3.override { + self = python; + packageOverrides = final: prev: { + django = prev.django_6; + }; + }; + + uwsgiWithPython = uwsgi.override { + plugins = [ "python3" ]; + python3 = python; + }; + + # Compile the SQLite ICU extension for case-insensitive search and ordering. + # This mirrors the compile-icu stage in the upstream Dockerfile. + icuExtension = stdenv.mkDerivation { + pname = "linkding-sqlite-icu"; + inherit version; + + src = fetchurl { + url = "https://www.sqlite.org/src/raw/ext/icu/icu.c?name=91c021c7e3e8bbba286960810fa303295c622e323567b2e6def4ce58e4466e60"; + name = "icu.c"; + hash = "sha256-DkELE5p82yZVz0GVFdWWAEU8eo10ob0fqx66Q7rxv+U="; + }; + + nativeBuildInputs = [ + gcc + pkg-config + ]; + + buildInputs = [ + icu.dev + sqlite.dev + ]; + + dontUnpack = true; + + buildPhase = '' + runHook preBuild + gcc -fPIC -shared $src \ + -I${sqlite.dev}/include \ + $(pkg-config --libs --cflags icu-uc icu-io) \ + -o libicu.so + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + install -Dm755 libicu.so $out/lib/libicu.so + runHook postInstall + ''; + }; +in +python.pkgs.buildPythonApplication (finalAttrs: { + pname = "linkding"; + inherit version; + pyproject = true; + + src = fetchFromGitHub { + owner = "sissbruecker"; + repo = "linkding"; + tag = "v${finalAttrs.version}"; + hash = "sha256-iGvUKmOPL0akfR52hzSGH6wu06/WP9ygiQ/HxsmrYWg="; + }; + + __structuredAttrs = true; + + build-system = with python.pkgs; [ + setuptools + ]; + + dependencies = with python.pkgs; [ + beautifulsoup4 + bleach + bleach-allowlist + django + djangorestframework + huey + markdown + mozilla-django-oidc + requests + waybackpy + ]; + + optional-dependencies = { + postgres = with python.pkgs; [ psycopg ]; + }; + + dontCheckRuntimeDeps = true; + # Django's runserver re-executes sys.argv[0] via the Python interpreter, + # so manage.py must remain a valid Python script and cannot be wrapped in bash. + dontWrapPythonPrograms = true; + + pyprojectAppendix = '' + [tool.setuptools.packages.find] + include = ["bookmarks*"] + [tool.setuptools.package-data] + bookmarks = ["static/**/*", "styles/**/*", "templates/**/*", "version.txt"] + ''; + + ui = buildNpmPackage { + inherit (finalAttrs) version; + + pname = "${finalAttrs.pname}-ui"; + src = finalAttrs.src; + + npmDepsHash = "sha256-zUMgl+h0BPm9QzGi1WZG8f0tDoYk8p+Al3q6uEKXqLk="; + + installPhase = '' + runHook preInstall + mkdir -p $out/bookmarks + mv bookmarks/static $out/bookmarks + runHook postInstall + ''; + }; + + postPatch = '' + echo "$pyprojectAppendix" >> pyproject.toml + + # Point the SQLite ICU extension to its store path so it is found + # regardless of the working directory at runtime. + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'SQLITE_ICU_EXTENSION_PATH = "./libicu.so"' \ + 'SQLITE_ICU_EXTENSION_PATH = "${icuExtension}/lib/libicu.so"' + + # Allow overriding the data directory via an internal environment variable + # so that the NixOS module can point it at the mutable state directory + # (/var/lib/linkding). The variable name is intentionally NixOS-specific + # and not a real linkding option to avoid confusing users. + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))' \ + 'BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))''\nDATA_DIR = os.getenv("_NIXOS_LINKDING_DATA_DIR", os.path.join(os.getcwd(), "data"))' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + '"filename": os.path.join(BASE_DIR, "data", "tasks.sqlite3"),' \ + '"filename": os.path.join(DATA_DIR, "tasks.sqlite3"),' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + '"NAME": os.path.join(BASE_DIR, "data", "db.sqlite3"),' \ + '"NAME": os.path.join(DATA_DIR, "db.sqlite3"),' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'LD_FAVICON_FOLDER = os.path.join(BASE_DIR, "data", "favicons")' \ + 'LD_FAVICON_FOLDER = os.path.join(DATA_DIR, "favicons")' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'LD_PREVIEW_FOLDER = os.path.join(BASE_DIR, "data", "previews")' \ + 'LD_PREVIEW_FOLDER = os.path.join(DATA_DIR, "previews")' + + substituteInPlace bookmarks/settings/base.py \ + --replace-fail \ + 'LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")' \ + 'LD_ASSET_FOLDER = os.path.join(DATA_DIR, "assets")' + + substituteInPlace bookmarks/utils.py \ + --replace-fail \ + 'import datetime' \ + 'import datetime''\nimport os' + + substituteInPlace bookmarks/utils.py \ + --replace-fail \ + 'with open("version.txt") as f:' \ + 'with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "version.txt")) as f:' + + substituteInPlace bookmarks/settings/prod.py \ + --replace-fail \ + 'with open(os.path.join(BASE_DIR, "data", "secretkey.txt")) as f:' \ + 'with open(os.path.join(DATA_DIR, "secretkey.txt")) as f:' + + substituteInPlace bookmarks/settings/dev.py \ + --replace-fail \ + 'os.path.join(BASE_DIR, "data", "favicons"),' \ + 'os.path.join(DATA_DIR, "favicons"),' + + substituteInPlace bookmarks/settings/dev.py \ + --replace-fail \ + 'os.path.join(BASE_DIR, "data", "previews"),' \ + 'os.path.join(DATA_DIR, "previews"),' + + # Patch management commands that bypass Django settings and use hardcoded + # relative "data/" paths. Replace them with paths derived from DATA_DIR + # via django.conf.settings so the data directory is always correct. + substituteInPlace bookmarks/management/commands/generate_secret_key.py \ + --replace-fail \ + 'from django.core.management.utils import get_random_secret_key' \ + 'from django.conf import settings''\nfrom django.core.management.utils import get_random_secret_key' + substituteInPlace bookmarks/management/commands/generate_secret_key.py \ + --replace-fail \ + 'secret_key_file = os.path.join("data", "secretkey.txt")' \ + 'secret_key_file = os.path.join(settings.DATA_DIR, "secretkey.txt")' + substituteInPlace bookmarks/management/commands/migrate_tasks.py \ + --replace-fail \ + 'import sqlite3' \ + 'import sqlite3''\nfrom django.conf import settings' + substituteInPlace bookmarks/management/commands/migrate_tasks.py \ + --replace-fail \ + 'db = sqlite3.connect(os.path.join("data", "db.sqlite3"))' \ + 'db = sqlite3.connect(os.path.join(settings.DATA_DIR, "db.sqlite3"))' + + # Place the version.txt file inside of the bookmarks package + # so that it can be installed by setuptools alongside the package + # in the Nix store. + mv version.txt bookmarks/version.txt + ''; + + preBuild = '' + cp -r ${finalAttrs.ui}/bookmarks/static/* bookmarks/static + ''; + + # Collect static files at build time so the result is a pure store path + # that can be served directly by the NixOS module without any runtime step. + # STATIC_ROOT in base.py defaults to $PWD/static, so the collected files + # land there and are picked up by postInstall. + postBuild = '' + mkdir data + ${python.interpreter} manage.py collectstatic --no-input + ''; + + postInstall = + let + pythonPath = python.pkgs.makePythonPath finalAttrs.passthru.dependencies; + in + '' + mkdir $out/bin + + cp -r static/* $out/${python.sitePackages}/bookmarks/static + + cp ./manage.py $out/bin/.manage.py + chmod +x $out/bin/.manage.py + + makeWrapper $out/bin/.manage.py $out/bin/linkding \ + --prefix PYTHONPATH : "${pythonPath}:$out/${python.sitePackages}" + + # Bootstrap script mirroring the upstream bootstrap.sh: creates data + # directories and runs Django management commands to initialize a fresh + # linkding installation. The linkding binary is referenced by its + # absolute store path so the script works without PATH manipulation. + cat > $out/bin/linkding-bootstrap << EOF + #!/bin/sh + DATA_DIR="\''${_NIXOS_LINKDING_DATA_DIR:-data}" + mkdir -p "\$DATA_DIR"/favicons "\$DATA_DIR"/previews "\$DATA_DIR"/assets + $out/bin/linkding generate_secret_key + $out/bin/linkding migrate + $out/bin/linkding enable_wal + $out/bin/linkding create_initial_superuser + $out/bin/linkding migrate_tasks + EOF + chmod +x $out/bin/linkding-bootstrap + ''; + + passthru = { + inherit + python + icuExtension + uwsgiWithPython + ; + tests = { + inherit (nixosTests) linkding linkding-postgres; + }; + updateScript = nix-update-script { + extraArgs = [ + "--subpackage" + "ui" + ]; + }; + }; + + meta = { + description = "Self-hosted bookmark manager designed to be minimal, fast, and easy to set up"; + homepage = "https://linkding.link/"; + changelog = "https://github.com/sissbruecker/linkding/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ + squat + ]; + platforms = lib.platforms.linux; + }; +})