From f1a8a05417b6a8d997e75d460ddad85c1d99723c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?= <53227802+Kisakay@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:03:23 +0200 Subject: [PATCH] trust proxy --- .env.example | 6 ++ deploy/nixos/{downloadwf.nix => download.nix} | 79 +++++++++++++++---- src/config.ts | 16 +++- src/routes/upload.ts | 4 +- src/server.ts | 2 +- 5 files changed, 88 insertions(+), 19 deletions(-) rename deploy/nixos/{downloadwf.nix => download.nix} (50%) diff --git a/.env.example b/.env.example index 9ffec1d..62be411 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,12 @@ HOST=0.0.0.0 PORT=3000 +# Public base URL of the site (the domain your users see, including scheme, +# WITHOUT a trailing slash). When set, share links returned by the API and the +# dashboard are absolute URLs (e.g. https://files.example.com/xmK5lLMRErlN). +# Leave unset to keep relative paths (the browser host is used). +PUBLIC_BASE_URL=https://files.example.com + # Storage: persistent filesystem directory where uploaded files and # metadata live. Files survive restarts. STORAGE_DIR=./storage diff --git a/deploy/nixos/downloadwf.nix b/deploy/nixos/download.nix similarity index 50% rename from deploy/nixos/downloadwf.nix rename to deploy/nixos/download.nix index 5643f29..53d9ec4 100644 --- a/deploy/nixos/downloadwf.nix +++ b/deploy/nixos/download.nix @@ -5,10 +5,10 @@ ... }: let - cfg = config.services.downloadwf; + cfg = config.services.download; in { - options.services.downloadwf = { + options.services.download = { enable = lib.mkEnableOption "the File Drop private file host"; user = lib.mkOption { @@ -25,7 +25,7 @@ in storageDir = lib.mkOption { type = lib.types.str; default = "/srv/download/storage"; - description = "Where files and metadata are persisted."; + description = "Persistent directory where files and metadata are stored."; }; host = lib.mkOption { @@ -52,7 +52,25 @@ in description = "Milliseconds before an uploaded file expires and is deleted."; }; + publicBaseUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Public domain of the site (e.g. https://files.example.com)."; + }; + trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)"; + + repoUrl = lib.mkOption { + type = lib.types.str; + default = "https://github.com/Kisakay/download.git"; + description = "Git repository to clone/pull from."; + }; + + deployOnBoot = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Pull the repo at boot (like the nightcord-deploy pattern)."; + }; }; config = lib.mkIf cfg.enable { @@ -60,11 +78,39 @@ in "d ${cfg.storageDir} 0755 ${cfg.user} ${cfg.user} -" ]; - systemd.services.downloadwf = { - description = "File Drop private file host"; - wantedBy = [ "multi-user.target" ]; + systemd.services.download-deploy = { + description = "Update download from git"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; + wantedBy = lib.mkIf cfg.deployOnBoot [ "multi-user.target" ]; + + path = with pkgs; [ git bun ]; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + WorkingDirectory = cfg.workingDir; + Environment = [ "HOME=/home/${cfg.user}" ]; + ExecStartPost = "+${pkgs.systemd}/bin/systemctl restart download.service"; + }; + + 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 + cd ${cfg.workingDir} + bun install + ''; + }; + + systemd.services.download = { + description = "File Drop private file host"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; path = with pkgs; [ bun ]; @@ -73,22 +119,23 @@ in User = cfg.user; Group = cfg.user; WorkingDirectory = cfg.workingDir; - Environment = [ - "HOME=/home/${cfg.user}" - "PORT=${toString cfg.port}" - "HOST=${cfg.host}" - "STORAGE_DIR=${cfg.storageDir}" - "MAX_FILE_SIZE=${cfg.maxFileSize}" - "FILE_TTL_MS=${toString cfg.fileTtlMs}" - ] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ]; + Environment = + [ + "HOME=/home/${cfg.user}" + "PORT=${toString cfg.port}" + "HOST=${cfg.host}" + "STORAGE_DIR=${cfg.storageDir}" + "MAX_FILE_SIZE=${cfg.maxFileSize}" + "FILE_TTL_MS=${toString cfg.fileTtlMs}" + ] + ++ lib.optionals (cfg.publicBaseUrl != null) [ "PUBLIC_BASE_URL=${cfg.publicBaseUrl}" ] + ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ]; - # no runtime deps, so no `bun install` needed at startup ExecStart = "${pkgs.bun}/bin/bun src/server.ts"; Restart = "on-failure"; RestartSec = 3; TimeoutStopSec = 30; - # hardening NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "full"; diff --git a/src/config.ts b/src/config.ts index cb564af..98c8540 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ export interface Config { storageDir: string; maxFileSize: number; fileTtlMs: number; + publicBaseUrl?: string; rateLimitEnabled: boolean; rateLimitMax: number; rateLimitWindowMs: number; @@ -69,15 +70,28 @@ export function loadConfig(env: Record = process.env throw new Error(`Invalid RATE_LIMIT_WINDOW_MS: ${env.RATE_LIMIT_WINDOW_MS}`); } + const trustProxy = boolFromEnv(env.TRUST_PROXY, false); + + let publicBaseUrl: string | undefined; + const rawBase = env.PUBLIC_BASE_URL?.trim(); + if (rawBase) { + const normalized = rawBase.replace(/\/+$/, ""); + if (!/^https?:\/\/[^\s/]+$/.test(normalized)) { + throw new Error(`Invalid PUBLIC_BASE_URL: "${rawBase}"`); + } + publicBaseUrl = normalized; + } + return { host, port, storageDir, maxFileSize, fileTtlMs, + publicBaseUrl, rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true), rateLimitMax, rateLimitWindowMs, - trustProxy: boolFromEnv(env.TRUST_PROXY, false), + trustProxy, }; } diff --git a/src/routes/upload.ts b/src/routes/upload.ts index ae86e54..a028a62 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -26,6 +26,7 @@ export async function handleUpload( storage: Storage, maxFileSize: number, ttlMs: number, + publicBaseUrl?: string, ): Promise { const contentType = req.headers.get("content-type"); const boundary = contentType ? getBoundary(contentType) : null; @@ -77,8 +78,9 @@ export async function handleUpload( expiresAt: new Date(Date.now() + ttlMs).toISOString(), }); + const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`; log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); - return json({ id, key, url: `/${key}`, originalName, size, protected: true }); + return json({ id, key, url, originalName, size, protected: true }); } catch (err) { await storage.delete(id); await storage.deletePartial(id); diff --git a/src/server.ts b/src/server.ts index a0868d6..acae6a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -108,7 +108,7 @@ async function route(req: Request, srv: Server): Promise { } if (path === "/api/upload" && method === "POST") { - return handleUpload(req, storage, cfg.maxFileSize, cfg.fileTtlMs); + return handleUpload(req, storage, cfg.maxFileSize, cfg.fileTtlMs, cfg.publicBaseUrl); } if (path.startsWith("/api/auth/") && method === "POST") {