515 lines
16 KiB
PHP
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;
|
||
|
|
}
|