Phase 2: media upload pipeline
- cja_media table; POST /api/media/upload (admin-gated, multipart). - Images optimised with GD (downscale to 1600px, re-encode; WebP for transparency, JPG for photos). Video via ffmpeg (scale, compress, drop audio). - Stored in app/public/media/ (source tree) so uploads survive rebuilds and are copied into public/ on build. - requireAdmin() moved to PublicController base (fixes a static/non-static clash with AdminAuth). Type detection uses getimagesize (fileinfo ext absent). Note: php-fpm must be in the caweb group to write app/public/media (restart after adding www to caweb). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
This commit is contained in:
parent
b967419717
commit
dc69d1fd0d
5 changed files with 328 additions and 12 deletions
|
|
@ -30,6 +30,18 @@ class PublicController extends JsonController
|
|||
}
|
||||
|
||||
/** Guard helper: enforce origin + rate limit, or emit the error envelope and stop. */
|
||||
/**
|
||||
* Gate an admin-only endpoint. Emits 401 and stops unless the current
|
||||
* session is an authenticated admin (set by AdminAuth on login). Every
|
||||
* structured-content and media endpoint calls this first.
|
||||
*/
|
||||
protected function requireAdmin(): void
|
||||
{
|
||||
if (empty($_SESSION['admin']['ok'])) {
|
||||
$this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Admin login required']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function guardPublic(string $key, int $max = 60, int $window = 60): void
|
||||
{
|
||||
if (!$this->checkOrigin()) {
|
||||
|
|
|
|||
21
api/db/migrations/010_create_cja_media.sql
Normal file
21
api/db/migrations/010_create_cja_media.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Media library for the admin console.
|
||||
--
|
||||
-- Every uploaded image/video, optimised and stored under app/public/media/
|
||||
-- (source tree, so it survives rebuilds and is copied into public/ on build).
|
||||
-- Rows let the admin browse and reuse uploads rather than re-uploading.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cja_media` (
|
||||
`media_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`kind` enum('image','video') NOT NULL DEFAULT 'image',
|
||||
`url` varchar(255) NOT NULL, -- e.g. /media/ginny-goldman-a1b2.jpg
|
||||
`filename` varchar(255) NOT NULL,
|
||||
`mime` varchar(80) NOT NULL DEFAULT '',
|
||||
`width` int(10) unsigned DEFAULT NULL,
|
||||
`height` int(10) unsigned DEFAULT NULL,
|
||||
`bytes` int(10) unsigned DEFAULT NULL,
|
||||
`alt` varchar(255) NOT NULL DEFAULT '',
|
||||
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`media_id`),
|
||||
UNIQUE KEY `uq_cja_media_url` (`url`),
|
||||
KEY `idx_cja_media_feed` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
|
@ -19,18 +19,7 @@ use App\Controllers\PublicController;
|
|||
*/
|
||||
class AdminAuth extends PublicController
|
||||
{
|
||||
/** Guard for privileged console endpoints. Emits 401 and stops if not admin. */
|
||||
public static function requireAdmin(): void
|
||||
{
|
||||
if (empty($_SESSION['admin']['ok'])) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode(['ok' => false, 'data' => null, 'error' => [
|
||||
'code' => 'unauthorized', 'message' => 'Admin login required',
|
||||
]]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
// requireAdmin() is inherited from PublicController (non-static).
|
||||
|
||||
private function logAttempt(string $action, string $detail): void
|
||||
{
|
||||
|
|
|
|||
168
api/public/controllers/media.php
Normal file
168
api/public/controllers/media.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
use App\Controllers\PublicController;
|
||||
|
||||
/**
|
||||
* Media uploads for the admin console.
|
||||
*
|
||||
* POST /api/media/upload (multipart: file=<image|video>, alt?) -> { url, ... }
|
||||
* GET /api/media/list -> recent uploads
|
||||
*
|
||||
* Admin-gated. Images are optimised with GD (downscaled, re-encoded); videos
|
||||
* are passed through ffmpeg (scaled, compressed, audio dropped). Everything is
|
||||
* written to app/public/media/ — the SOURCE tree — so it survives rebuilds and
|
||||
* is copied into public/ on the next build. A cja_media row is recorded.
|
||||
*/
|
||||
class Media extends PublicController
|
||||
{
|
||||
private const MAX_IMAGE_W = 1600; // downscale wider images
|
||||
private const MAX_VIDEO_W = 900;
|
||||
private const MEDIA_DIR = '/www/wwwroot/CarlosAriasPersonal/app/public/media';
|
||||
|
||||
private const IMAGE_MIME = [
|
||||
'image/jpeg' => 'jpg', 'image/png' => 'png',
|
||||
'image/webp' => 'webp', 'image/gif' => 'gif',
|
||||
];
|
||||
private const VIDEO_MIME = ['video/mp4' => 'mp4', 'video/quicktime' => 'mp4'];
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$this->guardPublic('media_upload', 60, 300);
|
||||
|
||||
if (empty($_FILES['file']) || ($_FILES['file']['error'] ?? 1) !== UPLOAD_ERR_OK) {
|
||||
$this->json(null, 400, ['code' => 'no_file', 'message' => 'No file received.']);
|
||||
}
|
||||
|
||||
$tmp = $_FILES['file']['tmp_name'];
|
||||
$orig = $_FILES['file']['name'];
|
||||
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||
$alt = mb_substr(trim((string) ($_POST['alt'] ?? '')), 0, 255);
|
||||
|
||||
if (!is_dir(self::MEDIA_DIR)) {
|
||||
@mkdir(self::MEDIA_DIR, 0775, true);
|
||||
}
|
||||
|
||||
$base = $this->slug(pathinfo($orig, PATHINFO_FILENAME));
|
||||
$rand = substr(bin2hex(random_bytes(4)), 0, 6);
|
||||
|
||||
// Detect type without fileinfo: getimagesize() covers images (and gives
|
||||
// the mime); video is recognised by extension and validated by ffmpeg.
|
||||
$info = @getimagesize($tmp);
|
||||
if ($info && !empty($info['mime']) && isset(self::IMAGE_MIME[$info['mime']])) {
|
||||
$result = $this->handleImage($tmp, $base, $rand, $info['mime']);
|
||||
} elseif (in_array($ext, ['mp4', 'mov', 'm4v'], true)) {
|
||||
$result = $this->handleVideo($tmp, $base, $rand);
|
||||
} else {
|
||||
$this->json(null, 415, ['code' => 'bad_type', 'message' => 'Only images (JPG, PNG, WebP, GIF) and MP4 video are allowed.']);
|
||||
}
|
||||
|
||||
\Db::insert('cja_media', [
|
||||
'kind' => $result['kind'],
|
||||
'url' => $result['url'],
|
||||
'filename' => $result['filename'],
|
||||
'mime' => $result['mime'],
|
||||
'width' => $result['width'] ?? null,
|
||||
'height' => $result['height'] ?? null,
|
||||
'bytes' => $result['bytes'] ?? null,
|
||||
'alt' => $alt,
|
||||
]);
|
||||
|
||||
$this->json($result + ['alt' => $alt]);
|
||||
}
|
||||
|
||||
public function list(): void
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$rows = \Db::select('SELECT kind, url, filename, width, height, alt, created_at
|
||||
FROM cja_media ORDER BY created_at DESC LIMIT 200');
|
||||
$this->json(['media' => $rows]);
|
||||
}
|
||||
|
||||
// ---- image: GD downscale + re-encode ------------------------------------
|
||||
private function handleImage(string $tmp, string $base, string $rand, string $mime): array
|
||||
{
|
||||
// GIFs (often animated) pass through untouched; others are re-encoded.
|
||||
if ($mime === 'image/gif') {
|
||||
$name = "{$base}-{$rand}.gif";
|
||||
$dest = self::MEDIA_DIR . "/{$name}";
|
||||
copy($tmp, $dest);
|
||||
@chmod($dest, 0664);
|
||||
[$w, $h] = @getimagesize($dest) ?: [null, null];
|
||||
return ['kind' => 'image', 'url' => "/media/{$name}", 'filename' => $name,
|
||||
'mime' => 'image/gif', 'width' => $w, 'height' => $h, 'bytes' => filesize($dest)];
|
||||
}
|
||||
|
||||
$src = match ($mime) {
|
||||
'image/jpeg' => imagecreatefromjpeg($tmp),
|
||||
'image/png' => imagecreatefrompng($tmp),
|
||||
'image/webp' => imagecreatefromwebp($tmp),
|
||||
};
|
||||
if (!$src) {
|
||||
$this->json(null, 422, ['code' => 'decode_failed', 'message' => 'Could not read that image.']);
|
||||
}
|
||||
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
if ($w > self::MAX_IMAGE_W) {
|
||||
$nw = self::MAX_IMAGE_W;
|
||||
$nh = (int) round($h * ($nw / $w));
|
||||
$dst = imagecreatetruecolor($nw, $nh);
|
||||
// preserve alpha for png/webp
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
||||
imagedestroy($src);
|
||||
$src = $dst;
|
||||
$w = $nw; $h = $nh;
|
||||
}
|
||||
|
||||
// JPG output for photos; keep PNG/WebP for graphics with transparency.
|
||||
$hasAlpha = in_array($mime, ['image/png', 'image/webp'], true);
|
||||
$ext = $hasAlpha ? 'webp' : 'jpg';
|
||||
$name = "{$base}-{$rand}.{$ext}";
|
||||
$dest = self::MEDIA_DIR . "/{$name}";
|
||||
|
||||
if ($ext === 'webp') {
|
||||
imagewebp($src, $dest, 82);
|
||||
} else {
|
||||
imagejpeg($src, $dest, 82);
|
||||
}
|
||||
imagedestroy($src);
|
||||
@chmod($dest, 0664);
|
||||
|
||||
return ['kind' => 'image', 'url' => "/media/{$name}", 'filename' => $name,
|
||||
'mime' => $ext === 'webp' ? 'image/webp' : 'image/jpeg',
|
||||
'width' => $w, 'height' => $h, 'bytes' => filesize($dest)];
|
||||
}
|
||||
|
||||
// ---- video: ffmpeg scale + compress -------------------------------------
|
||||
private function handleVideo(string $tmp, string $base, string $rand): array
|
||||
{
|
||||
$name = "{$base}-{$rand}.mp4";
|
||||
$dest = self::MEDIA_DIR . "/{$name}";
|
||||
|
||||
// scale down to <= MAX_VIDEO_W wide (keep even dims), drop audio, web-optimise
|
||||
$cmd = sprintf(
|
||||
'ffmpeg -y -v error -i %s -vf "scale=%d:-2:flags=lanczos" -an ' .
|
||||
'-c:v libx264 -preset veryfast -crf 30 -pix_fmt yuv420p -movflags +faststart %s',
|
||||
escapeshellarg($tmp), self::MAX_VIDEO_W, escapeshellarg($dest)
|
||||
);
|
||||
exec($cmd, $out, $code);
|
||||
if ($code !== 0 || !file_exists($dest)) {
|
||||
$this->json(null, 422, ['code' => 'video_failed', 'message' => 'Could not process that video.']);
|
||||
}
|
||||
@chmod($dest, 0664);
|
||||
|
||||
return ['kind' => 'video', 'url' => "/media/{$name}", 'filename' => $name,
|
||||
'mime' => 'video/mp4', 'width' => null, 'height' => null, 'bytes' => filesize($dest)];
|
||||
}
|
||||
|
||||
private function slug(string $s): string
|
||||
{
|
||||
$s = strtolower(trim($s));
|
||||
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
|
||||
$s = trim($s, '-');
|
||||
return $s !== '' ? mb_substr($s, 0, 40) : 'file';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,130 @@
|
|||
[
|
||||
{
|
||||
"id": "err_6a629906221e95.58240486",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/media\/upload",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/media.php",
|
||||
"line": 39,
|
||||
"fullerror": "Call to undefined function mime_content_type()",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(227): Media->upload()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#4 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:43:18"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6298f253b530.01848186",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "Exception",
|
||||
"errorcode": "1",
|
||||
"url": "https:\/\/carlosarias.co\/api\/media\/upload",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/media.php",
|
||||
"line": 39,
|
||||
"fullerror": "Call to undefined function mime_content_type()",
|
||||
"trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(227): Media->upload()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(64): Bootstrap->_callControllerMethod()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#4 {main}",
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:42:58"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6298d60d6d97.99170107",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "E_COMPILE_ERROR",
|
||||
"errorcode": "64",
|
||||
"url": "http:\/\/carlosarias.co",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php",
|
||||
"line": 23,
|
||||
"fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth",
|
||||
"trace": null,
|
||||
"method": "GET",
|
||||
"ip_address": null,
|
||||
"user_agent": null,
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:42:30"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6298be759dd9.23504396",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "E_COMPILE_ERROR",
|
||||
"errorcode": "64",
|
||||
"url": "https:\/\/carlosarias.co\/api\/adminauth\/me",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php",
|
||||
"line": 23,
|
||||
"fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth",
|
||||
"trace": null,
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:42:06"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6298acccfc65.81224266",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "E_COMPILE_ERROR",
|
||||
"errorcode": "64",
|
||||
"url": "https:\/\/carlosarias.co\/api\/adminauth\/me",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php",
|
||||
"line": 23,
|
||||
"fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth",
|
||||
"trace": null,
|
||||
"method": "GET",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:41:48"
|
||||
},
|
||||
{
|
||||
"id": "err_6a6298acc52754.63468900",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "E_COMPILE_ERROR",
|
||||
"errorcode": "64",
|
||||
"url": "https:\/\/carlosarias.co\/api\/adminauth\/login",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php",
|
||||
"line": 23,
|
||||
"fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth",
|
||||
"trace": null,
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:41:48"
|
||||
},
|
||||
{
|
||||
"id": "err_6a62989570c429.43724336",
|
||||
"userid": null,
|
||||
"org_id": null,
|
||||
"errortype": "E_COMPILE_ERROR",
|
||||
"errorcode": "64",
|
||||
"url": "https:\/\/carlosarias.co\/api\/adminauth\/login",
|
||||
"file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/adminauth.php",
|
||||
"line": 23,
|
||||
"fullerror": "Cannot make non static method App\\Controllers\\PublicController::requireAdmin() static in class AdminAuth",
|
||||
"trace": null,
|
||||
"method": "POST",
|
||||
"ip_address": "207.246.79.95",
|
||||
"user_agent": "curl\/8.18.0",
|
||||
"referer": null,
|
||||
"status": "unresolved",
|
||||
"createdate": "2026-07-23 18:41:25"
|
||||
},
|
||||
{
|
||||
"id": "err_6a627fa4393a50.82526004",
|
||||
"userid": null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue