- Added R2 multipart upload support and streamed fallback uploads in [R2Client.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/R2/R2Client.php).
Added a shared resumable upload session service in [ResumableUploadService.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/Service/ResumableUploadService.php). Wired dashboard JSON upload endpoints in [AppController.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/Controller/AppController.php). Reworked the upload UI/JS to chunk files directly from the browser to presigned R2 multipart URLs in [app.js](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/public/assets/app.js). Added the dedicated iPhone/Safari side uploader at [side-upload.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/public/side-upload.php), with its own password and fixed destination prefix via script constants, .env, or side-upload.json. Added [side-upload.example.json](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/side-upload.example.json), ignored the real side-upload.json, and documented setup/CORS in [README.md](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/README.md).
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use R2Manager\R2\R2Client;
|
||||
use R2Manager\R2\R2Exception;
|
||||
use R2Manager\Repository\ObjectRepository;
|
||||
use R2Manager\Repository\UsageRepository;
|
||||
use R2Manager\Service\ResumableUploadService;
|
||||
use R2Manager\Support\Config;
|
||||
use R2Manager\Support\Database;
|
||||
use R2Manager\Support\R2Key;
|
||||
use R2Manager\Support\Tags;
|
||||
|
||||
const SIDE_UPLOAD_PASSWORD = '';
|
||||
const SIDE_UPLOAD_PASSWORD_HASH = '';
|
||||
const SIDE_UPLOAD_PREFIX = 'iphone-uploads/';
|
||||
const SIDE_UPLOAD_CONFIG_FILE = __DIR__ . '/../side-upload.json';
|
||||
|
||||
require dirname(__DIR__) . '/src/bootstrap.php';
|
||||
|
||||
$config = new Config(dirname(__DIR__));
|
||||
$database = new Database($config);
|
||||
$r2 = new R2Client($config);
|
||||
$objects = new ObjectRepository($database);
|
||||
$usage = new UsageRepository($database);
|
||||
|
||||
$side = side_upload_config($config);
|
||||
side_upload_start_session((string) $side['session_name']);
|
||||
$csrf = side_upload_csrf();
|
||||
$flash = side_upload_flash();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
|
||||
if (side_upload_is_resumable_action($action)) {
|
||||
side_upload_handle_resumable($action, $csrf, $side, new ResumableUploadService($config, $r2, $objects, $usage));
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!side_upload_validate_csrf($_POST['csrf'] ?? null)) {
|
||||
side_upload_set_flash('Your session token expired. Try again.');
|
||||
side_upload_redirect();
|
||||
}
|
||||
|
||||
if ($action === 'login') {
|
||||
if (!side_upload_enabled($side)) {
|
||||
side_upload_set_flash('Side upload is not configured yet.');
|
||||
} elseif (side_upload_verify_password((string) ($_POST['password'] ?? ''), $side)) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['side_upload_authenticated'] = true;
|
||||
side_upload_set_flash('Signed in.');
|
||||
} else {
|
||||
side_upload_set_flash('That password did not work.');
|
||||
}
|
||||
side_upload_redirect();
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
unset($_SESSION['side_upload_authenticated']);
|
||||
side_upload_set_flash('Signed out.');
|
||||
side_upload_redirect();
|
||||
}
|
||||
}
|
||||
|
||||
$authenticated = !empty($_SESSION['side_upload_authenticated']);
|
||||
$enabled = side_upload_enabled($side);
|
||||
$prefix = R2Key::normalizePrefix((string) $side['prefix']);
|
||||
$appName = (string) $side['app_name'];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($appName) ?></title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="side-upload.php">
|
||||
<span class="brand-mark">R2</span>
|
||||
<span><?= h($appName) ?></span>
|
||||
</a>
|
||||
<?php if ($authenticated): ?>
|
||||
<form method="post" class="logout-form">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="action" value="logout">
|
||||
<button type="submit" class="button button-ghost">Sign out</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php if ($flash !== ''): ?>
|
||||
<section class="flash-stack" aria-live="polite">
|
||||
<div class="flash <?= str_contains(strtolower($flash), 'signed') ? 'flash-success' : 'flash-error' ?>"><?= h($flash) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="side-upload-shell">
|
||||
<?php if (!$enabled): ?>
|
||||
<section class="notice notice-warning">
|
||||
<strong>Side upload is disabled.</strong>
|
||||
Set <code>SIDE_UPLOAD_PASSWORD</code> or <code>SIDE_UPLOAD_PASSWORD_HASH</code> in this script, <code>.env</code>, or <code>side-upload.json</code>.
|
||||
</section>
|
||||
<?php elseif (!$authenticated): ?>
|
||||
<form method="post" class="login-panel">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<div>
|
||||
<p class="eyebrow">Quick upload</p>
|
||||
<h1><?= h($appName) ?></h1>
|
||||
<p class="muted">Sign in with the side-upload password.</p>
|
||||
</div>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="current-password" autofocus required>
|
||||
</label>
|
||||
<button type="submit" class="button button-primary">Sign in</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<section class="panel stack">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Destination</p>
|
||||
<h1><?= h($prefix === '' ? 'Bucket root' : $prefix) ?></h1>
|
||||
</div>
|
||||
</div>
|
||||
<span class="destination-pill"><?= h($prefix === '' ? 'Bucket root' : $prefix) ?></span>
|
||||
</section>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="panel stack" data-resumable-upload data-upload-endpoint="side-upload.php">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="prefix" value="<?= h($prefix) ?>">
|
||||
<label>
|
||||
Files
|
||||
<input type="file" name="files[]" multiple required>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3" placeholder="Optional note for the local mirror"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="button button-primary">Upload</button>
|
||||
<div class="upload-progress" data-upload-progress aria-live="polite"></div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function side_upload_config(Config $config): array
|
||||
{
|
||||
$side = [
|
||||
'app_name' => 'Side Upload',
|
||||
'password' => SIDE_UPLOAD_PASSWORD,
|
||||
'password_hash' => SIDE_UPLOAD_PASSWORD_HASH,
|
||||
'prefix' => SIDE_UPLOAD_PREFIX,
|
||||
'session_name' => 'r2_side_upload_session',
|
||||
'permission' => 'private',
|
||||
'tags' => 'source=side-upload',
|
||||
];
|
||||
|
||||
if (is_file(SIDE_UPLOAD_CONFIG_FILE)) {
|
||||
$json = json_decode((string) file_get_contents(SIDE_UPLOAD_CONFIG_FILE), true);
|
||||
if (is_array($json)) {
|
||||
$side = array_replace($side, $json);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'SIDE_UPLOAD_APP_NAME' => 'app_name',
|
||||
'SIDE_UPLOAD_PASSWORD' => 'password',
|
||||
'SIDE_UPLOAD_PASSWORD_HASH' => 'password_hash',
|
||||
'SIDE_UPLOAD_PREFIX' => 'prefix',
|
||||
'SIDE_UPLOAD_SESSION_NAME' => 'session_name',
|
||||
'SIDE_UPLOAD_PERMISSION' => 'permission',
|
||||
'SIDE_UPLOAD_TAGS' => 'tags',
|
||||
] as $env => $key) {
|
||||
$value = $config->get($env, '');
|
||||
if ($value !== null && $value !== '') {
|
||||
$side[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$side['prefix'] = R2Key::normalizePrefix((string) $side['prefix']);
|
||||
return $side;
|
||||
}
|
||||
|
||||
function side_upload_start_session(string $name): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session_name($name !== '' ? $name : 'r2_side_upload_session');
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
function side_upload_csrf(): string
|
||||
{
|
||||
if (empty($_SESSION['side_upload_csrf'])) {
|
||||
$_SESSION['side_upload_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return (string) $_SESSION['side_upload_csrf'];
|
||||
}
|
||||
|
||||
function side_upload_validate_csrf(mixed $token): bool
|
||||
{
|
||||
return is_string($token) && hash_equals(side_upload_csrf(), $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $side
|
||||
*/
|
||||
function side_upload_enabled(array $side): bool
|
||||
{
|
||||
return trim((string) ($side['password'] ?? '')) !== '' || trim((string) ($side['password_hash'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $side
|
||||
*/
|
||||
function side_upload_verify_password(string $password, array $side): bool
|
||||
{
|
||||
$hash = trim((string) ($side['password_hash'] ?? ''));
|
||||
if ($hash !== '') {
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
return hash_equals((string) ($side['password'] ?? ''), $password);
|
||||
}
|
||||
|
||||
function side_upload_is_resumable_action(string $action): bool
|
||||
{
|
||||
return in_array($action, [
|
||||
'resumable_start',
|
||||
'resumable_part_url',
|
||||
'resumable_part_done',
|
||||
'resumable_complete',
|
||||
'resumable_abort',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $side
|
||||
*/
|
||||
function side_upload_handle_resumable(string $action, string $csrf, array $side, ResumableUploadService $uploads): void
|
||||
{
|
||||
if (!side_upload_validate_csrf($_POST['csrf'] ?? null) || !hash_equals($csrf, side_upload_csrf())) {
|
||||
side_upload_json(['ok' => false, 'error' => 'Your session token expired. Refresh and try again.'], 419);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($_SESSION['side_upload_authenticated'])) {
|
||||
side_upload_json(['ok' => false, 'error' => 'Sign in again before uploading.'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$id = (string) ($_POST['id'] ?? '');
|
||||
$partNumber = (int) ($_POST['part_number'] ?? 0);
|
||||
switch ($action) {
|
||||
case 'resumable_start':
|
||||
side_upload_json($uploads->start(
|
||||
(string) $side['prefix'],
|
||||
(string) ($_POST['file_name'] ?? ''),
|
||||
(int) ($_POST['file_size'] ?? 0),
|
||||
(string) ($_POST['content_type'] ?? ''),
|
||||
(string) ($_POST['fingerprint'] ?? ''),
|
||||
side_upload_tags($side),
|
||||
side_upload_permission((string) ($side['permission'] ?? 'private')),
|
||||
trim((string) ($_POST['notes'] ?? '')),
|
||||
'side-upload'
|
||||
));
|
||||
break;
|
||||
case 'resumable_part_url':
|
||||
side_upload_json($uploads->partUrl($id, $partNumber));
|
||||
break;
|
||||
case 'resumable_part_done':
|
||||
side_upload_json($uploads->markPart($id, $partNumber, (string) ($_POST['etag'] ?? '')));
|
||||
break;
|
||||
case 'resumable_complete':
|
||||
side_upload_json($uploads->complete($id));
|
||||
break;
|
||||
case 'resumable_abort':
|
||||
side_upload_json($uploads->abort($id));
|
||||
break;
|
||||
}
|
||||
} catch (R2Exception $exception) {
|
||||
side_upload_json(['ok' => false, 'error' => $exception->getMessage()], $exception->statusCode() ?: 500);
|
||||
} catch (Throwable $exception) {
|
||||
side_upload_json(['ok' => false, 'error' => $exception->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $side
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function side_upload_tags(array $side): array
|
||||
{
|
||||
$tags = $side['tags'] ?? 'source=side-upload';
|
||||
if (is_array($tags)) {
|
||||
$normalized = [];
|
||||
foreach ($tags as $name => $value) {
|
||||
$normalized[(string) $name] = (string) $value;
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return Tags::parse((string) $tags);
|
||||
}
|
||||
|
||||
function side_upload_permission(string $permission): string
|
||||
{
|
||||
return in_array($permission, ['private', 'public-read', 'signed-only', 'archived'], true) ? $permission : 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
function side_upload_json(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{"ok":false}';
|
||||
}
|
||||
|
||||
function side_upload_flash(): string
|
||||
{
|
||||
$message = (string) ($_SESSION['side_upload_flash'] ?? '');
|
||||
unset($_SESSION['side_upload_flash']);
|
||||
return $message;
|
||||
}
|
||||
|
||||
function side_upload_set_flash(string $message): void
|
||||
{
|
||||
$_SESSION['side_upload_flash'] = $message;
|
||||
}
|
||||
|
||||
function side_upload_redirect(): void
|
||||
{
|
||||
header('Location: side-upload.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
function h(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
Reference in New Issue
Block a user