The Show page announced "This ticket has been merged and is read-only.",
hid the reply composer and withheld the Merge button — and that was the
whole guard. It was presentation, on one page.
The Edit link and the Split button were still rendered, `edit()` still
served the form (with its Status select blank, because `merged` was not
one of the five slugs the form hard-coded), and `update()` took the save
with `errors: {}`: status went back to `open` while `merged_into_id` kept
pointing at the merge target. The row that came out was live and
assignable in the All Open queue AND merged into another ticket, holding
none of its own messages because the merge had moved them — an empty
shell that reads as a real duplicate of the ticket it was folded into.
So the rule now lives on the server, once — `Ticket::isMerged()` and
`refuseIfMerged()` — and every write the page presents as unavailable
calls it: update, edit, split, participants add/remove, and a reply or an
attachment (both hidden by the banner, neither refused by its route).
Two deliberate choices:
- the guard reads `status === 'merged'`, NOT `merged_into_id !== null`.
Reading the pointer would look stricter and would freeze every row
this bug already minted (`status = open, merged_into_id = 56`), so
the only way to correct one would be SQL.
- it refuses with a redirect carrying the reason, not `abort(403)`.
The realistic way to arrive here is a page that was already open when
somebody else merged the ticket, and that operator is better served
by the sentence than by an error screen.
The blank Status select was the report's second symptom, and it was the
same defect one layer up: the five hard-coded options meant any ticket
whose status was not among them — `merged`, or any status added on the
Statuses settings tab — rendered with nothing selected, and the first
save silently rewrote the status to whatever happened to be showing. The
options now come from the same table `update()` validates against.
83 lines
2.7 KiB
PHP
83 lines
2.7 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Ticketing\Http\Controllers;
|
|
|
|
use Dashboard\Ticketing\Models\Ticket;
|
|
use Dashboard\Ticketing\Models\TicketAttachment;
|
|
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controller;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class TicketAttachmentController extends Controller
|
|
{
|
|
private const MAX_SIZE_MB = 20;
|
|
|
|
private function canAccessTicket(Ticket $ticket): bool
|
|
{
|
|
$user = Auth::user();
|
|
$isAgent = TicketingAgentAccess::where('user_id', $user->id)
|
|
->where('group_id', $ticket->group_id)
|
|
->exists();
|
|
return $isAgent || $ticket->submitter_id === $user->id;
|
|
}
|
|
|
|
public function store(Request $request, Ticket $ticket)
|
|
{
|
|
if (!$this->canAccessTicket($ticket)) {
|
|
abort(403);
|
|
}
|
|
|
|
// Same read-only rule as a reply — see TicketMessageController::store(). #920
|
|
if ($ticket->isMerged()) {
|
|
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
|
|
}
|
|
|
|
$request->validate([
|
|
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
|
|
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
|
|
'message_id' => [
|
|
'nullable',
|
|
'integer',
|
|
function ($attribute, $value, $fail) use ($ticket) {
|
|
if ($value !== null) {
|
|
$exists = \Dashboard\Ticketing\Models\TicketMessage::where('id', $value)
|
|
->where('ticket_id', $ticket->id)
|
|
->exists();
|
|
if (!$exists) {
|
|
$fail('The message does not belong to this ticket.');
|
|
}
|
|
}
|
|
},
|
|
],
|
|
]);
|
|
|
|
$file = $request->file('file');
|
|
$disk = config('ticketing.storage_disk', 'local');
|
|
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
|
|
|
|
TicketAttachment::create([
|
|
'ticket_id' => $ticket->id,
|
|
'message_id' => $request->message_id,
|
|
'filename' => $file->getClientOriginalName(),
|
|
'path' => $path,
|
|
'mime_type' => $file->getMimeType(),
|
|
'size' => $file->getSize(),
|
|
]);
|
|
|
|
return back()->with('success', 'Attachment uploaded.');
|
|
}
|
|
|
|
public function show(TicketAttachment $attachment)
|
|
{
|
|
$ticket = $attachment->ticket;
|
|
if (!$this->canAccessTicket($ticket)) {
|
|
abort(403);
|
|
}
|
|
|
|
$disk = config('ticketing.storage_disk', 'local');
|
|
return Storage::disk($disk)->download($attachment->path, $attachment->filename);
|
|
}
|
|
}
|