1638 lines
47 KiB
Markdown
1638 lines
47 KiB
Markdown
# Dynamic CORS + CDN Proxy Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add runtime-editable CORS origins backed by PostgreSQL (managed via CRUD API, protected by super_admin bypass mask) and a CDN proxy that maps keys to local container URLs with a DB registry; add loading spinners to all admin views.
|
||
|
||
**Architecture:** CORS uses a custom async Axum middleware (`cors_layer`) that reads from an `Arc<RwLock<CorsState>>` in-memory cache, populated from the `cors_origins` table and reloaded after every mutation via the CRUD hook. The CDN adds dedicated routes at `/api/cdn` and `/api/cdn/:key` that read from `cdn_objects` and proxy to internal URLs via `reqwest`; write operations are blocked for non-super-admins by blacklist migration. Both features get Vue admin views.
|
||
|
||
**Tech Stack:** Rust/axum 0.7, sqlx 0.7, tokio, reqwest 0.12, Vue 3/TypeScript
|
||
|
||
## Global Constraints
|
||
|
||
- All DB access uses `sqlx::query_as` / `sqlx::query` (not `sqlx::query!` macro — no compile-time DB required).
|
||
- Blacklist super-admin bypass bit is `'32'` (string, matches existing migrations).
|
||
- All new Rust modules follow existing `pub mod` declaration in their parent `mod.rs`.
|
||
- Vue components import auth store via `import { useAuthStore } from '../../stores/auth'`.
|
||
- No new npm dependencies — existing NychButton, NychDialog, NychInputText, NychSelect are available globally.
|
||
- `cargo test` must pass after every Rust task.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Action |
|
||
|---|---|
|
||
| `docker-compose.yml` | Modify — add `cdn` (MinIO), `cdn-init`, `cdn_data` volume; `api` depends on `cdn` |
|
||
| `Dockerfile` | Modify — add `minio-download` stage; copy `minio` + `mc` binaries to final image |
|
||
| `entrypoint.sh` | Modify — start MinIO in background, wait for health, create bucket before Postgres |
|
||
| `.env.example` | Modify — add `CDN_ACCESS_KEY`, `CDN_SECRET_KEY`, `CDN_BUCKET` |
|
||
| `src/db/migrations/007_cors_origins.sql` | Create |
|
||
| `src/db/migrations/008_cdn_objects.sql` | Create |
|
||
| `src/state.rs` | Modify — add `CorsCache`, `CorsState`, `http_client`; update `AppState` |
|
||
| `src/models/cdn.rs` | Create |
|
||
| `src/models/mod.rs` | Modify — add `pub mod cdn` |
|
||
| `src/auth/middleware.rs` | Modify — add `cors_layer` |
|
||
| `src/routes/cdn.rs` | Create |
|
||
| `src/routes/mod.rs` | Modify — add `pub mod cdn` |
|
||
| `src/routes/crud.rs` | Modify — add `reload_cors`, hook |
|
||
| `src/routes/admin/tables.rs` | Modify — add `cors_origins`, `cdn_objects` to `PROTECTED_TABLES` |
|
||
| `src/main.rs` | Modify — remove `build_cors`, wire `cors_cache`, `http_client`, `cors_layer`, CDN routes |
|
||
| `Cargo.toml` | Modify — add `reqwest`, remove `cors` feature from `tower-http` |
|
||
| `ui/src/assets/main.css` | Modify — add spinner keyframe + classes |
|
||
| `ui/src/views/admin/Blacklist.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/ApiKeys.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/Cache.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/Queries.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/Tables.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/Users.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/Permissions.vue` | Modify — add loading state |
|
||
| `ui/src/views/admin/Cors.vue` | Create |
|
||
| `ui/src/views/admin/Cdn.vue` | Create |
|
||
| `ui/src/router/index.ts` | Modify — add `/admin/cors` and `/admin/cdn` routes |
|
||
| `ui/src/views/admin/Layout.vue` | Modify — add CORS and CDN nav items |
|
||
|
||
---
|
||
|
||
### Task 0: Infrastructure — Docker Compose + Dockerfile + Entrypoint
|
||
|
||
> **Already done** — these files were updated during planning. Mark complete and move to Task 1.
|
||
|
||
**Files (already modified):**
|
||
- `docker-compose.yml` — `cdn` (MinIO) + `cdn-init` services; `cdn_data` volume; `api` `depends_on` cdn healthy
|
||
- `Dockerfile` — `minio-download` stage (`alpine:3`, downloads `minio` + `mc` via `TARGETARCH`); copies binaries to final image; exposes 9000/9001
|
||
- `entrypoint.sh` — starts MinIO in background, loops `mc alias set` until ready, creates `$CDN_BUCKET` with anonymous download policy, then starts Postgres + Mercury
|
||
- `.env.example` — documents `CDN_ACCESS_KEY`, `CDN_SECRET_KEY`, `CDN_BUCKET`, and the URL prefix pattern
|
||
|
||
**CDN URL pattern (important for admin users registering objects):**
|
||
- docker-compose stack: `http://cdn:9000/mercury/<filename>`
|
||
- standalone image: `http://localhost:9000/mercury/<filename>`
|
||
|
||
The bucket is created with anonymous read (`mc anonymous set download`), so the Mercury proxy makes unauthenticated GET requests — no credentials needed in the API.
|
||
|
||
- [ ] **Mark complete — files already written.**
|
||
|
||
---
|
||
|
||
### Task 1: DB Migrations
|
||
|
||
**Files:**
|
||
- Create: `src/db/migrations/007_cors_origins.sql`
|
||
- Create: `src/db/migrations/008_cdn_objects.sql`
|
||
|
||
**Interfaces:**
|
||
- Produces: `cors_origins(id, origin, created_at)` and `cdn_objects(id, key, url, content_type, description, created_at)` tables; blacklist seeds for both.
|
||
|
||
- [ ] **Step 1: Write 007_cors_origins.sql**
|
||
|
||
```sql
|
||
CREATE TABLE cors_origins (
|
||
id SERIAL PRIMARY KEY,
|
||
origin TEXT NOT NULL UNIQUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- Restrict CRUD mutations to super-admins (bit 32). Mirrors blacklist/queries pattern.
|
||
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
|
||
('/api/cors_origins', NULL, 'admin-only table', true, '32'),
|
||
('/api/cors_origins/**', NULL, 'admin-only table', true, '32');
|
||
```
|
||
|
||
- [ ] **Step 2: Write 008_cdn_objects.sql**
|
||
|
||
```sql
|
||
CREATE TABLE cdn_objects (
|
||
id SERIAL PRIMARY KEY,
|
||
key TEXT NOT NULL UNIQUE,
|
||
url TEXT NOT NULL,
|
||
content_type TEXT,
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- Block mutations to /api/cdn for non-super-admins. GET is unblocked (no entry).
|
||
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
|
||
('/api/cdn', 'POST,PUT,PATCH,DELETE', 'CDN write operations are super-admin only', true, '32'),
|
||
('/api/cdn/**', 'POST,PUT,PATCH,DELETE', 'CDN write operations are super-admin only', true, '32');
|
||
```
|
||
|
||
- [ ] **Step 3: Verify migrations compile**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
|
||
```
|
||
Expected: `Finished dev` (sqlx migrate! scans the directory; new files are included automatically).
|
||
|
||
---
|
||
|
||
### Task 2: CorsCache + AppState
|
||
|
||
**Files:**
|
||
- Modify: `src/state.rs`
|
||
- Modify: `Cargo.toml`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `CorsCache::new() -> CorsCache`
|
||
- `CorsCache::load(origins: Vec<String>) -> ()` (async)
|
||
- `AppState.cors_cache: CorsCache`
|
||
- `AppState.http_client: reqwest::Client`
|
||
|
||
- [ ] **Step 1: Add reqwest to Cargo.toml**
|
||
|
||
Replace the tower-http line and add reqwest:
|
||
|
||
```toml
|
||
tower-http = { version = "0.5", features = ["fs"] }
|
||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Add to the `#[cfg(test)]` block at the bottom of `src/state.rs`:
|
||
|
||
```rust
|
||
#[tokio::test]
|
||
async fn test_cors_cache_wildcard() {
|
||
let cache = CorsCache::new();
|
||
cache.load(vec!["*".to_string()]).await;
|
||
let guard = cache.inner.read().await;
|
||
assert!(guard.wildcard);
|
||
assert!(guard.origins.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cors_cache_specific_origin() {
|
||
let cache = CorsCache::new();
|
||
cache.load(vec!["https://example.com".to_string()]).await;
|
||
let guard = cache.inner.read().await;
|
||
assert!(!guard.wildcard);
|
||
assert_eq!(guard.origins.len(), 1);
|
||
assert_eq!(guard.origins[0], "https://example.com");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cors_cache_empty() {
|
||
let cache = CorsCache::new();
|
||
cache.load(vec![]).await;
|
||
let guard = cache.inner.read().await;
|
||
assert!(!guard.wildcard);
|
||
assert!(guard.origins.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cors_cache_load_replaces() {
|
||
let cache = CorsCache::new();
|
||
cache.load(vec!["https://a.com".to_string()]).await;
|
||
cache.load(vec!["https://b.com".to_string()]).await;
|
||
let guard = cache.inner.read().await;
|
||
assert_eq!(guard.origins.len(), 1);
|
||
assert_eq!(guard.origins[0], "https://b.com");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo test cors_cache 2>&1 | tail -5
|
||
```
|
||
Expected: compile error — `CorsCache` not defined yet.
|
||
|
||
- [ ] **Step 4: Add CorsCache + CorsState structs and update AppState**
|
||
|
||
In `src/state.rs`, add these imports at the top:
|
||
|
||
```rust
|
||
use axum::http::HeaderValue;
|
||
```
|
||
|
||
Add after the `BlacklistCache` impl block:
|
||
|
||
```rust
|
||
#[derive(Clone)]
|
||
pub struct CorsCache {
|
||
pub inner: Arc<RwLock<CorsState>>,
|
||
}
|
||
|
||
#[derive(Clone, Default)]
|
||
pub struct CorsState {
|
||
pub wildcard: bool,
|
||
pub origins: Vec<HeaderValue>,
|
||
}
|
||
|
||
impl CorsCache {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
inner: Arc::new(RwLock::new(CorsState::default())),
|
||
}
|
||
}
|
||
|
||
pub async fn load(&self, origins: Vec<String>) {
|
||
let wildcard = origins.iter().any(|o| o == "*");
|
||
let parsed: Vec<HeaderValue> = origins
|
||
.iter()
|
||
.filter(|o| *o != "*")
|
||
.filter_map(|o| o.parse().ok())
|
||
.collect();
|
||
let mut guard = self.inner.write().await;
|
||
*guard = CorsState { wildcard, origins: parsed };
|
||
}
|
||
}
|
||
```
|
||
|
||
Update `AppState`:
|
||
|
||
```rust
|
||
#[derive(Clone)]
|
||
pub struct AppState {
|
||
pub pool: PgPool,
|
||
pub query_cache: QueryCache,
|
||
pub blacklist_cache: BlacklistCache,
|
||
pub cors_cache: CorsCache,
|
||
pub http_client: reqwest::Client,
|
||
pub config: Arc<Config>,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo test cors_cache 2>&1 | tail -5
|
||
```
|
||
Expected: `test result: ok. 4 passed`
|
||
|
||
---
|
||
|
||
### Task 3: CdnObject Model
|
||
|
||
**Files:**
|
||
- Create: `src/models/cdn.rs`
|
||
- Modify: `src/models/mod.rs`
|
||
|
||
**Interfaces:**
|
||
- Produces: `CdnObject`, `CreateCdnObject`, `UpdateCdnObject` — used by `routes/cdn.rs`
|
||
|
||
- [ ] **Step 1: Create src/models/cdn.rs**
|
||
|
||
```rust
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||
pub struct CdnObject {
|
||
pub id: i32,
|
||
pub key: String,
|
||
pub url: String,
|
||
pub content_type: Option<String>,
|
||
pub description: Option<String>,
|
||
pub created_at: DateTime<Utc>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CreateCdnObject {
|
||
pub key: String,
|
||
pub url: String,
|
||
pub content_type: Option<String>,
|
||
pub description: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct UpdateCdnObject {
|
||
pub key: Option<String>,
|
||
pub url: Option<String>,
|
||
pub content_type: Option<String>,
|
||
pub description: Option<String>,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add to src/models/mod.rs**
|
||
|
||
```rust
|
||
pub mod api_key;
|
||
pub mod blacklist;
|
||
pub mod cdn;
|
||
pub mod permission;
|
||
pub mod query;
|
||
pub mod user;
|
||
```
|
||
|
||
- [ ] **Step 3: Verify compilation**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
|
||
```
|
||
Expected: `Finished dev`
|
||
|
||
---
|
||
|
||
### Task 4: cors_layer Middleware
|
||
|
||
**Files:**
|
||
- Modify: `src/auth/middleware.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AppState.cors_cache: CorsCache`
|
||
- Produces: `cors_layer(State<AppState>, Request, Next) -> Response` — applied globally in `main.rs`
|
||
|
||
- [ ] **Step 1: Update imports in src/auth/middleware.rs**
|
||
|
||
Replace the existing imports block with:
|
||
|
||
```rust
|
||
use axum::{
|
||
extract::{Request, State},
|
||
http::{header, HeaderMap, HeaderValue, Method, StatusCode},
|
||
middleware::Next,
|
||
response::{IntoResponse, Response},
|
||
};
|
||
|
||
use crate::{
|
||
auth::{decode_jwt, resolve_api_key, Claims},
|
||
state::AppState,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Add cors_layer function**
|
||
|
||
Add after the `require_admin_cache` function:
|
||
|
||
```rust
|
||
pub async fn cors_layer(
|
||
State(state): State<AppState>,
|
||
req: Request,
|
||
next: Next,
|
||
) -> Response {
|
||
let is_preflight = req.method() == Method::OPTIONS;
|
||
let origin_header = req.headers().get(header::ORIGIN).cloned();
|
||
|
||
enum CorsDecision {
|
||
None,
|
||
Wildcard,
|
||
Specific(HeaderValue),
|
||
}
|
||
|
||
let decision = {
|
||
let guard = state.cors_cache.inner.read().await;
|
||
if guard.wildcard {
|
||
CorsDecision::Wildcard
|
||
} else if let Some(origin) = origin_header.as_ref() {
|
||
if guard.origins.contains(origin) {
|
||
CorsDecision::Specific(origin.clone())
|
||
} else {
|
||
CorsDecision::None
|
||
}
|
||
} else {
|
||
CorsDecision::None
|
||
}
|
||
};
|
||
|
||
let (cors_origin, vary) = match &decision {
|
||
CorsDecision::None => (None, false),
|
||
CorsDecision::Wildcard => (Some(HeaderValue::from_static("*")), false),
|
||
CorsDecision::Specific(v) => (Some(v.clone()), true),
|
||
};
|
||
|
||
if is_preflight {
|
||
let mut headers = HeaderMap::new();
|
||
if let Some(origin) = cors_origin {
|
||
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||
headers.insert(
|
||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||
HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS"),
|
||
);
|
||
headers.insert(
|
||
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||
HeaderValue::from_static("content-type, authorization"),
|
||
);
|
||
if vary {
|
||
headers.insert(header::VARY, HeaderValue::from_static("Origin"));
|
||
}
|
||
}
|
||
return (StatusCode::NO_CONTENT, headers).into_response();
|
||
}
|
||
|
||
let mut response = next.run(req).await;
|
||
if let Some(origin) = cors_origin {
|
||
response
|
||
.headers_mut()
|
||
.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||
if vary {
|
||
response
|
||
.headers_mut()
|
||
.append(header::VARY, HeaderValue::from_static("Origin"));
|
||
}
|
||
}
|
||
response
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify compilation**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
|
||
```
|
||
Expected: `Finished dev`
|
||
|
||
---
|
||
|
||
### Task 5: CDN Route Handlers
|
||
|
||
**Files:**
|
||
- Create: `src/routes/cdn.rs`
|
||
- Modify: `src/routes/mod.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AppState.pool`, `AppState.http_client`, `CdnObject`, `CreateCdnObject`, `UpdateCdnObject`
|
||
- Produces: `cdn_list`, `cdn_proxy`, `cdn_create`, `cdn_update`, `cdn_delete` — registered in `main.rs`
|
||
|
||
- [ ] **Step 1: Create src/routes/cdn.rs**
|
||
|
||
```rust
|
||
use axum::{
|
||
extract::{Path, State},
|
||
http::{header, StatusCode},
|
||
response::IntoResponse,
|
||
Json,
|
||
};
|
||
use serde_json::Value;
|
||
use sqlx::Row;
|
||
|
||
use crate::{
|
||
models::cdn::{CdnObject, CreateCdnObject, UpdateCdnObject},
|
||
state::AppState,
|
||
};
|
||
|
||
pub async fn cdn_list(
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<Vec<CdnObject>>, StatusCode> {
|
||
let objects = sqlx::query_as::<_, CdnObject>(
|
||
"SELECT id, key, url, content_type, description, created_at \
|
||
FROM cdn_objects ORDER BY id",
|
||
)
|
||
.fetch_all(&state.pool)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("cdn_list: {}", e);
|
||
StatusCode::INTERNAL_SERVER_ERROR
|
||
})?;
|
||
Ok(Json(objects))
|
||
}
|
||
|
||
pub async fn cdn_proxy(
|
||
State(state): State<AppState>,
|
||
Path(key): Path<String>,
|
||
) -> Result<impl IntoResponse, StatusCode> {
|
||
let row = sqlx::query(
|
||
"SELECT url, content_type FROM cdn_objects WHERE key = $1",
|
||
)
|
||
.bind(&key)
|
||
.fetch_optional(&state.pool)
|
||
.await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.ok_or(StatusCode::NOT_FOUND)?;
|
||
|
||
let url: String = row.try_get("url").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
let content_type: Option<String> = row.try_get::<Option<String>, _>("content_type").ok().flatten();
|
||
|
||
let upstream = state
|
||
.http_client
|
||
.get(&url)
|
||
.send()
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("cdn_proxy upstream error for key={}: {}", key, e);
|
||
StatusCode::BAD_GATEWAY
|
||
})?;
|
||
|
||
let status =
|
||
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||
|
||
let bytes = upstream
|
||
.bytes()
|
||
.await
|
||
.map_err(|_| StatusCode::BAD_GATEWAY)?;
|
||
|
||
let ct = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||
|
||
Ok((status, [(header::CONTENT_TYPE, ct)], bytes))
|
||
}
|
||
|
||
pub async fn cdn_create(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<CreateCdnObject>,
|
||
) -> Result<Json<CdnObject>, StatusCode> {
|
||
let obj = sqlx::query_as::<_, CdnObject>(
|
||
"INSERT INTO cdn_objects (key, url, content_type, description) \
|
||
VALUES ($1, $2, $3, $4) \
|
||
RETURNING id, key, url, content_type, description, created_at",
|
||
)
|
||
.bind(&body.key)
|
||
.bind(&body.url)
|
||
.bind(&body.content_type)
|
||
.bind(&body.description)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.map_err(|e| {
|
||
tracing::error!("cdn_create: {}", e);
|
||
StatusCode::INTERNAL_SERVER_ERROR
|
||
})?;
|
||
Ok(Json(obj))
|
||
}
|
||
|
||
pub async fn cdn_update(
|
||
State(state): State<AppState>,
|
||
Path(key): Path<String>,
|
||
Json(body): Json<UpdateCdnObject>,
|
||
) -> Result<Json<CdnObject>, StatusCode> {
|
||
let obj = sqlx::query_as::<_, CdnObject>(
|
||
"UPDATE cdn_objects \
|
||
SET key = COALESCE($2, key), \
|
||
url = COALESCE($3, url), \
|
||
content_type = COALESCE($4, content_type), \
|
||
description = COALESCE($5, description) \
|
||
WHERE key = $1 \
|
||
RETURNING id, key, url, content_type, description, created_at",
|
||
)
|
||
.bind(&key)
|
||
.bind(&body.key)
|
||
.bind(&body.url)
|
||
.bind(&body.content_type)
|
||
.bind(&body.description)
|
||
.fetch_optional(&state.pool)
|
||
.await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.ok_or(StatusCode::NOT_FOUND)?;
|
||
Ok(Json(obj))
|
||
}
|
||
|
||
pub async fn cdn_delete(
|
||
State(state): State<AppState>,
|
||
Path(key): Path<String>,
|
||
) -> Result<Json<Value>, StatusCode> {
|
||
let rows = sqlx::query("DELETE FROM cdn_objects WHERE key = $1")
|
||
.bind(&key)
|
||
.execute(&state.pool)
|
||
.await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||
.rows_affected();
|
||
if rows == 0 {
|
||
return Err(StatusCode::NOT_FOUND);
|
||
}
|
||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add pub mod cdn to src/routes/mod.rs**
|
||
|
||
```rust
|
||
pub mod admin;
|
||
pub mod auth;
|
||
pub mod cdn;
|
||
pub mod crud;
|
||
|
||
pub fn is_valid_identifier(name: &str) -> bool {
|
||
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify compilation**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo build 2>&1 | tail -3
|
||
```
|
||
Expected: `Finished dev`
|
||
|
||
---
|
||
|
||
### Task 6: reload_cors Hook + Protected Tables
|
||
|
||
**Files:**
|
||
- Modify: `src/routes/crud.rs`
|
||
- Modify: `src/routes/admin/tables.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `AppState.cors_cache: CorsCache`
|
||
- Produces: `reload_cors` called after every non-GET mutation to `cors_origins` table
|
||
|
||
- [ ] **Step 1: Add reload_cors to src/routes/crud.rs**
|
||
|
||
Add after the existing `reload_blacklist` function (around line 193):
|
||
|
||
```rust
|
||
async fn reload_cors(state: &AppState) -> Result<(), StatusCode> {
|
||
let origins: Vec<String> = sqlx::query_scalar::<_, String>(
|
||
"SELECT origin FROM cors_origins ORDER BY id",
|
||
)
|
||
.fetch_all(&state.pool)
|
||
.await
|
||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||
state.cors_cache.load(origins).await;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add cors_origins hook in handle_crud**
|
||
|
||
Find the block starting at line 335 (the blacklist reload hook) and update it to:
|
||
|
||
```rust
|
||
// Reload in-memory caches after mutations to their backing tables.
|
||
if table == "blacklist" && method_str != "GET" {
|
||
reload_blacklist(&state).await?;
|
||
}
|
||
if table == "cors_origins" && method_str != "GET" {
|
||
reload_cors(&state).await?;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add cors_origins + cdn_objects to PROTECTED_TABLES in src/routes/admin/tables.rs**
|
||
|
||
Find line 27:
|
||
```rust
|
||
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
|
||
```
|
||
Replace with:
|
||
```rust
|
||
const PROTECTED_TABLES: &[&str] = &[
|
||
"users", "blacklist", "api_keys", "queries", "permissions",
|
||
"cors_origins", "cdn_objects",
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo test 2>&1 | tail -5
|
||
```
|
||
Expected: all tests pass.
|
||
|
||
---
|
||
|
||
### Task 7: main.rs Wiring
|
||
|
||
**Files:**
|
||
- Modify: `src/main.rs`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CorsCache`, `cors_layer`, `cdn_list`, `cdn_proxy`, `cdn_create`, `cdn_update`, `cdn_delete`
|
||
- Produces: running server with dynamic CORS + CDN proxy routes
|
||
|
||
- [ ] **Step 1: Update imports in src/main.rs**
|
||
|
||
Replace the current imports block with:
|
||
|
||
```rust
|
||
mod auth;
|
||
mod cache;
|
||
mod config;
|
||
mod db;
|
||
mod models;
|
||
mod routes;
|
||
mod state;
|
||
|
||
use std::io::{self, Write};
|
||
use std::sync::Arc;
|
||
|
||
use axum::{
|
||
extract::DefaultBodyLimit,
|
||
middleware,
|
||
routing::{delete, get, post, put},
|
||
Router,
|
||
};
|
||
use tower_http::services::{ServeDir, ServeFile};
|
||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||
|
||
use crate::{
|
||
auth::middleware::{blacklist_layer, cors_layer, require_auth},
|
||
cache::spawn_sweep_task,
|
||
config::Config,
|
||
db::create_pool,
|
||
models::blacklist::BlacklistEntry,
|
||
routes::{
|
||
admin::admin_router,
|
||
auth::login,
|
||
cdn::{cdn_create, cdn_delete, cdn_list, cdn_proxy, cdn_update},
|
||
crud::handle_crud,
|
||
},
|
||
state::{AppState, BlacklistCache, CorsCache, QueryCache},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Remove build_cors function**
|
||
|
||
Delete the entire `fn build_cors(origins: &[String]) -> CorsLayer` function (lines 35–53 in the original file).
|
||
|
||
- [ ] **Step 3: Add cors_origins seeding + cors_cache + http_client after the blacklist load block**
|
||
|
||
Find the existing blacklist load block (around line 127) and add after `blacklist_cache.load(entries).await;`:
|
||
|
||
```rust
|
||
// Load CORS origins; seed from CORS_ORIGINS env var if table is empty.
|
||
let cors_count: i64 = sqlx::query_scalar::<_, Option<i64>>("SELECT COUNT(*) FROM cors_origins")
|
||
.fetch_one(&pool)
|
||
.await?
|
||
.unwrap_or(0);
|
||
if cors_count == 0 && !config.cors_origins.is_empty() {
|
||
for origin in &config.cors_origins {
|
||
sqlx::query(
|
||
"INSERT INTO cors_origins (origin) VALUES ($1) ON CONFLICT DO NOTHING",
|
||
)
|
||
.bind(origin)
|
||
.execute(&pool)
|
||
.await?;
|
||
}
|
||
tracing::info!(
|
||
"seeded {} CORS origin(s) from CORS_ORIGINS env var",
|
||
config.cors_origins.len()
|
||
);
|
||
}
|
||
let cors_origins: Vec<String> =
|
||
sqlx::query_scalar::<_, String>("SELECT origin FROM cors_origins ORDER BY id")
|
||
.fetch_all(&pool)
|
||
.await?;
|
||
let cors_cache = CorsCache::new();
|
||
cors_cache.load(cors_origins).await;
|
||
|
||
let http_client = reqwest::Client::new();
|
||
```
|
||
|
||
- [ ] **Step 4: Update AppState construction**
|
||
|
||
Find `let state = AppState { ... }` and update to:
|
||
|
||
```rust
|
||
let state = AppState {
|
||
pool,
|
||
query_cache,
|
||
blacklist_cache,
|
||
cors_cache,
|
||
http_client,
|
||
config: config.clone(),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Update router construction**
|
||
|
||
Replace the entire `let crud_routes = ...`, `let cors_layer = ...`, and `let app = ...` block with:
|
||
|
||
```rust
|
||
let crud_routes = Router::new()
|
||
.route("/api/:table", get(handle_crud).post(handle_crud))
|
||
.route("/api/:table/", get(handle_crud).post(handle_crud))
|
||
.route(
|
||
"/api/:table/:id",
|
||
get(handle_crud).put(handle_crud).delete(handle_crud),
|
||
)
|
||
.route(
|
||
"/api/:table/:id/",
|
||
get(handle_crud).put(handle_crud).delete(handle_crud),
|
||
)
|
||
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
|
||
|
||
// CDN routes: GET is public; POST/PUT/DELETE are blacklisted for non-super-admins.
|
||
let cdn_routes = Router::new()
|
||
.route("/api/cdn", get(cdn_list).post(cdn_create))
|
||
.route("/api/cdn/:key", get(cdn_proxy).put(cdn_update).delete(cdn_delete))
|
||
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
|
||
|
||
let app = Router::new()
|
||
.route("/auth/login", post(login))
|
||
.merge(cdn_routes)
|
||
.merge(crud_routes)
|
||
.nest("/api/admin", admin_router(state.clone()))
|
||
.nest_service(
|
||
"/",
|
||
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
|
||
)
|
||
.layer(middleware::from_fn_with_state(state.clone(), cors_layer))
|
||
.with_state(state);
|
||
```
|
||
|
||
Note: `cdn_routes` is merged before `crud_routes` so `/api/cdn` literal paths take precedence over the `/api/:table` parameterised CRUD routes.
|
||
|
||
- [ ] **Step 6: Run full test suite**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury && cargo test 2>&1 | tail -5
|
||
```
|
||
Expected: all tests pass.
|
||
|
||
---
|
||
|
||
### Task 8: Spinner CSS
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/assets/main.css`
|
||
|
||
**Interfaces:**
|
||
- Produces: `.loading-overlay` and `.loading-spinner` classes available globally to all Vue views
|
||
|
||
- [ ] **Step 1: Append spinner styles to ui/src/assets/main.css**
|
||
|
||
Add at the end of the file:
|
||
|
||
```css
|
||
/* ── Loading spinner ─────────────────────────────────────── */
|
||
@keyframes mercury-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 1.25rem;
|
||
height: 1.25rem;
|
||
border: 2px solid var(--border);
|
||
border-top-color: var(--primary);
|
||
border-radius: 50%;
|
||
animation: mercury-spin 0.7s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.loading-overlay {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.6rem;
|
||
padding: 3rem 2rem;
|
||
color: var(--text-muted);
|
||
font-size: 0.85rem;
|
||
font-family: var(--font-sans);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Loading State — Existing Admin Views
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/views/admin/Blacklist.vue`
|
||
- Modify: `ui/src/views/admin/ApiKeys.vue`
|
||
- Modify: `ui/src/views/admin/Cache.vue`
|
||
- Modify: `ui/src/views/admin/Queries.vue`
|
||
- Modify: `ui/src/views/admin/Tables.vue`
|
||
- Modify: `ui/src/views/admin/Users.vue`
|
||
- Modify: `ui/src/views/admin/Permissions.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `.loading-overlay` + `.loading-spinner` from `main.css`
|
||
|
||
---
|
||
|
||
#### 9a — Blacklist.vue
|
||
|
||
- [ ] **Step 1: Add loading ref to script**
|
||
|
||
In the `<script setup>` block, after `const form = ref(...)`, add:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Wrap load() with loading state**
|
||
|
||
Replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
|
||
entries.value = await res.json()
|
||
}
|
||
```
|
||
With:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
|
||
entries.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
In the `<div class="table-card">`, insert the loading overlay immediately after `<div class="table-card-header">...</div>`:
|
||
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
```
|
||
|
||
Also change `<template v-if="entries.length">` to `<template v-else-if="entries.length">` and `<div class="empty-state" v-else>` stays as-is (it will only show if not loading and no entries).
|
||
|
||
---
|
||
|
||
#### 9b — ApiKeys.vue
|
||
|
||
- [ ] **Step 1: Add loading ref**
|
||
|
||
In `<script setup>`, after `const form = ref(...)`, add:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Wrap load()**
|
||
|
||
Replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
|
||
keys.value = await res.json()
|
||
}
|
||
```
|
||
With:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
|
||
keys.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
After `<div class="table-card-header">...</div>`, insert:
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
```
|
||
Change `<template v-if="keys.length">` → `<template v-else-if="keys.length">`.
|
||
|
||
---
|
||
|
||
#### 9c — Cache.vue
|
||
|
||
- [ ] **Step 1: Add loading ref**
|
||
|
||
In `<script setup>`, add after `const stats = ref(...)`:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Wrap load()**
|
||
|
||
Replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
|
||
stats.value = await res.json()
|
||
}
|
||
```
|
||
With:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
|
||
stats.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
Change the existing template block:
|
||
```html
|
||
<div class="stats-grid" v-if="stats">
|
||
...
|
||
</div>
|
||
|
||
<div class="no-stats" v-else>
|
||
...
|
||
</div>
|
||
```
|
||
To:
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
|
||
<div class="stats-grid" v-else-if="stats">
|
||
...
|
||
</div>
|
||
|
||
<div class="no-stats" v-else>
|
||
...
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
#### 9d — Queries.vue
|
||
|
||
- [ ] **Step 1: Add loading ref**
|
||
|
||
In `<script setup>`, after `const editForm = ref(...)`, add:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Wrap load()**
|
||
|
||
Replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
|
||
queries.value = await res.json()
|
||
}
|
||
```
|
||
With:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
|
||
queries.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
After `<div class="table-card-header">...</div>`, insert:
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
```
|
||
Change `<template v-if="queries.length">` → `<template v-else-if="queries.length">`.
|
||
|
||
---
|
||
|
||
#### 9e — Tables.vue
|
||
|
||
- [ ] **Step 1: Add loading ref**
|
||
|
||
In `<script setup>`, after the existing `ref` declarations, add:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Find the load() function for tables and wrap it**
|
||
|
||
Find `async function load()` (the one that fetches `/api/admin/tables`) and replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
|
||
tables.value = await res.json()
|
||
}
|
||
```
|
||
With:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
|
||
tables.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
After `<div class="table-card-header">...</div>`, insert:
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
```
|
||
Change `<template v-if="tables.length">` → `<template v-else-if="tables.length">`.
|
||
|
||
---
|
||
|
||
#### 9f — Users.vue
|
||
|
||
- [ ] **Step 1: Add loading ref**
|
||
|
||
In `<script setup>`, after `const form = ref(...)`, add:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Wrap load()**
|
||
|
||
Replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/users', { headers: auth.authHeaders() })
|
||
users.value = (await res.json()).filter((u: any) => !u.password_hash)
|
||
}
|
||
```
|
||
|
||
Check the exact fetch URL by reading the file. Wrap it:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/users', { headers: auth.authHeaders() })
|
||
users.value = (await res.json()).filter((u: any) => !u.password_hash)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
After `<div class="table-card-header">...</div>`, insert:
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
```
|
||
Change `<template v-if="users.length">` → `<template v-else-if="users.length">`.
|
||
|
||
---
|
||
|
||
#### 9g — Permissions.vue
|
||
|
||
- [ ] **Step 1: Add loading ref**
|
||
|
||
In `<script setup>`, after `const form = ref(...)`, add:
|
||
|
||
```ts
|
||
const loading = ref(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Wrap load()**
|
||
|
||
Replace:
|
||
```ts
|
||
async function load() {
|
||
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
|
||
permissions.value = await res.json()
|
||
}
|
||
```
|
||
With:
|
||
```ts
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
|
||
permissions.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add spinner to template**
|
||
|
||
After `<div class="table-card-header">...</div>`, insert:
|
||
```html
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
```
|
||
Change `<template v-if="permissions.length">` → `<template v-else-if="permissions.length">`.
|
||
|
||
---
|
||
|
||
### Task 10: Cors.vue
|
||
|
||
**Files:**
|
||
- Create: `ui/src/views/admin/Cors.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /api/cors_origins`, `POST /api/cors_origins`, `DELETE /api/cors_origins/:id`
|
||
|
||
- [ ] **Step 1: Create ui/src/views/admin/Cors.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<div class="page-header">
|
||
<div class="page-title">
|
||
<h2>CORS Origins</h2>
|
||
<span class="subtitle">Allowed cross-origin request sources — matched against the request Origin header</span>
|
||
</div>
|
||
<NychButton @click="showCreate = true" label="+ Add Origin" />
|
||
</div>
|
||
|
||
<div class="table-card">
|
||
<div class="table-card-header">
|
||
<span class="count">{{ origins.length }} {{ origins.length === 1 ? 'origin' : 'origins' }}</span>
|
||
</div>
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
<template v-else-if="origins.length">
|
||
<div class="table-scroll">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Origin</th>
|
||
<th>Added</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="o in origins" :key="o.id">
|
||
<td><code>{{ o.origin }}</code></td>
|
||
<td class="date-cell">{{ fmtDate(o.created_at) }}</td>
|
||
<td class="actions-cell">
|
||
<NychButton size="small" severity="danger" @click="deleteOrigin(o.id)" label="Delete" />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
<div class="empty-state" v-else>
|
||
<span class="empty-icon">✦</span>
|
||
<span class="empty-label">No origins configured</span>
|
||
<span class="empty-hint">Cross-origin requests will be rejected until an origin is added.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<NychDialog v-model:visible="showCreate" header="Add CORS Origin" :modal="true" :draggable="false" style="width: min(480px, 95vw)">
|
||
<form @submit.prevent="submitCreate" class="dialog-form">
|
||
<div class="field">
|
||
<label>Origin</label>
|
||
<NychInputText v-model="form.origin" placeholder="https://app.example.com" fluid />
|
||
<p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
|
||
</div>
|
||
<NychButton type="submit" fluid label="Add Origin" :disabled="!form.origin || loading" />
|
||
</form>
|
||
</NychDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { useAuthStore } from '../../stores/auth'
|
||
|
||
const auth = useAuthStore()
|
||
const origins = ref<any[]>([])
|
||
const showCreate = ref(false)
|
||
const loading = ref(false)
|
||
const form = ref({ origin: '' })
|
||
|
||
function fmtDate(s: string) {
|
||
return new Date(s).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/cors_origins', { headers: auth.authHeaders() })
|
||
origins.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function submitCreate() {
|
||
loading.value = true
|
||
try {
|
||
await fetch('/api/cors_origins', {
|
||
method: 'POST',
|
||
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ origin: form.value.origin }),
|
||
})
|
||
showCreate.value = false
|
||
form.value = { origin: '' }
|
||
await load()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function deleteOrigin(id: number) {
|
||
if (!confirm('Remove this CORS origin?')) return
|
||
loading.value = true
|
||
try {
|
||
await fetch(`/api/cors_origins/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
||
await load()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.date-cell { color: var(--text-muted); font-size: 0.85rem; }
|
||
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Cdn.vue
|
||
|
||
**Files:**
|
||
- Create: `ui/src/views/admin/Cdn.vue`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /api/cdn`, `POST /api/cdn`, `PUT /api/cdn/:key`, `DELETE /api/cdn/:key`
|
||
|
||
- [ ] **Step 1: Create ui/src/views/admin/Cdn.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<div class="page-header">
|
||
<div class="page-title">
|
||
<h2>CDN Objects</h2>
|
||
<span class="subtitle">Object storage registry — keys proxied through the API to the local CDN</span>
|
||
</div>
|
||
<NychButton @click="showCreate = true" label="+ Add Object" />
|
||
</div>
|
||
|
||
<div class="table-card">
|
||
<div class="table-card-header">
|
||
<span class="count">{{ objects.length }} {{ objects.length === 1 ? 'object' : 'objects' }}</span>
|
||
</div>
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner"></div>
|
||
<span>Loading…</span>
|
||
</div>
|
||
<template v-else-if="objects.length">
|
||
<div class="table-scroll">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Key</th>
|
||
<th>URL</th>
|
||
<th>Content Type</th>
|
||
<th>Description</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="o in objects" :key="o.id">
|
||
<td><code>{{ o.key }}</code></td>
|
||
<td class="url-cell"><code>{{ o.url }}</code></td>
|
||
<td class="ct-cell">{{ o.content_type ?? '—' }}</td>
|
||
<td class="desc-cell">{{ o.description ?? '—' }}</td>
|
||
<td class="actions-cell">
|
||
<NychButton size="small" @click="openEdit(o)" label="Edit" />
|
||
<NychButton size="small" severity="danger" @click="deleteObject(o.key)" label="Delete" />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
<div class="empty-state" v-else>
|
||
<span class="empty-icon">▣</span>
|
||
<span class="empty-label">No CDN objects</span>
|
||
<span class="empty-hint">Add objects to expose local CDN assets through the API.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<NychDialog v-model:visible="showCreate" header="Add CDN Object" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
|
||
<form @submit.prevent="submitCreate" class="dialog-form">
|
||
<div class="field">
|
||
<label>Key</label>
|
||
<NychInputText v-model="form.key" placeholder="logo.png" fluid />
|
||
<p class="hint">Accessed at <code>/api/cdn/<key></code></p>
|
||
</div>
|
||
<div class="field">
|
||
<label>URL</label>
|
||
<NychInputText v-model="form.url" placeholder="http://cdn-service:9000/bucket/logo.png" fluid />
|
||
<p class="hint">Internal URL the API will proxy to.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label>Content Type <span class="optional">(optional)</span></label>
|
||
<NychInputText v-model="form.content_type" placeholder="image/png" fluid />
|
||
</div>
|
||
<div class="field">
|
||
<label>Description <span class="optional">(optional)</span></label>
|
||
<NychInputText v-model="form.description" placeholder="App logo" fluid />
|
||
</div>
|
||
<NychButton type="submit" fluid label="Add Object" :disabled="!form.key || !form.url || loading" />
|
||
</form>
|
||
</NychDialog>
|
||
|
||
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editKey}`" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
|
||
<form @submit.prevent="submitEdit" class="dialog-form">
|
||
<div class="field">
|
||
<label>Key</label>
|
||
<NychInputText v-model="editForm.key" fluid />
|
||
</div>
|
||
<div class="field">
|
||
<label>URL</label>
|
||
<NychInputText v-model="editForm.url" fluid />
|
||
</div>
|
||
<div class="field">
|
||
<label>Content Type <span class="optional">(optional)</span></label>
|
||
<NychInputText v-model="editForm.content_type" fluid />
|
||
</div>
|
||
<div class="field">
|
||
<label>Description <span class="optional">(optional)</span></label>
|
||
<NychInputText v-model="editForm.description" fluid />
|
||
</div>
|
||
<NychButton type="submit" fluid label="Save Changes" :disabled="loading" />
|
||
</form>
|
||
</NychDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { useAuthStore } from '../../stores/auth'
|
||
|
||
const auth = useAuthStore()
|
||
const objects = ref<any[]>([])
|
||
const showCreate = ref(false)
|
||
const showEdit = ref(false)
|
||
const editKey = ref('')
|
||
const loading = ref(false)
|
||
const form = ref({ key: '', url: '', content_type: '', description: '' })
|
||
const editForm = ref({ key: '', url: '', content_type: '', description: '' })
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetch('/api/cdn', { headers: auth.authHeaders() })
|
||
objects.value = await res.json()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function submitCreate() {
|
||
loading.value = true
|
||
try {
|
||
await fetch('/api/cdn', {
|
||
method: 'POST',
|
||
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
key: form.value.key,
|
||
url: form.value.url,
|
||
content_type: form.value.content_type || null,
|
||
description: form.value.description || null,
|
||
}),
|
||
})
|
||
showCreate.value = false
|
||
form.value = { key: '', url: '', content_type: '', description: '' }
|
||
await load()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function openEdit(o: any) {
|
||
editKey.value = o.key
|
||
editForm.value = {
|
||
key: o.key,
|
||
url: o.url,
|
||
content_type: o.content_type ?? '',
|
||
description: o.description ?? '',
|
||
}
|
||
showEdit.value = true
|
||
}
|
||
|
||
async function submitEdit() {
|
||
loading.value = true
|
||
try {
|
||
await fetch(`/api/cdn/${editKey.value}`, {
|
||
method: 'PUT',
|
||
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
key: editForm.value.key || null,
|
||
url: editForm.value.url || null,
|
||
content_type: editForm.value.content_type || null,
|
||
description: editForm.value.description || null,
|
||
}),
|
||
})
|
||
showEdit.value = false
|
||
await load()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function deleteObject(key: string) {
|
||
if (!confirm(`Delete CDN object "${key}"?`)) return
|
||
loading.value = true
|
||
try {
|
||
await fetch(`/api/cdn/${key}`, { method: 'DELETE', headers: auth.authHeaders() })
|
||
await load()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.url-cell { font-size: 0.8rem; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ct-cell { color: var(--text-muted); font-size: 0.85rem; font-family: var(--font-mono); }
|
||
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
|
||
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Router + Nav Updates
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/router/index.ts`
|
||
- Modify: `ui/src/views/admin/Layout.vue`
|
||
|
||
**Interfaces:**
|
||
- Produces: `/admin/cors` and `/admin/cdn` routed and visible in sidebar
|
||
|
||
- [ ] **Step 1: Add routes to ui/src/router/index.ts**
|
||
|
||
Replace the children array:
|
||
```ts
|
||
children: [
|
||
{ path: 'queries', component: () => import('../views/admin/Queries.vue') },
|
||
{ path: 'tables', component: () => import('../views/admin/Tables.vue') },
|
||
{ path: 'users', component: () => import('../views/admin/Users.vue') },
|
||
{ path: 'permissions', component: () => import('../views/admin/Permissions.vue') },
|
||
{ path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') },
|
||
{ path: 'api-keys', component: () => import('../views/admin/ApiKeys.vue') },
|
||
{ path: 'cache', component: () => import('../views/admin/Cache.vue') },
|
||
{ path: 'cors', component: () => import('../views/admin/Cors.vue') },
|
||
{ path: 'cdn', component: () => import('../views/admin/Cdn.vue') },
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 2: Add nav items to Layout.vue**
|
||
|
||
Replace the `navItems` array:
|
||
```ts
|
||
const navItems = [
|
||
{ to: "/admin/queries", label: "Queries", icon: "⌗" },
|
||
{ to: "/admin/tables", label: "Tables", icon: "▦" },
|
||
{ to: "/admin/users", label: "Users", icon: "◉" },
|
||
{ to: "/admin/permissions", label: "Permissions", icon: "⬡" },
|
||
{ to: "/admin/blacklist", label: "Blacklist", icon: "⊘" },
|
||
{ to: "/admin/api-keys", label: "API Keys", icon: "⚿" },
|
||
{ to: "/admin/cache", label: "Cache", icon: "◈" },
|
||
{ to: "/admin/cors", label: "CORS", icon: "✦" },
|
||
{ to: "/admin/cdn", label: "CDN Objects", icon: "▣" },
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 3: Build the frontend**
|
||
|
||
```bash
|
||
cd /home/mcpeakml/code/rust/Mercury/ui && bun run build 2>&1 | tail -5
|
||
```
|
||
Expected: `dist/` rebuilt successfully with no errors.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage check:**
|
||
- ✅ CORS origins stored in DB (`cors_origins` table) — Task 1
|
||
- ✅ CORS in-memory cache (`CorsCache`) — Task 2
|
||
- ✅ Custom async `cors_layer` middleware (replaces static `CorsLayer`) — Task 4
|
||
- ✅ CORS reload after CRUD mutation — Task 6
|
||
- ✅ CORS_ORIGINS env var seeds DB on first startup — Task 7
|
||
- ✅ CDN DB table (`cdn_objects`) — Task 1
|
||
- ✅ CDN proxy handler (`cdn_proxy`) — Task 5
|
||
- ✅ CDN CRUD handlers — Task 5
|
||
- ✅ CDN blacklist for mutations (POST/PUT/DELETE), GET unblocked — Task 1 + Task 7
|
||
- ✅ `cors_origins` + `cdn_objects` in PROTECTED_TABLES (drop-safe) — Task 6
|
||
- ✅ Spinner CSS — Task 8
|
||
- ✅ Loading state across 7 existing views — Task 9
|
||
- ✅ Cors.vue admin view — Task 10
|
||
- ✅ Cdn.vue admin view — Task 11
|
||
- ✅ Router + nav for both views — Task 12
|
||
|
||
**Type consistency check:**
|
||
- `CorsCache` defined in Task 2, consumed in Task 4 (middleware) and Task 6 (reload_cors) and Task 7 (AppState init) — consistent.
|
||
- `CdnObject`, `CreateCdnObject`, `UpdateCdnObject` defined in Task 3, consumed in Task 5 — consistent.
|
||
- `cdn_list`, `cdn_proxy`, `cdn_create`, `cdn_update`, `cdn_delete` defined in Task 5, imported in Task 7 — consistent.
|
||
- `cors_layer` defined in Task 4, imported in Task 7 — consistent.
|
||
- `AppState.http_client: reqwest::Client` added in Task 2, used in Task 5 (`state.http_client`) — consistent.
|
||
- Vue views use `GET /api/cdn` (not `/api/cdn_objects`) for list — matches CDN route defined in Task 7.
|
||
- Vue Cors.vue uses `GET /api/cors_origins` — matches CRUD route (blacklist allows super_admin GET via bypass_mask) — consistent.
|