4b4c18f2d9
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).
401 lines
21 KiB
PHP
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>
|