This commit is contained in:
Anaïs Saraiva
2026-08-25 12:32:54 +02:00
parent b1b4f03814
commit 1b3b45b348
12 changed files with 193 additions and 95 deletions
+40 -41
View File
@@ -1,84 +1,83 @@
# File Drop
Un hébergeur de fichiers privé, auto-hébergé et minimal. On dépose un fichier,
on reçoit un lien secret et intraçable. Les images, vidéos et sons s'affichent
directement dans le navigateur ; les autres fichiers se téléchargent.
A minimal, self-hosted private file host. Drop a file, get a secret,
unguessable link. Images, videos and audio render directly in the browser;
everything else downloads.
- **Backend** — Rust (Axum + Tokio)
- **Frontend** — Svelte (Vite + Tailwind)
- **Stockage** — système de fichiers local, sans base de données
- **Storage** — local filesystem, no database
## Démarrage rapide
## Quick start
```bash
# 1. Construire le frontend (produit frontend/dist)
# 1. Build the frontend (outputs frontend/dist)
cd frontend
npm install
npm run build
cd ..
# 2. Lancer le serveur (depuis la racine du projet)
# 2. Run the server (from the project root)
cargo run --release --manifest-path backend/Cargo.toml
```
Ouvrir ensuite http://localhost:3000.
Then open http://localhost:3000.
> Les chemins `STORAGE_DIR` et `STATIC_DIR` sont relatifs au dossier de
> lancement. Lance donc le serveur depuis la racine du projet (ou règle ces
> variables en absolu dans `.env`).
> `STORAGE_DIR` and `STATIC_DIR` are relative to where you run the server.
> Run it from the project root, or set absolute paths in `.env`.
## Configuration
Copier `backend/.env.example` vers `.env` puis l'adapter.
Copy `backend/.env.example` to `.env` and edit it.
| Variable | Défaut | Rôle |
| ---------------------- | ----------------- | ----------------------------------------- |
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Adresse d'écoute. |
| `STORAGE_DIR` | `./storage` | Dossier des fichiers et métadonnées. |
| `STATIC_DIR` | `./frontend/dist` | Dossier du frontend construit. |
| `MAX_FILE_SIZE` | `10GB` | Taille maximale d'envoi. |
| `FILE_TTL_MS` | `172800000` | Durée de vie d'un fichier (48 h max). |
| `KEY_LENGTH` | `8` | Longueur de la clé de partage (`a-z0-9`). |
| `PUBLIC_BASE_URL` | — | URL publique pour les liens absolus. |
| `RATE_LIMIT_ENABLED` | `1` | Limite de requêtes par IP. |
| `RATE_LIMIT_MAX` | `300` | Requêtes max par fenêtre. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Fenêtre en millisecondes. |
| `TRUST_PROXY` | `0` | Activer derrière un proxy (`X-Forwarded-For`). |
| Variable | Default | Description |
| ---------------------- | ----------------- | ---------------------------------------------- |
| `HOST` / `PORT` | `0.0.0.0` / `3000`| Bind address. |
| `STORAGE_DIR` | `./storage` | Files and metadata directory. |
| `STATIC_DIR` | `./frontend/dist` | Built frontend directory. |
| `MAX_FILE_SIZE` | `10GB` | Maximum upload size. |
| `FILE_TTL_MS` | `172800000` | File lifetime (48 h max). |
| `KEY_LENGTH` | `8` | Share key length (`a-z0-9`). |
| `PUBLIC_BASE_URL` | — | Public URL for absolute links. |
| `RATE_LIMIT_ENABLED` | `1` | Per-IP rate limiting. |
| `RATE_LIMIT_MAX` | `300` | Max requests per window. |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Window in milliseconds. |
| `TRUST_PROXY` | `0` | Enable behind a proxy (`X-Forwarded-For`). |
## API
| Méthode | Route | Rôle |
| --------- | -------------------- | ------------------------------------------- |
| `POST` | `/api/upload` | Envoi d'un fichier (`multipart/form-data`). |
| `GET` | `/:key` | Téléchargement (inline pour les médias). |
| `POST` | `/api/auth/:key` | Mot de passe d'un fichier protégé. |
| `DELETE` | `/api/files/:id` | Suppression (en-tête `X-Delete-Key`). |
| `GET` | `/api/health` | État du service. |
| Method | Route | Description |
| -------- | ------------------ | --------------------------------------------- |
| `POST` | `/api/upload` | Upload a file (`multipart/form-data`). |
| `GET` | `/:key` | Download (inline for media). |
| `POST` | `/api/auth/:key` | Submit the password for a protected file. |
| `DELETE` | `/api/files/:id` | Delete a file (`X-Delete-Key` header). |
| `GET` | `/api/health` | Service status. |
Exemple :
Example:
```bash
curl -F "[email protected]" http://localhost:3000/api/upload
```
Le lien renvoyé est de la forme `http://localhost:3000/<clé>.png`. Ajouter
`?dl=1` force le téléchargement au lieu de l'affichage.
The returned link looks like `http://localhost:3000/<key>.png`. Append `?dl=1`
to force a download instead of inline display.
## Structure
```
backend/ serveur Rust (Axum)
frontend/ interface Svelte (Vite + Tailwind)
backend/ Rust server (Axum)
frontend/ Svelte interface (Vite + Tailwind)
scripts/ tests
storage/ données (généré au premier lancement)
storage/ data (created on first run)
```
## Tests
```bash
cargo build # dans backend/
cargo build # in backend/
bash scripts/e2e-test.sh
```
Le test couvre l'envoi, le téléchargement, l'authentification par mot de passe,
les clés invalides, la sanitisation des noms et la suppression.
The test covers upload, download, password auth, invalid keys, name
sanitization and deletion.
+7 -7
View File
@@ -106,19 +106,19 @@ pub async fn auth(
pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response {
let message = if error {
"<p class=\"err\">Mot de passe incorrect. Réessayez.</p>"
"<p class=\"err\">Incorrect password. Please try again.</p>"
} else {
""
};
let safe_filename = escape_html(filename);
let html = format!(
r#"<!doctype html>
<html lang="fr">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Fichier protégé</title>
<title>Protected file</title>
<style>
* {{ box-sizing: border-box; }}
body {{
@@ -153,12 +153,12 @@ pub fn render_password_form(key: &str, filename: &str, error: bool) -> Response
<img src="/logo.svg" alt="logo" />
<span>File Drop</span>
</div>
<h1>Fichier protégé</h1>
<p>Entrez le mot de passe pour télécharger <strong>{safe_filename}</strong>.</p>
<h1>Protected file</h1>
<p>Enter the password to download <strong>{safe_filename}</strong>.</p>
{message}
<form method="post" action="/api/auth/{key}">
<input type="password" name="password" placeholder="Mot de passe" autofocus autocomplete="off" />
<button type="submit">Télécharger</button>
<input type="password" name="password" placeholder="Password" autofocus autocomplete="off" />
<button type="submit">Download</button>
</form>
</main>
</body>
+10 -1
View File
@@ -4,9 +4,18 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="theme-color" content="#0b0d12" />
<meta name="theme-color" content="#fafafa" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>File Drop</title>
<script>
(function () {
var t = localStorage.getItem('filehost.theme');
if (t !== 'dark' && t !== 'light') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.classList.toggle('dark', t === 'dark');
})();
</script>
</head>
<body>
<div id="app"></div>
+50 -11
View File
@@ -1,11 +1,14 @@
<script>
import { onMount } from 'svelte';
import Dropzone from './components/Dropzone.svelte';
import ResultPanel from './components/ResultPanel.svelte';
import FileList from './components/FileList.svelte';
import Toast from './components/Toast.svelte';
import { uploadFile } from './lib/api.js';
import { addUpload } from './lib/store.js';
import { currentTheme, toggleTheme } from './lib/theme.js';
let theme = 'light';
let currentFile = null;
let uploading = false;
let progress = 0;
@@ -13,6 +16,10 @@
let password = '';
let toast = { message: '', kind: 'info', id: 0 };
onMount(() => {
theme = currentTheme();
});
function notify(message, kind = 'info') {
toast = { message, kind, id: Date.now() };
}
@@ -21,13 +28,17 @@
const file = e.detail;
if (!file) return;
if (file.size === 0) {
notify('Les fichiers vides ne sont pas pris en charge.', 'error');
notify('Empty files are not supported.', 'error');
return;
}
currentFile = file;
notify('', 'info');
}
function onToggleTheme() {
theme = toggleTheme();
}
async function submit() {
if (!currentFile || uploading) return;
uploading = true;
@@ -49,11 +60,11 @@
createdAt: new Date().toISOString(),
protected: !!data.protected
});
notify('Envoi terminé.', 'success');
notify('Upload complete.', 'success');
currentFile = null;
password = '';
} catch (e) {
notify(e.message || 'Échec de l\'envoi.', 'error');
notify(e.message || 'Upload failed.', 'error');
} finally {
uploading = false;
progress = 0;
@@ -64,31 +75,59 @@
<div class="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
<header class="flex items-center justify-between gap-4 py-6">
<div class="flex items-center gap-3">
<img src="/logo.svg" alt="File Drop" class="h-8 w-8" />
<img src="/logo.svg" alt="File Drop" class="h-8 w-8 dark:invert" />
<div>
<h1 class="text-base font-semibold leading-tight text-ink">File Drop</h1>
<p class="text-xs text-muted">Hébergement de fichiers privé</p>
<p class="text-xs text-muted">Private file hosting</p>
</div>
</div>
<a href="/" class="btn btn-ghost px-3 py-1.5 text-xs">Nouvel envoi</a>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost p-1.5"
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
on:click={onToggleTheme}
>
{#if theme === 'dark'}
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
{/if}
</button>
<a href="/" class="btn btn-ghost px-3 py-1.5 text-xs">New upload</a>
</div>
</header>
<main class="grid grid-cols-1 items-start gap-4 lg:grid-cols-[360px_1fr]">
<aside class="card p-5 lg:sticky lg:top-6">
<h2 class="mb-4 text-sm font-semibold text-ink">Envoyer un fichier</h2>
<h2 class="mb-4 text-sm font-semibold text-ink">Upload a file</h2>
<Dropzone bind:file={currentFile} disabled={uploading} on:file={onFile} />
<div class="mt-4">
<label for="pw" class="mb-1.5 block text-xs font-medium text-muted">
Mot de passe <span class="font-normal opacity-70">(optionnel)</span>
Password <span class="font-normal opacity-70">(optional)</span>
</label>
<input
id="pw"
type="password"
class="field"
bind:value={password}
placeholder="Protéger le téléchargement"
placeholder="Protect the download"
autocomplete="new-password"
on:keydown={(e) => {
if (e.key === 'Enter') submit();
@@ -99,7 +138,7 @@
{#if uploading}
<div class="mt-4">
<div class="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted">
<span class="truncate">{currentFile ? currentFile.name : 'Envoi…'}</span>
<span class="truncate">{currentFile ? currentFile.name : 'Uploading…'}</span>
<span class="tabular-nums">{progress}%</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-raised">
@@ -114,7 +153,7 @@
disabled={!currentFile || uploading}
on:click={submit}
>
{uploading ? 'Envoi…' : 'Envoyer'}
{uploading ? 'Uploading…' : 'Upload'}
</button>
<div class="mt-4">
+32 -2
View File
@@ -2,6 +2,32 @@
@tailwind components;
@tailwind utilities;
:root {
--c-base: 250 250 250;
--c-surface: 255 255 255;
--c-raised: 245 245 245;
--c-edge: 229 229 229;
--c-ink: 23 23 23;
--c-muted: 115 115 115;
--c-accent: 23 23 23;
--c-accent-strong: 10 10 10;
--c-danger: 220 38 38;
--c-ok: 22 163 74;
}
.dark {
--c-base: 10 10 10;
--c-surface: 23 23 23;
--c-raised: 38 38 38;
--c-edge: 64 64 64;
--c-ink: 250 250 250;
--c-muted: 163 163 163;
--c-accent: 250 250 250;
--c-accent-strong: 229 229 229;
--c-danger: 248 113 113;
--c-ok: 74 222 128;
}
@layer base {
html {
-webkit-font-smoothing: antialiased;
@@ -14,7 +40,11 @@
}
::selection {
background: rgba(23, 23, 23, 0.08);
background: rgba(0, 0, 0, 0.12);
}
.dark ::selection {
background: rgba(255, 255, 255, 0.2);
}
summary {
@@ -37,7 +67,7 @@
}
.btn-primary {
@apply bg-accent text-white hover:bg-accent-strong;
@apply bg-accent text-surface hover:bg-accent-strong;
}
.btn-ghost {
+3 -3
View File
@@ -83,9 +83,9 @@
{#if file}
<p class="max-w-full truncate text-sm font-medium text-ink">{file.name}</p>
<p class="mt-1 text-xs text-muted">Déposez un autre fichier pour le remplacer</p>
<p class="mt-1 text-xs text-muted">Drop another file to replace it</p>
{:else}
<p class="text-sm font-medium text-ink">Glissez-déposez un fichier ici</p>
<p class="mt-1 text-xs text-muted">ou cliquez pour parcourir</p>
<p class="text-sm font-medium text-ink">Drag &amp; drop a file here</p>
<p class="mt-1 text-xs text-muted">or click to browse</p>
{/if}
</div>
+8 -8
View File
@@ -8,22 +8,22 @@
async function onDelete(item) {
const ok = window.confirm(
`Supprimer définitivement « ${item.originalName || item.downloadName} » ?`
`Delete "${item.originalName || item.downloadName}" permanently? This cannot be undone.`
);
if (!ok) return;
try {
await deleteFile(item);
removeUpload(item.id);
dispatch('notify', { message: 'Fichier supprimé.', kind: 'success' });
dispatch('notify', { message: 'File deleted.', kind: 'success' });
} catch (e) {
dispatch('notify', { message: e.message || 'Suppression impossible.', kind: 'error' });
dispatch('notify', { message: e.message || 'Deletion failed.', kind: 'error' });
}
}
function onClear() {
if ($uploads.length === 0) return;
const ok = window.confirm(
'Vider cette liste ? Seules les entrées de CE navigateur sont supprimées. Les fichiers restent sur le serveur.'
'Clear this list? This only removes the entries from THIS browser. Files on the server are not touched.'
);
if (!ok) return;
clearUploads();
@@ -33,14 +33,14 @@
<section class="card p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-baseline gap-2">
<h2 class="text-sm font-semibold text-ink">Fichiers</h2>
<h2 class="text-sm font-semibold text-ink">Files</h2>
{#if $uploads.length}
<span class="text-xs text-muted">{$uploads.length}</span>
{/if}
</div>
{#if $uploads.length}
<button type="button" class="text-xs font-medium text-muted transition-colors hover:text-danger" on:click={onClear}>
Vider la liste
Clear list
</button>
{/if}
</div>
@@ -62,8 +62,8 @@
/>
</svg>
</div>
<p class="text-sm text-muted">Aucun fichier pour l'instant.</p>
<p class="mt-1 text-xs text-muted">Envoyez votre premier fichier pour le voir ici.</p>
<p class="text-sm text-muted">No files yet.</p>
<p class="mt-1 text-xs text-muted">Upload your first file to see it here.</p>
</div>
{/if}
</section>
+4 -4
View File
@@ -50,18 +50,18 @@
<div class="flex flex-col gap-2 md:items-end">
<div class="flex flex-wrap gap-2">
<a href={forcedDownloadUrl(item)} class="btn btn-primary px-3 py-1.5 text-xs">Télécharger</a>
<a href={forcedDownloadUrl(item)} class="btn btn-primary px-3 py-1.5 text-xs">Download</a>
<button type="button" class="btn btn-ghost px-3 py-1.5 text-xs" on:click={copy}>
{copied ? 'Copié' : 'Copier'}
{copied ? 'Copied' : 'Copy'}
</button>
<button type="button" class="btn btn-danger px-3 py-1.5 text-xs" on:click={() => dispatch('delete', item)}>
Supprimer
Delete
</button>
</div>
<details>
<summary class="cursor-pointer select-none text-xs font-medium text-muted transition-colors hover:text-ink">
Liens
Links
</summary>
<div class="mt-2 md:flex md:justify-end">
<LinkFormats {item} />
+1 -1
View File
@@ -36,7 +36,7 @@
</div>
<div class="mt-3">
<p class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">Partager</p>
<p class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">Share links</p>
<LinkFormats item={result} size="md" />
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
const KEY = 'filehost.theme';
export function currentTheme() {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
export function applyTheme(theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
try {
localStorage.setItem(KEY, theme);
} catch {
/* ignore */
}
}
export function toggleTheme() {
const next = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
return next;
}
+11 -10
View File
@@ -1,19 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{svelte,js}'],
theme: {
extend: {
colors: {
base: '#fafafa',
surface: '#ffffff',
raised: '#f5f5f5',
edge: '#e5e5e5',
ink: '#171717',
muted: '#737373',
accent: '#171717',
'accent-strong': '#0a0a0a',
danger: '#dc2626',
ok: '#16a34a'
base: 'rgb(var(--c-base) / <alpha-value>)',
surface: 'rgb(var(--c-surface) / <alpha-value>)',
raised: 'rgb(var(--c-raised) / <alpha-value>)',
edge: 'rgb(var(--c-edge) / <alpha-value>)',
ink: 'rgb(var(--c-ink) / <alpha-value>)',
muted: 'rgb(var(--c-muted) / <alpha-value>)',
accent: 'rgb(var(--c-accent) / <alpha-value>)',
'accent-strong': 'rgb(var(--c-accent-strong) / <alpha-value>)',
danger: 'rgb(var(--c-danger) / <alpha-value>)',
ok: 'rgb(var(--c-ok) / <alpha-value>)'
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif']
+7 -7
View File
@@ -13,13 +13,13 @@ if [[ "${1:-}" == "--debug" ]]; then
PROFILE="debug"
fi
command -v cargo >/dev/null 2>&1 || { echo "cargo (Rust) est requis." >&2; exit 1; }
command -v npm >/dev/null 2>&1 || { echo "npm (Node.js) est requis." >&2; exit 1; }
command -v cargo >/dev/null 2>&1 || { echo "cargo (Rust) is required." >&2; exit 1; }
command -v npm >/dev/null 2>&1 || { echo "npm (Node.js) is required." >&2; exit 1; }
echo "==> Frontend (Svelte)"
cd "$ROOT/frontend"
if [[ ! -d node_modules ]]; then
echo " installation des dépendances..."
echo " installing dependencies..."
npm install
fi
npm run build
@@ -33,10 +33,10 @@ else
fi
echo
echo "Build terminé."
echo "Build complete."
if [[ "$PROFILE" == "release" ]]; then
echo " binaire : $ROOT/backend/target/release/filehost"
echo " binary : $ROOT/backend/target/release/filehost"
else
echo " binaire : $ROOT/backend/target/debug/filehost"
echo " binary : $ROOT/backend/target/debug/filehost"
fi
echo " frontend : $ROOT/frontend/dist"
echo " frontend: $ROOT/frontend/dist"