diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26004b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +APP_NAME="R2 Manager" +APP_PASSWORD="change-me" +# APP_PASSWORD_HASH="$2y$10$replace-with-password-hash" +APP_SESSION_NAME="r2_manager_session" +APP_DEBUG=false + +R2_ACCOUNT_ID="" +R2_ACCESS_KEY_ID="" +R2_SECRET_ACCESS_KEY="" +R2_BUCKET="" +R2_REGION="auto" +R2_ENDPOINT="" +R2_PATH_STYLE=true + +# Optional public bucket or custom domain URL, for display/copy links only. +R2_PUBLIC_URL="" + +DB_PATH="storage/database/r2-manager.sqlite" +SYNC_PAGE_LIMIT=1000 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9b0a02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +storage/database/*.sqlite +storage/database/*.sqlite-* +storage/cache/* +!storage/cache/.gitkeep +!storage/database/.gitkeep +.DS_Store + diff --git a/README.md b/README.md index cf4cf65..436146b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,102 @@ # CloudflareR2-Manager -A CloudFlare R2 manager built for the web usage and command line. \ No newline at end of file +A self-contained PHP web application for managing a Cloudflare R2 bucket with a local SQLite mirror. + +## Features + +- Simple password authentication with CSRF protection. +- R2 object browser backed by SQLite, so normal navigation uses the local mirror instead of repeatedly listing the bucket. +- Prefix/folder organization, including zero-byte marker objects for empty folders. +- Upload one or more files directly to R2 with local tags, permission labels, and operator notes. +- Download, delete, copy, move, rename, and refresh object metadata. +- Generate presigned `GET`, `PUT`, `HEAD`, and `DELETE` URLs. +- Manage bucket CORS from JSON. +- Track local usage events for sync, upload, download, copy, move, delete, CORS, and signed URL generation. +- Optional public URL display for public buckets or custom R2 domains. + +## Cloudflare R2 Notes + +Cloudflare R2 uses the S3-compatible API at `https://.r2.cloudflarestorage.com` and the S3 region is `auto`. + +As of Cloudflare's R2 S3 compatibility docs updated June 8, 2026, S3 ACLs and object tagging APIs are not implemented by R2. This app therefore stores tags, permission labels, and notes locally in SQLite while keeping file bytes in R2. Bucket CORS, object list/get/put/delete/copy, and presigned URLs are handled through R2's S3-compatible API. + +## Requirements + +- PHP 8.1 or newer. +- PHP extensions: `pdo_sqlite`, `curl`, `simplexml`, `fileinfo`. +- A Cloudflare R2 bucket. +- R2 API credentials with access to that bucket. + +Composer is not required. + +## Setup + +1. Copy the environment example: + + ```sh + cp .env.example .env + ``` + +2. Edit `.env`: + + ```dotenv + APP_PASSWORD="choose-a-real-password" + R2_ACCOUNT_ID="your-cloudflare-account-id" + R2_ACCESS_KEY_ID="your-r2-access-key-id" + R2_SECRET_ACCESS_KEY="your-r2-secret-access-key" + R2_BUCKET="your-bucket-name" + ``` + +3. Start the PHP development server: + + ```sh + php -S 127.0.0.1:8080 -t public + ``` + +4. Open `http://127.0.0.1:8080`, sign in, and run **Sync current prefix**. + +## Password Hashes + +For a plain setup, use `APP_PASSWORD`. For a hashed password, generate a hash with PHP and set `APP_PASSWORD_HASH` instead: + +```sh +php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), PHP_EOL;' +``` + +## Public URLs + +If your bucket has public access or a custom domain, set: + +```dotenv +R2_PUBLIC_URL="https://static.example.com" +``` + +The app will display copyable public links. It does not make a bucket public; public access is controlled in Cloudflare. + +## Usage Tracking + +The usage dashboard is a local operational ledger. It records actions performed through this app and current mirrored storage size. It is useful for estimating activity, but it is not a replacement for Cloudflare's official billing or analytics. + +## CORS Format + +Enter CORS as an array of S3-style rules: + +```json +[ + { + "AllowedOrigins": ["https://example.com"], + "AllowedMethods": ["GET", "PUT", "HEAD"], + "AllowedHeaders": ["Content-Type"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } +] +``` + +## Security + +- Keep `.env` outside version control. +- Serve the app behind HTTPS in production. +- Use least-privilege R2 credentials. +- Restrict access at your web server or VPN if this manages production buckets. + diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..9a33b22 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,550 @@ +:root { + color-scheme: light; + --bg: #f6f7fb; + --surface: #ffffff; + --surface-muted: #eef2f7; + --text: #182033; + --muted: #647084; + --line: #dfe5ef; + --primary: #0f766e; + --primary-strong: #0b5f59; + --danger: #b42318; + --danger-bg: #fee4e2; + --warning-bg: #fff4d6; + --warning-line: #f5c84c; + --shadow: 0 12px 30px rgba(20, 31, 56, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 15px; +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button, +input, +select, +textarea { + font: inherit; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--text); + padding: 10px 12px; +} + +textarea { + resize: vertical; +} + +label { + display: grid; + gap: 6px; + color: var(--muted); + font-weight: 650; +} + +label input, +label select, +label textarea { + color: var(--text); + font-weight: 450; +} + +code { + border-radius: 6px; + background: var(--surface-muted); + padding: 2px 5px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + padding: 0 24px; + background: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(14px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--text); + font-weight: 800; + text-decoration: none; +} + +.brand-mark { + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--primary); + color: #fff; + letter-spacing: 0; +} + +.logout-form { + margin: 0; +} + +.app-shell { + width: min(1500px, calc(100% - 32px)); + margin: 24px auto 48px; +} + +.login-shell { + display: grid; + min-height: calc(100vh - 64px); + place-items: center; + padding: 24px; +} + +.login-panel { + display: grid; + gap: 26px; + width: min(440px, 100%); + padding: 32px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.login-panel h1, +.hero-band h1, +.panel h2 { + margin: 0; +} + +.login-panel h1 { + font-size: 34px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--primary); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.muted { + color: var(--muted); +} + +.flash-stack { + position: fixed; + top: 76px; + right: 18px; + z-index: 20; + display: grid; + gap: 10px; + width: min(460px, calc(100vw - 36px)); +} + +.flash, +.notice { + border-radius: 8px; + padding: 14px 16px; + border: 1px solid var(--line); + background: var(--surface); + box-shadow: var(--shadow); +} + +.flash-success { + border-color: #9bd5c4; + background: #ecfdf5; +} + +.flash-error { + border-color: #f1aaa4; + background: #fff1f0; +} + +.notice { + margin-bottom: 18px; +} + +.notice-warning { + border-color: var(--warning-line); + background: var(--warning-bg); +} + +.hero-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; + padding: 26px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.hero-band h1 { + font-size: clamp(28px, 4vw, 48px); + line-height: 1; +} + +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + color: var(--muted); +} + +.stats-grid, +.workspace-grid, +.two-column { + display: grid; + gap: 16px; +} + +.stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 16px; +} + +.stat, +.panel { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.stat { + display: grid; + gap: 8px; + padding: 18px; +} + +.stat span, +.panel-heading p, +.detail-list dt, +.share-list small, +.usage-list span { + color: var(--muted); +} + +.stat strong { + font-size: 24px; +} + +.workspace-grid { + grid-template-columns: minmax(0, 1fr) 390px; + align-items: start; +} + +.main-column, +.side-column, +.stack { + display: grid; + gap: 16px; +} + +.two-column { + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.55fr); +} + +.panel { + padding: 18px; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-heading p { + margin: 4px 0 0; +} + +.search-form, +.inline-form, +.button-row, +.bulk-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.search-form { + width: min(680px, 100%); +} + +.search-form input { + min-width: 220px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + border: 1px solid transparent; + border-radius: 8px; + padding: 9px 14px; + cursor: pointer; + font-weight: 800; + text-decoration: none; + white-space: nowrap; +} + +.button-primary { + background: var(--primary); + color: #fff; +} + +.button-primary:hover { + background: var(--primary-strong); +} + +.button-ghost { + border-color: var(--line); + background: var(--surface); + color: var(--text); +} + +.button-danger { + border-color: #f1aaa4; + background: var(--danger-bg); + color: var(--danger); +} + +.object-table { + display: grid; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; +} + +.object-row { + display: grid; + grid-template-columns: 34px minmax(220px, 1fr) minmax(86px, 0.35fr) minmax(105px, 0.45fr) minmax(142px, 0.55fr) minmax(88px, 0.35fr); + align-items: center; + gap: 12px; + min-height: 54px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); + color: var(--text); +} + +.object-row:last-child { + border-bottom: 0; +} + +.object-row:hover { + background: #f8fbfc; + text-decoration: none; +} + +.object-head { + min-height: 42px; + background: var(--surface-muted); + color: var(--muted); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.object-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.object-row strong, +.object-row small { + display: block; + overflow-wrap: anywhere; +} + +.object-row small { + margin-top: 4px; + color: var(--muted); + font-weight: 500; +} + +.folder-row { + background: #fbfefd; +} + +.empty-state { + padding: 28px; + color: var(--muted); + text-align: center; +} + +.bulk-actions { + justify-content: flex-end; + margin-top: 12px; +} + +.code-textarea { + min-height: 300px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; +} + +.detail-key { + overflow-wrap: anywhere; + border-radius: 8px; + background: var(--surface-muted); + padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; +} + +.detail-list { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px 12px; + margin: 0; +} + +.detail-list dd { + margin: 0; + overflow-wrap: anywhere; +} + +.pill { + border-radius: 999px; + background: var(--surface-muted); + color: var(--muted); + padding: 5px 10px; + font-size: 12px; + font-weight: 800; +} + +.share-list, +.usage-list, +.billing-grid { + display: grid; + gap: 12px; +} + +.billing-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 16px; +} + +.billing-grid div { + display: grid; + gap: 4px; + border-radius: 8px; + background: var(--surface-muted); + padding: 12px; +} + +.billing-grid span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.share-list article, +.usage-list div { + display: grid; + gap: 6px; + border-top: 1px solid var(--line); + padding-top: 12px; +} + +.share-list article:first-child, +.usage-list div:first-child { + border-top: 0; + padding-top: 0; +} + +.share-list input { + font-size: 12px; +} + +@media (max-width: 1100px) { + .workspace-grid, + .stats-grid, + .two-column { + grid-template-columns: 1fr; + } + + .side-column { + order: -1; + } +} + +@media (max-width: 760px) { + .topbar, + .hero-band, + .panel-heading, + .search-form, + .inline-form, + .button-row, + .bulk-actions { + align-items: stretch; + flex-direction: column; + } + + .topbar { + position: static; + gap: 12px; + padding: 14px; + } + + .app-shell { + width: min(100% - 20px, 1500px); + margin-top: 12px; + } + + .hero-band { + padding: 18px; + } + + .search-form { + width: 100%; + } + + .object-table { + overflow-x: auto; + } + + .object-row { + grid-template-columns: 30px minmax(180px, 1fr) 86px 105px 142px 88px; + min-width: 760px; + } +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..585da03 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,14 @@ +document.addEventListener('click', (event) => { + const button = event.target.closest('[data-confirm]'); + if (button && !window.confirm(button.dataset.confirm)) { + event.preventDefault(); + } +}); + +document.addEventListener('focus', (event) => { + const input = event.target.closest('[data-copyable]'); + if (input) { + input.select(); + } +}, true); + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..7b7f814 --- /dev/null +++ b/public/index.php @@ -0,0 +1,31 @@ +handle(); + diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php new file mode 100644 index 0000000..1f6a765 --- /dev/null +++ b/src/Controller/AppController.php @@ -0,0 +1,631 @@ +security->startSession(); + + if (($_GET['page'] ?? '') === 'download') { + $this->download(); + return; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handlePost(); + return; + } + + if (!$this->security->isAuthenticated()) { + View::render('login', [ + 'appName' => $this->config->appName(), + 'csrf' => $this->security->csrfToken(), + 'flash' => Flash::consume(), + ]); + return; + } + + $this->dashboard(); + } + + private function handlePost(): void + { + $action = (string) ($_POST['action'] ?? ''); + $prefix = R2Key::normalizePrefix($_POST['prefix'] ?? $_GET['prefix'] ?? ''); + + if (!$this->security->validateCsrf($_POST['csrf'] ?? null)) { + Flash::add('error', 'Your session token expired. Try again.'); + $this->redirect(['prefix' => $prefix]); + } + + if ($action === 'login') { + if ($this->security->attemptLogin((string) ($_POST['password'] ?? ''))) { + Flash::add('success', 'Signed in.'); + $this->redirect(); + } + + Flash::add('error', 'That password did not work.'); + $this->redirect(); + } + + if (!$this->security->isAuthenticated()) { + $this->redirect(); + } + + try { + switch ($action) { + case 'logout': + $this->logout(); + break; + case 'sync': + $this->sync($prefix); + break; + case 'upload': + $this->upload($prefix); + break; + case 'create_folder': + $this->createFolder($prefix); + break; + case 'delete': + $this->deleteObjects($prefix); + break; + case 'copy': + $this->copyOrMove(false); + break; + case 'move': + $this->copyOrMove(true); + break; + case 'save_meta': + $this->saveMeta($prefix); + break; + case 'generate_url': + $this->generateUrl($prefix); + break; + case 'fetch_cors': + $this->fetchCors($prefix); + break; + case 'save_cors': + $this->saveCors($prefix); + break; + case 'delete_cors': + $this->deleteCors($prefix); + break; + case 'refresh_head': + $this->refreshHead($prefix); + break; + default: + Flash::add('error', 'Unknown action.'); + } + } catch (R2Exception $exception) { + Flash::add('error', $exception->getMessage()); + } catch (Throwable $exception) { + Flash::add('error', $this->config->bool('APP_DEBUG') ? $exception->getMessage() : 'Something went wrong while processing that request.'); + } + + $this->redirect(['prefix' => $prefix, 'key' => $_POST['key'] ?? null]); + } + + private function dashboard(): void + { + $prefix = R2Key::normalizePrefix($_GET['prefix'] ?? ''); + $search = trim((string) ($_GET['q'] ?? '')); + $tag = trim((string) ($_GET['tag'] ?? '')); + $selectedKey = R2Key::normalizeKey($_GET['key'] ?? ''); + + $listing = $this->objects->listChildren($prefix, $search, $tag); + $stats = $this->objects->stats(); + $selected = $selectedKey !== '' ? $this->objects->find($selectedKey) : null; + + View::render('dashboard', [ + 'appName' => $this->config->appName(), + 'bucket' => $this->r2->bucket(), + 'configured' => $this->r2->isConfigured(), + 'missingSettings' => $this->config->missingR2Settings(), + 'prefix' => $prefix, + 'breadcrumbs' => $this->breadcrumbs($prefix), + 'search' => $search, + 'tagFilter' => $tag, + 'tagNames' => $this->objects->allTagNames(), + 'folders' => $listing['folders'], + 'objects' => $listing['objects'], + 'stats' => $stats, + 'eventSummary' => $this->usage->eventSummary(30), + 'billingSummary' => $this->usage->billingSummary(30), + 'dailySummary' => $this->usage->dailySummary(14), + 'recentShares' => $this->usage->recentShares(), + 'shareCount' => $this->usage->shareCount(), + 'selected' => $selected, + 'selectedPublicUrl' => $selected ? $this->publicUrlFor((string) $selected['object_key']) : null, + 'corsJson' => $this->settings->get('cors_json', $this->defaultCorsJson()), + 'csrf' => $this->security->csrfToken(), + 'flash' => Flash::consume(), + ]); + } + + private function logout(): void + { + $this->security->logout(); + Flash::add('success', 'Signed out.'); + $this->redirect(); + } + + private function sync(string $prefix): void + { + $startedAt = gmdate('c'); + $token = null; + $objects = 0; + $requests = 0; + + do { + $page = $this->r2->listObjectsV2($prefix, $token, $this->config->int('SYNC_PAGE_LIMIT', 1000)); + $requests++; + foreach ($page['objects'] as $object) { + $this->objects->upsertFromR2($object, $startedAt); + $objects++; + } + $token = $page['next_token']; + } while ($page['truncated'] && $token !== null && $token !== ''); + + $deleted = $this->objects->syncFinished($startedAt, $prefix === '' ? null : $prefix); + $this->usage->record('sync', $prefix === '' ? null : $prefix, 0, $requests, ['objects' => $objects, 'stale_removed' => $deleted]); + Flash::add('success', 'Synced ' . number_format($objects) . ' object' . ($objects === 1 ? '' : 's') . ' from R2.'); + } + + private function upload(string $prefix): void + { + $files = $this->normalizeUploads($_FILES['files'] ?? null); + if ($files === []) { + Flash::add('error', 'Choose at least one file to upload.'); + return; + } + + $tags = Tags::parse((string) ($_POST['tags'] ?? '')); + $permission = $this->permission((string) ($_POST['permission'] ?? 'private')); + $notes = trim((string) ($_POST['notes'] ?? '')); + $uploaded = 0; + $bytes = 0; + $syncedAt = gmdate('c'); + + foreach ($files as $file) { + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + continue; + } + + $name = R2Key::safeName(basename((string) $file['name'])); + if ($name === '') { + continue; + } + + $key = R2Key::join($prefix, $name); + $body = file_get_contents((string) $file['tmp_name']); + if ($body === false) { + continue; + } + + $contentType = $this->contentType((string) $file['tmp_name'], (string) ($file['type'] ?? '')); + $response = $this->r2->putObject($key, $body, $contentType); + $size = strlen($body); + $bytes += $size; + $uploaded++; + + $this->objects->upsertFromR2([ + 'key' => $key, + 'size' => $size, + 'etag' => trim((string) $response->header('etag', ''), '"'), + 'content_type' => $contentType, + 'last_modified' => gmdate('c'), + 'storage_class' => $response->header('x-amz-storage-class'), + ], $syncedAt); + $this->objects->saveLocalMeta($key, $tags, $permission, $notes); + } + + $this->usage->record('upload', $prefix === '' ? null : $prefix, $bytes, max(1, $uploaded), ['files' => $uploaded]); + Flash::add($uploaded > 0 ? 'success' : 'error', $uploaded > 0 ? 'Uploaded ' . number_format($uploaded) . ' file(s).' : 'No files were uploaded.'); + } + + private function createFolder(string $prefix): void + { + $name = R2Key::safeName((string) ($_POST['folder_name'] ?? '')); + $name = trim($name, '/'); + if ($name === '') { + Flash::add('error', 'Folder name is required.'); + return; + } + + $folderKey = R2Key::normalizePrefix(R2Key::join($prefix, $name)); + $this->r2->putObject($folderKey, '', 'application/x-directory', ['r2-manager' => 'folder']); + $this->objects->ensureFolder($folderKey, false); + $this->objects->upsertFromR2([ + 'key' => $folderKey, + 'size' => 0, + 'etag' => null, + 'content_type' => 'application/x-directory', + 'last_modified' => gmdate('c'), + ], gmdate('c')); + $this->usage->record('create_folder', $folderKey); + Flash::add('success', 'Created folder ' . $folderKey); + } + + private function deleteObjects(string $prefix): void + { + $keys = $_POST['keys'] ?? []; + if (!is_array($keys)) { + $keys = [(string) $keys]; + } + + $deleted = 0; + $requests = 0; + foreach ($keys as $key) { + $key = R2Key::normalizeKey((string) $key); + if ($key === '') { + continue; + } + + if (str_ends_with($key, '/')) { + $children = $this->objects->objectsByPrefix($key); + foreach ($children as $child) { + $this->deleteR2Object((string) $child['object_key']); + $deleted++; + $requests++; + } + if ($children === []) { + $this->deleteR2Object($key, true); + $requests++; + } + $this->objects->markPrefixDeleted($key); + } else { + $this->deleteR2Object($key); + $this->objects->markDeleted($key); + $deleted++; + $requests++; + } + } + + $this->usage->record('delete', $prefix === '' ? null : $prefix, 0, max(1, $requests), ['objects' => $deleted]); + Flash::add('success', 'Deleted ' . number_format($deleted) . ' object(s).'); + } + + private function copyOrMove(bool $move): void + { + $key = R2Key::normalizeKey($_POST['key'] ?? ''); + $destination = R2Key::normalizeKey($_POST['destination'] ?? ''); + + if ($key === '' || $destination === '') { + Flash::add('error', 'Source and destination are required.'); + return; + } + + $requests = 0; + $bytes = 0; + $copied = 0; + $syncedAt = gmdate('c'); + + if (str_ends_with($key, '/')) { + $destinationPrefix = R2Key::normalizePrefix($destination); + $children = $this->objects->objectsByPrefix($key); + foreach ($children as $child) { + $source = (string) $child['object_key']; + $target = $destinationPrefix . substr($source, strlen($key)); + $this->r2->copyObject($source, $target, $child['content_type'] ?? null); + $requests++; + $copied++; + $bytes += (int) $child['size']; + + $newObject = $child; + $newObject['key'] = $target; + $this->objects->upsertFromR2($newObject, $syncedAt); + $this->objects->saveLocalMeta($target, Tags::fromJson($child['local_tags_json'] ?? '{}'), (string) $child['permission'], (string) $child['notes']); + + if ($move) { + $this->deleteR2Object($source, true); + $this->objects->markDeleted($source); + $requests++; + } + } + + if ($move) { + $this->objects->markPrefixDeleted($key); + } + $this->objects->ensureFolder($destinationPrefix, false); + } else { + $source = $this->objects->find($key); + $target = str_ends_with($destination, '/') ? $destination . R2Key::basename($key) : $destination; + $this->r2->copyObject($key, $target, $source['content_type'] ?? null); + $requests++; + $copied++; + + $head = $this->r2->headObject($target); + $this->objects->upsertFromR2($head, $syncedAt); + if ($source !== null) { + $this->objects->saveLocalMeta($target, Tags::fromJson($source['local_tags_json'] ?? '{}'), (string) $source['permission'], (string) $source['notes']); + $bytes = (int) $source['size']; + } + + if ($move) { + $this->deleteR2Object($key); + $this->objects->markDeleted($key); + $requests++; + } + } + + $event = $move ? 'move' : 'copy'; + $this->usage->record($event, $key, $bytes, max(1, $requests), ['objects' => $copied, 'destination' => $destination]); + Flash::add('success', ucfirst($event) . ' complete for ' . number_format($copied) . ' object(s).'); + } + + private function saveMeta(string $prefix): void + { + $key = R2Key::normalizeKey($_POST['key'] ?? ''); + if ($key === '') { + Flash::add('error', 'Choose an object first.'); + return; + } + + $tags = Tags::parse((string) ($_POST['tags'] ?? '')); + $permission = $this->permission((string) ($_POST['permission'] ?? 'private')); + $notes = trim((string) ($_POST['notes'] ?? '')); + + $this->objects->saveLocalMeta($key, $tags, $permission, $notes); + $this->usage->record('metadata', $key); + Flash::add('success', 'Saved local tags and permission notes.'); + } + + private function generateUrl(string $prefix): void + { + $key = R2Key::normalizeKey($_POST['key'] ?? ''); + $method = strtoupper((string) ($_POST['method'] ?? 'GET')); + $expires = (int) ($_POST['expires'] ?? 3600); + + if ($key === '' || !in_array($method, ['GET', 'PUT', 'HEAD', 'DELETE'], true)) { + Flash::add('error', 'Choose an object and a supported URL method.'); + return; + } + + $headers = []; + $contentType = trim((string) ($_POST['signed_content_type'] ?? '')); + if ($method === 'PUT' && $contentType !== '') { + $headers['Content-Type'] = $contentType; + } + + $url = $this->r2->presign($method, $key, $expires, $headers); + $expiresAt = gmdate('c', time() + max(1, min(604800, $expires))); + $this->usage->createShare($key, $method, $url, $expiresAt); + $this->usage->record('presign', $key); + Flash::add('success', 'Generated a signed ' . $method . ' URL. It is listed in Recent signed URLs.'); + } + + private function fetchCors(string $prefix): void + { + $xml = $this->r2->getBucketCors(); + $json = $xml === null ? $this->defaultCorsJson() : $this->corsXmlToJson($xml); + $this->settings->set('cors_json', $json); + $this->usage->record('cors_get', null); + Flash::add('success', $xml === null ? 'No CORS policy is set on this bucket.' : 'Fetched the current CORS policy.'); + } + + private function saveCors(string $prefix): void + { + $json = trim((string) ($_POST['cors_json'] ?? '')); + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + Flash::add('error', 'CORS policy must be valid JSON.'); + return; + } + + $pretty = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->r2->putBucketCorsJson($pretty ?: $json); + $this->settings->set('cors_json', $pretty ?: $json); + $this->usage->record('cors_put', null); + Flash::add('success', 'Saved CORS policy to R2.'); + } + + private function deleteCors(string $prefix): void + { + $this->r2->deleteBucketCors(); + $this->settings->delete('cors_json'); + $this->usage->record('cors_delete', null); + Flash::add('success', 'Deleted the bucket CORS policy.'); + } + + private function refreshHead(string $prefix): void + { + $key = R2Key::normalizeKey($_POST['key'] ?? ''); + if ($key === '') { + Flash::add('error', 'Choose an object first.'); + return; + } + + $head = $this->r2->headObject($key); + $this->objects->upsertFromR2($head, gmdate('c')); + $this->usage->record('head', $key); + Flash::add('success', 'Refreshed object metadata from R2.'); + } + + private function download(): void + { + if (!$this->security->isAuthenticated()) { + $this->redirect(); + } + + $key = R2Key::normalizeKey($_GET['key'] ?? ''); + if ($key === '') { + http_response_code(404); + echo 'Missing object key.'; + return; + } + + try { + $response = $this->r2->getObject($key); + $this->usage->record('download', $key, strlen($response->body)); + header('Content-Type: ' . ($response->header('content-type', 'application/octet-stream') ?? 'application/octet-stream')); + header('Content-Length: ' . strlen($response->body)); + header('Content-Disposition: attachment; filename="' . addcslashes(R2Key::basename($key), '"\\') . '"'); + echo $response->body; + } catch (R2Exception $exception) { + http_response_code($exception->statusCode() ?: 500); + echo $exception->getMessage(); + } + } + + /** + * @param mixed $files + * @return list> + */ + private function normalizeUploads(mixed $files): array + { + if (!is_array($files) || !isset($files['name'])) { + return []; + } + + if (!is_array($files['name'])) { + return [$files]; + } + + $normalized = []; + foreach ($files['name'] as $index => $name) { + $normalized[] = [ + 'name' => $name, + 'type' => $files['type'][$index] ?? '', + 'tmp_name' => $files['tmp_name'][$index] ?? '', + 'error' => $files['error'][$index] ?? UPLOAD_ERR_NO_FILE, + 'size' => $files['size'][$index] ?? 0, + ]; + } + + return $normalized; + } + + private function deleteR2Object(string $key, bool $ignoreNotFound = false): void + { + try { + $this->r2->deleteObject($key); + } catch (R2Exception $exception) { + if (!$ignoreNotFound || $exception->statusCode() !== 404) { + throw $exception; + } + } + } + + private function contentType(string $path, string $fallback): string + { + if ($fallback !== '') { + return $fallback; + } + + if (function_exists('mime_content_type')) { + $detected = mime_content_type($path); + if (is_string($detected) && $detected !== '') { + return $detected; + } + } + + return 'application/octet-stream'; + } + + private function permission(string $permission): string + { + return in_array($permission, ['private', 'public-read', 'signed-only', 'archived'], true) ? $permission : 'private'; + } + + /** + * @return list + */ + private function breadcrumbs(string $prefix): array + { + $crumbs = [['label' => 'Bucket root', 'prefix' => '']]; + $current = ''; + foreach (array_filter(explode('/', trim($prefix, '/'))) as $part) { + $current .= $part . '/'; + $crumbs[] = ['label' => $part, 'prefix' => $current]; + } + + return $crumbs; + } + + private function publicUrlFor(string $key): ?string + { + $base = $this->config->publicUrl(); + return $base === null ? null : $base . '/' . str_replace('%2F', '/', rawurlencode($key)); + } + + private function defaultCorsJson(): string + { + return json_encode([ + [ + 'AllowedOrigins' => ['https://example.com'], + 'AllowedMethods' => ['GET', 'HEAD'], + 'AllowedHeaders' => ['Content-Type'], + 'ExposeHeaders' => ['ETag'], + 'MaxAgeSeconds' => 3600, + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '[]'; + } + + private function corsXmlToJson(string $xmlBody): string + { + if (!function_exists('simplexml_load_string')) { + return $xmlBody; + } + + $xml = @simplexml_load_string($xmlBody); + if (!$xml instanceof \SimpleXMLElement) { + return $xmlBody; + } + + $rules = []; + $namespace = $xml->getDocNamespaces(true)[''] ?? null; + $root = $namespace ? $xml->children($namespace) : $xml; + + foreach ($root->CORSRule ?? [] as $rule) { + $ruleNode = $namespace ? $rule->children($namespace) : $rule; + $rules[] = [ + 'AllowedOrigins' => array_map('strval', iterator_to_array($ruleNode->AllowedOrigin ?? [])), + 'AllowedMethods' => array_map('strval', iterator_to_array($ruleNode->AllowedMethod ?? [])), + 'AllowedHeaders' => array_map('strval', iterator_to_array($ruleNode->AllowedHeader ?? [])), + 'ExposeHeaders' => array_map('strval', iterator_to_array($ruleNode->ExposeHeader ?? [])), + 'MaxAgeSeconds' => isset($ruleNode->MaxAgeSeconds) ? (int) $ruleNode->MaxAgeSeconds : null, + ]; + } + + return json_encode($rules, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '[]'; + } + + /** + * @param array $params + */ + private function redirect(array $params = []): never + { + $query = array_filter($params, static fn ($value): bool => $value !== null && $value !== ''); + $location = 'index.php' . ($query === [] ? '' : '?' . http_build_query($query)); + header('Location: ' . $location); + exit; + } +} diff --git a/src/R2/R2Client.php b/src/R2/R2Client.php new file mode 100644 index 0000000..e881f9c --- /dev/null +++ b/src/R2/R2Client.php @@ -0,0 +1,523 @@ +accountId = trim((string) $config->get('R2_ACCOUNT_ID', '')); + $this->accessKeyId = trim((string) $config->get('R2_ACCESS_KEY_ID', '')); + $this->secretAccessKey = trim((string) $config->get('R2_SECRET_ACCESS_KEY', '')); + $this->bucket = trim((string) $config->get('R2_BUCKET', '')); + $this->region = trim((string) $config->get('R2_REGION', 'auto')); + $this->endpoint = rtrim((string) $config->get('R2_ENDPOINT', ''), '/'); + $this->pathStyle = $config->bool('R2_PATH_STYLE', true); + + if ($this->endpoint === '' && $this->accountId !== '') { + $this->endpoint = 'https://' . $this->accountId . '.r2.cloudflarestorage.com'; + } + } + + public function isConfigured(): bool + { + return $this->accountId !== '' + && $this->accessKeyId !== '' + && $this->secretAccessKey !== '' + && $this->bucket !== '' + && $this->endpoint !== ''; + } + + public function bucket(): string + { + return $this->bucket; + } + + /** + * @return array{objects: list>, prefixes: list, truncated: bool, next_token: ?string} + */ + public function listObjectsV2(string $prefix = '', ?string $continuationToken = null, int $maxKeys = 1000): array + { + $query = [ + 'list-type' => '2', + 'max-keys' => (string) max(1, min(1000, $maxKeys)), + ]; + + if ($prefix !== '') { + $query['prefix'] = $prefix; + } + + if ($continuationToken !== null && $continuationToken !== '') { + $query['continuation-token'] = $continuationToken; + } + + $response = $this->request('GET', '', $query); + $xml = $this->xml($response->body); + $root = $this->xmlChildren($xml); + + $objects = []; + foreach ($root->Contents ?? [] as $content) { + $contentNode = $this->xmlChildren($content); + $objects[] = [ + 'key' => (string) $contentNode->Key, + 'last_modified' => (string) $contentNode->LastModified, + 'etag' => trim((string) $contentNode->ETag, '"'), + 'size' => (int) $contentNode->Size, + 'storage_class' => isset($contentNode->StorageClass) ? (string) $contentNode->StorageClass : null, + ]; + } + + $prefixes = []; + foreach ($root->CommonPrefixes ?? [] as $commonPrefix) { + $prefixNode = $this->xmlChildren($commonPrefix); + $prefixes[] = (string) $prefixNode->Prefix; + } + + return [ + 'objects' => $objects, + 'prefixes' => $prefixes, + 'truncated' => strtolower((string) ($root->IsTruncated ?? 'false')) === 'true', + 'next_token' => isset($root->NextContinuationToken) ? (string) $root->NextContinuationToken : null, + ]; + } + + /** + * @return array + */ + public function headObject(string $key): array + { + $response = $this->request('HEAD', $key); + + return [ + 'key' => $key, + 'etag' => trim((string) $response->header('etag', ''), '"'), + 'size' => (int) ($response->header('content-length', '0') ?? 0), + 'content_type' => $response->header('content-type'), + 'last_modified' => $this->normalizeHttpDate($response->header('last-modified')), + 'storage_class' => $response->header('x-amz-storage-class'), + ]; + } + + public function getObject(string $key): R2Response + { + return $this->request('GET', $key); + } + + /** + * @param array $metadata + */ + public function putObject(string $key, string $body, string $contentType = 'application/octet-stream', array $metadata = []): R2Response + { + $headers = ['Content-Type' => $contentType]; + foreach ($metadata as $name => $value) { + $headers['x-amz-meta-' . strtolower($name)] = $value; + } + + return $this->request('PUT', $key, [], $headers, $body); + } + + public function deleteObject(string $key): R2Response + { + return $this->request('DELETE', $key); + } + + public function copyObject(string $sourceKey, string $destinationKey, ?string $contentType = null): R2Response + { + $headers = [ + 'x-amz-copy-source' => '/' . $this->bucket . '/' . $this->encodeKey($sourceKey), + ]; + + if ($contentType !== null && $contentType !== '') { + $headers['Content-Type'] = $contentType; + $headers['x-amz-metadata-directive'] = 'REPLACE'; + } + + return $this->request('PUT', $destinationKey, [], $headers); + } + + public function getBucketCors(): ?string + { + try { + return $this->request('GET', '', ['cors' => ''])->body; + } catch (R2Exception $exception) { + if ($exception->statusCode() === 404) { + return null; + } + + throw $exception; + } + } + + public function putBucketCorsJson(string $json): R2Response + { + $rules = json_decode($json, true); + if (!is_array($rules)) { + throw new R2Exception('CORS JSON must be an array of rules.'); + } + + $xml = $this->corsJsonToXml($rules); + + return $this->request('PUT', '', ['cors' => ''], ['Content-Type' => 'application/xml'], $xml); + } + + public function deleteBucketCors(): R2Response + { + return $this->request('DELETE', '', ['cors' => '']); + } + + /** + * @param array $headers + */ + public function presign(string $method, string $key, int $expires, array $headers = []): string + { + $this->assertConfigured(); + + $method = strtoupper($method); + $expires = max(1, min(604800, $expires)); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $amzDate = $now->format('Ymd\THis\Z'); + $dateStamp = $now->format('Ymd'); + + $urlParts = $this->urlParts($key); + $headers = $this->normalizeHeaders($headers); + $headers['host'] = $urlParts['host']; + ksort($headers); + + $credentialScope = $dateStamp . '/' . $this->region . '/s3/aws4_request'; + $signedHeaders = implode(';', array_keys($headers)); + $query = [ + 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256', + 'X-Amz-Credential' => $this->accessKeyId . '/' . $credentialScope, + 'X-Amz-Date' => $amzDate, + 'X-Amz-Expires' => (string) $expires, + 'X-Amz-SignedHeaders' => $signedHeaders, + ]; + + $canonicalRequest = implode("\n", [ + $method, + $urlParts['path'], + $this->canonicalQueryString($query), + $this->canonicalHeaders($headers), + $signedHeaders, + 'UNSIGNED-PAYLOAD', + ]); + + $stringToSign = implode("\n", [ + 'AWS4-HMAC-SHA256', + $amzDate, + $credentialScope, + hash('sha256', $canonicalRequest), + ]); + + $query['X-Amz-Signature'] = hash_hmac('sha256', $stringToSign, $this->signingKey($dateStamp)); + + return $urlParts['base'] . $urlParts['path'] . '?' . $this->canonicalQueryString($query); + } + + /** + * @param array $query + * @param array $headers + */ + private function request(string $method, string $key = '', array $query = [], array $headers = [], string $body = ''): R2Response + { + $this->assertConfigured(); + if (!function_exists('curl_init')) { + throw new R2Exception('The PHP curl extension is required to talk to R2.'); + } + + $method = strtoupper($method); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $amzDate = $now->format('Ymd\THis\Z'); + $dateStamp = $now->format('Ymd'); + $payloadHash = hash('sha256', $body); + $urlParts = $this->urlParts($key); + + $headers = $this->normalizeHeaders($headers); + $headers['host'] = $urlParts['host']; + $headers['x-amz-content-sha256'] = $payloadHash; + $headers['x-amz-date'] = $amzDate; + ksort($headers); + + $signedHeaders = implode(';', array_keys($headers)); + $credentialScope = $dateStamp . '/' . $this->region . '/s3/aws4_request'; + $canonicalRequest = implode("\n", [ + $method, + $urlParts['path'], + $this->canonicalQueryString($query), + $this->canonicalHeaders($headers), + $signedHeaders, + $payloadHash, + ]); + + $stringToSign = implode("\n", [ + 'AWS4-HMAC-SHA256', + $amzDate, + $credentialScope, + hash('sha256', $canonicalRequest), + ]); + + $signature = hash_hmac('sha256', $stringToSign, $this->signingKey($dateStamp)); + $headers['authorization'] = 'AWS4-HMAC-SHA256 Credential=' . $this->accessKeyId . '/' . $credentialScope + . ', SignedHeaders=' . $signedHeaders + . ', Signature=' . $signature; + + $url = $urlParts['base'] . $urlParts['path']; + $queryString = $this->canonicalQueryString($query); + if ($queryString !== '') { + $url .= '?' . $queryString; + } + + $responseHeaders = []; + $curlHeaders = []; + foreach ($headers as $name => $value) { + $curlHeaders[] = $name . ': ' . $value; + } + + $handle = curl_init($url); + curl_setopt_array($handle, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $curlHeaders, + CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int { + $length = strlen($line); + $line = trim($line); + if ($line === '' || str_contains($line, 'HTTP/')) { + return $length; + } + + $position = strpos($line, ':'); + if ($position !== false) { + $name = strtolower(trim(substr($line, 0, $position))); + $responseHeaders[$name] = trim(substr($line, $position + 1)); + } + + return $length; + }, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => 300, + CURLOPT_FOLLOWLOCATION => false, + ]); + + if ($method === 'HEAD') { + curl_setopt($handle, CURLOPT_NOBODY, true); + } elseif ($body !== '' || in_array($method, ['PUT', 'POST'], true)) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $body); + } + + $rawBody = curl_exec($handle); + if ($rawBody === false) { + $error = curl_error($handle); + curl_close($handle); + throw new R2Exception('R2 request failed: ' . $error); + } + + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + curl_close($handle); + + $response = new R2Response($status, $responseHeaders, (string) $rawBody); + if ($status < 200 || $status >= 300) { + throw new R2Exception($this->errorMessage($response), $status, $response->body); + } + + return $response; + } + + private function assertConfigured(): void + { + if (!$this->isConfigured()) { + throw new R2Exception('R2 is not configured. Add your account ID, access keys, and bucket to .env.'); + } + } + + /** + * @return array{base: string, host: string, path: string} + */ + private function urlParts(string $key): array + { + $parts = parse_url($this->endpoint); + $scheme = $parts['scheme'] ?? 'https'; + $host = $parts['host'] ?? ''; + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + + if ($host === '') { + throw new R2Exception('R2_ENDPOINT must include a hostname.'); + } + + if (!$this->pathStyle) { + $host = $this->bucket . '.' . $host; + $path = '/' . $this->encodeKey($key); + if ($path === '/') { + $path = '/'; + } + } else { + $path = '/' . rawurlencode($this->bucket); + if ($key !== '') { + $path .= '/' . $this->encodeKey($key); + } + } + + return [ + 'base' => $scheme . '://' . $host . $port, + 'host' => $host . $port, + 'path' => $path, + ]; + } + + private function encodeKey(string $key): string + { + if ($key === '') { + return ''; + } + + return implode('/', array_map('rawurlencode', explode('/', $key))); + } + + /** + * @param array $query + */ + private function canonicalQueryString(array $query): string + { + $pairs = []; + foreach ($query as $name => $value) { + $pairs[] = [rawurlencode((string) $name), rawurlencode((string) $value)]; + } + + usort($pairs, static fn (array $a, array $b): int => $a[0] === $b[0] ? strcmp($a[1], $b[1]) : strcmp($a[0], $b[0])); + + return implode('&', array_map(static fn (array $pair): string => $pair[0] . '=' . $pair[1], $pairs)); + } + + /** + * @param array $headers + * @return array + */ + private function normalizeHeaders(array $headers): array + { + $normalized = []; + foreach ($headers as $name => $value) { + $normalized[strtolower(trim((string) $name))] = trim(preg_replace('/\s+/', ' ', (string) $value) ?? ''); + } + + return array_filter($normalized, static fn (string $value): bool => $value !== ''); + } + + /** + * @param array $headers + */ + private function canonicalHeaders(array $headers): string + { + ksort($headers); + $lines = []; + foreach ($headers as $name => $value) { + $lines[] = strtolower($name) . ':' . trim($value); + } + + return implode("\n", $lines) . "\n"; + } + + private function signingKey(string $dateStamp): string + { + $dateKey = hash_hmac('sha256', $dateStamp, 'AWS4' . $this->secretAccessKey, true); + $dateRegionKey = hash_hmac('sha256', $this->region, $dateKey, true); + $dateRegionServiceKey = hash_hmac('sha256', 's3', $dateRegionKey, true); + return hash_hmac('sha256', 'aws4_request', $dateRegionServiceKey, true); + } + + private function xml(string $body): \SimpleXMLElement + { + if (!function_exists('simplexml_load_string')) { + throw new R2Exception('The PHP SimpleXML extension is required to parse R2 responses.'); + } + + $xml = @simplexml_load_string($body); + if (!$xml instanceof \SimpleXMLElement) { + throw new R2Exception('R2 returned invalid XML.'); + } + + return $xml; + } + + /** + * @param array $rules + */ + private function corsJsonToXml(array $rules): string + { + if (!class_exists(\SimpleXMLElement::class)) { + throw new R2Exception('The PHP SimpleXML extension is required to write CORS XML.'); + } + + $xml = new \SimpleXMLElement(''); + foreach ($rules as $rule) { + if (!is_array($rule)) { + continue; + } + + $corsRule = $xml->addChild('CORSRule'); + foreach ((array) ($rule['AllowedOrigins'] ?? []) as $origin) { + $corsRule->addChild('AllowedOrigin', htmlspecialchars((string) $origin, ENT_XML1)); + } + foreach ((array) ($rule['AllowedMethods'] ?? []) as $method) { + $corsRule->addChild('AllowedMethod', htmlspecialchars(strtoupper((string) $method), ENT_XML1)); + } + foreach ((array) ($rule['AllowedHeaders'] ?? []) as $header) { + $corsRule->addChild('AllowedHeader', htmlspecialchars((string) $header, ENT_XML1)); + } + foreach ((array) ($rule['ExposeHeaders'] ?? []) as $header) { + $corsRule->addChild('ExposeHeader', htmlspecialchars((string) $header, ENT_XML1)); + } + if (isset($rule['MaxAgeSeconds']) && is_numeric($rule['MaxAgeSeconds'])) { + $corsRule->addChild('MaxAgeSeconds', (string) (int) $rule['MaxAgeSeconds']); + } + } + + return $xml->asXML() ?: ''; + } + + private function normalizeHttpDate(?string $date): ?string + { + if ($date === null || $date === '') { + return null; + } + + $timestamp = strtotime($date); + return $timestamp === false ? null : gmdate('c', $timestamp); + } + + private function errorMessage(R2Response $response): string + { + $body = trim($response->body); + if ($body === '') { + return 'R2 returned HTTP ' . $response->status . '.'; + } + + if (function_exists('simplexml_load_string')) { + $xml = @simplexml_load_string($body); + if ($xml instanceof \SimpleXMLElement) { + $root = $this->xmlChildren($xml); + if (isset($root->Message)) { + return 'R2 returned HTTP ' . $response->status . ': ' . (string) $root->Message; + } + } + } + + return 'R2 returned HTTP ' . $response->status . ': ' . substr($body, 0, 500); + } + + private function xmlChildren(\SimpleXMLElement $xml): \SimpleXMLElement + { + $namespace = $xml->getDocNamespaces(true)[''] ?? null; + return $namespace ? $xml->children($namespace) : $xml; + } +} diff --git a/src/R2/R2Exception.php b/src/R2/R2Exception.php new file mode 100644 index 0000000..8ae1bfe --- /dev/null +++ b/src/R2/R2Exception.php @@ -0,0 +1,29 @@ +statusCode; + } + + public function responseBody(): string + { + return $this->responseBody; + } +} + diff --git a/src/R2/R2Response.php b/src/R2/R2Response.php new file mode 100644 index 0000000..573a83f --- /dev/null +++ b/src/R2/R2Response.php @@ -0,0 +1,24 @@ + $headers + */ + public function __construct( + public readonly int $status, + public readonly array $headers, + public readonly string $body + ) { + } + + public function header(string $name, ?string $default = null): ?string + { + return $this->headers[strtolower($name)] ?? $default; + } +} + diff --git a/src/Repository/ObjectRepository.php b/src/Repository/ObjectRepository.php new file mode 100644 index 0000000..91de459 --- /dev/null +++ b/src/Repository/ObjectRepository.php @@ -0,0 +1,295 @@ +pdo = $database->pdo(); + } + + /** + * @return array{objects: list>, folders: list>} + */ + public function listChildren(string $prefix = '', string $search = '', string $tag = ''): array + { + $prefix = R2Key::normalizePrefix($prefix); + $search = trim($search); + $tag = trim($tag); + + $folderStatement = $this->pdo->prepare('SELECT * FROM folders WHERE parent_prefix = :prefix ORDER BY name COLLATE NOCASE ASC'); + $folderStatement->execute(['prefix' => $prefix]); + $folders = $folderStatement->fetchAll(); + + $sql = 'SELECT * FROM objects WHERE parent_prefix = :prefix AND is_folder = 0 AND deleted_at IS NULL'; + $params = ['prefix' => $prefix]; + if ($search !== '') { + $sql .= ' AND (object_key LIKE :search OR display_name LIKE :search OR notes LIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + $sql .= ' ORDER BY display_name COLLATE NOCASE ASC'; + + $objectStatement = $this->pdo->prepare($sql); + $objectStatement->execute($params); + $objects = $objectStatement->fetchAll(); + + $folders = array_values(array_filter($folders, static function (array $folder) use ($search): bool { + return $search === '' || stripos((string) $folder['prefix'], $search) !== false || stripos((string) $folder['name'], $search) !== false; + })); + + if ($tag !== '') { + $objects = array_values(array_filter($objects, static function (array $object) use ($tag): bool { + $tags = Tags::fromJson($object['local_tags_json'] ?? '{}'); + return array_key_exists($tag, $tags); + })); + } + + foreach ($objects as &$object) { + $object['tags'] = Tags::fromJson($object['local_tags_json'] ?? '{}'); + } + unset($object); + + return ['objects' => $objects, 'folders' => $folders]; + } + + /** + * @return array|null + */ + public function find(string $key): ?array + { + $statement = $this->pdo->prepare('SELECT * FROM objects WHERE object_key = :key AND deleted_at IS NULL'); + $statement->execute(['key' => R2Key::normalizeKey($key)]); + $row = $statement->fetch(); + if (!$row) { + return null; + } + + $row['tags'] = Tags::fromJson($row['local_tags_json'] ?? '{}'); + return $row; + } + + /** + * @return list> + */ + public function objectsByPrefix(string $prefix): array + { + $prefix = R2Key::normalizePrefix($prefix); + $statement = $this->pdo->prepare('SELECT * FROM objects WHERE object_key LIKE :prefix AND deleted_at IS NULL ORDER BY object_key ASC'); + $statement->execute(['prefix' => $prefix . '%']); + return $statement->fetchAll(); + } + + /** + * @param array $object + */ + public function upsertFromR2(array $object, string $syncedAt): void + { + $key = R2Key::normalizeKey((string) ($object['key'] ?? '')); + if ($key === '') { + return; + } + + $isFolder = str_ends_with($key, '/') ? 1 : 0; + $this->ensureAncestors($key); + if ($isFolder === 1) { + $this->ensureFolder($key, false); + } + + $existing = $this->findIncludingDeleted($key); + $tags = $existing['local_tags_json'] ?? '{}'; + $permission = $existing['permission'] ?? 'private'; + $notes = $existing['notes'] ?? ''; + + $statement = $this->pdo->prepare(<<<'SQL' +INSERT INTO objects ( + object_key, display_name, parent_prefix, is_folder, size, etag, content_type, storage_class, + last_modified, synced_at, deleted_at, metadata_json, local_tags_json, permission, notes +) VALUES ( + :object_key, :display_name, :parent_prefix, :is_folder, :size, :etag, :content_type, :storage_class, + :last_modified, :synced_at, NULL, :metadata_json, :local_tags_json, :permission, :notes +) +ON CONFLICT(object_key) DO UPDATE SET + display_name = excluded.display_name, + parent_prefix = excluded.parent_prefix, + is_folder = excluded.is_folder, + size = excluded.size, + etag = excluded.etag, + content_type = COALESCE(excluded.content_type, objects.content_type), + storage_class = excluded.storage_class, + last_modified = excluded.last_modified, + synced_at = excluded.synced_at, + deleted_at = NULL, + metadata_json = excluded.metadata_json +SQL); + + $statement->execute([ + 'object_key' => $key, + 'display_name' => R2Key::basename($key), + 'parent_prefix' => R2Key::parentPrefix($key), + 'is_folder' => $isFolder, + 'size' => (int) ($object['size'] ?? 0), + 'etag' => $object['etag'] ?? null, + 'content_type' => $object['content_type'] ?? null, + 'storage_class' => $object['storage_class'] ?? null, + 'last_modified' => $object['last_modified'] ?? null, + 'synced_at' => $syncedAt, + 'metadata_json' => json_encode($object['metadata'] ?? [], JSON_UNESCAPED_SLASHES), + 'local_tags_json' => $tags, + 'permission' => $permission, + 'notes' => $notes, + ]); + } + + /** + * @param array $tags + */ + public function saveLocalMeta(string $key, array $tags, string $permission, string $notes): void + { + $statement = $this->pdo->prepare('UPDATE objects SET local_tags_json = :tags, permission = :permission, notes = :notes WHERE object_key = :key'); + $statement->execute([ + 'key' => R2Key::normalizeKey($key), + 'tags' => json_encode($tags, JSON_UNESCAPED_SLASHES), + 'permission' => $permission, + 'notes' => $notes, + ]); + } + + public function markDeleted(string $key): void + { + $statement = $this->pdo->prepare('UPDATE objects SET deleted_at = :deleted_at WHERE object_key = :key'); + $statement->execute([ + 'key' => R2Key::normalizeKey($key), + 'deleted_at' => gmdate('c'), + ]); + } + + public function markPrefixDeleted(string $prefix): void + { + $prefix = R2Key::normalizePrefix($prefix); + $statement = $this->pdo->prepare('UPDATE objects SET deleted_at = :deleted_at WHERE object_key LIKE :prefix'); + $statement->execute([ + 'prefix' => $prefix . '%', + 'deleted_at' => gmdate('c'), + ]); + + $folderStatement = $this->pdo->prepare('DELETE FROM folders WHERE prefix = :prefix OR prefix LIKE :children'); + $folderStatement->execute(['prefix' => $prefix, 'children' => $prefix . '%']); + } + + public function ensureFolder(string $prefix, bool $localOnly = true): void + { + $prefix = R2Key::normalizePrefix($prefix); + if ($prefix === '') { + return; + } + + $this->ensureAncestors($prefix); + + $statement = $this->pdo->prepare(<<<'SQL' +INSERT INTO folders (prefix, name, parent_prefix, created_at, local_only) +VALUES (:prefix, :name, :parent_prefix, :created_at, :local_only) +ON CONFLICT(prefix) DO UPDATE SET + name = excluded.name, + parent_prefix = excluded.parent_prefix, + local_only = CASE WHEN folders.local_only = 0 THEN 0 ELSE excluded.local_only END +SQL); + + $statement->execute([ + 'prefix' => $prefix, + 'name' => R2Key::basename($prefix), + 'parent_prefix' => R2Key::parentPrefix($prefix), + 'created_at' => gmdate('c'), + 'local_only' => $localOnly ? 1 : 0, + ]); + } + + public function syncFinished(string $startedAt, ?string $prefix = null): int + { + $sql = 'UPDATE objects SET deleted_at = :deleted_at WHERE synced_at IS NOT NULL AND synced_at < :started_at AND deleted_at IS NULL'; + $params = ['deleted_at' => gmdate('c'), 'started_at' => $startedAt]; + + if ($prefix !== null && R2Key::normalizePrefix($prefix) !== '') { + $sql .= ' AND object_key LIKE :prefix'; + $params['prefix'] = R2Key::normalizePrefix($prefix) . '%'; + } + + $statement = $this->pdo->prepare($sql); + $statement->execute($params); + return $statement->rowCount(); + } + + /** + * @return array{objects: int, folders: int, bytes: int, latest_sync: ?string} + */ + public function stats(): array + { + $objects = (int) $this->pdo->query('SELECT COUNT(*) FROM objects WHERE is_folder = 0 AND deleted_at IS NULL')->fetchColumn(); + $folders = (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn(); + $bytes = (int) $this->pdo->query('SELECT COALESCE(SUM(size), 0) FROM objects WHERE is_folder = 0 AND deleted_at IS NULL')->fetchColumn(); + $latestSync = $this->pdo->query('SELECT MAX(synced_at) FROM objects WHERE synced_at IS NOT NULL')->fetchColumn(); + + return [ + 'objects' => $objects, + 'folders' => $folders, + 'bytes' => $bytes, + 'latest_sync' => is_string($latestSync) ? $latestSync : null, + ]; + } + + /** + * @return list + */ + public function allTagNames(): array + { + $rows = $this->pdo->query('SELECT local_tags_json FROM objects WHERE deleted_at IS NULL')->fetchAll(); + $names = []; + foreach ($rows as $row) { + foreach (array_keys(Tags::fromJson($row['local_tags_json'] ?? '{}')) as $name) { + $names[$name] = true; + } + } + + $names = array_keys($names); + sort($names, SORT_NATURAL | SORT_FLAG_CASE); + return $names; + } + + /** + * @return array|null + */ + private function findIncludingDeleted(string $key): ?array + { + $statement = $this->pdo->prepare('SELECT * FROM objects WHERE object_key = :key'); + $statement->execute(['key' => R2Key::normalizeKey($key)]); + $row = $statement->fetch(); + return $row ?: null; + } + + private function ensureAncestors(string $keyOrPrefix): void + { + foreach (R2Key::ancestors($keyOrPrefix) as $prefix) { + $statement = $this->pdo->prepare(<<<'SQL' +INSERT INTO folders (prefix, name, parent_prefix, created_at, local_only) +VALUES (:prefix, :name, :parent_prefix, :created_at, 0) +ON CONFLICT(prefix) DO NOTHING +SQL); + $statement->execute([ + 'prefix' => $prefix, + 'name' => R2Key::basename($prefix), + 'parent_prefix' => R2Key::parentPrefix($prefix), + 'created_at' => gmdate('c'), + ]); + } + } +} + diff --git a/src/Repository/SettingsRepository.php b/src/Repository/SettingsRepository.php new file mode 100644 index 0000000..c0e964a --- /dev/null +++ b/src/Repository/SettingsRepository.php @@ -0,0 +1,50 @@ +pdo = $database->pdo(); + } + + public function get(string $key, ?string $default = null): ?string + { + $statement = $this->pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = :key'); + $statement->execute(['key' => $key]); + $value = $statement->fetchColumn(); + + return is_string($value) ? $value : $default; + } + + public function set(string $key, string $value): void + { + $statement = $this->pdo->prepare(<<<'SQL' +INSERT INTO settings (setting_key, setting_value, updated_at) +VALUES (:key, :value, :updated_at) +ON CONFLICT(setting_key) DO UPDATE SET + setting_value = excluded.setting_value, + updated_at = excluded.updated_at +SQL); + $statement->execute([ + 'key' => $key, + 'value' => $value, + 'updated_at' => gmdate('c'), + ]); + } + + public function delete(string $key): void + { + $statement = $this->pdo->prepare('DELETE FROM settings WHERE setting_key = :key'); + $statement->execute(['key' => $key]); + } +} + diff --git a/src/Repository/UsageRepository.php b/src/Repository/UsageRepository.php new file mode 100644 index 0000000..fe823c3 --- /dev/null +++ b/src/Repository/UsageRepository.php @@ -0,0 +1,159 @@ +pdo = $database->pdo(); + } + + /** + * @param array $metadata + */ + public function record(string $eventType, ?string $objectKey = null, int $bytes = 0, int $requests = 1, array $metadata = []): void + { + $statement = $this->pdo->prepare(<<<'SQL' +INSERT INTO usage_events (event_type, object_key, bytes, requests, metadata_json, occurred_at) +VALUES (:event_type, :object_key, :bytes, :requests, :metadata_json, :occurred_at) +SQL); + + $statement->execute([ + 'event_type' => $eventType, + 'object_key' => $objectKey === null ? null : R2Key::normalizeKey($objectKey), + 'bytes' => max(0, $bytes), + 'requests' => max(1, $requests), + 'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES), + 'occurred_at' => gmdate('c'), + ]); + } + + /** + * @return list> + */ + public function eventSummary(int $days = 30): array + { + $since = gmdate('c', time() - ($days * 86400)); + $statement = $this->pdo->prepare(<<<'SQL' +SELECT event_type, COUNT(*) AS events, COALESCE(SUM(requests), 0) AS requests, COALESCE(SUM(bytes), 0) AS bytes +FROM usage_events +WHERE occurred_at >= :since +GROUP BY event_type +ORDER BY event_type ASC +SQL); + $statement->execute(['since' => $since]); + + return $statement->fetchAll(); + } + + /** + * @return list> + */ + public function dailySummary(int $days = 14): array + { + $since = gmdate('c', time() - ($days * 86400)); + $statement = $this->pdo->prepare(<<<'SQL' +SELECT substr(occurred_at, 1, 10) AS day, COALESCE(SUM(requests), 0) AS requests, COALESCE(SUM(bytes), 0) AS bytes +FROM usage_events +WHERE occurred_at >= :since +GROUP BY substr(occurred_at, 1, 10) +ORDER BY day DESC +SQL); + $statement->execute(['since' => $since]); + + return $statement->fetchAll(); + } + + /** + * @return array{storage_bytes: int, class_a_requests: int, class_b_requests: int, local_requests: int, uploaded_bytes: int, downloaded_bytes: int} + */ + public function billingSummary(int $days = 30): array + { + $since = gmdate('c', time() - ($days * 86400)); + $storageBytes = (int) $this->pdo->query('SELECT COALESCE(SUM(size), 0) FROM objects WHERE is_folder = 0 AND deleted_at IS NULL')->fetchColumn(); + + return [ + 'storage_bytes' => $storageBytes, + 'class_a_requests' => $this->sumRequests($since, ['sync', 'upload', 'create_folder', 'delete', 'copy', 'move', 'cors_put', 'cors_delete']), + 'class_b_requests' => $this->sumRequests($since, ['download', 'head', 'cors_get']), + 'local_requests' => $this->sumRequests($since, ['presign', 'metadata']), + 'uploaded_bytes' => $this->sumBytes($since, ['upload']), + 'downloaded_bytes' => $this->sumBytes($since, ['download']), + ]; + } + + public function createShare(string $objectKey, string $method, string $url, string $expiresAt): void + { + $statement = $this->pdo->prepare(<<<'SQL' +INSERT INTO shares (object_key, method, url, expires_at, created_at) +VALUES (:object_key, :method, :url, :expires_at, :created_at) +SQL); + $statement->execute([ + 'object_key' => R2Key::normalizeKey($objectKey), + 'method' => strtoupper($method), + 'url' => $url, + 'expires_at' => $expiresAt, + 'created_at' => gmdate('c'), + ]); + } + + /** + * @return list> + */ + public function recentShares(int $limit = 8): array + { + $statement = $this->pdo->prepare('SELECT * FROM shares ORDER BY created_at DESC LIMIT :limit'); + $statement->bindValue('limit', max(1, $limit), PDO::PARAM_INT); + $statement->execute(); + + return $statement->fetchAll(); + } + + public function shareCount(): int + { + $statement = $this->pdo->prepare('SELECT COUNT(*) FROM shares WHERE expires_at >= :now'); + $statement->execute(['now' => gmdate('c')]); + return (int) $statement->fetchColumn(); + } + + /** + * @param list $eventTypes + */ + private function sumRequests(string $since, array $eventTypes): int + { + return $this->sumColumn('requests', $since, $eventTypes); + } + + /** + * @param list $eventTypes + */ + private function sumBytes(string $since, array $eventTypes): int + { + return $this->sumColumn('bytes', $since, $eventTypes); + } + + /** + * @param list $eventTypes + */ + private function sumColumn(string $column, string $since, array $eventTypes): int + { + if ($eventTypes === []) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($eventTypes), '?')); + $statement = $this->pdo->prepare("SELECT COALESCE(SUM($column), 0) FROM usage_events WHERE occurred_at >= ? AND event_type IN ($placeholders)"); + $statement->execute(array_merge([$since], $eventTypes)); + + return (int) $statement->fetchColumn(); + } +} diff --git a/src/Support/Config.php b/src/Support/Config.php new file mode 100644 index 0000000..b2b6260 --- /dev/null +++ b/src/Support/Config.php @@ -0,0 +1,85 @@ + + */ + private array $env; + + public function __construct(private readonly string $rootPath) + { + $this->env = Env::load($this->rootPath . '/.env'); + } + + public function rootPath(string $path = ''): string + { + return $path === '' ? $this->rootPath : $this->rootPath . '/' . ltrim($path, '/'); + } + + public function get(string $key, ?string $default = null): ?string + { + $value = $_ENV[$key] ?? getenv($key); + if ($value === false || $value === null || $value === '') { + return $this->env[$key] ?? $default; + } + + return (string) $value; + } + + public function bool(string $key, bool $default = false): bool + { + $value = $this->get($key); + if ($value === null || $value === '') { + return $default; + } + + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + public function int(string $key, int $default): int + { + $value = $this->get($key); + if ($value === null || $value === '' || !is_numeric($value)) { + return $default; + } + + return (int) $value; + } + + public function dbPath(): string + { + return $this->rootPath($this->get('DB_PATH', 'storage/database/r2-manager.sqlite') ?? 'storage/database/r2-manager.sqlite'); + } + + public function appName(): string + { + return $this->get('APP_NAME', 'R2 Manager') ?? 'R2 Manager'; + } + + public function publicUrl(): ?string + { + $url = trim((string) $this->get('R2_PUBLIC_URL', '')); + return $url === '' ? null : rtrim($url, '/'); + } + + /** + * @return list + */ + public function missingR2Settings(): array + { + $missing = []; + foreach (['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET'] as $key) { + if (trim((string) $this->get($key, '')) === '') { + $missing[] = $key; + } + } + + return $missing; + } +} + diff --git a/src/Support/Database.php b/src/Support/Database.php new file mode 100644 index 0000000..ede213e --- /dev/null +++ b/src/Support/Database.php @@ -0,0 +1,107 @@ +pdo instanceof PDO) { + return $this->pdo; + } + + $path = $this->config->dbPath(); + $directory = dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $this->pdo = new PDO('sqlite:' . $path, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $this->pdo->exec('PRAGMA foreign_keys = ON'); + $this->pdo->exec('PRAGMA journal_mode = WAL'); + $this->migrate($this->pdo); + + return $this->pdo; + } + + private function migrate(PDO $pdo): void + { + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS objects ( + object_key TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + parent_prefix TEXT NOT NULL DEFAULT '', + is_folder INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL DEFAULT 0, + etag TEXT, + content_type TEXT, + storage_class TEXT, + last_modified TEXT, + synced_at TEXT, + deleted_at TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + local_tags_json TEXT NOT NULL DEFAULT '{}', + permission TEXT NOT NULL DEFAULT 'private', + notes TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_objects_parent ON objects(parent_prefix, deleted_at); +CREATE INDEX IF NOT EXISTS idx_objects_synced ON objects(synced_at); + +CREATE TABLE IF NOT EXISTS folders ( + prefix TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_prefix TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + local_only INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_prefix); + +CREATE TABLE IF NOT EXISTS shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + object_key TEXT NOT NULL, + method TEXT NOT NULL, + url TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_shares_object ON shares(object_key); + +CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + object_key TEXT, + bytes INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 1, + metadata_json TEXT NOT NULL DEFAULT '{}', + occurred_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_usage_events_type_time ON usage_events(event_type, occurred_at); + +CREATE TABLE IF NOT EXISTS settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT NOT NULL, + updated_at TEXT NOT NULL +); +SQL); + } +} + diff --git a/src/Support/Env.php b/src/Support/Env.php new file mode 100644 index 0000000..771ac23 --- /dev/null +++ b/src/Support/Env.php @@ -0,0 +1,62 @@ + + */ + public static function load(string $path): array + { + if (!is_file($path)) { + return []; + } + + $values = []; + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return []; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + if (str_starts_with($line, 'export ')) { + $line = trim(substr($line, 7)); + } + + $position = strpos($line, '='); + if ($position === false) { + continue; + } + + $key = trim(substr($line, 0, $position)); + $value = trim(substr($line, $position + 1)); + + if ($key === '') { + continue; + } + + if ( + strlen($value) >= 2 + && (($value[0] === '"' && $value[strlen($value) - 1] === '"') + || ($value[0] === "'" && $value[strlen($value) - 1] === "'")) + ) { + $value = substr($value, 1, -1); + } + + $values[$key] = $value; + $_ENV[$key] = $value; + putenv($key . '=' . $value); + } + + return $values; + } +} + diff --git a/src/Support/Flash.php b/src/Support/Flash.php new file mode 100644 index 0000000..48ebc05 --- /dev/null +++ b/src/Support/Flash.php @@ -0,0 +1,24 @@ + $type, 'message' => $message]; + } + + /** + * @return list + */ + public static function consume(): array + { + $messages = $_SESSION['flash'] ?? []; + unset($_SESSION['flash']); + return is_array($messages) ? $messages : []; + } +} + diff --git a/src/Support/Formatter.php b/src/Support/Formatter.php new file mode 100644 index 0000000..4e54d40 --- /dev/null +++ b/src/Support/Formatter.php @@ -0,0 +1,50 @@ += 1024 && $unit < count($units) - 1) { + $value /= 1024; + $unit++; + } + + return ($unit === 0 ? number_format($value, 0) : number_format($value, 2)) . ' ' . $units[$unit]; + } + + public static function date(?string $value): string + { + if ($value === null || $value === '') { + return 'Never'; + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + return $value; + } + + return date('M j, Y g:i A', $timestamp); + } + + /** + * @param array $tags + */ + public static function tagString(array $tags): string + { + $parts = []; + foreach ($tags as $key => $value) { + $parts[] = $key . '=' . $value; + } + + return implode(', ', $parts); + } +} + diff --git a/src/Support/R2Key.php b/src/Support/R2Key.php new file mode 100644 index 0000000..cd3a104 --- /dev/null +++ b/src/Support/R2Key.php @@ -0,0 +1,89 @@ + + */ + public static function ancestors(string $keyOrPrefix): array + { + $prefix = str_ends_with($keyOrPrefix, '/') + ? self::normalizePrefix($keyOrPrefix) + : self::parentPrefix($keyOrPrefix); + + if ($prefix === '') { + return []; + } + + $parts = array_filter(explode('/', trim($prefix, '/')), static fn (string $part): bool => $part !== ''); + $ancestors = []; + $current = ''; + foreach ($parts as $part) { + $current .= $part . '/'; + $ancestors[] = $current; + } + + return $ancestors; + } + + public static function safeName(string $name): string + { + $name = trim(str_replace(["\0", '\\'], ['', '/'], $name)); + $name = ltrim($name, '/'); + return preg_replace('#/+#', '/', $name) ?? $name; + } +} + diff --git a/src/Support/Security.php b/src/Support/Security.php new file mode 100644 index 0000000..32be09f --- /dev/null +++ b/src/Support/Security.php @@ -0,0 +1,83 @@ +config->get('APP_SESSION_NAME', 'r2_manager_session') ?? 'r2_manager_session'; + session_name($name); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + } + + public function isAuthenticated(): bool + { + $this->startSession(); + return !empty($_SESSION['authenticated']); + } + + public function attemptLogin(string $password): bool + { + $this->startSession(); + + $hash = trim((string) $this->config->get('APP_PASSWORD_HASH', '')); + $plain = (string) $this->config->get('APP_PASSWORD', 'change-me'); + + $valid = $hash !== '' + ? password_verify($password, $hash) + : hash_equals($plain, $password); + + if ($valid) { + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['login_at'] = time(); + } + + return $valid; + } + + public function logout(): void + { + $this->startSession(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'] ?? '', $params['secure'], $params['httponly']); + } + session_destroy(); + } + + public function csrfToken(): string + { + $this->startSession(); + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + return (string) $_SESSION['csrf_token']; + } + + public function validateCsrf(?string $token): bool + { + return is_string($token) && hash_equals($this->csrfToken(), $token); + } +} + diff --git a/src/Support/Tags.php b/src/Support/Tags.php new file mode 100644 index 0000000..f477bb8 --- /dev/null +++ b/src/Support/Tags.php @@ -0,0 +1,68 @@ + + */ + public static function parse(string $input): array + { + $tags = []; + $input = trim($input); + if ($input === '') { + return $tags; + } + + foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + + $position = strpos($part, '='); + if ($position === false) { + $tags[$part] = ''; + continue; + } + + $name = trim(substr($part, 0, $position)); + $value = trim(substr($part, $position + 1)); + if ($name !== '') { + $tags[$name] = $value; + } + } + + ksort($tags); + return $tags; + } + + /** + * @param array|string|null $json + * @return array + */ + public static function fromJson(array|string|null $json): array + { + if (is_array($json)) { + $data = $json; + } else { + $data = json_decode((string) $json, true); + } + + if (!is_array($data)) { + return []; + } + + $tags = []; + foreach ($data as $name => $value) { + $tags[(string) $name] = (string) $value; + } + ksort($tags); + + return $tags; + } +} + diff --git a/src/Support/View.php b/src/Support/View.php new file mode 100644 index 0000000..32ff143 --- /dev/null +++ b/src/Support/View.php @@ -0,0 +1,29 @@ + $data + */ + public static function render(string $template, array $data = [], ?string $layout = 'layout'): void + { + $viewsPath = dirname(__DIR__, 2) . '/views'; + extract($data, EXTR_SKIP); + + ob_start(); + require $viewsPath . '/' . $template . '.php'; + $content = ob_get_clean(); + + if ($layout === null) { + echo $content; + return; + } + + require $viewsPath . '/' . $layout . '.php'; + } +} + diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..732a9e5 --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,24 @@ + true]; + +require dirname(__DIR__) . '/src/bootstrap.php'; + +$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__) . '/src')); +foreach ($iterator as $file) { + if ($file instanceof SplFileInfo && $file->isFile() && $file->getExtension() === 'php' && $file->getFilename() !== 'bootstrap.php') { + require_once $file->getPathname(); + } +} + +$config = new Config(dirname(__DIR__)); +$database = new Database($config); +new ObjectRepository($database); +new UsageRepository($database); +new SettingsRepository($database); + +ob_start(); +View::render('login', [ + 'appName' => 'R2 Manager', + 'csrf' => 'token', + 'flash' => [], +]); + +View::render('dashboard', [ + 'appName' => 'R2 Manager', + 'bucket' => 'example-bucket', + 'configured' => false, + 'missingSettings' => ['R2_ACCOUNT_ID'], + 'prefix' => '', + 'breadcrumbs' => [['label' => 'Bucket root', 'prefix' => '']], + 'search' => '', + 'tagFilter' => '', + 'tagNames' => [], + 'folders' => [], + 'objects' => [], + 'stats' => ['objects' => 0, 'folders' => 0, 'bytes' => 0, 'latest_sync' => null], + 'eventSummary' => [], + 'billingSummary' => [ + 'storage_bytes' => 0, + 'class_a_requests' => 0, + 'class_b_requests' => 0, + 'local_requests' => 0, + 'uploaded_bytes' => 0, + 'downloaded_bytes' => 0, + ], + 'dailySummary' => [], + 'recentShares' => [], + 'shareCount' => 0, + 'selected' => null, + 'selectedPublicUrl' => null, + 'corsJson' => '[]', + 'csrf' => 'token', + 'flash' => [], +]); +$html = ob_get_clean(); + +if (!is_string($html) || !str_contains($html, 'Storage manager') || !str_contains($html, 'Objects')) { + throw new RuntimeException('Expected smoke-render content was not produced.'); +} + +echo "Smoke OK\n"; diff --git a/views/dashboard.php b/views/dashboard.php new file mode 100644 index 0000000..6099747 --- /dev/null +++ b/views/dashboard.php @@ -0,0 +1,399 @@ + $missingSettings */ +/** @var string $prefix */ +/** @var list $breadcrumbs */ +/** @var string $search */ +/** @var string $tagFilter */ +/** @var list $tagNames */ +/** @var list> $folders */ +/** @var list> $objects */ +/** @var array $stats */ +/** @var list> $eventSummary */ +/** @var array $billingSummary */ +/** @var list> $dailySummary */ +/** @var list> $recentShares */ +/** @var int $shareCount */ +/** @var array|null $selected */ +/** @var string|null $selectedPublicUrl */ +/** @var string $corsJson */ + +$currentQuery = ['prefix' => $prefix]; +if ($search !== '') { + $currentQuery['q'] = $search; +} +if ($tagFilter !== '') { + $currentQuery['tag'] = $tagFilter; +} +?> +
+ +
+ R2 is not configured yet. + Add to your .env file before syncing or mutating objects. +
+ + +
+
+

Bucket

+

+ +
+
+ + + + +
+
+ +
+
+ Objects + +
+
+ Mirrored folders + +
+
+ Storage + +
+
+ Latest sync + +
+
+ +
+
+
+
+
+

Objects

+

+
+
+ + + + +
+
+ +
+ + +
+
+ + Name + Size + Permission + Modified + +
+ + + + + .. + + + + Up + + + + + + + + + + + + + +
No mirrored objects here yet. Sync this prefix or upload a file.
+ +
+ +
+ +
+
+
+ +
+
+ + + +
+

Upload

+
+ + + + + +
+ +
+ + + +
+

Folder

+
+ + +

Creates a zero-byte marker object so empty folders survive syncs.

+
+
+ +
+
+

CORS

+
+ + + + +
+
+
+ + + +
+ + +
+
+
+
+ + +
+
diff --git a/views/layout.php b/views/layout.php new file mode 100644 index 0000000..c90e306 --- /dev/null +++ b/views/layout.php @@ -0,0 +1,44 @@ + $flash */ +?> + + + + + + <?= htmlspecialchars($appName) ?> + + + +
+ + R2 + + + +
+ + + +
+ +
+ + +
+ +
+ +
+ +
+ + + + + + + diff --git a/views/login.php b/views/login.php new file mode 100644 index 0000000..4695518 --- /dev/null +++ b/views/login.php @@ -0,0 +1,23 @@ + +
+ +
+