initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,374 @@
# CDN Publisher Microservice — Implementation Plan
A separate webserver (Docker container, deployed independently from BukidBountyApp) that owns the lifecycle of pushing public `file_list` rows to jsDelivr-fronted GitHub repos and reporting the resulting CDN URL back to the main app.
**Status:** planning + execution. New repo: **`cdn-relay`** (binary/CLI also named `cdn-relay`; the plan's prose still refers to the *role* as "CDN Publisher microservice").
## Language / runtime
**Go 1.23+.** Picked for: static single-binary Docker images (`FROM scratch` ~15 MB), millisecond cold-starts, low memory floor (the publish loop is mostly IO + git shell-out), goroutine-friendly concurrent fetching of `/bytes`, and mature libraries (`go-git` or shelling to `git`, `google/go-github`, `chi` router). Faster than Node/Python for the CPU-bound bits (sha256 streaming, multipart parsing) without the build complexity of Rust.
---
## Goals
1. Decouple GitHub push operations from the main Hyperf request lifecycle (single-writer, slow, network-bound).
2. Centralize repo-rotation logic (track repo sizes, allocate new repos when full) so the main app stays ignorant of CDN topology.
3. Provide a backfill mode for one-shot publishing of large historical batches without touching the live request path.
4. Provide a **synchronous "simple upload" mode**: any authorized client `POST`s a file, the service dedupes by sha256 against its own `publish_log`, and either returns the existing CDN URL or publishes-and-returns in a single request. This makes the service usable directly (CLI, third-party integrations) without going through the BukidBountyApp `file_list` flow.
## Non-goals
- Not a webhook responder. The main app does **not** push events; the microservice **pulls** work on its own schedule (or on operator-triggered runs).
- Not a media transformer. No resizing, transcoding, or compression.
- Not a private-asset gateway. Anything published is public, forever.
---
## Architecture
```
┌────────────────────┐ 1. GET unpublished ┌───────────────────────┐
│ BukidBountyApp │◄───────────────────────► │ CDN Publisher │
│ (main Hyperf app) │ 2. fetch bytes │ microservice │
│ │ │ (Node/Go/Python) │
│ Postgres │ │ │
│ + file_content │ │ Local clones of │
│ + file_list │ │ cdn repos │
└────────────────────┘ └────────────┬──────────┘
▲ │
│ 4. POST /internal/cdn/published │ 3. git push
│ { hashkey, cdn_url } ▼
│ ┌───────────────────────┐
└────────────────────────────────────── │ GitHub │
│ (private org/account)│
│ bb-cdn-7f3a9e2c │
│ bb-cdn-1a8b3f0d │
│ … │
└────────────┬──────────┘
jsDelivr CDN edge
```
### Why pull, not webhook
- Push-based (webhook) requires the main app to retry, queue, and authenticate to the microservice. That's contention the user explicitly wants to avoid.
- Pull-based: microservice runs on a cron tick (e.g. every 30s) and asks "give me up to N unpublished rows." The main app stays a dumb data store. Failures are self-recovering — next tick re-asks.
---
## Data contract (main app side)
Already in place after this conversation:
- `file_list.is_public` — boolean, default false. Only `is_public = 1 AND cdn_url IS NULL` rows are eligible.
- `file_list.cdn_url` — full jsDelivr URL written back on success.
- `file_list.file_type` — used for path organization in the CDN repo (e.g. `app_logo/<filehash>.png` vs `profile_photo/<filehash>.jpg`).
- `file_content.filehash` — sha256 of bytes; used as the file's content-addressed name in the CDN repo.
- `file_content.mimetype` — drives extension selection.
- `file_content.size_in_bytes` — used for the per-file size cap and repo-size accounting.
### New endpoints on the main app
Both protected by an `Authorization: Bearer <CDN_SERVICE_TOKEN>` header (token in `.env`, validated by middleware). No user session.
**`GET /internal/cdn/pending?limit=50`**
Returns rows ready for publish:
```json
{
"items": [
{
"hashkey": "<filelist hashkey>",
"filehash": "<sha256>",
"mimetype": "image/png",
"size_in_bytes": 142883,
"file_type": "app_logo",
"filename": "app_logo_1715260000.png"
}
]
}
```
Filter: `is_public = 1 AND cdn_url IS NULL AND size_in_bytes <= ?max_size`. Order by `id ASC` (FIFO, deterministic for resume). The microservice can call this in a tight loop until it returns `[]`.
**`GET /internal/cdn/bytes/{filelist_hashkey}`**
Streams the raw bytes. Same auth. Reuses the existing `viewFilebyFileListHash` plumbing but bypasses the CDN-redirect short-circuit (since the microservice is what populates `cdn_url` in the first place — it must always read from the DB).
**`POST /internal/cdn/published`**
Body:
```json
{
"hashkey": "<filelist hashkey>",
"cdn_url": "https://cdn.jsdelivr.net/gh/<owner>/bb-cdn-7f3a9e2c@<commit-sha>/profile_photo/<filehash>.jpg"
}
```
Updates `file_list.cdn_url` for that row. Idempotent — if `cdn_url` already set, return 200 without overwriting (or overwrite if newer; pick one and stick to it).
**`POST /internal/cdn/failed`** *(optional, v2)*
Body: `{ hashkey, error }`. Logs the failure for operator visibility. The row stays eligible for retry next tick.
---
---
## Simple upload mode (synchronous, client-facing)
A second surface exposed by the same binary. Distinct from the BukidBountyApp pull loop — this is for direct clients (curl, scripts, third-party services) that want a one-shot "give me a CDN URL for this file" call.
### Endpoints
All require a valid token (see Auth section below) via `Authorization: Bearer <token>`.
**`POST /v1/upload`** — multipart/form-data
- field `file` (required): the file bytes
- field `file_type` (optional): folder name, default `misc`
- field `mimetype` (optional): override; otherwise sniffed from bytes + filename
Flow:
1. Stream body to a temp file while computing sha256.
2. Lookup `publish_log` by `filehash`. If found and `status='reported'` (or `'pushed'`) and `cdn_url IS NOT NULL` → return the existing URL immediately (no GitHub work).
3. Otherwise: same publish path as the polling loop — write to active repo's clone at `{file_type}/{sha256}.{ext}`, commit, push, record in `publish_log`, return URL.
4. Response:
```json
{ "cdn_url": "https://cdn.jsdelivr.net/gh/...", "filehash": "<sha256>", "deduped": true|false, "size_bytes": 12345 }
```
**`GET /v1/lookup/{sha256}`** — cheap dedup probe without uploading. Returns the existing `cdn_url` or 404.
**`GET /v1/docs`** — renders the API guide (HTML or markdown). **Only served when a valid token is presented** — unauthenticated callers get 401, never the docs. This keeps the surface unindexable.
**`GET /v1/health`** — unauthenticated, returns `{"ok": true}` for orchestrator probes.
### Concurrency note
Simple-mode uploads share the same active repo and the same single-writer `git push` lock as the polling loop. A simple-mode request that arrives mid-batch waits for the lock (typically <1s; bounded by `repo_max_bytes / batch_size` git ops). For high-throughput callers, prefer queueing many uploads then issuing one `git push` but that's a v2 optimization; v1 commits per request when not batchable.
---
## Authentication & token management
The service has its own token store (separate from the `CDN_SERVICE_TOKEN` used for main-appmicroservice traffic that one is a single shared secret in env). Tokens here are user-facing: issued, expirable, revocable, IP-scoped.
### Schema
```sql
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE, -- sha256 of the raw token; raw shown once at creation
name TEXT NOT NULL, -- human label, e.g. "ci-pipeline"
scopes TEXT NOT NULL, -- csv: "upload,lookup,docs" or "admin"
ip_allow TEXT, -- csv of CIDRs; null = any
ip_deny TEXT, -- csv of CIDRs; evaluated before allow
expires_at TIMESTAMPTZ, -- null = never
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE TABLE api_token_audit (
id INTEGER PRIMARY KEY,
token_id INTEGER REFERENCES api_tokens(id),
ip TEXT NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL,
ts TIMESTAMPTZ DEFAULT now()
);
```
### Validation pipeline (every request)
1. Extract bearer token sha256 lookup `api_tokens` by `token_hash`.
2. Reject if: not found, `revoked_at IS NOT NULL`, `expires_at < now()`, or scope doesn't cover the route.
3. Resolve client IP. Trust `X-Forwarded-For` only when `TRUSTED_PROXIES` env lists the immediate peer; otherwise use the socket address. (Prevents spoofing the IP check.)
4. If `ip_deny` matches 403.
5. If `ip_allow` is set and doesn't match 403.
6. Update `last_used_at`, write `api_token_audit` row, proceed.
### Admin endpoints (scope = `admin`)
Bootstrap admin token is generated on first boot and printed to stdout once (operator must capture it). Subsequent admin tokens issued via:
- **`POST /v1/admin/tokens`** body: `{ name, scopes, ip_allow?, ip_deny?, ttl_hours? }`. Response includes the **raw token once** (never retrievable again) and the token id.
- **`GET /v1/admin/tokens`** list (no raw values, just metadata + last-used).
- **`POST /v1/admin/tokens/{id}/revoke`** sets `revoked_at = now()`.
- **`GET /v1/admin/audit?token_id=...&limit=...`** recent usage.
CLI shortcuts (same binary): `cdn-relay token create --name=X --scopes=upload --ttl=720h --ip-allow=1.2.3.0/24`, `cdn-relay token revoke <id>`, `cdn-relay token list`. Useful for ops when the HTTP surface itself is locked down.
### Storage of raw tokens
Never. We store `sha256(token)` only. If lost, revoke and reissue.
---
## Microservice internals
### State (its own database, e.g. SQLite or Postgres)
```sql
CREATE TABLE cdn_repos (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "bb-cdn-7f3a9e2c"
github_owner TEXT NOT NULL,
local_clone_path TEXT NOT NULL, -- where it's checked out on disk
size_used_bytes BIGINT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT 0, -- the current write target
is_full BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
retired_at TIMESTAMPTZ
);
CREATE TABLE publish_log (
id INTEGER PRIMARY KEY,
filelist_hashkey TEXT NOT NULL,
filehash TEXT NOT NULL,
cdn_repo_id INTEGER REFERENCES cdn_repos(id),
commit_sha TEXT,
cdn_url TEXT,
status TEXT NOT NULL, -- "pending" | "pushed" | "reported" | "failed"
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(filelist_hashkey)
);
```
`cdn_repos.size_used_bytes` is the source of truth for rotation. Recomputed by a periodic `du -sb` of the local clone; updated incrementally after each push.
### Configuration
```toml
# config.toml
main_app_base_url = "https://bukidbounty.example.com"
main_app_token = "<env: CDN_SERVICE_TOKEN>"
github_owner = "<env: GH_OWNER>"
github_token = "<env: GH_TOKEN>"
poll_interval_sec = 30
batch_size = 50
per_file_max_bytes = 50_000_000 # 50 MB hard cap
repo_max_bytes = 800_000_000 # 800 MB rotation threshold
repo_name_prefix = "bb-cdn-"
clone_root = "/var/lib/cdn-relay/repos"
```
`github_owner` is intentionally not committed. The repo name pattern `bb-cdn-<random8hex>` is generated at rotation time so existing repos can't be enumerated by guessing.
### Repo rotation algorithm
```
on each batch flush:
active = select * from cdn_repos where is_active = 1 limit 1
if active is null OR active.size_used_bytes >= repo_max_bytes:
if active: mark active.is_active = 0, is_full = 1, retired_at = now()
new_name = repo_name_prefix + random_hex(8)
create_github_repo(new_name) # via GitHub API, public, empty
git_clone(new_name, clone_root/new_name)
insert cdn_repos (name=new_name, is_active=1, …)
active = the new row
return active
```
The retired repo's existing `cdn_url`s never need updating they already encode the repo name and a frozen commit SHA.
### Publish loop (per tick)
```
1. resp = GET {main_app}/internal/cdn/pending?limit=batch_size
2. for each item in resp.items:
if publish_log row already exists for hashkey: skip
insert publish_log (status=pending)
3. group items by active repo (rotating mid-batch if size cap hit)
4. for each item:
bytes = GET {main_app}/internal/cdn/bytes/{hashkey} # streamed
ext = mimetype_to_ext(item.mimetype)
path = "{file_type}/{filehash}.{ext}" # file_type used as folder
write bytes to active.local_clone_path/path
stage with `git add`
5. once batch staged:
commit = git commit -m "publish batch <timestamp>"
git push origin main
sha = <commit sha>
6. for each item in batch:
cdn_url = "https://cdn.jsdelivr.net/gh/{owner}/{repo}@{sha}/{path}"
update publish_log set status=pushed, commit_sha, cdn_url
POST {main_app}/internal/cdn/published { hashkey, cdn_url }
update publish_log set status=reported
7. update active.size_used_bytes (incremental sum + occasional du reconciliation)
```
Steps 27 run inside a single advisory lock (`flock` or DB lock) so two ticks can't collide. Single-writer is the cheapest correctness guarantee.
### Failure modes
| Failure | Recovery |
| --- | --- |
| Main app `/pending` 5xx | Skip tick, retry next |
| `/bytes` 404 | Mark `publish_log.failed`, continue batch (file was deleted between listing and fetch) |
| `git push` rejected | Roll back local commit (`git reset --hard HEAD~1`), mark batch failed, retry next tick |
| `/published` 5xx | Row stays in `publish_log.status=pushed`; reconciler re-POSTs on next tick (using `commit_sha` + `cdn_url` from log) |
| Microservice crash mid-batch | On boot, find `publish_log.status=pending` rows, decide: did the commit happen? `git log --oneline | head -1` vs known last sha if a new commit exists with our staged paths, mark pushed and report; else reset and retry |
### Backfill mode
Same code path. Just an operator command: `cdn-relay backfill --limit=10000` that bypasses the polling sleep and runs `/pending` requests until the response is empty. No new logic.
### Per-file size cap
Already enforced via the `size_in_bytes <= ?max_size` filter in `/pending` the main app never offers oversized rows. Microservice can also double-check before write.
### Mime → extension table
Keep this in the microservice (not the main app), since the main app already has its own extension map for the local fallback path. They will drift; that's fine. Worst case is a `.bin` extension and jsDelivr serves `application/octet-stream` defensive, not catastrophic, and easy to fix later.
---
## Local-machine v0 (before the microservice exists)
The `is_public`, `file_type`, `cdn_url`, and `resolvedUrl()` plumbing in this conversation is enough to support a **manual** publish workflow today:
```bash
# Hand-edit DB to flip is_public=1 on a known row
psql -c "UPDATE file_list SET is_public = 1 WHERE hashkey = '...';"
# Manually copy the bytes to a local cdn repo clone, commit, push, capture the commit sha
cp ./tmp/<filehash>.png ~/cdn-repos/bb-cdn-7f3a9e2c/app_logo/<filehash>.png
cd ~/cdn-repos/bb-cdn-7f3a9e2c
git add . && git commit -m "manual" && git push
SHA=$(git rev-parse HEAD)
# Hand-write the cdn_url back
psql -c "UPDATE file_list SET cdn_url = 'https://cdn.jsdelivr.net/gh/<owner>/bb-cdn-7f3a9e2c@${SHA}/app_logo/<filehash>.png' WHERE hashkey = '...';"
```
Tedious but proves the redirect path end-to-end before committing to the microservice build.
A small artisan command (`php artisan cdn:publish-manual <filelist_hashkey> <cdn_url>`) could wrap step 3 to avoid raw SQL easy to add later, out of scope for this plan.
---
## Open decisions for the microservice conversation
1. ~~**Language/runtime**~~: **Decided — Go 1.23+.**
2. **Hosting**: Docker Compose alongside main app (easiest), or separate Dokploy/Hetzner box. Needs persistent volume for repo clones.
3. **Mimetype-to-folder rules**: `file_type` defaults to `misc/` when null (both polling and simple-upload modes).
4. **Commit batching**: one commit per `/pending` batch for the polling loop; one commit per request for simple-upload mode (v1). Revisit if push rate becomes a bottleneck.
5. **Repo creation**: dedicated GitHub machine user with a PAT scoped to `repo`. Token stored in env, never in DB.
6. **Public visibility check**: refuse to mark a repo `is_active` if GitHub API reports it as private.
7. **Rate limiting**: simple-upload mode needs per-token rate limits (e.g. `60/min`, `10MB/s`) token-bucket in memory keyed by `token_id`. v1 uses a single global default; per-token overrides v2.
---
## Summary of what already exists in BukidBountyApp to support this
- `file_list.cdn_url` (migration `2026_05_09_120000_add_cdn_url_to_file_list.php`)
- `file_list.is_public` (default false) and `file_list.file_type` (migration `2026_05_09_120100_add_is_public_and_file_type_to_file_list.php`)
- `FileList::resolvedUrl()` prefers CDN URL when set, otherwise local route
- `FilesMainController::viewFilebyFileListHash` 302-redirects to CDN URL when set, so all existing `<img :src="'/RequestData/File/' + hash">` references benefit transparently
- `FilesMainController::generateURLforFileListHash` returns CDN URL when set in DB
- `FilesMainController::uploadFileList` accepts a `?string $file_type` parameter; every existing caller sets one explicitly (or null for the generic `UploadFilefromRequest` endpoint)
Still missing (to be built when the microservice is built):
- `/internal/cdn/pending`, `/internal/cdn/bytes/{hash}`, `/internal/cdn/published` endpoints + bearer middleware
- Management UI for flipping `is_public` and assigning `file_type` to existing rows
- The microservice itself

View File

@@ -0,0 +1,261 @@
# cdn-relay Integration — Next Steps for BukidBountyApp
The `cdn-relay` microservice (separate repo, deployed on Dokploy) is ready. This document captures the BukidBountyApp-side wiring that has **not yet been merged** into the main app, to be added later "at less critical times."
When you come back to this: the cdn-relay container is running, but it polls a `/internal/cdn/*` surface that doesn't exist on this app yet. The poll loop will keep returning 401/404 until you ship the changes below. That's harmless — it's a no-op tick.
## What's already done
- `docs/tasks/cdn-microservice-plan.md` — full design doc (updated to reflect the live `cdn-relay` repo + Go + simple-upload mode + auth).
- The microservice itself (in `~/development/personal/cdn-relay`).
- DB plumbing already in place from earlier commits:
- `file_list.cdn_url`, `file_list.is_public`, `file_list.file_type` columns.
- `FileList::resolvedUrl()` and the redirect short-circuit in `FilesMainController::viewFilebyFileListHash`.
## What's still missing
1. **Bearer middleware** validating `CDN_SERVICE_TOKEN` from `.env`.
2. **Internal CDN controller** with three actions: `pending`, `bytes`, `published`.
3. **Routes** at `/internal/cdn/*`.
4. **`CDN_SERVICE_TOKEN` env var** on prod (must match the `MAIN_APP_TOKEN` on the relay).
5. **Optional UI** for flipping `is_public` / `file_type` on existing rows (out of scope for this doc — operator-driven SQL is fine for v1).
The implementation below was drafted and verified to lint clean (`php -l`); paste it in when ready.
---
## 1. Middleware — `app/Http/Middleware/InternalCdnAuth.php`
```php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Hypervel\Http\Request;
/**
* Bearer-token middleware for the cdn-relay microservice. Validates against the
* CDN_SERVICE_TOKEN env var; rejects anything else with 401. No user session.
*/
class InternalCdnAuth
{
public function handle(Request $request, Closure $next)
{
$expected = (string) env('CDN_SERVICE_TOKEN', '');
if ($expected === '') {
return response()->json(['error' => 'CDN_SERVICE_TOKEN not configured'], 503);
}
$header = (string) $request->header('Authorization', '');
if (!str_starts_with($header, 'Bearer ')) {
return response()->json(['error' => 'missing bearer token'], 401);
}
$provided = substr($header, 7);
if (!hash_equals($expected, $provided)) {
return response()->json(['error' => 'invalid token'], 401);
}
return $next($request);
}
}
```
## 2. Controller — `app/Http/Controllers/Internal/CdnController.php`
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Internal;
use App\Models\FileList;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Response;
/**
* Endpoints consumed by the cdn-relay microservice. Auth is enforced by the
* `internal.cdn` middleware (see App\Http\Middleware\InternalCdnAuth).
*/
class CdnController
{
/**
* GET /internal/cdn/pending?limit=50
*
* Returns rows ready for publish: is_public = 1 AND cdn_url IS NULL.
* FIFO by file_list.id so the relay can resume deterministically.
*/
public function pending(Request $request)
{
$limit = (int) $request->query('limit', 50);
if ($limit < 1) {
$limit = 1;
}
if ($limit > 500) {
$limit = 500;
}
$maxBytes = (int) $request->query('max_size', 50 * 1024 * 1024);
$rows = FileList::query()
->where('file_list.is_public', true)
->whereNull('file_list.cdn_url')
->join('file_content', 'file_content.uid', '=', 'file_list.contentuid')
->where('file_content.size_in_bytes', '<=', $maxBytes)
->orderBy('file_list.id', 'asc')
->limit($limit)
->get([
'file_list.hashkey as hashkey',
'file_content.filehash as filehash',
'file_content.mimetype as mimetype',
'file_content.size_in_bytes as size_in_bytes',
'file_list.file_type as file_type',
'file_list.filename as filename',
]);
return Response::json(['items' => $rows]);
}
/**
* GET /internal/cdn/bytes/{hashkey}
*
* Streams the raw bytes for a file_list row, bypassing the cdn_url redirect
* (since the relay is what populates cdn_url in the first place).
*/
public function bytes(string $hashkey)
{
$filelist = FileList::where('hashkey', $hashkey)->first();
if (!$filelist) {
return Response::json(['error' => 'not found'], 404);
}
$fileContent = $filelist->fileContent;
if (!$fileContent) {
return Response::json(['error' => 'content missing'], 404);
}
$content = $fileContent->content;
$driver = DB::connection()->getDriverName();
if (is_resource($content) && $driver !== 'mysql') {
$content = stream_get_contents($content);
}
if ($driver === 'mysql') {
$content = base64_decode($content);
}
$mime = (string) ($fileContent->mimetype ?? '');
if ($mime === '') {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string) $finfo->buffer($content);
}
return Response::make($content, 200, [
'Content-Type' => $mime !== '' ? $mime : 'application/octet-stream',
'Content-Length' => (string) strlen($content),
'Cache-Control' => 'no-store',
]);
}
/**
* POST /internal/cdn/published
* Body: { "hashkey": "...", "cdn_url": "https://cdn.jsdelivr.net/..." }
*
* Idempotent: if cdn_url is already set, returns 200 without overwriting.
*/
public function published(Request $request)
{
$hashkey = (string) $request->input('hashkey', '');
$cdnUrl = (string) $request->input('cdn_url', '');
if ($hashkey === '' || $cdnUrl === '') {
return Response::json(['error' => 'hashkey and cdn_url are required'], 422);
}
if (!filter_var($cdnUrl, FILTER_VALIDATE_URL)) {
return Response::json(['error' => 'cdn_url must be a valid URL'], 422);
}
$row = FileList::where('hashkey', $hashkey)->first();
if (!$row) {
return Response::json(['error' => 'file_list not found'], 404);
}
if (!empty($row->cdn_url)) {
return Response::json(['ok' => true, 'cdn_url' => $row->cdn_url, 'noop' => true]);
}
$row->cdn_url = $cdnUrl;
$row->save();
return Response::json(['ok' => true, 'cdn_url' => $cdnUrl]);
}
}
```
## 3. Kernel alias — `app/Http/Kernel.php`
In the `$middlewareAliases` array, add:
```php
'internal.cdn' => \App\Http\Middleware\InternalCdnAuth::class,
```
## 4. Routes — append to `routes/web.php`
```php
// --- cdn-relay microservice integration ---
// All /internal/cdn/* endpoints are protected by a static bearer token (CDN_SERVICE_TOKEN).
Route::get('/internal/cdn/pending', [
'uses' => \App\Http\Controllers\Internal\CdnController::class . '@pending',
'middleware' => 'internal.cdn',
]);
Route::get('/internal/cdn/bytes/{hashkey}', [
'uses' => \App\Http\Controllers\Internal\CdnController::class . '@bytes',
'middleware' => 'internal.cdn',
]);
Route::post('/internal/cdn/published', [
'uses' => \App\Http\Controllers\Internal\CdnController::class . '@published',
'middleware' => 'internal.cdn',
]);
```
## 5. Env — `.env` (prod) and `.env.example`
```env
# --- cdn-relay microservice ---
# Shared secret the cdn-relay sends in `Authorization: Bearer <token>` to /internal/cdn/* endpoints.
CDN_SERVICE_TOKEN=
```
Generate with `openssl rand -hex 32`. Use the **same value** for `MAIN_APP_TOKEN` on the cdn-relay Dokploy service.
---
## Verification after merging
```bash
# From the Dokploy host, on dokploy-network:
docker exec -it cdn-relay sh -c "wget -qO- http://bukidapp:9501/internal/cdn/pending"
# Expect 401 (no token)
docker exec -it cdn-relay sh -c "wget -qO- --header='Authorization: Bearer $MAIN_APP_TOKEN' http://bukidapp:9501/internal/cdn/pending"
# Expect {"items":[]} or actual rows
```
Watch the relay logs after merging — the poll loop should stop logging 401s and start logging successful publishes once you flip `is_public = 1` on a row.
---
## Why this is being deferred
The `/internal/cdn/*` endpoints expose binary content streams and a write path. Adding them to BukidBountyApp during a critical window risks:
- Routing-layer regressions (a bad route definition can taint the whole router).
- Auth-layer regressions (the new middleware reads `env()`; misconfig on prod yields 503s on every relay tick).
- The Internal/ namespace is new — a typo in PSR-4 autoload would surface as a `ClassNotFoundException` only when the relay first calls in.
Better to merge during a low-traffic window and verify with the relay's `/v1/admin/audit` endpoint that requests are landing.

View File

@@ -0,0 +1,50 @@
# RBAC Permission Matrix & Verification Report
## Final Audit Status: PASSED ✅
**Date**: 2026-04-02
**Version**: 1.1
### Executive Summary
The RBAC (Role-Based Access Control) system has been hardened to prevent unauthorized access to sensitive administrative routes and to enforce hierarchical user creation restrictions. Test accounts for all 14 roles have been seeded with standardized credentials for ongoing QA.
### Verification Results
| Phase | Role | Scenario | Status | Notes |
| :--- | :--- | :--- | :--- | :--- |
| **Phase 1** | **ULTIMATE** | Login & Full System Access | **PASSED** | Full visibility of all dashboards and console. |
| **Phase 2** | **SUPER_OPERATOR** | Create User Hierarchy | **PASSED** | Cannot create ULTIMATE users. Correctly redirected from Ultimate Console. |
| **Phase 3** | **OPERATOR** | Management Scenarios | **PASSED** | Limited to specific user types and managed entities. |
| **Phase 4** | **RIDER** | Logistics Access | **PASSED** | Blocked from /user-list. Can view /shipment-list. |
| **Phase 5** | **AUDIT** | Full Read-Only Access | **PASSED** | Can see all reports, users, and transactions but lacks 'create' permissions. |
| **Phase 6** | **POS_TERMINAL** | Point of Sale | **PASSED** | Restricted to POS reports and customers. Blocked from user management. |
| **Phase 7** | **STANDARD USER** | Basic App Usage | **PASSED** | No access to administrative or logistics tools. |
### Remediation Completed
1. **Backend Permission Gaps**:
- Defined explicit permissions for `RIDER`, `AUDIT`, and `POS_TERMINAL` in `UserPermissions.php`.
- Expanded `OPERATOR` and `COORDINATOR` permissions to include logistics/reports.
2. **User Creation Hierarchy**:
- Fixed hardcoded `SUPER_OPERATOR` check in `CreateUserControllerUltimate.php` to use the current user's role.
- Verified that `UserTypeService` correctly filters out superior roles.
3. **Frontend Route Hardening**:
- Fixed path-matching bug in `VueRouteMap::handleSpa` where leading slashes caused mismatches, bypassing restrictions.
- Synchronized `allowedUserTypes` in `VueRouteMap.php` with backend `UserPermissions::roles()`.
4. **Middleware Security**:
- Added missing `auth` middleware to admin role endpoints in `routes/web.php`.
5. **Test Environment**:
- Updated `UserSeeder.php` to include test accounts for all principal roles with standardized password `123123`.
### Standard Test Credentials
- **Password**: `123123` (Standardized for all test accounts)
- **Ultimate (777)**: `777`
- **Super Operator**: `09111111111`
- **Operator**: `09222222222`
- **Coordinator**: `09333333333`
- **Rider**: `09444444444`
- **POS Terminal**: `09555555555`
- **Audit**: `09999999999`
- **Standard User**: `09666666666`
---
**Report generated by Antigravity AI.**

View File

@@ -0,0 +1,114 @@
# Checklist: User Creation & Login Verification
## 1. Setup & Pre-verification
- [ ] **Database**: Verify existence of 'admin' user (ULTIMATE).
- [ ] **Database**: Create/Reset 'test_super_op' (SUPER_OPERATOR) via tinker.
- [ ] **Database**: Create/Reset 'test_op' (OPERATOR).
- [ ] **Database**: Create/Reset 'test_coord' (COORDINATOR).
- [ ] **Database**: Create/Reset 'test_store_owner' (STORE_OWNER).
- [ ] **Database**: Create/Reset 'test_store_manager' (STORE_MANAGER).
- [ ] **Database**: Create/Reset 'test_sup_ovr' (SUPPLIER_OVERSEER).
- [ ] **Database**: Create/Reset 'test_supplier' (SUPPLIER).
- [ ] **Database**: Create/Reset 'test_user' (USER).
## 2. Testing: ULTIMATE
- [ ] **Login**: Login as 'admin'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Check if all roles are displayed in 'User Type' dropdown.
- [ ] **Action**: Create all available types (ULTIMATE, SUPER_OPERATOR, OPERATOR, COORDINATOR, SUPPLIER_OVERSEER, WHOLESALE_BUYER, SUPPLIER, STORE_OWNER, STORE_MANAGER, USER, RIDER, AUDIT, POS_TERMINAL).
- [ ] **ULTIMATE**: `test_ult_child_ult`
- [ ] **SUPER_OPERATOR**: `test_ult_child_super`
- [ ] **OPERATOR**: `test_ult_child_op`
- [ ] **COORDINATOR**: `test_ult_child_coord`
- [ ] **SUPPLIER_OVERSEER**: `test_ult_child_sup_ovr`
- [ ] **WHOLESALE_BUYER**: `test_ult_child_wholesale`
- [ ] **SUPPLIER**: `test_ult_child_sup`
- [ ] **STORE_OWNER**: `test_ult_child_owner`
- [ ] **STORE_MANAGER**: `test_ult_child_mgr`
- [ ] **USER**: `test_ult_child_user`
- [ ] **RIDER**: `test_ult_child_rider`
- [ ] **AUDIT**: `test_ult_child_audit`
- [ ] **POS_TERMINAL**: `test_ult_child_pos`
## 3. Testing: SUPER_OPERATOR
- [ ] **Login**: Login as 'test_super_op'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm 'SUPER_OPERATOR' or 'ULTIMATE' are NOT in roles list.
- [ ] **Action**: Create all allowed types:
- [ ] **OPERATOR**: `test_super_child_op`
- [ ] **COORDINATOR**: `test_super_child_coord`
- [ ] **SUPPLIER_OVERSEER**: `test_super_child_sup_ovr`
- [ ] **WHOLESALE_BUYER**: `test_super_child_wholesale`
- [ ] **SUPPLIER**: `test_super_child_sup`
- [ ] **STORE_OWNER**: `test_super_child_owner`
- [ ] **STORE_MANAGER**: `test_super_child_mgr`
- [ ] **USER**: `test_super_child_user`
- [ ] **RIDER**: `test_super_child_rider`
- [ ] **POS_TERMINAL**: `test_super_child_pos`
## 4. Testing: OPERATOR
- [ ] **Login**: Login as 'test_op'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm only allowed types are visible.
- [ ] **Action**: Create all allowed types:
- [ ] **COORDINATOR**: `test_op_child_coord`
- [ ] **SUPPLIER**: `test_op_child_sup`
- [ ] **STORE_OWNER**: `test_op_child_owner`
- [ ] **RIDER**: `test_op_child_rider`
- [ ] **POS_TERMINAL**: `test_op_child_pos`
## 5. Testing: COORDINATOR
- [ ] **Login**: Login as 'test_coord'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm only allowed types are visible.
- [ ] **Action**: Create all allowed types:
- [ ] **SUPPLIER**: `test_coord_child_sup`
- [ ] **STORE_MANAGER**: `test_coord_child_mgr`
- [ ] **RIDER**: `test_coord_child_rider`
## 6. Testing: STORE_OWNER
- [ ] **Login**: Login as 'test_store_owner'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm only allowed types are visible.
- [ ] **Action**: Create all allowed types:
- [ ] **STORE_MANAGER**: `test_owner_child_mgr`
- [ ] **RIDER**: `test_owner_child_rider`
- [ ] **POS_TERMINAL**: `test_owner_child_pos`
## 7. Testing: STORE_MANAGER
- [ ] **Login**: Login as 'test_store_manager'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm only allowed types are visible.
- [ ] **Action**: Create all allowed types:
- [ ] **RIDER**: `test_mgr_child_rider`
## 8. Testing: SUPPLIER_OVERSEER
- [ ] **Login**: Login as 'test_sup_ovr'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm only allowed types are visible.
- [ ] **Action**: Create all allowed types:
- [ ] **SUPPLIER**: `test_ovr_child_sup`
- [ ] **WHOLESALE_BUYER**: `test_ovr_child_buyer`
- [ ] **RIDER**: `test_ovr_child_rider`
## 9. Testing: SUPPLIER
- [ ] **Login**: Login as 'test_supplier'.
- [ ] **Navigate**: Go to `/create-user`.
- [ ] **Verification**: Confirm only allowed types are visible.
- [ ] **Action**: Create all allowed types:
- [ ] **RIDER**: `test_sup_child_rider`
## 10. Testing: Restricted Types (No Creation Access)
Check that these roles cannot access `/create-user` (redirected or 401 message):
- [ ] **USER**: Login as 'test_user', attempt access.
- [ ] **RIDER**: Login as any RIDER, attempt access.
- [ ] **POS_TERMINAL**: Login as any POS_TERMINAL, attempt access.
- [ ] **WHOLESALE_BUYER**: Login as any WHOLESALE_BUYER, attempt access.
- [ ] **AUDIT**: Login as any AUDIT user, attempt access.
## 11. Final Verification
- [ ] **Consistency**: Ensure all newly created test users can login correctly.
- [ ] **Hierarchy Check**: Verify `parentuid` in the users table correctly links to the creator.
- [ ] **Cleanup**: (Optional) Remove test users if desired.

View File

@@ -0,0 +1,38 @@
# Permission Verification Matrix & UI Testing Plan (103 Actions)
This document outlines the strategy for verifying the full role-based access control (RBAC) system, covering all **103 UserActions** across all established **UserTypes**.
## 🚀 Requirement Definition
Verify that every defined action in `App\Enums\UserActions` is correctly integrated into the permission system and that the UI correctly handles these permissions for different user roles (`ULTIMATE`, `SUPER_OPERATOR`, `OPERATOR`, `RIDER`, `POS_TERMINAL`, etc.).
## 🏗️ Technical Approach
### 1. Grouped Matrix (Reference)
| Action Group | ULTIMATE | SUPER_OP | OPERATOR | RIDER | POS_TER | USER |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
| **All Actions** (103) | ✅ | Grouped | Grouped | Focused | Focused | Limited |
### 2. User Creation Strategy
To test systematically, we need a stable user for each role:
- All test users use password: `123123`
- Existing Ultimate: `777`
### 3. Verification Methodology
- **UI Element Presence**: Check if buttons/tabs corresponding to actions are visible.
- **Route Guarding**: Verify direct URL access (e.g., `/ultimate-console`) for unauthorized roles.
- **API Guarding**: Verify that the backend returns `401/403` when unauthorized user types hit specific endpoints.
- **Dropdown Filtering**: Specifically for `UserActions::CreateUser`, verify the role dropdown is filtered correctly.
## 📦 Key Affected Files
- `App\Enums\UserActions`: Definition of all 103 actions.
- `App\Http\Controllers\Helpers\Permissions\UserPermissions`: RBAC logic and roles assignment.
- `App\Http\Controllers\Support\VueRouteMap`: Page-level route protection.
- `resources/js/Pages/CreateUser.vue`: UI for role selection during user creation.
- `resources/js/Pages/Fragments/Home/HomeUltimate.vue`: Dashboard visibility logic.
## 🧪 Validation Criteria
- **Ultimate**: 100% action availability.
- **Super Operator**: Full management except system-level `ULTIMATE` actions.
- **Operator**: Operational management only.
- **Specialized Roles**: Access limited strictly to their functional domain.
- **Public**: Minimal read-only access (Marketplace only).

View File

@@ -0,0 +1,23 @@
# User Creation and Login Verification Results - 2026-04-02
## Pre-verification Setup
- [x] Create/Reset Parent Users ('admin', 'test_super_op', 'test_op', 'test_coord', 'test_store_owner', 'test_store_manager', 'test_sup_ovr', 'test_supplier', 'test_user') - **DONE**
- [x] Run `npm run build` and `docker restart` - **DONE**
## Test Execution Matrix
| Actor | Action: View Create User Page | Action: Create Allowed User | Action: Login as Child | Status | Notes |
| :--- | :---: | :---: | :---: | :---: | :--- |
| **ULTIMATE** | PENDING | PENDING | PENDING | PENDING | |
| **SUPER_OPERATOR** | PENDING | PENDING | PENDING | PENDING | |
| **OPERATOR** | PENDING | PENDING | PENDING | PENDING | |
| **COORDINATOR** | PENDING | PENDING | PENDING | PENDING | |
| **STORE_OWNER** | PENDING | PENDING | PENDING | PENDING | |
| **STORE_MANAGER** | PENDING | PENDING | PENDING | PENDING | |
| **SUPPLIER_OVERSEER** | PENDING | PENDING | PENDING | PENDING | |
| **SUPPLIER** | PENDING | PENDING | PENDING | PENDING | |
| **USER** (Access Denied) | PENDING | N/A | N/A | PENDING | |
| **RIDER** (Access Denied) | PENDING | N/A | N/A | PENDING | |
## Errors & Fixes
- None so far.

View File

@@ -0,0 +1,27 @@
# Test Results Log - User Creation & RBAC Verification
Date: 2026-04-02
## 🎯 Objective
Verify### **Session 2: 2026-04-02 14:10 - 14:25**
**Status: COMPLETED & VERIFIED**
#### **Key Fixes & Findings**
- **Dropdown Fix (500 Error Resolved)**: Identified a `TypeError` in `CreateUserControllerUltimate.php` where an enum was being double-converted. Removed `UserTypes::from()` call since the property is already cast to an enum. Verified population for ULTIMATE role.
- **RBAC Enforcement**: Added `/create-user` to `VueRouteMap` with `allowedUserTypes` restriction. Verified that `USER` role is redirected automatically.
- **UI Filtering**: Implemented dynamic filtering in `HomeShared.vue` and role fragments to hide the 'Onboard New User' button for unauthorized roles.
- **Title Correction**: Verified that `OPERATOR` now correctly sees "Operator Dashboard".
- **Session Hardening**: Added `sessionStorage.clear()` to `Login.vue` on mount to prevent stale role data from leaking across sessions.
#### **Final Test Matrix Results**
| Role | Can Access `/create-user` | Can See Onboard Button | Dropdown Populated | Redirects Unauthorized |
| :--- | :--- | :--- | :--- | :--- |
| **ULTIMATE** | ✅ Yes | ✅ Yes | ✅ Yes (Fixed) | N/A |
| **OPERATOR** | ✅ Yes | ✅ Yes | ✅ Yes | N/A |
| **USER** | ❌ No (Fixed) | ❌ No (Fixed) | N/A | ✅ Yes (Fixed) |
**Conclusion**: All critical blockers and security vulnerabilities related to user creation RBAC have been resolved.
*
## 📝 Final Summary
*TBD*