initial: bootstrap from BukidBountyApp base
This commit is contained in:
261
docs/tasks/cdn-relay-integration-next-steps.md
Normal file
261
docs/tasks/cdn-relay-integration-next-steps.md
Normal 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.
|
||||
Reference in New Issue
Block a user