331 lines
9.1 KiB
PHP
331 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Common;
|
|
|
|
use App\Abstracts\Http\Controller;
|
|
use App\Models\Common\Media;
|
|
use App\Traits\Uploads as Helper;
|
|
use App\Utilities\QiniuPrivateUrlSigner;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class Uploads extends Controller
|
|
{
|
|
use Helper;
|
|
|
|
/**
|
|
* Get the specified resource.
|
|
*
|
|
* @param $id
|
|
* @return mixed
|
|
*/
|
|
public function get($id, QiniuPrivateUrlSigner $privateUrlSigner)
|
|
{
|
|
try {
|
|
$media = Media::find($id);
|
|
} catch (\Exception $e) {
|
|
return response(null, 204);
|
|
}
|
|
|
|
// Authorization: prevent same-company IDOR (CWE-639/862).
|
|
if (! $this->authorizeMedia($media)) {
|
|
return response(null, 204);
|
|
}
|
|
|
|
// Get file path
|
|
if (!$this->getMediaPathOnStorage($media)) {
|
|
return response(null, 204);
|
|
}
|
|
|
|
if ($privateUrlSigner->supports($media->disk)) {
|
|
return redirect()->away(
|
|
$privateUrlSigner->forPath($media->getDiskPath())
|
|
);
|
|
}
|
|
|
|
return $this->streamMedia($media);
|
|
}
|
|
|
|
public function inline($id, QiniuPrivateUrlSigner $privateUrlSigner)
|
|
{
|
|
try {
|
|
$media = Media::find($id);
|
|
} catch (\Exception $e) {
|
|
return response(null, 204);
|
|
}
|
|
|
|
// Authorization: prevent same-company IDOR (CWE-639/862).
|
|
if (! $this->authorizeMedia($media)) {
|
|
return response(null, 204);
|
|
}
|
|
|
|
// Get file path
|
|
if (!$this->getMediaPathOnStorage($media)) {
|
|
return response(null, 204);
|
|
}
|
|
|
|
if ($privateUrlSigner->supports($media->disk)) {
|
|
return redirect()->away(
|
|
$privateUrlSigner->forPath($media->getDiskPath())
|
|
);
|
|
}
|
|
|
|
return $this->streamMedia($media, 'inline');
|
|
}
|
|
|
|
/**
|
|
* Get the specified resource.
|
|
*
|
|
* @param $id
|
|
* @return mixed
|
|
*/
|
|
public function show($id, Request $request)
|
|
{
|
|
$file = false;
|
|
$options = false;
|
|
$column_name = 'attachment';
|
|
|
|
if ($request->has('column_name')) {
|
|
$column_name = $request->get('column_name');
|
|
}
|
|
|
|
if ($request->has('page')) {
|
|
$options = [
|
|
'page' => $request->get('page'),
|
|
'key' => $request->get('key'),
|
|
];
|
|
}
|
|
|
|
try {
|
|
$media = Media::find($id);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => true,
|
|
'data' => [],
|
|
'message' => 'null',
|
|
'html' => '',
|
|
]);
|
|
}
|
|
|
|
// Authorization: prevent same-company IDOR (CWE-639/862).
|
|
if (! $this->authorizeMedia($media)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => true,
|
|
'data' => [],
|
|
'message' => 'null',
|
|
'html' => '',
|
|
]);
|
|
}
|
|
|
|
// Get file path
|
|
if (!$this->getMediaPathOnStorage($media)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => true,
|
|
'data' => [],
|
|
'message' => 'null',
|
|
'html' => '',
|
|
]);
|
|
}
|
|
|
|
$file = $media;
|
|
|
|
$html = view('components.media.file', compact('file', 'column_name', 'options'))->render();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'error' => false,
|
|
'data' => [],
|
|
'message' => 'null',
|
|
'html' => $html,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Download the specified resource.
|
|
*
|
|
* @param $id
|
|
* @return mixed
|
|
*/
|
|
public function download($id)
|
|
{
|
|
try {
|
|
$media = Media::find($id);
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
|
|
// Authorization: prevent same-company IDOR (CWE-639/862).
|
|
if (! $this->authorizeMedia($media)) {
|
|
return false;
|
|
}
|
|
|
|
// Get file path
|
|
if (!$this->getMediaPathOnStorage($media)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->streamMedia($media);
|
|
}
|
|
|
|
/**
|
|
* Destroy the specified resource.
|
|
*
|
|
* @param $id
|
|
* @return callable
|
|
*/
|
|
public function destroy($id, Request $request)
|
|
{
|
|
$return = back();
|
|
|
|
if ($request->has('ajax') && $request->get('ajax')) {
|
|
$return = [
|
|
'success' => true,
|
|
'errors' => false,
|
|
'message' => '',
|
|
'redirect' => $request->get('redirect')
|
|
];
|
|
}
|
|
|
|
try {
|
|
$media = Media::find($id);
|
|
} catch (\Exception $e) {
|
|
return $return;
|
|
}
|
|
|
|
// Get file path
|
|
if (!$path = $this->getMediaPathOnStorage($media)) {
|
|
$message = trans('messages.warning.deleted', ['name' => $media->basename, 'text' => $media->basename]);
|
|
|
|
flash($message)->warning()->important();
|
|
|
|
return $return;
|
|
}
|
|
|
|
$media->delete(); //will not delete files
|
|
|
|
Storage::disk($media->disk)->delete($path);
|
|
|
|
if (!empty($request->input('page'))) {
|
|
switch ($request->input('page')) {
|
|
case 'setting':
|
|
setting()->set($request->input('key'), '');
|
|
|
|
setting()->save();
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Authorize media access to prevent IDOR (CWE-639/862).
|
|
*
|
|
* The common uploads routes sit behind plain `auth` middleware with no
|
|
* permission check. The Media `Tenants` trait scopes by company_id so
|
|
* cross-company access is blocked, but any same-company low-privilege user
|
|
* (including a portal customer) could enumerate media IDs to read other
|
|
* users' files (invoice attachments, bills, receipts, etc.).
|
|
*
|
|
* Policy:
|
|
* - Admin users (read-admin-panel): allowed — they have legitimate
|
|
* company-wide file access.
|
|
* - Portal users (read-client-portal): allowed only if the media is
|
|
* attached to a mediable parent whose `contact_id` matches the portal
|
|
* user's own contact. This mirrors the existing ownership checks in
|
|
* Portal\InvoiceShow / Portal\PaymentShow FormRequests.
|
|
* - Otherwise: denied (403 / 204).
|
|
*
|
|
* @param Media $media
|
|
* @return bool
|
|
*/
|
|
private function authorizeMedia($media): bool
|
|
{
|
|
if (! is_object($media)) {
|
|
return false;
|
|
}
|
|
|
|
$user = user();
|
|
|
|
// Defensive: auth middleware guarantees a user, but guard against
|
|
// any edge-case invocation without one (default-deny).
|
|
if (! $user) {
|
|
return false;
|
|
}
|
|
|
|
// Admin users have company-wide file access.
|
|
if ($user->can('read-admin-panel')) {
|
|
return true;
|
|
}
|
|
|
|
// Portal users: check ownership via the mediable parent's contact_id.
|
|
if ($user->isCustomer()) {
|
|
return $this->mediaBelongsToContact($media, $user);
|
|
}
|
|
|
|
// Any other authenticated user without admin/portal access: deny.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Determine whether the given media is attached to a mediable record
|
|
* owned by the portal user's contact.
|
|
*
|
|
* Resolves the polymorphic mediables pivot for the media and, for each
|
|
* parent model that has a `contact_id` column, checks whether it matches
|
|
* the user's contact_id. If no mediable parent exists or none has a
|
|
* contact_id, access is denied (default-deny).
|
|
*
|
|
* @param Media $media
|
|
* @param \App\Models\Auth\User $user
|
|
* @return bool
|
|
*/
|
|
private function mediaBelongsToContact($media, $user): bool
|
|
{
|
|
$contact = $user->contact;
|
|
|
|
if (! $contact) {
|
|
return false;
|
|
}
|
|
|
|
$rows = DB::table(config('mediable.mediables_table', 'mediables'))
|
|
->where('media_id', $media->id)
|
|
->get(['mediable_type', 'mediable_id']);
|
|
|
|
if ($rows->isEmpty()) {
|
|
// Orphaned media (no mediable parent) — deny for portal users.
|
|
return false;
|
|
}
|
|
|
|
foreach ($rows as $row) {
|
|
if (! class_exists($row->mediable_type)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$parent = app($row->mediable_type)::find($row->mediable_id);
|
|
} catch (\Exception $e) {
|
|
continue;
|
|
}
|
|
|
|
if (! $parent) {
|
|
continue;
|
|
}
|
|
|
|
// If the parent model has a contact_id column, enforce ownership.
|
|
if (in_array('contact_id', $parent->getFillable(), true) || isset($parent->contact_id)) {
|
|
if (isset($parent->contact_id) && (int) $parent->contact_id === (int) $contact->id) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No matching owned parent found — default deny.
|
|
return false;
|
|
}
|
|
}
|