Files
Ty Clifford 4b4c18f2d9 - 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).
2026-06-21 11:24:09 -04:00

401 lines
21 KiB
PHP

<?php
use R2Manager\Support\Formatter;
use R2Manager\Support\R2Key;
/** @var string $csrf */
/** @var string $bucket */
/** @var bool $configured */
/** @var list<string> $missingSettings */
/** @var string $prefix */
/** @var list<array{label: string, prefix: string}> $breadcrumbs */
/** @var string $search */
/** @var string $tagFilter */
/** @var list<string> $tagNames */
/** @var list<array<string, mixed>> $folders */
/** @var list<array<string, mixed>> $objects */
/** @var array<string, mixed> $stats */
/** @var list<array<string, mixed>> $eventSummary */
/** @var array<string, int> $billingSummary */
/** @var list<array<string, mixed>> $dailySummary */
/** @var list<array<string, mixed>> $recentShares */
/** @var int $shareCount */
/** @var array<string, mixed>|null $selected */
/** @var string|null $selectedPublicUrl */
/** @var string $corsJson */
$currentQuery = ['prefix' => $prefix];
if ($search !== '') {
$currentQuery['q'] = $search;
}
if ($tagFilter !== '') {
$currentQuery['tag'] = $tagFilter;
}
?>
<main class="app-shell">
<?php if (!$configured): ?>
<section class="notice notice-warning">
<strong>R2 is not configured yet.</strong>
Add <?= htmlspecialchars(implode(', ', $missingSettings)) ?> to your <code>.env</code> file before syncing or mutating objects.
</section>
<?php endif; ?>
<section class="hero-band">
<div>
<p class="eyebrow">Bucket</p>
<h1><?= htmlspecialchars($bucket !== '' ? $bucket : 'Not configured') ?></h1>
<nav class="breadcrumbs" aria-label="Folder breadcrumb">
<?php foreach ($breadcrumbs as $index => $crumb): ?>
<?php if ($index > 0): ?><span>/</span><?php endif; ?>
<a href="index.php?<?= http_build_query(['prefix' => $crumb['prefix']]) ?>"><?= htmlspecialchars($crumb['label']) ?></a>
<?php endforeach; ?>
</nav>
</div>
<form method="post" class="inline-form">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="sync">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<button type="submit" class="button button-primary">Sync current prefix</button>
</form>
</section>
<section class="stats-grid">
<div class="stat">
<span>Objects</span>
<strong><?= number_format((int) $stats['objects']) ?></strong>
</div>
<div class="stat">
<span>Mirrored folders</span>
<strong><?= number_format((int) $stats['folders']) ?></strong>
</div>
<div class="stat">
<span>Storage</span>
<strong><?= Formatter::bytes((int) $stats['bytes']) ?></strong>
</div>
<div class="stat">
<span>Latest sync</span>
<strong><?= htmlspecialchars(Formatter::date($stats['latest_sync'] ?? null)) ?></strong>
</div>
</section>
<section class="workspace-grid">
<div class="main-column">
<section class="panel">
<div class="panel-heading">
<div>
<h2>Objects</h2>
<p><?= htmlspecialchars($prefix === '' ? 'Bucket root' : $prefix) ?></p>
</div>
<form method="get" class="search-form">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<input type="search" name="q" value="<?= htmlspecialchars($search) ?>" placeholder="Search keys or notes">
<select name="tag">
<option value="">All tags</option>
<?php foreach ($tagNames as $tagName): ?>
<option value="<?= htmlspecialchars($tagName) ?>" <?= $tagFilter === $tagName ? 'selected' : '' ?>><?= htmlspecialchars($tagName) ?></option>
<?php endforeach; ?>
</select>
<button class="button button-ghost" type="submit">Filter</button>
</form>
</div>
<form method="post" id="object-list-form">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<div class="object-table" role="table">
<div class="object-row object-head" role="row">
<span></span>
<span>Name</span>
<span>Size</span>
<span>Permission</span>
<span>Modified</span>
<span></span>
</div>
<?php if ($prefix !== ''): ?>
<a class="object-row" href="index.php?<?= http_build_query(['prefix' => R2Key::parentPrefix($prefix)]) ?>">
<span></span>
<strong>..</strong>
<span></span>
<span></span>
<span></span>
<span>Up</span>
</a>
<?php endif; ?>
<?php foreach ($folders as $folder): ?>
<label class="object-row folder-row">
<input type="checkbox" name="keys[]" value="<?= htmlspecialchars((string) $folder['prefix']) ?>">
<a href="index.php?<?= http_build_query(['prefix' => $folder['prefix']]) ?>"><strong><?= htmlspecialchars((string) $folder['name']) ?></strong></a>
<span>Folder</span>
<span>Local mirror</span>
<span><?= htmlspecialchars(Formatter::date((string) $folder['created_at'])) ?></span>
<span></span>
</label>
<?php endforeach; ?>
<?php foreach ($objects as $object): ?>
<?php $objectKey = (string) $object['object_key']; ?>
<label class="object-row">
<input type="checkbox" name="keys[]" value="<?= htmlspecialchars($objectKey) ?>">
<a href="index.php?<?= http_build_query($currentQuery + ['key' => $objectKey]) ?>">
<strong><?= htmlspecialchars((string) $object['display_name']) ?></strong>
<?php if (!empty($object['tags'])): ?>
<small><?= htmlspecialchars(Formatter::tagString($object['tags'])) ?></small>
<?php endif; ?>
</a>
<span><?= Formatter::bytes((int) $object['size']) ?></span>
<span><?= htmlspecialchars((string) $object['permission']) ?></span>
<span><?= htmlspecialchars(Formatter::date($object['last_modified'] ?? null)) ?></span>
<span><a href="index.php?page=download&key=<?= urlencode($objectKey) ?>">Download</a></span>
</label>
<?php endforeach; ?>
<?php if ($folders === [] && $objects === []): ?>
<div class="empty-state">No mirrored objects here yet. Sync this prefix or upload a file.</div>
<?php endif; ?>
</div>
<div class="bulk-actions">
<button type="submit" name="action" value="delete" class="button button-danger" data-confirm="Delete selected objects from R2?">Delete selected</button>
</div>
</form>
</section>
<section class="two-column">
<form method="post" enctype="multipart/form-data" class="panel stack" data-resumable-upload data-upload-endpoint="index.php" data-upload-reload="true">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="upload">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<div class="panel-heading">
<h2>Upload</h2>
</div>
<label>
Files
<input type="file" name="files[]" multiple required>
</label>
<label>
Tags
<input name="tags" placeholder="project=site, env=prod">
</label>
<label>
Permission label
<select name="permission">
<option value="private">Private</option>
<option value="signed-only">Signed URL only</option>
<option value="public-read">Public read</option>
<option value="archived">Archived</option>
</select>
</label>
<label>
Notes
<textarea name="notes" rows="3" placeholder="Local notes for operators"></textarea>
</label>
<button type="submit" class="button button-primary">Upload to R2</button>
<div class="upload-progress" data-upload-progress aria-live="polite"></div>
</form>
<form method="post" class="panel stack">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="create_folder">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<div class="panel-heading">
<h2>Folder</h2>
</div>
<label>
Folder name
<input name="folder_name" placeholder="reports" required>
</label>
<button type="submit" class="button button-primary">Create folder</button>
<p class="muted">Creates a zero-byte marker object so empty folders survive syncs.</p>
</form>
</section>
<section class="panel">
<div class="panel-heading">
<h2>CORS</h2>
<form method="post" class="inline-form">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="fetch_cors">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<button type="submit" class="button button-ghost">Fetch from R2</button>
</form>
</div>
<form method="post" class="stack">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<label>
CORS JSON
<textarea name="cors_json" class="code-textarea" rows="12"><?= htmlspecialchars($corsJson) ?></textarea>
</label>
<div class="button-row">
<button type="submit" name="action" value="save_cors" class="button button-primary">Save CORS to R2</button>
<button type="submit" name="action" value="delete_cors" class="button button-danger" data-confirm="Delete the bucket CORS policy?">Delete CORS</button>
</div>
</form>
</section>
</div>
<aside class="side-column">
<section class="panel stack">
<div class="panel-heading">
<h2>Selected Object</h2>
</div>
<?php if ($selected): ?>
<div class="detail-key"><?= htmlspecialchars((string) $selected['object_key']) ?></div>
<dl class="detail-list">
<dt>Size</dt><dd><?= Formatter::bytes((int) $selected['size']) ?></dd>
<dt>Type</dt><dd><?= htmlspecialchars((string) ($selected['content_type'] ?? 'Unknown')) ?></dd>
<dt>ETag</dt><dd><?= htmlspecialchars((string) ($selected['etag'] ?? '')) ?></dd>
</dl>
<?php if ($selectedPublicUrl): ?>
<label>
Public URL
<input readonly value="<?= htmlspecialchars($selectedPublicUrl) ?>" data-copyable>
</label>
<?php endif; ?>
<form method="post" class="stack">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<input type="hidden" name="key" value="<?= htmlspecialchars((string) $selected['object_key']) ?>">
<label>
Tags
<input name="tags" value="<?= htmlspecialchars(Formatter::tagString($selected['tags'] ?? [])) ?>">
</label>
<label>
Permission label
<select name="permission">
<?php foreach (['private', 'signed-only', 'public-read', 'archived'] as $permission): ?>
<option value="<?= $permission ?>" <?= ($selected['permission'] ?? '') === $permission ? 'selected' : '' ?>><?= htmlspecialchars($permission) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Notes
<textarea name="notes" rows="4"><?= htmlspecialchars((string) ($selected['notes'] ?? '')) ?></textarea>
</label>
<div class="button-row">
<button name="action" value="save_meta" class="button button-primary">Save</button>
<button name="action" value="refresh_head" class="button button-ghost">Refresh head</button>
</div>
</form>
<form method="post" class="stack">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<input type="hidden" name="key" value="<?= htmlspecialchars((string) $selected['object_key']) ?>">
<label>
Signed URL method
<select name="method">
<option>GET</option>
<option>PUT</option>
<option>HEAD</option>
<option>DELETE</option>
</select>
</label>
<label>
Expires in seconds
<input name="expires" type="number" min="1" max="604800" value="3600">
</label>
<label>
PUT content type
<input name="signed_content_type" placeholder="image/png">
</label>
<button name="action" value="generate_url" class="button button-primary">Generate URL</button>
</form>
<form method="post" class="stack">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
<input type="hidden" name="key" value="<?= htmlspecialchars((string) $selected['object_key']) ?>">
<label>
Destination key or folder
<input name="destination" placeholder="<?= htmlspecialchars($prefix . R2Key::basename((string) $selected['object_key'])) ?>">
</label>
<div class="button-row">
<button name="action" value="copy" class="button button-ghost">Copy</button>
<button name="action" value="move" class="button button-ghost">Move</button>
</div>
</form>
<?php else: ?>
<p class="muted">Choose an object to edit local tags, generate signed URLs, download, copy, or move it.</p>
<?php endif; ?>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Recent signed URLs</h2>
<span class="pill"><?= number_format($shareCount) ?> active</span>
</div>
<div class="share-list">
<?php foreach ($recentShares as $share): ?>
<article>
<strong><?= htmlspecialchars((string) $share['method']) ?> <?= htmlspecialchars((string) $share['object_key']) ?></strong>
<small>Expires <?= htmlspecialchars(Formatter::date((string) $share['expires_at'])) ?></small>
<input readonly value="<?= htmlspecialchars((string) $share['url']) ?>" data-copyable>
</article>
<?php endforeach; ?>
<?php if ($recentShares === []): ?>
<p class="muted">No signed URLs yet.</p>
<?php endif; ?>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Usage</h2>
<span class="pill">30 days</span>
</div>
<div class="billing-grid">
<div>
<span>Mirrored storage</span>
<strong><?= Formatter::bytes((int) $billingSummary['storage_bytes']) ?></strong>
</div>
<div>
<span>Class A-style</span>
<strong><?= number_format((int) $billingSummary['class_a_requests']) ?></strong>
</div>
<div>
<span>Class B-style</span>
<strong><?= number_format((int) $billingSummary['class_b_requests']) ?></strong>
</div>
<div>
<span>Local-only</span>
<strong><?= number_format((int) $billingSummary['local_requests']) ?></strong>
</div>
</div>
<div class="usage-list">
<?php foreach ($eventSummary as $event): ?>
<div>
<strong><?= htmlspecialchars((string) $event['event_type']) ?></strong>
<span><?= number_format((int) $event['requests']) ?> req · <?= Formatter::bytes((int) $event['bytes']) ?></span>
</div>
<?php endforeach; ?>
<?php if ($eventSummary === []): ?>
<p class="muted">Usage events will appear after syncs, uploads, downloads, and URL generation.</p>
<?php endif; ?>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Daily traffic</h2>
</div>
<div class="usage-list">
<?php foreach ($dailySummary as $day): ?>
<div>
<strong><?= htmlspecialchars((string) $day['day']) ?></strong>
<span><?= number_format((int) $day['requests']) ?> req · <?= Formatter::bytes((int) $day['bytes']) ?></span>
</div>
<?php endforeach; ?>
<?php if ($dailySummary === []): ?>
<p class="muted">No local usage recorded yet.</p>
<?php endif; ?>
</div>
</section>
</aside>
</section>
</main>