commit 06a31cd59e8ebe27845d8ada539b466cfe1cce7c Author: Julien Cabillot Date: Thu Dec 18 10:09:38 2025 -0500 feat: import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..706d740 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.htpasswd +public/files/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f5fabfa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-illegal-windows-names + - id: check-json + - id: mixed-line-ending + - id: requirements-txt-fixer diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d43ef99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Send.cabillot.eu + +Ce projet est une application PHP dont le but est simple : + + * Une page HTML d'index principale avec un simple champ pour ajouter un fichier et un boutton `upload`. + * Une page PHP d'administration, ou je peux consulter tout les fichiers envoyes. + +Seul les fichiers de moins de 10G sont acceptes. +Les fichiers doivent ensuite etre accessible publiquement via un path hashé qui n'est pas fourni a l'utilisateur qui envoyé le fichier. +Seul la page d'administration permet de voir les liens des fichiers. + +## Implementation + +L'application tourne via un container dans un cluster kubernetes. +Le serveur utilise doit etre frankenphp. +Les 2 pages web (index et admin) doivent jolies mais ne doivent pas avoir de dependances exterieurs (css, js). +Je pense que seul la page d'administration a besoin de PHP, surement tout le code peut aller dans ce fichier sans besoin d'eclater la logique sur differents fichiers. + +## MVP livré + +- [`public/index.html`](public/index.html) : page statique avec styles inline, formulaire `multipart/form-data` pointant vers `/admin.php?action=upload`, texte explicatif sur la limite de 10 GB et bannière de statut alimentée par `?status=`. +- [`public/admin.php`](public/admin.php) : unique point d'entrée PHP, protège l'accès via HTTP Basic Auth (variables d'environnement `ADMIN_USER` / `ADMIN_PASS`), assure la création de `/upload`, gère `POST action=upload`, stocke les fichiers sous `/upload/YYYY/MM/DD//` (hash aléatoire hex), valide les erreurs PHP et la taille <10 GB, puis redirige vers la page précédente sans révéler le hash. La vue GET affiche formulaire, état des uploads (nom original, date UTC, taille lisible, MIME `finfo`, slug hashé, lien `/files/...`). +- [`public/php.ini`](public/php.ini) : règles d'upload (10 GB) et réglages runtime (`memory_limit`, `max_execution_time`, etc.). +- [`public/files/.gitkeep`](public/files/.gitkeep) : marqueur du répertoire monté côté serveur pour exposer les fichiers uploadés. + +## Notes d'exploitation + +- Volume : `/upload` doit être persistant et monté également sous `/app/public/files` dans le container FrankenPHP afin que les URLs `/files/...` servent les documents. +- Sécurité : ne jamais exposer l'URL hashée à l'uploadeur. Seule la page admin affiche les liens. +- Limites : `MAX_FILE_BYTES` défini à 10 GiB. Les formulaires refusent les fichiers plus gros et redirigent avec `status=toolarge`. +- Authentification : sans variables d'environnement, l'app retourne 500. Fournir `ADMIN_USER` et `ADMIN_PASS` via Kubernetes secrets. +- Journalisation / suivi : les erreurs d'upload utilisent les codes PHP standards et redirigent avec un statut générique pour éviter les fuites d'information. diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..fcb3ce2 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,38 @@ +pipeline { + environment { + registry = 'https://registry.hub.docker.com' + registryCredential = 'dockerhub_jcabillot' + dockerImage = 'jcabillot/send' + } + + agent any + + triggers { + cron('@midnight') + } + + stages { + stage('Clone repository') { + steps{ + checkout scm + } + } + + stage('Build image') { + steps{ + sh 'docker build --force-rm=true --no-cache=true --pull -t ${dockerImage} -f pkg/Dockerfile .' + } + } + + stage('Deploy Image') { + steps{ + script { + withCredentials([usernamePassword(credentialsId: 'dockerhub_jcabillot', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh 'docker login --username ${DOCKER_USER} --password ${DOCKER_PASS}' + sh 'docker push ${dockerImage}' + } + } + } + } + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac846c4 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +# Configuration +COMPOSE := podman-compose + +# Declare phony targets +.PHONY: htpasswd + +htpasswd: + podman run --rm --entrypoint /usr/bin/caddy -it docker.io/library/caddy hash-password diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..61dca02 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + web: + build: + context: . + dockerfile: pkg/Dockerfile + image: send-frankenphp:test + container_name: send-web + ports: + - "8081:80" + volumes: + - ./public:/app + - ./pkg/files/Caddyfile:/etc/frankenphp/Caddyfile + - ./.htpasswd:/etc/frankenphp/htpasswd + environment: + - LOG_LEVEL=DEBUG + - CADDY_GLOBAL_OPTIONS=debug + security_opt: + - label=disable + restart: unless-stopped diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..cfdce44 --- /dev/null +++ b/php.ini @@ -0,0 +1,10 @@ +file_uploads = On +upload_max_filesize = 10G +post_max_size = 10G +max_file_uploads = 20 +memory_limit = 512M +max_execution_time = 300 +max_input_time = 300 +expose_php = Off +display_errors = Off +log_errors = On diff --git a/pkg/Dockerfile b/pkg/Dockerfile new file mode 100644 index 0000000..8037efc --- /dev/null +++ b/pkg/Dockerfile @@ -0,0 +1,16 @@ +FROM docker.io/dunglas/frankenphp:1-php8.5-alpine +LABEL maintainer="Julien Cabillot " + +WORKDIR "/app" + +# Copy Caddy configuration +COPY --chown=www-data:www-data pkg/files/Caddyfile /etc/frankenphp/Caddyfile +# TODO: necessaire ? +#RUN chown www-data:www-data /data/caddy && \ +# chmod 2770 /data/caddy + +# Copy application files +COPY --chown=www-data:www-data "../public" "/app" +# TODO: php.ini + +USER www-data diff --git a/pkg/files/Caddyfile b/pkg/files/Caddyfile new file mode 100644 index 0000000..e4f120d --- /dev/null +++ b/pkg/files/Caddyfile @@ -0,0 +1,38 @@ +{ + auto_https off + frankenphp + + servers { + enable_full_duplex + } +} + +:80 { + encode zstd br gzip + + root * /app/ + + route { + handle { + basic_auth { + # Format : + # + import /etc/frankenphp/htpasswd + } + + try_files {path} {path}/index.html /index.php?{query} + + php_server + + file_server + } + } + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + } +} diff --git a/public/admin.php b/public/admin.php new file mode 100644 index 0000000..58304c2 --- /dev/null +++ b/public/admin.php @@ -0,0 +1,514 @@ + MAX_FILE_BYTES) { + redirectWithStatus('toolarge'); + return; + } + + $originalName = (string)($file['name'] ?? 'upload.bin'); + $safeOriginal = sanitizeFilename($originalName); + + try { + $hash = bin2hex(random_bytes(16)); + } catch (Throwable) { + redirectWithStatus('error'); + return; + } + + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $targetDir = sprintf( + '%s/%s/%s/%s/%s', + UPLOAD_ROOT, + $now->format('Y'), + $now->format('m'), + $now->format('d'), + $hash + ); + + if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { + redirectWithStatus('error'); + return; + } + + $timestamp = $now->format('U'); + $finalName = $timestamp . '-' . $safeOriginal; + $targetPath = $targetDir . '/' . $finalName; + + if (!move_uploaded_file((string)$file['tmp_name'], $targetPath)) { + redirectWithStatus('error'); + return; + } + + redirectWithStatus('success'); +} + +function handleDelete(): void +{ + $target = $_POST['target'] ?? ''; + + if (!is_string($target) || $target === '') { + redirectWithStatus('delete-invalid'); + return; + } + + $relative = sanitizeRelativePath($target); + if ($relative === null) { + redirectWithStatus('delete-invalid'); + return; + } + + $fullPath = UPLOAD_ROOT . '/' . $relative; + + if (!is_file($fullPath)) { + redirectWithStatus('delete-missing'); + return; + } + + if (!@unlink($fullPath)) { + redirectWithStatus('delete-error'); + return; + } + + cleanupEmptyDirectories(dirname($fullPath)); + redirectWithStatus('delete-success'); +} + +function sanitizeFilename(string $name): string +{ + $name = trim($name); + $name = basename($name); + $name = preg_replace('/[\s]+/', '_', $name); + $name = preg_replace('/[^A-Za-z0-9._-]/', '', $name) ?? ''; + + return $name !== '' ? $name : 'file.bin'; +} + +function sanitizeRelativePath(string $path): ?string +{ + $path = trim(str_replace("\0", '', (string)$path)); + $path = ltrim($path, '/'); + + if ($path === '' || str_contains($path, '..')) { + return null; + } + + $segments = array_values(array_filter( + explode('/', $path), + static fn (string $segment): bool => $segment !== '' && $segment !== '.' + )); + + if (count($segments) < 5) { + return null; + } + + return implode('/', $segments); +} + +function redirectWithStatus(string $status): void +{ + $redirectTarget = $_POST['redirect_to'] ?? '/admin.php'; + $redirectTarget = sanitizeRedirectTarget($redirectTarget); + $location = appendStatusParam($redirectTarget, $status); + header('Location: ' . $location, true, 302); +} + +function cleanupEmptyDirectories(string $directory): void +{ + $root = realpath(UPLOAD_ROOT); + $current = realpath($directory); + + if ($root === false || $current === false) { + return; + } + + while ($current !== $root && $current !== false && str_starts_with($current, $root)) { + $items = @scandir($current); + if ($items === false || count($items) > 2) { + break; + } + + if (!@rmdir($current)) { + break; + } + + $current = dirname($current); + } +} + +function sanitizeRedirectTarget(string $target): string +{ + $target = trim($target); + if ($target === '' || $target[0] !== '/') { + return '/admin.php'; + } + + $target = strtok($target, "\r\n"); + return $target ?: '/admin.php'; +} + +function appendStatusParam(string $url, string $status): string +{ + $parts = explode('?', $url, 2); + $path = $parts[0]; + $params = []; + if (isset($parts[1])) { + parse_str($parts[1], $params); + } + $params['status'] = $status; + $query = http_build_query($params); + return $path . ($query !== '' ? '?' . $query : ''); +} + +function gatherUploads(): array +{ + if (!is_dir(UPLOAD_ROOT)) { + return []; + } + + $files = []; + $flags = FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(UPLOAD_ROOT, $flags)); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + + $fullPath = $fileInfo->getPathname(); + $relative = ltrim(str_replace('\\', '/', substr($fullPath, strlen(UPLOAD_ROOT))), '/'); + $segments = explode('/', $relative); + if (count($segments) < 5) { + continue; + } + + [$year, $month, $day, $hash] = array_slice($segments, 0, 4); + $storedName = end($segments) ?: $fileInfo->getFilename(); + $originalName = preg_replace('/^\d{10,}-/', '', $storedName) ?: $storedName; + + $files[] = [ + 'original' => $originalName, + 'stored' => $storedName, + 'slug' => sprintf('%s/%s/%s/%s', $year, $month, $day, $hash), + 'url' => '/files/' . $relative, + 'size' => $fileInfo->getSize(), + 'mime' => $finfo ? (finfo_file($finfo, $fullPath) ?: 'application/octet-stream') : 'application/octet-stream', + 'uploaded_at' => $fileInfo->getMTime(), + 'relative' => $relative, + ]; + } + + if (is_resource($finfo)) { + finfo_close($finfo); + } + + usort( + $files, + fn (array $a, array $b): int => $b['uploaded_at'] <=> $a['uploaded_at'] + ); + + return $files; +} + +function renderDashboard(array $files, ?string $status): void +{ + $totalSize = array_sum(array_column($files, 'size')); + $statusMeta = resolveStatusMessage($status); + ?> + + + + + + Send • Admin + + + +
+
+

Send Admin

+

Monitor uploads ( files · stored)

+
+ + +
+
+ +
+
+ +
+ +
No uploads yet.
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FileUploadedSizeMIMEHash slugDownloadDelete
Open +
+ + + + +
+
+
+ +
+
+ + + ['File uploaded successfully.', 'success'], + 'missing' => ['No file selected for upload.', 'warning'], + 'toolarge' => ['File exceeds the 10 GiB limit.', 'error'], + 'error' => ['Unexpected error while handling the upload.', 'error'], + 'delete-success' => ['File deleted successfully.', 'success'], + 'delete-missing' => ['File was not found or already deleted.', 'warning'], + 'delete-error' => ['Unable to delete the requested file.', 'error'], + 'delete-invalid' => ['Invalid delete request received.', 'error'], + ]; + + return $map[$status] ?? null; +} diff --git a/public/files/.gitkeep b/public/files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..69efd28 --- /dev/null +++ b/public/index.html @@ -0,0 +1,115 @@ + + + + + + Send • Upload + + + +
+

Send a file

+

+ Upload any file up to 10 GB. The file will be stored on our + secure cluster; only admins can access the download link. +

+
+ + + +
+
+
+ + +