feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\FlexObjects\Controllers;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Utils;
|
||||
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
|
||||
use Grav\Framework\Media\Interfaces\MediaInterface;
|
||||
use Grav\Framework\Psr7\Response;
|
||||
use Grav\Framework\Psr7\Stream;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Public, permission-aware proxy for Flex Object media.
|
||||
*
|
||||
* PROTOTYPE (see docs/specs/media-proxy.md).
|
||||
*
|
||||
* Flex Objects store their data file and their uploaded media in the same
|
||||
* folder under `user://data/<type>/<key>`. Historically the media was linked
|
||||
* with a direct `/user/data/...` URL, which means the webserver — not Grav —
|
||||
* decides who can read it, and a blanket deny on `user/data` breaks every
|
||||
* image (getgrav/grav#4129).
|
||||
*
|
||||
* This controller serves a single media file through PHP after resolving the
|
||||
* owning object and (optionally) checking its read ACL, so the data folder can
|
||||
* stay private while public media still loads. It is the retrieval half of the
|
||||
* "store in user://data, serve via a lightweight proxy" design.
|
||||
*
|
||||
* Route (registered in flex-objects.php, base configurable):
|
||||
* GET /flex-media/<type>/<key>/<filename>[?field=<field>]
|
||||
*/
|
||||
final class MediaProxyController
|
||||
{
|
||||
/** Media types we are willing to serve inline. Mirrors the .htaccess allow-list. */
|
||||
private const SERVEABLE = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico',
|
||||
'mp4', 'webm', 'ogg', 'ogv', 'mov', 'mp3', 'wav', 'm4a', 'flac', 'pdf',
|
||||
];
|
||||
|
||||
public function __construct(private readonly Grav $grav)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public proxy URL for a media file on an object.
|
||||
*
|
||||
* Templates use this instead of `medium.url` while the proxy is opt-in;
|
||||
* the core follow-up rewrites `Medium::url()` to emit it automatically.
|
||||
*/
|
||||
public static function url(FlexObjectInterface $object, string $filename, ?string $field = null): string
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$base = (string) $grav['config']->get('plugins.flex-objects.media_proxy.base', '/flex-media');
|
||||
|
||||
$path = rtrim($base, '/')
|
||||
. '/' . rawurlencode($object->getFlexType())
|
||||
. '/' . rawurlencode($object->getKey())
|
||||
. '/' . str_replace('%2F', '/', rawurlencode($filename));
|
||||
|
||||
if ($field !== null && $field !== '') {
|
||||
$path .= '?field=' . rawurlencode($field);
|
||||
}
|
||||
|
||||
return $grav['base_url'] . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and stream the requested media file.
|
||||
*
|
||||
* @return ResponseInterface 200/206 with the file, or 304/403/404.
|
||||
*/
|
||||
public function serve(
|
||||
string $type,
|
||||
string $key,
|
||||
string $filename,
|
||||
?string $field,
|
||||
ServerRequestInterface $request
|
||||
): ResponseInterface {
|
||||
$config = $this->grav['config'];
|
||||
|
||||
// Reject path traversal and hidden files outright.
|
||||
if ($filename === '' || str_contains($filename, '..') || str_starts_with($filename, '.')) {
|
||||
return $this->error(404);
|
||||
}
|
||||
|
||||
$extension = strtolower(Utils::pathinfo($filename, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, self::SERVEABLE, true)) {
|
||||
// Never let the proxy hand out data files, databases, keys, etc.
|
||||
return $this->error(404);
|
||||
}
|
||||
|
||||
$flex = $this->grav['flex'] ?? null;
|
||||
$object = $flex ? $flex->getObject($key, $type) : null;
|
||||
if (!$object instanceof FlexObjectInterface || !$object->exists()) {
|
||||
return $this->error(404);
|
||||
}
|
||||
|
||||
// Permission gate. Off by default — the proxy currently exists to keep a
|
||||
// single retrieval chokepoint, not to ACL-gate reads. When explicitly
|
||||
// enabled, only an explicit "no" blocks the file, so directories without
|
||||
// a read ACL keep behaving as public media.
|
||||
if ($config->get('plugins.flex-objects.media_proxy.authorize', false)) {
|
||||
$user = $this->grav['user'] ?? null;
|
||||
if ($object->isAuthorized('read', 'frontend', $user) === false) {
|
||||
return $this->error(403);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the media collection (field-scoped or the object's own media).
|
||||
$media = $field
|
||||
? (method_exists($object, 'getMediaField') ? $object->getMediaField($field) : null)
|
||||
: ($object instanceof MediaInterface ? $object->getMedia() : null);
|
||||
|
||||
$medium = $media[$filename] ?? null;
|
||||
if ($medium === null) {
|
||||
return $this->error(404);
|
||||
}
|
||||
|
||||
$filepath = $medium->get('filepath');
|
||||
if (!is_string($filepath) || !is_file($filepath)) {
|
||||
return $this->error(404);
|
||||
}
|
||||
|
||||
return $this->stream($filepath, $medium->get('mime') ?: null, $request, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file with caching, conditional-GET (304) and single-range (206) support.
|
||||
*/
|
||||
private function stream(string $filepath, ?string $mime, ServerRequestInterface $request, $config): ResponseInterface
|
||||
{
|
||||
$size = (int) filesize($filepath);
|
||||
$mtime = (int) filemtime($filepath);
|
||||
$etag = '"' . dechex($mtime) . '-' . dechex($size) . '"';
|
||||
$mime = $mime ?: (Utils::getMimeByExtension(Utils::pathinfo($filepath, PATHINFO_EXTENSION), 'application/octet-stream'));
|
||||
|
||||
$cacheControl = (string) $config->get('plugins.flex-objects.media_proxy.cache_control', 'public, max-age=604800');
|
||||
|
||||
$baseHeaders = [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => $cacheControl,
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
|
||||
'ETag' => $etag,
|
||||
'Accept-Ranges' => 'bytes',
|
||||
// Defense in depth: never let a served file be interpreted as a document.
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Content-Disposition' => 'inline; filename="' . basename($filepath) . '"',
|
||||
];
|
||||
|
||||
// Conditional GET — return 304 when the client's copy is still fresh.
|
||||
$ifNoneMatch = $request->getHeaderLine('If-None-Match');
|
||||
$ifModifiedSince = $request->getHeaderLine('If-Modified-Since');
|
||||
if (($ifNoneMatch !== '' && trim($ifNoneMatch) === $etag)
|
||||
|| ($ifModifiedSince !== '' && @strtotime($ifModifiedSince) >= $mtime)) {
|
||||
return new Response(304, $baseHeaders);
|
||||
}
|
||||
|
||||
// Single-range support (enough for video/audio seeking).
|
||||
$range = $request->getHeaderLine('Range');
|
||||
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m)) {
|
||||
$start = $m[1] === '' ? null : (int) $m[1];
|
||||
$end = $m[2] === '' ? null : (int) $m[2];
|
||||
if ($start === null && $end !== null) { // suffix range: last N bytes
|
||||
$start = max(0, $size - $end);
|
||||
$end = $size - 1;
|
||||
} else {
|
||||
$start ??= 0;
|
||||
$end = $end === null ? $size - 1 : min($end, $size - 1);
|
||||
}
|
||||
if ($start > $end || $start >= $size) {
|
||||
return new Response(416, $baseHeaders + ['Content-Range' => "bytes */$size"]);
|
||||
}
|
||||
|
||||
$length = $end - $start + 1;
|
||||
$fh = fopen($filepath, 'rb');
|
||||
fseek($fh, $start);
|
||||
$body = stream_get_contents($fh, $length);
|
||||
fclose($fh);
|
||||
|
||||
return new Response(206, $baseHeaders + [
|
||||
'Content-Range' => "bytes $start-$end/$size",
|
||||
'Content-Length' => (string) $length,
|
||||
], $body);
|
||||
}
|
||||
|
||||
// Full file — stream the resource so we don't buffer it all in memory.
|
||||
$body = Stream::create(fopen($filepath, 'rb'));
|
||||
|
||||
return new Response(200, $baseHeaders + ['Content-Length' => (string) $size], $body);
|
||||
}
|
||||
|
||||
private function error(int $code): ResponseInterface
|
||||
{
|
||||
$text = [403 => 'Forbidden', 404 => 'Not Found'][$code] ?? 'Error';
|
||||
|
||||
return new Response($code, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
], $text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user