f8a1310c0f
Security fixes (27 vulnerabilities across 20 files): CRITICAL: - MediaManager: whitelist mediable types prevents RCE via class instantiation - MediaManager/OfflineSyncController: IDOR fixes, remove Auth::id()??1 fallback - ClientProjects: verify project ownership on all mutations (IDOR) - CompanyManagement: Admin role check on mount() and mutations (auth bypass) - ProjectMap: scope feature/template lookups to current project (IDOR x5) - PhaseList/TemplateManager/LayerManager: scope mutations to owned resources (IDOR) - ProjectEditTabs: Gate::authorize on mount() and updateProject() - routes/web.php: reports routes moved inside can:manage all middleware (auth bypass) MEDIUM: - layer-manager: escapeHtml() on Leaflet popup interpolations (XSS) - MediaManager: server-side MIME validation + 50MB limit - ProjectList/ProjectUsers/ProjectCompanies/PhaseProgress: auth checks added - AdminUsers/ReportsDashboard/ExportController: role/permission checks added LOW: - config/session.php: secure cookie tied to production env - OfflineSyncController: sanitize storage path (path traversal) UI integration: - project-map: Issues tab (4th) with open-count badge - project-map: project navigation bar (Dashboard/Map/Gantt/Report/Issues) - project-dashboard: action buttons for Map/Gantt/Report/Issues - project-form: validation error summary + per-field @error spans - template-manager: validation error display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
6.8 KiB
PHP
212 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use Livewire\Component;
|
|
use Livewire\WithFileUploads;
|
|
use App\Models\Media;
|
|
use App\Models\Project;
|
|
use App\Models\Phase;
|
|
use App\Models\Layer;
|
|
use App\Models\Feature;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class MediaManager extends Component
|
|
{
|
|
use WithFileUploads;
|
|
|
|
/**
|
|
* Whitelist of allowed mediable types (prevents arbitrary class instantiation).
|
|
* Keys are the public string accepted in mount(); values are FQCN.
|
|
*/
|
|
private const ALLOWED_TYPES = [
|
|
'App\\Models\\Project' => \App\Models\Project::class,
|
|
'App\\Models\\Phase' => \App\Models\Phase::class,
|
|
'App\\Models\\Layer' => \App\Models\Layer::class,
|
|
'App\\Models\\Feature' => \App\Models\Feature::class,
|
|
'App\\Models\\Inspection' => \App\Models\Inspection::class,
|
|
'App\\Models\\Issue' => \App\Models\Issue::class,
|
|
];
|
|
|
|
public $mediableType;
|
|
public $mediableId;
|
|
|
|
public $entity;
|
|
public $mediaItems = [];
|
|
|
|
public $uploadFiles = [];
|
|
public $uploadDescription = '';
|
|
public $uploadCategory = 'image';
|
|
|
|
public $showViewer = false;
|
|
public $viewingMedia = null;
|
|
|
|
protected $rules = [
|
|
'uploadFiles.*' => 'required|file|max:51200', // 50 MB per file
|
|
'uploadDescription' => 'nullable|string|max:500',
|
|
'uploadCategory' => 'required|in:image,document,other',
|
|
];
|
|
|
|
protected $messages = [
|
|
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 50 MB.',
|
|
];
|
|
|
|
public function mount($mediableType, $mediableId)
|
|
{
|
|
// Validate type against whitelist to prevent RCE via class instantiation
|
|
if (!array_key_exists($mediableType, self::ALLOWED_TYPES)) {
|
|
abort(400, 'Invalid mediable type.');
|
|
}
|
|
|
|
$this->mediableType = $mediableType;
|
|
$this->mediableId = (int) $mediableId;
|
|
|
|
$modelClass = self::ALLOWED_TYPES[$mediableType];
|
|
$this->entity = $modelClass::findOrFail($this->mediableId);
|
|
|
|
$this->loadMedia();
|
|
}
|
|
|
|
public function loadMedia()
|
|
{
|
|
$this->mediaItems = Media::where('mediable_type', $this->mediableType)
|
|
->where('mediable_id', $this->mediableId)
|
|
->with('uploader')
|
|
->latest()
|
|
->get();
|
|
}
|
|
|
|
public function upload()
|
|
{
|
|
$user = Auth::user();
|
|
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
|
session()->flash('error', 'Sin permisos.');
|
|
return;
|
|
}
|
|
|
|
$this->validate();
|
|
|
|
if (empty($this->uploadFiles)) {
|
|
session()->flash('error', 'Selecciona al menos un archivo.');
|
|
return;
|
|
}
|
|
|
|
// Allowed MIME types (server-side validation)
|
|
$allowedMimes = [
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
'application/pdf',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'text/plain', 'text/csv',
|
|
'application/zip', 'application/x-zip-compressed',
|
|
];
|
|
|
|
$uploaded = 0;
|
|
foreach ($this->uploadFiles as $file) {
|
|
$mime = $file->getMimeType();
|
|
|
|
if (!in_array($mime, $allowedMimes, true)) {
|
|
session()->flash('error', "Tipo de archivo no permitido: {$mime}");
|
|
continue;
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension();
|
|
$size = $file->getSize();
|
|
$name = substr($file->getClientOriginalName(), 0, 255);
|
|
|
|
$category = $this->uploadCategory;
|
|
if (str_starts_with($mime, 'image/')) {
|
|
$category = 'image';
|
|
} elseif (in_array($mime, [
|
|
'application/pdf', 'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
], true)) {
|
|
$category = 'document';
|
|
}
|
|
|
|
$entityType = class_basename($this->entity);
|
|
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
|
|
$path = $file->store($dir, 'public');
|
|
|
|
Media::create([
|
|
'mediable_type' => $this->mediableType,
|
|
'mediable_id' => $this->mediableId,
|
|
'name' => $name,
|
|
'file_path' => $path,
|
|
'file_type' => $mime,
|
|
'file_extension' => $ext,
|
|
'file_size' => $size,
|
|
'category' => $category,
|
|
'description' => $this->uploadDescription,
|
|
'uploaded_by' => $user->id,
|
|
]);
|
|
|
|
$uploaded++;
|
|
}
|
|
|
|
$this->reset(['uploadFiles', 'uploadDescription']);
|
|
$this->loadMedia();
|
|
|
|
$this->dispatch('mediaUploaded', [
|
|
'mediableType' => $this->mediableType,
|
|
'mediableId' => $this->mediableId,
|
|
]);
|
|
|
|
session()->flash('message', "{$uploaded} archivo(s) subido(s) correctamente.");
|
|
}
|
|
|
|
public function deleteMedia($mediaId)
|
|
{
|
|
// Ensure the media belongs to the entity this component manages (IDOR prevention)
|
|
$media = Media::where('id', $mediaId)
|
|
->where('mediable_type', $this->mediableType)
|
|
->where('mediable_id', $this->mediableId)
|
|
->firstOrFail();
|
|
|
|
$user = Auth::user();
|
|
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
|
|
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
|
|
return;
|
|
}
|
|
|
|
$media->delete();
|
|
$this->loadMedia();
|
|
session()->flash('message', 'Archivo eliminado.');
|
|
}
|
|
|
|
public function viewMedia($mediaId)
|
|
{
|
|
$media = Media::where('id', $mediaId)
|
|
->where('mediable_type', $this->mediableType)
|
|
->where('mediable_id', $this->mediableId)
|
|
->firstOrFail();
|
|
|
|
if (!$media->is_image) {
|
|
$this->dispatch('openUrl', $media->url);
|
|
return;
|
|
}
|
|
$this->viewingMedia = $media;
|
|
$this->showViewer = true;
|
|
}
|
|
|
|
public function closeViewer()
|
|
{
|
|
$this->showViewer = false;
|
|
$this->viewingMedia = null;
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.media-manager', [
|
|
'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id),
|
|
'images' => $this->mediaItems->filter(fn ($m) => $m->is_image),
|
|
'documents' => $this->mediaItems->filter(fn ($m) => !$m->is_image),
|
|
]);
|
|
}
|
|
}
|