3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-04-06 07:47:47 +00:00
Files
snipe-it/app/Http/Controllers/StorageProxyController.php
Guillaume Delbergue 1482abc8b9 feat: add PUBLIC_S3_PROXY option to serve public uploads through the app
When PUBLIC_S3_PROXY=true, public uploads (images, logos, avatars) are
served through a proxy controller instead of directly from S3. This
allows using a single fully private S3 bucket for all storage, with no
public ACLs or direct S3 URLs exposed to the browser.

The proxy streams files from the configured public disk with proper
cache headers (ETag, Last-Modified, Cache-Control). Disabled by default
for full backward compatibility.
2026-03-19 19:41:05 +01:00

75 lines
2.5 KiB
PHP

<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorageProxyController extends Controller
{
/**
* Proxy files from the "public" storage disk through the application.
*
* When PUBLIC_S3_PROXY is enabled, this serves files that would normally
* be accessed directly from S3 (images, logos, avatars, etc.), allowing
* a fully private S3 bucket setup.
*/
public function show(string $path): Response|StreamedResponse
{
$disk = Storage::disk('public');
// The S3 adapter includes the disk's root prefix in generated URLs,
// but Flysystem also prepends it internally on every operation.
// Strip it here to avoid double-prefixing.
$root = trim(config('filesystems.disks.public.root', ''), '/');
if ($root !== '' && str_starts_with($path, $root . '/')) {
$path = substr($path, strlen($root) + 1);
}
if (! $disk->exists($path)) {
abort(404);
}
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
$lastModified = $disk->lastModified($path);
$etag = md5($path . $lastModified);
$size = $disk->size($path);
if ($this->isNotModified($etag, $lastModified)) {
return response('', 304)
->header('ETag', '"' . $etag . '"')
->header('Cache-Control', 'public, max-age=86400');
}
return new StreamedResponse(function () use ($disk, $path) {
$stream = $disk->readStream($path);
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $size,
'ETag' => '"' . $etag . '"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
'Cache-Control' => 'public, max-age=86400',
]);
}
private function isNotModified(string $etag, int $lastModified): bool
{
$requestEtag = request()->header('If-None-Match');
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
return true;
}
$ifModifiedSince = request()->header('If-Modified-Since');
if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
return true;
}
return false;
}
}