222 lines
7.2 KiB
PHP
222 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Documents;
|
|
|
|
use App\Models\Document;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class DocumentStreamService
|
|
{
|
|
/**
|
|
* Stream a document either inline or as attachment with all Windows/public fallbacks.
|
|
*/
|
|
public function stream(Document $document, bool $inline = true): StreamedResponse|Response
|
|
{
|
|
$disk = $document->disk ?: 'public';
|
|
$relPath = $this->normalizePath($document->path ?? '');
|
|
|
|
// Handle DOC/DOCX previews for inline viewing
|
|
if ($inline) {
|
|
$previewResponse = $this->tryPreview($document);
|
|
if ($previewResponse) {
|
|
return $previewResponse;
|
|
}
|
|
}
|
|
|
|
// Try to find the file using multiple path candidates
|
|
$found = $this->findFile($disk, $relPath);
|
|
|
|
if (! $found) {
|
|
// Try public/ fallback
|
|
$found = $this->tryPublicFallback($relPath);
|
|
if (! $found) {
|
|
abort(404, 'Document file not found');
|
|
}
|
|
}
|
|
|
|
$headers = $this->buildHeaders($document, $inline);
|
|
|
|
// Try streaming first
|
|
$stream = Storage::disk($disk)->readStream($found);
|
|
if ($stream !== false) {
|
|
return response()->stream(function () use ($stream) {
|
|
fpassthru($stream);
|
|
}, 200, $headers);
|
|
}
|
|
|
|
// Fallbacks on readStream failure
|
|
return $this->fallbackStream($disk, $found, $document, $relPath, $headers);
|
|
}
|
|
|
|
/**
|
|
* Normalize path for Windows and legacy prefixes.
|
|
*/
|
|
protected function normalizePath(string $path): string
|
|
{
|
|
$path = str_replace('\\', '/', $path);
|
|
$path = ltrim($path, '/');
|
|
if (str_starts_with($path, 'public/')) {
|
|
$path = substr($path, 7);
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Build path candidates to try.
|
|
*/
|
|
protected function buildPathCandidates(string $relPath, ?string $documentPath): array
|
|
{
|
|
$candidates = [$relPath];
|
|
$raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : null;
|
|
|
|
if ($raw && $raw !== $relPath) {
|
|
$candidates[] = $raw;
|
|
}
|
|
if (str_starts_with($relPath, 'storage/')) {
|
|
$candidates[] = substr($relPath, 8);
|
|
}
|
|
if ($raw && str_starts_with($raw, 'storage/')) {
|
|
$candidates[] = substr($raw, 8);
|
|
}
|
|
|
|
return array_unique($candidates);
|
|
}
|
|
|
|
/**
|
|
* Try to find file using path candidates.
|
|
*/
|
|
protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string
|
|
{
|
|
$candidates = $this->buildPathCandidates($relPath, $documentPath);
|
|
|
|
foreach ($candidates as $cand) {
|
|
if (Storage::disk($disk)->exists($cand)) {
|
|
return $cand;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Try public/ fallback path.
|
|
*/
|
|
protected function tryPublicFallback(string $relPath): ?string
|
|
{
|
|
$publicFull = public_path($relPath);
|
|
$real = @realpath($publicFull);
|
|
$publicRoot = @realpath(public_path());
|
|
$realN = $real ? str_replace('\\\\', '/', $real) : null;
|
|
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
|
|
|
|
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
|
|
return $real;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Try to stream preview for DOC/DOCX files.
|
|
*/
|
|
protected function tryPreview(Document $document): StreamedResponse|Response|null
|
|
{
|
|
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
|
if (! in_array($ext, ['doc', 'docx'])) {
|
|
return null;
|
|
}
|
|
|
|
$previewDisk = config('files.preview_disk', 'public');
|
|
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
|
|
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
|
|
if ($stream !== false) {
|
|
$previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
|
|
|
|
return response()->stream(function () use ($stream) {
|
|
fpassthru($stream);
|
|
}, 200, [
|
|
'Content-Type' => $document->preview_mime ?: 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"',
|
|
'Cache-Control' => 'private, max-age=0, no-cache',
|
|
'Pragma' => 'no-cache',
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Queue preview generation if not available
|
|
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
|
|
|
return response('Preview is being generated. Please try again shortly.', 202);
|
|
}
|
|
|
|
/**
|
|
* Build response headers.
|
|
*/
|
|
protected function buildHeaders(Document $document, bool $inline): array
|
|
{
|
|
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
|
|
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
|
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
|
|
|
|
return [
|
|
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
|
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"',
|
|
'Cache-Control' => 'private, max-age=0, no-cache',
|
|
'Pragma' => 'no-cache',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Fallback streaming methods when readStream fails.
|
|
*/
|
|
protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response
|
|
{
|
|
// Fallback 1: get() the bytes directly
|
|
try {
|
|
$bytes = Storage::disk($disk)->get($found);
|
|
if (! is_null($bytes) && $bytes !== false) {
|
|
return response($bytes, 200, $headers);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Continue to next fallback
|
|
}
|
|
|
|
// Fallback 2: open via absolute storage path
|
|
$abs = null;
|
|
try {
|
|
if (method_exists(Storage::disk($disk), 'path')) {
|
|
$abs = Storage::disk($disk)->path($found);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$abs = null;
|
|
}
|
|
|
|
if ($abs && is_file($abs)) {
|
|
$fp = @fopen($abs, 'rb');
|
|
if ($fp !== false) {
|
|
return response()->stream(function () use ($fp) {
|
|
fpassthru($fp);
|
|
}, 200, $headers);
|
|
}
|
|
}
|
|
|
|
// Fallback 3: serve from public path if available
|
|
$publicFull = public_path($found);
|
|
$real = @realpath($publicFull);
|
|
if ($real && is_file($real)) {
|
|
$fp = @fopen($real, 'rb');
|
|
if ($fp !== false) {
|
|
return response()->stream(function () use ($fp) {
|
|
fpassthru($fp);
|
|
}, 200, $headers);
|
|
}
|
|
}
|
|
|
|
abort(404, 'Document file could not be streamed');
|
|
}
|
|
}
|
|
|