This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
.htpasswd
|
||||
public/files/
|
||||
13
.pre-commit-config.yaml
Normal file
13
.pre-commit-config.yaml
Normal 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
32
AGENTS.md
Normal 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 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>/<timestamp-nom>` (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.
|
||||
38
Jenkinsfile
vendored
Normal file
38
Jenkinsfile
vendored
Normal 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
8
Makefile
Normal 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
21
docker-compose.yml
Normal 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
10
php.ini
Normal 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
16
pkg/Dockerfile
Normal 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
38
pkg/files/Caddyfile
Normal 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
514
public/admin.php
Normal 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
0
public/files/.gitkeep
Normal file
115
public/index.html
Normal file
115
public/index.html
Normal 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 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>
|
||||
Reference in New Issue
Block a user