feat: import
All checks were successful
web/send/pipeline/head This commit looks good

This commit is contained in:
Julien Cabillot
2025-12-18 10:09:38 -05:00
commit 06a31cd59e
12 changed files with 808 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.htpasswd
public/files/

13
.pre-commit-config.yaml Normal file
View File

@@ -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

32
AGENTS.md Normal file
View File

@@ -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 10GB 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>/<timestamp-nom>` (hash aléatoire hex), valide les erreurs PHP et la taille <10GB, 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 (10GB) 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 à 10GiB. 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.

38
Jenkinsfile vendored Normal file
View File

@@ -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}'
}
}
}
}
}
}

8
Makefile Normal file
View File

@@ -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

21
docker-compose.yml Normal file
View File

@@ -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

10
php.ini Normal file
View File

@@ -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

16
pkg/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM docker.io/dunglas/frankenphp:1-php8.5-alpine
LABEL maintainer="Julien Cabillot <dockerimages@cabillot.eu>"
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

38
pkg/files/Caddyfile Normal file
View File

@@ -0,0 +1,38 @@
{
auto_https off
frankenphp
servers {
enable_full_duplex
}
}
:80 {
encode zstd br gzip
root * /app/
route {
handle {
basic_auth {
# Format :
# <username> <caddy hash-password>
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"
}
}

514
public/admin.php Normal file
View File

@@ -0,0 +1,514 @@
<?php
declare(strict_types=1);
const UPLOAD_ROOT = __DIR__ . '/files';
const MAX_FILE_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
ensureUploadRoot();
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$action = $_GET['action'] ?? $_POST['action'] ?? 'dashboard';
$isUploadAction = $method === 'POST' && $action === 'upload';
$isDeleteAction = $method === 'POST' && $action === 'delete';
if ($isUploadAction) {
handleUpload();
exit;
}
if ($isDeleteAction) {
handleDelete();
exit;
}
$files = gatherUploads();
$status = isset($_GET['status']) ? (string)$_GET['status'] : null;
renderDashboard($files, $status);
function ensureUploadRoot(): void
{
if (is_dir(UPLOAD_ROOT)) {
return;
}
if (!mkdir(UPLOAD_ROOT, 0775, true) && !is_dir(UPLOAD_ROOT)) {
http_response_code(500);
exit('Unable to prepare upload storage.');
}
}
function handleUpload(): void
{
$file = $_FILES['upload'] ?? null;
$status = 'error';
if ($file === null || !is_array($file)) {
$status = 'missing';
redirectWithStatus($status);
return;
}
if (($file['error'] ?? UPLOAD_ERR_OK) === UPLOAD_ERR_NO_FILE) {
$status = 'missing';
redirectWithStatus($status);
return;
}
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
redirectWithStatus('error');
return;
}
$size = (int)($file['size'] ?? 0);
if ($size > 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);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Send • Admin</title>
<style>
:root {
color-scheme: light;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #0f172a;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(135deg, #0f172a, #1e293b 60%);
color: #e2e8f0;
}
.layout {
width: min(1200px, 95vw);
margin: 0 auto;
padding: 3rem 0 4rem;
}
header {
margin-bottom: 2rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 2rem;
}
p.subtitle {
margin: 0;
color: #94a3b8;
}
.status {
margin-bottom: 1.25rem;
}
.status-banner {
padding: 0.9rem 1.1rem;
border-radius: 0.85rem;
font-size: 0.95rem;
border: 1px solid transparent;
}
.status-success {
background: rgba(34,197,94,0.15);
border-color: rgba(34,197,94,0.35);
color: #bbf7d0;
}
.status-warning {
background: rgba(251,191,36,0.1);
border-color: rgba(251,191,36,0.35);
color: #fde68a;
}
.status-error {
background: rgba(248,113,113,0.12);
border-color: rgba(248,113,113,0.45);
color: #fecaca;
}
.card {
background: #111827;
border-radius: 1.25rem;
padding: 1.75rem;
box-shadow: 0 25px 60px rgba(2, 6, 23, 0.55);
margin-bottom: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.9rem 0.75rem;
}
th {
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #94a3b8;
}
tr {
border-bottom: 1px solid #1f2937;
}
tr:last-child {
border-bottom: none;
}
td a {
color: #38bdf8;
}
.empty {
color: #94a3b8;
text-align: center;
padding: 2rem 0;
}
.note {
font-size: 0.9rem;
color: #94a3b8;
margin-top: 1rem;
line-height: 1.4;
}
.delete-form {
margin: 0;
}
.delete-button {
background: transparent;
border: 1px solid rgba(248,113,113,0.6);
color: #fecaca;
padding: 0.35rem 0.9rem;
border-radius: 999px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.delete-button:hover {
background: rgba(248,113,113,0.12);
color: #fee2e2;
}
@media (max-width: 800px) {
table, thead, tbody, th, td, tr {
display: block;
}
thead {
display: none;
}
tr {
margin-bottom: 1rem;
border: 1px solid #1f2937;
border-radius: 0.85rem;
padding: 0.75rem;
}
td {
padding: 0.5rem 0;
}
td::before {
content: attr(data-label);
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 0.2rem;
}
}
</style>
</head>
<body>
<div class="layout">
<header>
<h1>Send Admin</h1>
<p class="subtitle">Monitor uploads (<?php echo count($files); ?> files · <?php echo htmlspecialchars(humanSize((int)$totalSize), ENT_QUOTES, 'UTF-8'); ?> stored)</p>
</header>
<?php if ($statusMeta !== null): ?>
<?php [$statusMessage, $statusVariant] = $statusMeta; ?>
<div class="status">
<div class="status-banner status-<?php echo htmlspecialchars($statusVariant, ENT_QUOTES, 'UTF-8'); ?>">
<?php echo htmlspecialchars($statusMessage, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
<?php endif; ?>
<section class="card">
<?php if (empty($files)): ?>
<div class="empty">No uploads yet.</div>
<?php else: ?>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>File</th>
<th>Uploaded</th>
<th>Size</th>
<th>MIME</th>
<th>Hash slug</th>
<th>Download</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
<?php foreach ($files as $file): ?>
<?php $uploadedAt = gmdate('Y-m-d H:i:s \U\T\C', $file['uploaded_at']); ?>
<tr>
<td data-label="File"><?php echo htmlspecialchars($file['original'], ENT_QUOTES, 'UTF-8'); ?></td>
<td data-label="Uploaded"><?php echo htmlspecialchars($uploadedAt, ENT_QUOTES, 'UTF-8'); ?></td>
<td data-label="Size"><?php echo htmlspecialchars(humanSize((int)$file['size']), ENT_QUOTES, 'UTF-8'); ?></td>
<td data-label="MIME"><?php echo htmlspecialchars($file['mime'], ENT_QUOTES, 'UTF-8'); ?></td>
<td data-label="Hash slug"><code><?php echo htmlspecialchars($file['slug'], ENT_QUOTES, 'UTF-8'); ?></code></td>
<td data-label="Download"><a href="<?php echo htmlspecialchars($file['url'], ENT_QUOTES, 'UTF-8'); ?>" target="_blank" rel="noopener">Open</a></td>
<td data-label="Delete">
<form method="post" action="/admin.php" class="delete-form">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="target" value="<?php echo htmlspecialchars($file['relative'], ENT_QUOTES, 'UTF-8'); ?>">
<input type="hidden" name="redirect_to" value="/admin.php">
<button type="submit" class="delete-button">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
</div>
</body>
</html>
<?php
}
function humanSize(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$power = (int)floor(log($bytes, 1024));
$power = min($power, count($units) - 1);
$value = $bytes / (1024 ** $power);
return number_format($value, $power === 0 ? 0 : 2) . ' ' . $units[$power];
}
function resolveStatusMessage(?string $status): ?array
{
if ($status === null || $status === '') {
return null;
}
$map = [
'success' => ['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;
}

0
public/files/.gitkeep Normal file
View File

115
public/index.html Normal file
View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Send • Upload</title>
<style>
:root {
color-scheme: light;
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #f6f7fb;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, #ffffff, #eef1f7);
}
.card {
background: #fff;
padding: 2.5rem;
border-radius: 1.25rem;
box-shadow: 0 20px 60px rgba(10, 31, 68, 0.12);
width: min(420px, 90vw);
}
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #0f172a;
}
p.description {
color: #4b5563;
line-height: 1.5;
margin-bottom: 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
input[type='file'] {
border: 1px dashed #94a3b8;
padding: 1rem;
border-radius: 0.75rem;
background: #f8fafc;
}
button {
padding: 0.85rem 1.2rem;
border: none;
background: #2563eb;
color: #fff;
border-radius: 0.75rem;
font-size: 1rem;
cursor: pointer;
transition: background 160ms ease;
}
button:hover {
background: #1e3a8a;
}
.status {
margin-top: 1rem;
padding: 0.85rem 1rem;
border-radius: 0.75rem;
font-size: 0.95rem;
}
.status.ok {
background: #ecfdf5;
color: #065f46;
}
.status.error {
background: #fef2f2;
color: #991b1b;
}
</style>
</head>
<body>
<main class="card">
<h1>Send a file</h1>
<p class="description">
Upload any file up to <strong>10&nbsp;GB</strong>. The file will be stored on our
secure cluster; only admins can access the download link.
</p>
<form action="/admin.php?action=upload" method="post" enctype="multipart/form-data">
<input type="hidden" name="redirect_to" value="/" />
<label>
<input type="file" name="upload" required />
</label>
<button type="submit">Upload</button>
</form>
<div class="status" id="status" role="status"></div>
</main>
<script>
(function () {
var box = document.getElementById('status');
if (!box) return;
var params = new URLSearchParams(window.location.search);
var status = params.get('status');
if (!status) {
box.style.display = 'none';
return;
}
var messages = {
success: 'Upload received. An administrator will finalize the share link.',
error: 'Upload failed. Please try again.',
toolarge: 'File rejected: exceeds the 10 GB limit.',
missing: 'No file was attached. Please choose a file first.'
};
box.textContent = messages[status] || 'Upload processed.';
box.classList.add(status === 'success' ? 'ok' : 'error');
})();
</script>
</body>
</html>