- 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:
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
storage/database/*.sqlite
|
||||||
|
storage/database/*.sqlite-*
|
||||||
|
storage/cache/*
|
||||||
|
!storage/cache/.gitkeep
|
||||||
|
!storage/database/.gitkeep
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -1,3 +1,102 @@
|
|||||||
# CloudflareR2-Manager
|
# CloudflareR2-Manager
|
||||||
|
|
||||||
A CloudFlare R2 manager built for the web usage and command line.
|
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://<ACCOUNT_ID>.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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use R2Manager\Controller\AppController;
|
||||||
|
use R2Manager\R2\R2Client;
|
||||||
|
use R2Manager\Repository\ObjectRepository;
|
||||||
|
use R2Manager\Repository\SettingsRepository;
|
||||||
|
use R2Manager\Repository\UsageRepository;
|
||||||
|
use R2Manager\Support\Config;
|
||||||
|
use R2Manager\Support\Database;
|
||||||
|
use R2Manager\Support\Security;
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/src/bootstrap.php';
|
||||||
|
|
||||||
|
$config = new Config(dirname(__DIR__));
|
||||||
|
$database = new Database($config);
|
||||||
|
$security = new Security($config);
|
||||||
|
$r2 = new R2Client($config);
|
||||||
|
|
||||||
|
$controller = new AppController(
|
||||||
|
$config,
|
||||||
|
$security,
|
||||||
|
$r2,
|
||||||
|
new ObjectRepository($database),
|
||||||
|
new UsageRepository($database),
|
||||||
|
new SettingsRepository($database)
|
||||||
|
);
|
||||||
|
|
||||||
|
$controller->handle();
|
||||||
|
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Controller;
|
||||||
|
|
||||||
|
use R2Manager\R2\R2Client;
|
||||||
|
use R2Manager\R2\R2Exception;
|
||||||
|
use R2Manager\Repository\ObjectRepository;
|
||||||
|
use R2Manager\Repository\SettingsRepository;
|
||||||
|
use R2Manager\Repository\UsageRepository;
|
||||||
|
use R2Manager\Support\Config;
|
||||||
|
use R2Manager\Support\Flash;
|
||||||
|
use R2Manager\Support\Formatter;
|
||||||
|
use R2Manager\Support\R2Key;
|
||||||
|
use R2Manager\Support\Security;
|
||||||
|
use R2Manager\Support\Tags;
|
||||||
|
use R2Manager\Support\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class AppController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Config $config,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly R2Client $r2,
|
||||||
|
private readonly ObjectRepository $objects,
|
||||||
|
private readonly UsageRepository $usage,
|
||||||
|
private readonly SettingsRepository $settings
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$this->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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array{label: string, prefix: string}>
|
||||||
|
*/
|
||||||
|
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<string, string|null> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\R2;
|
||||||
|
|
||||||
|
use R2Manager\Support\Config;
|
||||||
|
|
||||||
|
final class R2Client
|
||||||
|
{
|
||||||
|
private string $accountId;
|
||||||
|
private string $accessKeyId;
|
||||||
|
private string $secretAccessKey;
|
||||||
|
private string $bucket;
|
||||||
|
private string $region;
|
||||||
|
private string $endpoint;
|
||||||
|
private bool $pathStyle;
|
||||||
|
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
$this->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<array<string, string|int|null>>, prefixes: list<string>, 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<string, string|int|null>
|
||||||
|
*/
|
||||||
|
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<string, string> $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<string, string> $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<string, string> $query
|
||||||
|
* @param array<string, string> $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<string, string> $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<string, string> $headers
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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<string, string> $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<int, mixed> $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('<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\R2;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class R2Exception extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
private readonly int $statusCode = 0,
|
||||||
|
private readonly string $responseBody = ''
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function statusCode(): int
|
||||||
|
{
|
||||||
|
return $this->statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function responseBody(): string
|
||||||
|
{
|
||||||
|
return $this->responseBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\R2;
|
||||||
|
|
||||||
|
final class R2Response
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Repository;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use R2Manager\Support\Database;
|
||||||
|
use R2Manager\Support\R2Key;
|
||||||
|
use R2Manager\Support\Tags;
|
||||||
|
|
||||||
|
final class ObjectRepository
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(Database $database)
|
||||||
|
{
|
||||||
|
$this->pdo = $database->pdo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{objects: list<array<string, mixed>>, folders: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, string> $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<string>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Repository;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use R2Manager\Support\Database;
|
||||||
|
|
||||||
|
final class SettingsRepository
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(Database $database)
|
||||||
|
{
|
||||||
|
$this->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Repository;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use R2Manager\Support\Database;
|
||||||
|
use R2Manager\Support\R2Key;
|
||||||
|
|
||||||
|
final class UsageRepository
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(Database $database)
|
||||||
|
{
|
||||||
|
$this->pdo = $database->pdo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string> $eventTypes
|
||||||
|
*/
|
||||||
|
private function sumRequests(string $since, array $eventTypes): int
|
||||||
|
{
|
||||||
|
return $this->sumColumn('requests', $since, $eventTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $eventTypes
|
||||||
|
*/
|
||||||
|
private function sumBytes(string $since, array $eventTypes): int
|
||||||
|
{
|
||||||
|
return $this->sumColumn('bytes', $since, $eventTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class Database
|
||||||
|
{
|
||||||
|
private ?PDO $pdo = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdo(): PDO
|
||||||
|
{
|
||||||
|
if ($this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class Env
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class Flash
|
||||||
|
{
|
||||||
|
public static function add(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{type: string, message: string}>
|
||||||
|
*/
|
||||||
|
public static function consume(): array
|
||||||
|
{
|
||||||
|
$messages = $_SESSION['flash'] ?? [];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
return is_array($messages) ? $messages : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class Formatter
|
||||||
|
{
|
||||||
|
public static function bytes(int|float $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
$value = max(0, (float) $bytes);
|
||||||
|
$unit = 0;
|
||||||
|
|
||||||
|
while ($value >= 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<string, string> $tags
|
||||||
|
*/
|
||||||
|
public static function tagString(array $tags): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
foreach ($tags as $key => $value) {
|
||||||
|
$parts[] = $key . '=' . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class R2Key
|
||||||
|
{
|
||||||
|
public static function normalizePrefix(?string $prefix): string
|
||||||
|
{
|
||||||
|
$prefix = trim((string) $prefix);
|
||||||
|
$prefix = ltrim(str_replace('\\', '/', $prefix), '/');
|
||||||
|
if ($prefix === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($prefix, '/') . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeKey(?string $key): string
|
||||||
|
{
|
||||||
|
$key = trim((string) $key);
|
||||||
|
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||||
|
$key = preg_replace('#/+#', '/', $key) ?? $key;
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function join(string $prefix, string $name): string
|
||||||
|
{
|
||||||
|
$prefix = self::normalizePrefix($prefix);
|
||||||
|
$name = ltrim(self::normalizeKey($name), '/');
|
||||||
|
return $prefix . $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parentPrefix(string $key): string
|
||||||
|
{
|
||||||
|
$key = self::normalizeKey($key);
|
||||||
|
$trimmed = rtrim($key, '/');
|
||||||
|
$position = strrpos($trimmed, '/');
|
||||||
|
if ($position === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($trimmed, 0, $position + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function basename(string $key): string
|
||||||
|
{
|
||||||
|
$trimmed = rtrim(self::normalizeKey($key), '/');
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$position = strrpos($trimmed, '/');
|
||||||
|
return $position === false ? $trimmed : substr($trimmed, $position + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class Security
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Config $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startSession(): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class Tags
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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, mixed>|string|null $json
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace R2Manager\Support;
|
||||||
|
|
||||||
|
final class View
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
$prefix = 'R2Manager\\';
|
||||||
|
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = substr($class, strlen($prefix));
|
||||||
|
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
|
||||||
|
if (is_file($path)) {
|
||||||
|
require $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function app_path(string $path = ''): string
|
||||||
|
{
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
return $path === '' ? $root : $root . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use R2Manager\Repository\ObjectRepository;
|
||||||
|
use R2Manager\Repository\SettingsRepository;
|
||||||
|
use R2Manager\Repository\UsageRepository;
|
||||||
|
use R2Manager\Support\Config;
|
||||||
|
use R2Manager\Support\Database;
|
||||||
|
use R2Manager\Support\View;
|
||||||
|
|
||||||
|
$_SERVER['HTTPS'] = 'off';
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
$_SESSION = ['authenticated' => 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";
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user