Files
BarangaySystem/docs/tasks/cdn-relay-integration-next-steps.md
2026-06-06 18:43:00 +08:00

9.0 KiB

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

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

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:

'internal.cdn' => \App\Http\Middleware\InternalCdnAuth::class,

4. Routes — append to routes/web.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

# --- 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

# 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.