Files
send/public/admin.php
Julien Cabillot 06a31cd59e
All checks were successful
web/send/pipeline/head This commit looks good
feat: import
2025-12-18 14:35:48 -05:00

515 lines
16 KiB
PHP

<?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;
}