- Front controller and UI in [public/index.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/public/index.php) and [views/dashboard.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/views/dashboard.php)

R2 S3 Signature V4 client in 
[src/R2/R2Client.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/R2/R2Client.php)
SQLite mirror, usage ledger, settings, local tags, permission labels, 
and signed URL history under 
[src/Repository](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/Repository)
Simple password auth and CSRF protection
Uploads, folders, sync, copy/move/delete, downloads, presigned URLs, 
CORS JSON management, and usage/billing-style summaries
Setup docs in 
[README.md](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/README.md) 
and config template in 
[.env.example](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/.env.example)
Important R2 caveat: Cloudflare’s current R2 S3 compatibility docs say 
ACLs and object-tagging APIs are not implemented, so the app stores tags 
and permission labels locally in SQLite while keeping file bytes on R2. 
I used Cloudflare’s official docs for S3 compatibility, presigned URLs, 
and CORS.
This commit is contained in:
Ty Clifford
2026-06-20 13:57:21 -04:00
parent 1c7f697913
commit 3518555d1e
29 changed files with 3598 additions and 1 deletions
+399
View File
@@ -0,0 +1,399 @@
<?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">
<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>
</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>
+44
View File
@@ -0,0 +1,44 @@
<?php
/** @var string $content */
/** @var string $appName */
/** @var array<int, array{type: string, message: string}> $flash */
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($appName) ?></title>
<link rel="stylesheet" href="assets/app.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="index.php">
<span class="brand-mark">R2</span>
<span><?= htmlspecialchars($appName) ?></span>
</a>
<?php if (!empty($_SESSION['authenticated'])): ?>
<form method="post" class="logout-form">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf ?? '') ?>">
<input type="hidden" name="action" value="logout">
<button type="submit" class="button button-ghost">Sign out</button>
</form>
<?php endif; ?>
</header>
<?php if (!empty($flash)): ?>
<section class="flash-stack" aria-live="polite">
<?php foreach ($flash as $message): ?>
<div class="flash flash-<?= htmlspecialchars($message['type']) ?>">
<?= htmlspecialchars($message['message']) ?>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?= $content ?>
<script src="assets/app.js"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<?php
/** @var string $csrf */
?>
<main class="login-shell">
<section class="login-panel">
<div>
<p class="eyebrow">Cloudflare R2</p>
<h1>Storage manager</h1>
<p class="muted">Sign in with the app password from your environment file.</p>
</div>
<form method="post" class="stack">
<input type="hidden" name="action" value="login">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<label>
Password
<input type="password" name="password" autocomplete="current-password" autofocus required>
</label>
<button type="submit" class="button button-primary">Sign in</button>
</form>
</section>
</main>