fix
This commit is contained in:
+6
-2
@@ -3,12 +3,16 @@ HOST=0.0.0.0
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Storage: persistent filesystem directory where uploaded files and
|
# Storage: persistent filesystem directory where uploaded files and
|
||||||
# metadata live. Files survive restarts; no automatic expiration.
|
# metadata live. Files survive restarts.
|
||||||
STORAGE_DIR=./storage
|
STORAGE_DIR=./storage
|
||||||
|
|
||||||
# Maximum upload size. Accepts plain bytes or units (B, KB, MB, GB, TB,
|
# Maximum upload size. Accepts plain bytes or units (B, KB, MB, GB, TB,
|
||||||
# case-insensitive). A request larger than this is rejected with 413.
|
# case-insensitive). A request larger than this is rejected with 413.
|
||||||
MAX_FILE_SIZE=5GB
|
MAX_FILE_SIZE=10GB
|
||||||
|
|
||||||
|
# How long an uploaded file lives before it expires and is deleted.
|
||||||
|
# In milliseconds. Default is 1 week (604800000).
|
||||||
|
FILE_TTL_MS=604800000
|
||||||
|
|
||||||
# Brute-force protection: rate limit per client IP on download and
|
# Brute-force protection: rate limit per client IP on download and
|
||||||
# deletion endpoints. Set RATE_LIMIT_ENABLED=0 to disable.
|
# deletion endpoints. Set RATE_LIMIT_ENABLED=0 to disable.
|
||||||
|
|||||||
+7
-4
@@ -62,7 +62,8 @@ Relevant variables:
|
|||||||
HOST=127.0.0.1 # bind loopback only; the proxy talks to it
|
HOST=127.0.0.1 # bind loopback only; the proxy talks to it
|
||||||
PORT=3000
|
PORT=3000
|
||||||
STORAGE_DIR=/opt/filehost/storage
|
STORAGE_DIR=/opt/filehost/storage
|
||||||
MAX_FILE_SIZE=5GB # must be <= proxy upload limit
|
MAX_FILE_SIZE=10GB # must be <= proxy upload limit
|
||||||
|
FILE_TTL_MS=604800000 # expire files after 1 week
|
||||||
RATE_LIMIT_ENABLED=1
|
RATE_LIMIT_ENABLED=1
|
||||||
TRUST_PROXY=1 # proxy sets X-Forwarded-For
|
TRUST_PROXY=1 # proxy sets X-Forwarded-For
|
||||||
```
|
```
|
||||||
@@ -116,7 +117,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
client_max_body_size 6g; # >= MAX_FILE_SIZE
|
client_max_body_size 11g; # >= MAX_FILE_SIZE
|
||||||
proxy_request_buffering off; # stream uploads (critical for large files)
|
proxy_request_buffering off; # stream uploads (critical for large files)
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
@@ -170,7 +171,8 @@ If you proxy through Cloudflare:
|
|||||||
- Set the DNS record to **Proxied**.
|
- Set the DNS record to **Proxied**.
|
||||||
- Uploads/downloads are subject to Cloudflare's size caps (free plan ~100MB).
|
- Uploads/downloads are subject to Cloudflare's size caps (free plan ~100MB).
|
||||||
For larger files, add a **Cache rule** or **Origin rule** that bypasses
|
For larger files, add a **Cache rule** or **Origin rule** that bypasses
|
||||||
Cloudflare for `/api/upload` and `/d/*` so traffic hits your origin directly.
|
Cloudflare for `/api/upload` and `/*` (or at least the share links) so traffic
|
||||||
|
hits your origin directly.
|
||||||
- Enable `TRUST_PROXY=1`; the app reads `CF-Connecting-IP` as the client IP.
|
- Enable `TRUST_PROXY=1`; the app reads `CF-Connecting-IP` as the client IP.
|
||||||
|
|
||||||
## 9. Verification
|
## 9. Verification
|
||||||
@@ -245,5 +247,6 @@ Files in `storage/` are untouched by updates and keep working.
|
|||||||
short-lived HMAC-signed cookie (server secret in `storage/.secret`).
|
short-lived HMAC-signed cookie (server secret in `storage/.secret`).
|
||||||
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
|
- Rate limiting protects the keyed endpoints; `TRUST_PROXY=1` must only be set
|
||||||
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
|
when a proxy really sets `X-Forwarded-For`/`CF-Connecting-IP`.
|
||||||
- Files never expire. Deletion requires possession of the key
|
- Files auto-expire after one week (configurable via `FILE_TTL_MS`) and are
|
||||||
|
swept hourly. Deletion also requires possession of the key
|
||||||
(`DELETE /api/files/:id` with `X-Delete-Key`).
|
(`DELETE /api/files/:id` with `X-Delete-Key`).
|
||||||
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
A minimal, self-hosted private file hosting service built with **Bun** and
|
A minimal, self-hosted private file hosting service built with **Bun** and
|
||||||
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are
|
**TypeScript**. Upload a file, get a secret, unguessable download link. Files are
|
||||||
stored permanently on the local filesystem and never expire.
|
stored on the local filesystem and expire after one week.
|
||||||
|
|
||||||
Everything lives on disk — the filesystem is the source of truth. There is no
|
Everything lives on disk — the filesystem is the source of truth. There is no
|
||||||
database, no object storage, no queue, and no automatic cleanup.
|
database, no object storage, and no queue. Expired files are swept automatically.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Drag & drop / file-picker uploads with progress, straight from the browser.
|
- Drag & drop / file-picker uploads with progress, straight from the browser.
|
||||||
- **Required per-file password**: every download is protected by a password.
|
- **Required per-file password**: every download is protected by a password.
|
||||||
- Permanent storage on the local filesystem; files survive restarts.
|
- Storage on the local filesystem; files survive restarts.
|
||||||
- A cryptographically secure, unguessable download key for every file.
|
- A cryptographically secure, unguessable download key for every file.
|
||||||
- Streaming uploads and downloads — no full buffering in RAM, large files work.
|
- Streaming uploads and downloads — no full buffering in RAM, large files work.
|
||||||
- Configurable maximum upload size (`413` when exceeded).
|
- Configurable maximum upload size (`413` when exceeded, default 10GB).
|
||||||
|
- Files **auto-expire after one week** and are removed automatically.
|
||||||
- Authenticated deletion (possession of the file's secret key is required).
|
- Authenticated deletion (possession of the file's secret key is required).
|
||||||
- Rate-limited download/delete endpoints to blunt brute force.
|
- Rate-limited download/delete endpoints to blunt brute force.
|
||||||
- Zero runtime dependencies (Bun is the only runtime).
|
- Zero runtime dependencies (Bun is the only runtime).
|
||||||
@@ -47,8 +48,9 @@ cp .env.example .env
|
|||||||
| `HOST` | `0.0.0.0` | Bind address. |
|
| `HOST` | `0.0.0.0` | Bind address. |
|
||||||
| `PORT` | `3000` | Bind port. |
|
| `PORT` | `3000` | Bind port. |
|
||||||
| `STORAGE_DIR` | `./storage` | Directory where files + metadata are stored (relative to CWD). |
|
| `STORAGE_DIR` | `./storage` | Directory where files + metadata are stored (relative to CWD). |
|
||||||
| `MAX_FILE_SIZE` | `5GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). |
|
| `MAX_FILE_SIZE` | `10GB` | Max upload size. Units: `B`, `KB`, `MB`, `GB`, `TB` (or plain int). |
|
||||||
| `RATE_LIMIT_ENABLED` | `1` | Enable per-IP rate limiting on `/d/` and `/api/files/`. |
|
| `FILE_TTL_MS` | `604800000` | Milliseconds before an upload expires and is deleted (1 week). |
|
||||||
|
| `RATE_LIMIT_ENABLED` | `1` | Enable per-IP rate limiting on `/(key)` and `/api/files/`. |
|
||||||
| `RATE_LIMIT_MAX` | `300` | Max requests per IP per window. |
|
| `RATE_LIMIT_MAX` | `300` | Max requests per IP per window. |
|
||||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in ms. |
|
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in ms. |
|
||||||
| `TRUST_PROXY` | `0` | Set `1` only behind a proxy that sets `X-Forwarded-For`. |
|
| `TRUST_PROXY` | `0` | Set `1` only behind a proxy that sets `X-Forwarded-For`. |
|
||||||
@@ -66,12 +68,12 @@ Then open http://localhost:3000.
|
|||||||
|
|
||||||
1. Open the site.
|
1. Open the site.
|
||||||
2. Drop `example.zip` (or click to pick a file).
|
2. Drop `example.zip` (or click to pick a file).
|
||||||
3. The server stores it permanently and returns a URL like
|
3. The server stores it and returns a URL like
|
||||||
`https://files.example.com/d/8fJ...random-secret...`.
|
`https://files.example.com/8fJ...random-secret...`.
|
||||||
4. Copy the URL. Anyone possessing it can download the file — **after entering
|
4. Copy the URL. Anyone possessing it can download the file — **after entering
|
||||||
the password you set at upload**. Nobody without the secret URL and the
|
the password you set at upload**. Nobody without the secret URL and the
|
||||||
password can download it.
|
password can download it.
|
||||||
5. The file stays available indefinitely. It is removed only by explicit
|
5. The file is removed automatically after one week, or earlier by explicit
|
||||||
deletion.
|
deletion.
|
||||||
|
|
||||||
## Filesystem layout
|
## Filesystem layout
|
||||||
@@ -128,7 +130,7 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
|
|||||||
{
|
{
|
||||||
"id": "xmK5lLMRErlN",
|
"id": "xmK5lLMRErlN",
|
||||||
"key": "xmK5lLMRErlN",
|
"key": "xmK5lLMRErlN",
|
||||||
"url": "/d/xmK5lLMRErlN",
|
"url": "/xmK5lLMRErlN",
|
||||||
"originalName": "example.zip",
|
"originalName": "example.zip",
|
||||||
"size": 123456,
|
"size": 123456,
|
||||||
"protected": true
|
"protected": true
|
||||||
@@ -139,27 +141,29 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
|
|||||||
the short length keeps share URLs tidy, and the required password adds a second
|
the short length keeps share URLs tidy, and the required password adds a second
|
||||||
factor of protection.
|
factor of protection.
|
||||||
|
|
||||||
### `GET /d/:key` (also `HEAD`)
|
### `GET /:key` (also `HEAD`)
|
||||||
|
|
||||||
Every file is password-protected. Without a valid auth cookie the server shows a
|
The download URL is just the key at the site root (`https://host/xmK5lLMRErlN`).
|
||||||
password prompt; a wrong or non-existent key returns a generic `404`.
|
The legacy `/d/:key` form still works as an alias. Every file is
|
||||||
|
password-protected: without a valid auth cookie the server shows a password
|
||||||
|
prompt; a wrong or non-existent key returns a generic `404`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O http://localhost:3000/d/xmK5lLMRErlN # prompts for password first
|
curl -O http://localhost:3000/xmK5lLMRErlN # prompts for password first
|
||||||
```
|
```
|
||||||
|
|
||||||
### Password protection
|
### Password protection
|
||||||
|
|
||||||
Every file requires its password:
|
Every file requires its password:
|
||||||
|
|
||||||
1. `GET /d/:key` returns a small HTML password form.
|
1. `GET /:key` returns a small HTML password form.
|
||||||
2. The browser submits it to `POST /api/auth/:key` with `password=...`.
|
2. The browser submits it to `POST /api/auth/:key` with `password=...`.
|
||||||
3. On success the server sets a short-lived (30 min), HttpOnly, SameSite=Lax,
|
3. On success the server sets a short-lived (30 min), HttpOnly, SameSite=Lax,
|
||||||
signed cookie and redirects back to `/d/:key`, which then streams the file.
|
signed cookie and redirects back to `/:key`, which then streams the file.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -c cookies.txt -X POST -d "password=hunter2" http://localhost:3000/api/auth/:key
|
curl -c cookies.txt -X POST -d "password=hunter2" http://localhost:3000/api/auth/:key
|
||||||
curl -b cookies.txt -O http://localhost:3000/d/:key
|
curl -b cookies.txt -O http://localhost:3000/:key
|
||||||
```
|
```
|
||||||
|
|
||||||
The cookie is signed with a server secret (`storage/.secret`) so it cannot be
|
The cookie is signed with a server secret (`storage/.secret`) so it cannot be
|
||||||
@@ -237,7 +241,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name files.example.com;
|
server_name files.example.com;
|
||||||
|
|
||||||
client_max_body_size 6g; # >= MAX_FILE_SIZE
|
client_max_body_size 11g; # >= MAX_FILE_SIZE
|
||||||
proxy_request_buffering off; # stream uploads, don't buffer
|
proxy_request_buffering off; # stream uploads, don't buffer
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -343,7 +347,7 @@ src/
|
|||||||
├── log.ts # timestamped logging
|
├── log.ts # timestamped logging
|
||||||
├── routes/
|
├── routes/
|
||||||
│ ├── upload.ts # POST /api/upload
|
│ ├── upload.ts # POST /api/upload
|
||||||
│ ├── download.ts # GET /d/:key
|
│ ├── download.ts # GET /:key
|
||||||
│ └── delete.ts # DELETE /api/files/:id
|
│ └── delete.ts # DELETE /api/files/:id
|
||||||
└── public/ # plain HTML/CSS/JS frontend (no framework)
|
└── public/ # plain HTML/CSS/JS frontend (no framework)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -42,10 +42,16 @@ in
|
|||||||
|
|
||||||
maxFileSize = lib.mkOption {
|
maxFileSize = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "5GB";
|
default = "10GB";
|
||||||
description = "Maximum upload size (B/KB/MB/GB/TB).";
|
description = "Maximum upload size (B/KB/MB/GB/TB).";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fileTtlMs = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
description = "Milliseconds before an uploaded file expires and is deleted.";
|
||||||
|
};
|
||||||
|
|
||||||
trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)";
|
trustProxy = lib.mkEnableOption "trust of X-Forwarded-* headers (behind a reverse proxy)";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +79,7 @@ in
|
|||||||
"HOST=${cfg.host}"
|
"HOST=${cfg.host}"
|
||||||
"STORAGE_DIR=${cfg.storageDir}"
|
"STORAGE_DIR=${cfg.storageDir}"
|
||||||
"MAX_FILE_SIZE=${cfg.maxFileSize}"
|
"MAX_FILE_SIZE=${cfg.maxFileSize}"
|
||||||
|
"FILE_TTL_MS=${toString cfg.fileTtlMs}"
|
||||||
] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ];
|
] ++ lib.optionals cfg.trustProxy [ "TRUST_PROXY=1" ];
|
||||||
|
|
||||||
# no runtime deps, so no `bun install` needed at startup
|
# no runtime deps, so no `bun install` needed at startup
|
||||||
|
|||||||
+10
-1
@@ -5,12 +5,15 @@ export interface Config {
|
|||||||
port: number;
|
port: number;
|
||||||
storageDir: string;
|
storageDir: string;
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
|
fileTtlMs: number;
|
||||||
rateLimitEnabled: boolean;
|
rateLimitEnabled: boolean;
|
||||||
rateLimitMax: number;
|
rateLimitMax: number;
|
||||||
rateLimitWindowMs: number;
|
rateLimitWindowMs: number;
|
||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
||||||
|
|
||||||
const SIZE_UNITS: Record<string, number> = {
|
const SIZE_UNITS: Record<string, number> = {
|
||||||
B: 1,
|
B: 1,
|
||||||
K: 1 << 10,
|
K: 1 << 10,
|
||||||
@@ -49,9 +52,14 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
|||||||
}
|
}
|
||||||
const host = env.HOST ?? "0.0.0.0";
|
const host = env.HOST ?? "0.0.0.0";
|
||||||
const storageDir = resolve(env.STORAGE_DIR ?? "./storage");
|
const storageDir = resolve(env.STORAGE_DIR ?? "./storage");
|
||||||
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "5GB");
|
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "10GB");
|
||||||
if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB");
|
if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB");
|
||||||
|
|
||||||
|
const fileTtlMs = Number(env.FILE_TTL_MS ?? String(DEFAULT_TTL_MS));
|
||||||
|
if (!Number.isInteger(fileTtlMs) || fileTtlMs < 1) {
|
||||||
|
throw new Error(`Invalid FILE_TTL_MS: ${env.FILE_TTL_MS}`);
|
||||||
|
}
|
||||||
|
|
||||||
const rateLimitMax = Number(env.RATE_LIMIT_MAX ?? "300");
|
const rateLimitMax = Number(env.RATE_LIMIT_MAX ?? "300");
|
||||||
if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) {
|
if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) {
|
||||||
throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`);
|
throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`);
|
||||||
@@ -66,6 +74,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
|||||||
port,
|
port,
|
||||||
storageDir,
|
storageDir,
|
||||||
maxFileSize,
|
maxFileSize,
|
||||||
|
fileTtlMs,
|
||||||
rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true),
|
rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true),
|
||||||
rateLimitMax,
|
rateLimitMax,
|
||||||
rateLimitWindowMs,
|
rateLimitWindowMs,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { hasValidAuth, renderPasswordForm } from "../auth";
|
|||||||
import { notFound } from "../http";
|
import { notFound } from "../http";
|
||||||
import { log } from "../log";
|
import { log } from "../log";
|
||||||
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
|
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
|
||||||
import type { Storage } from "../storage";
|
import { isExpired, type Storage } from "../storage";
|
||||||
|
|
||||||
export async function handleDownload(
|
export async function handleDownload(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -17,6 +17,11 @@ export async function handleDownload(
|
|||||||
|
|
||||||
const meta = await storage.readMetadata(id);
|
const meta = await storage.readMetadata(id);
|
||||||
if (!meta) return notFound();
|
if (!meta) return notFound();
|
||||||
|
if (isExpired(meta)) {
|
||||||
|
await storage.delete(id);
|
||||||
|
log("info", `download rejected id=${id} expired`);
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
|
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
|
||||||
|
|
||||||
if (!hasValidAuth(req, secret, rawKey)) {
|
if (!hasValidAuth(req, secret, rawKey)) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function handleUpload(
|
|||||||
req: Request,
|
req: Request,
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
maxFileSize: number,
|
maxFileSize: number,
|
||||||
|
ttlMs: number,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const contentType = req.headers.get("content-type");
|
const contentType = req.headers.get("content-type");
|
||||||
const boundary = contentType ? getBoundary(contentType) : null;
|
const boundary = contentType ? getBoundary(contentType) : null;
|
||||||
@@ -73,6 +74,7 @@ export async function handleUpload(
|
|||||||
downloadKeyHash: hashSecret(key),
|
downloadKeyHash: hashSecret(key),
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
|
||||||
|
|||||||
+13
-1
@@ -26,6 +26,17 @@ if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
|
|||||||
const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs);
|
const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const expiryTimer = setInterval(() => {
|
||||||
|
storage
|
||||||
|
.expireExpired()
|
||||||
|
.then((removed) => {
|
||||||
|
if (removed > 0) log("info", `expiry sweep removed ${removed} file(s)`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log("error", `expiry sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
});
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
|
||||||
const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
|
const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
|
||||||
"/": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
"/": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
||||||
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
|
||||||
@@ -97,7 +108,7 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/api/upload" && method === "POST") {
|
if (path === "/api/upload" && method === "POST") {
|
||||||
return handleUpload(req, storage, cfg.maxFileSize);
|
return handleUpload(req, storage, cfg.maxFileSize, cfg.fileTtlMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.startsWith("/api/auth/") && method === "POST") {
|
if (path.startsWith("/api/auth/") && method === "POST") {
|
||||||
@@ -145,6 +156,7 @@ log(
|
|||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
process.once(signal, () => {
|
process.once(signal, () => {
|
||||||
log("info", `received ${signal}, shutting down`);
|
log("info", `received ${signal}, shutting down`);
|
||||||
|
clearInterval(expiryTimer);
|
||||||
server.stop();
|
server.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export interface FileMeta {
|
|||||||
downloadKeyHash: string;
|
downloadKeyHash: string;
|
||||||
passwordHash?: string;
|
passwordHash?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExpired(meta: FileMeta, now: number = Date.now()): boolean {
|
||||||
|
if (!meta.expiresAt) return false;
|
||||||
|
const expires = Date.parse(meta.expiresAt);
|
||||||
|
return Number.isFinite(expires) && expires <= now;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UploadTooLargeError extends Error {
|
export class UploadTooLargeError extends Error {
|
||||||
@@ -113,6 +120,21 @@ export class Storage {
|
|||||||
await rm(join(this.filesDir, `${id}.part`), { force: true });
|
await rm(join(this.filesDir, `${id}.part`), { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async expireExpired(now: number = Date.now()): Promise<number> {
|
||||||
|
let removed = 0;
|
||||||
|
for (const entry of await readdir(this.metadataDir)) {
|
||||||
|
if (!entry.endsWith(".json")) continue;
|
||||||
|
const id = entry.slice(0, -".json".length);
|
||||||
|
if (!ID_PATTERN.test(id)) continue;
|
||||||
|
const meta = await this.readMetadata(id);
|
||||||
|
if (meta && isExpired(meta, now)) {
|
||||||
|
await this.delete(id);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> {
|
async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> {
|
||||||
let removedFiles = 0;
|
let removedFiles = 0;
|
||||||
let removedMetadata = 0;
|
let removedMetadata = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user