{ config, lib, pkgs, ... }: let cfg = config.services.download; # The source tree the package is built from. By default it follows # `workingDir` (the boot-cloned/pulled checkout) so `deployOnBoot` actually # updates the built binary on the next rebuild; a caller may pin an explicit # `src` path instead (e.g. from a flake) to build reproducibly. sourcePath = if cfg.src != null then cfg.src else /. + cfg.workingDir; # ---- Backend: Rust binary built with nixpkgs cargo hooks (offline-safe). ---- filehostBackend = pkgs.rustPlatform.buildRustPackage { pname = "filehost"; version = "1.0.0"; # The repository root (backend/ + frontend/). `cargoRoot` points the build # at backend/Cargo.toml while keeping the rest of the tree available. src = sourcePath; cargoRoot = "backend"; # Derive the dependency hash automatically from the checked-in # backend/Cargo.lock (version 4; all deps are crates.io registry entries, so # no extra outputHashes are needed). No manual cargoHash to maintain. cargoLock.lockFile = sourcePath + "/backend/Cargo.lock"; buildInputs = [ ]; nativeBuildInputs = with pkgs; [ ]; # We only need the binary; disable tests to keep the closure small. doCheck = false; installPhase = '' runHook preInstall mkdir -p $out/bin install -Dm755 target/release/filehost $out/bin/filehost runHook postInstall ''; }; # ---- Frontend: Svelte static assets built with node/npm. ---- filehostFrontend = pkgs.stdenv.mkDerivation { pname = "filehost-frontend"; version = "1.0.0"; src = sourcePath; nativeBuildInputs = with pkgs; [ nodejs (nodejs.pkgs.npm or nodePackages.npm) ]; # `npm install` needs writable dirs that default to the source; redirect # caches so the build stays sandbox-friendly. npm_config_cache = "/tmp/npm-cache"; buildPhase = '' runHook preBuild cd frontend npm install --no-audit --no-fund npm run build runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out/share/filehost cp -r frontend/dist $out/share/filehost/dist runHook postInstall ''; }; in { options.services.download = { enable = lib.mkEnableOption "the File Drop private file host"; # Optional: pin an explicit source path. When unset (default), the package # is built from `workingDir` so `deployOnBoot` (git pull + rebuild) actually # updates the binary. src = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "Path to the download source tree (backend/ + frontend/). Defaults to workingDir."; }; user = lib.mkOption { type = lib.types.str; default = "filehost"; description = "System user that runs the service."; }; group = lib.mkOption { type = lib.types.str; default = "filehost"; description = "System group that runs the service."; }; storageDir = lib.mkOption { type = lib.types.str; default = "/srv/download/storage"; description = "Persistent directory where files and metadata are stored."; }; host = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = "Bind address. Use 0.0.0.0 to expose directly."; }; port = lib.mkOption { type = lib.types.port; default = 3000; description = "Listen port."; }; maxFileSize = lib.mkOption { type = lib.types.str; default = "10GB"; description = "Maximum upload size (B/KB/MB/GB/TB)."; }; fileTtlMs = lib.mkOption { type = lib.types.ints.positive; default = 48 * 60 * 60 * 1000; description = "Milliseconds before an uploaded file expires and is deleted (hard-capped at 48 hours)."; }; maxStorageUsage = lib.mkOption { type = lib.types.float; default = 0.5; description = "Maximum fraction (0.0-1.0) of the storage filesystem that may be used; uploads are rejected above this."; }; keyLength = lib.mkOption { type = lib.types.ints.between 4 64; default = 8; description = "Length in characters of the upload/download key token (a-z0-9)."; }; publicBaseUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Public domain of the site (e.g. https://files.example.com)."; }; rateLimitEnabled = lib.mkOption { type = lib.types.bool; default = true; description = "Per-IP rate limiting on download/delete/auth endpoints."; }; rateLimitMax = lib.mkOption { type = lib.types.ints.positive; default = 300; description = "Max requests per rate-limit window."; }; rateLimitWindowMs = lib.mkOption { type = lib.types.ints.positive; default = 60000; description = "Rate-limit window in milliseconds."; }; trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)"; stripMetadata = lib.mkOption { type = lib.types.bool; default = true; description = "Strip embedded EXIF/GPS/author/comments and video metadata server-side."; }; repoUrl = lib.mkOption { type = lib.types.str; default = "https://github.com/Kisakay/download.git"; description = "Git repository to clone/pull from."; }; workingDir = lib.mkOption { type = lib.types.str; default = "/srv/download"; description = "Directory containing the code."; }; deployOnBoot = lib.mkOption { type = lib.types.bool; default = true; description = "Pull the repo at boot and rebuild (like the nightcord-deploy pattern)."; }; rebuildCommand = lib.mkOption { type = lib.types.str; default = "${config.system.build.nixos-rebuild}"; description = "Command (run as root) to apply the pulled code after a boot deploy. Defaults to the system's own rebuild script (switch without re-evaluating the flake)."; }; }; config = lib.mkIf cfg.enable { systemd.tmpfiles.rules = [ "d ${cfg.storageDir} 0755 ${cfg.user} ${cfg.user} -" "d ${cfg.workingDir} 0755 ${cfg.user} ${cfg.user} -" ]; systemd.services.download-deploy = { description = "Update download from git and rebuild"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = lib.mkIf cfg.deployOnBoot [ "multi-user.target" ]; path = with pkgs; [ git ]; serviceConfig = { Type = "oneshot"; # Rebuild requires root (updates /run/current-system, generations, etc.). WorkingDirectory = cfg.workingDir; }; script = '' set -euo pipefail if [ ! -d "${cfg.workingDir}/.git" ]; then git clone ${cfg.repoUrl} ${cfg.workingDir} else git -C ${cfg.workingDir} pull --ff-only fi # Apply the freshly-pulled code. If the source is pinned via `src`, # nothing changed on disk, but the rebuild is still harmless. ${cfg.rebuildCommand} ''; }; systemd.services.download = { description = "File Drop private file host"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; Environment = [ "HOST=${cfg.host}" "PORT=${toString cfg.port}" "STORAGE_DIR=${cfg.storageDir}" "STATIC_DIR=${filehostFrontend}/share/filehost/dist" "MAX_FILE_SIZE=${cfg.maxFileSize}" "FILE_TTL_MS=${toString cfg.fileTtlMs}" "MAX_STORAGE_USAGE=${toString cfg.maxStorageUsage}" "KEY_LENGTH=${toString cfg.keyLength}" "RATE_LIMIT_ENABLED=${if cfg.rateLimitEnabled then "1" else "0"}" "RATE_LIMIT_MAX=${toString cfg.rateLimitMax}" "RATE_LIMIT_WINDOW_MS=${toString cfg.rateLimitWindowMs}" "STRIP_METADATA=${if cfg.stripMetadata then "1" else "0"}" ] ++ lib.optionals (cfg.publicBaseUrl != null) [ "PUBLIC_BASE_URL=${cfg.publicBaseUrl}" ] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ]; ExecStart = "${filehostBackend}/bin/filehost"; Restart = "on-failure"; RestartSec = 3; TimeoutStopSec = 30; NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "full"; ProtectHome = true; ReadWritePaths = [ cfg.storageDir ]; }; }; }; }