Dev branch
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AccountType;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ReferenceDataCache
|
||||
{
|
||||
private const TTL = 3600; // 1 hour
|
||||
|
||||
public function getAddressTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:address_types', self::TTL, fn () => AddressType::all());
|
||||
}
|
||||
|
||||
public function getPhoneTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all());
|
||||
}
|
||||
|
||||
public function getAccountTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all());
|
||||
}
|
||||
|
||||
public function getContractTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all reference data cache.
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
Cache::forget('reference_data:address_types');
|
||||
Cache::forget('reference_data:phone_types');
|
||||
Cache::forget('reference_data:account_types');
|
||||
Cache::forget('reference_data:contract_types');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific reference data cache.
|
||||
*/
|
||||
public function clear(string $type): void
|
||||
{
|
||||
Cache::forget("reference_data:{$type}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all types as an array for convenience.
|
||||
*/
|
||||
public function getAllTypes(): array
|
||||
{
|
||||
return [
|
||||
'address_types' => $this->getAddressTypes(),
|
||||
'phone_types' => $this->getPhoneTypes(),
|
||||
'account_types' => $this->getAccountTypes(),
|
||||
'contract_types' => $this->getContractTypes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user