feat: harden admin auth and ticket workflows
- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
This commit is contained in:
@@ -6,6 +6,12 @@ class EmailTemplateShell
|
||||
{
|
||||
private const BRAND = '#f16723';
|
||||
|
||||
public const TABLE_BORDER = '#f6d2b8';
|
||||
|
||||
public const TABLE_STYLE = 'box-sizing: border-box; width: 100%; border: 1px solid ' . self::TABLE_BORDER . '; border-radius: 10px; border-collapse: separate; overflow: hidden;';
|
||||
|
||||
public const HEAD_CELL_STYLE = 'box-sizing: border-box; padding: 11px 14px; color: #c2410c; font-size: 13px; font-weight: bold; letter-spacing: .4px; border-bottom: 1px solid ' . self::TABLE_BORDER . '; background-color: #fff5ee;';
|
||||
|
||||
private const SERVICE_LINKS = [
|
||||
'Premium Hosting' => 'https://tgs.com.vn/premium-hosting/',
|
||||
'Cloud Server' => 'https://tgs.com.vn/cloud-server/',
|
||||
@@ -25,7 +31,7 @@ class EmailTemplateShell
|
||||
|
||||
public static function body(string $innerHtml): string
|
||||
{
|
||||
return '<tr class="NoiDungKM"><td style="box-sizing: border-box; width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top" bgcolor="#ffffff" width="700">'
|
||||
return '<tr class="NoiDungKM"><td style="box-sizing: border-box; width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #1a1a1a;" valign="top" bgcolor="#ffffff" width="700">'
|
||||
. $innerHtml
|
||||
. '</td></tr>';
|
||||
}
|
||||
@@ -74,7 +80,7 @@ class EmailTemplateShell
|
||||
public static function note(string $html, string $label = 'Lưu ý:'): string
|
||||
{
|
||||
return '<table style="box-sizing: border-box; width: 100%; border-collapse: collapse; margin: 0 0 20px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody><tr>'
|
||||
. '<td style="box-sizing: border-box; padding: 14px 16px; border-left: 3px solid ' . self::BRAND . '; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;" bgcolor="#f8fafc">'
|
||||
. '<td style="box-sizing: border-box; padding: 14px 16px; border-left: 3px solid ' . self::BRAND . '; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #1a1a1a;" bgcolor="#f8fafc">'
|
||||
. '<strong style="box-sizing: border-box; color: #374151;">' . $label . '</strong> ' . $html
|
||||
. '</td></tr></tbody></table>';
|
||||
}
|
||||
@@ -99,15 +105,15 @@ class EmailTemplateShell
|
||||
$html .= self::row($label, $value);
|
||||
}
|
||||
|
||||
return '<table style="box-sizing: border-box; width: 100%; border-collapse: collapse; table-layout: fixed; margin: 0 0 20px 0; border: 1px solid #e8e8e8;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
return '<table style="' . self::TABLE_STYLE . ' table-layout: fixed; margin: 0 0 20px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
. $html . $extraRowsHtml
|
||||
. '</tbody></table>';
|
||||
}
|
||||
|
||||
public static function row(string $label, string $valueHtml): string
|
||||
{
|
||||
return '<tr><td style="box-sizing: border-box; width: 38%; padding: 8px 12px; border-bottom: 1px solid #e8e8e8; background-color: #fafafa; color: #626262;" bgcolor="#fafafa">' . $label . '</td>'
|
||||
. '<td style="box-sizing: border-box; padding: 8px 12px; border-bottom: 1px solid #e8e8e8; color: #374151;"><strong>' . $valueHtml . '</strong></td></tr>';
|
||||
return '<tr><td style="' . self::HEAD_CELL_STYLE . ' width: 38%; letter-spacing: 0; font-weight: 600;" bgcolor="#fff5ee">' . $label . '</td>'
|
||||
. '<td style="box-sizing: border-box; padding: 11px 14px; border-bottom: 1px solid ' . self::TABLE_BORDER . '; color: #374151;"><strong>' . $valueHtml . '</strong></td></tr>';
|
||||
}
|
||||
|
||||
public static function header(): string
|
||||
@@ -148,7 +154,6 @@ HTML;
|
||||
$text = 'box-sizing: border-box; font-size: 12px; line-height: 18px; color: #ffffff;';
|
||||
|
||||
return $line('<strong style="box-sizing: border-box; font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong>')
|
||||
. $line('<span style="' . $text . '">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span>')
|
||||
. $line('<span style="' . $text . '">Tel: (028) 7309. 7379 - MST: 0318443116</span>')
|
||||
. $line('<span style="' . $text . '">Email: <a style="box-sizing: border-box; color: #ffffff; text-decoration: none;" href="mailto:support@tgs.com.vn">support@tgs.com.vn</a></span>')
|
||||
. $line('<span style="box-sizing: border-box; font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">🥇 Thế Giới Số 🥇 - Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự🚀</span>', '6px 30px 16px 30px');
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class TicketGallery
|
||||
{
|
||||
private const DISK = 'local';
|
||||
|
||||
private const LEGACY_DISK = 'public';
|
||||
|
||||
private const LEGACY_URL_SEGMENT = '/storage/';
|
||||
|
||||
/**
|
||||
* @param UploadedFile[] $files
|
||||
* @return string[]
|
||||
*/
|
||||
public static function store(array $files, int $customerId): array
|
||||
{
|
||||
return array_map(
|
||||
fn (UploadedFile $file) => $file->store("ticket-attachments/{$customerId}", self::DISK),
|
||||
$files,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function clientUrls(Ticket $ticket): array
|
||||
{
|
||||
return array_map(
|
||||
fn (int $index) => "/v1/client/tickets/{$ticket->ticket_num}/images/{$index}",
|
||||
array_keys(self::entries($ticket)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function adminUrls(Ticket $ticket): array
|
||||
{
|
||||
return array_map(
|
||||
fn (int $index) => URL::temporarySignedRoute(
|
||||
'admin.tickets.image',
|
||||
now()->addDay(),
|
||||
['ticket' => $ticket->ticket_num, 'index' => $index],
|
||||
),
|
||||
array_keys(self::entries($ticket)),
|
||||
);
|
||||
}
|
||||
|
||||
public static function moveLegacyToPrivate(Ticket $ticket): bool
|
||||
{
|
||||
$moved = false;
|
||||
|
||||
$gallery = array_map(function (string $entry) use ($ticket, &$moved) {
|
||||
$location = self::locate($entry);
|
||||
|
||||
if ($location === null || $location[0] !== self::LEGACY_DISK) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
$target = "ticket-attachments/{$ticket->customer_id}/" . basename($location[1]);
|
||||
Storage::disk(self::DISK)->writeStream($target, Storage::disk(self::LEGACY_DISK)->readStream($location[1]));
|
||||
Storage::disk(self::LEGACY_DISK)->delete($location[1]);
|
||||
$moved = true;
|
||||
|
||||
return $target;
|
||||
}, self::entries($ticket));
|
||||
|
||||
if ($moved) {
|
||||
$ticket->forceFill(['gallery' => $gallery])->saveQuietly();
|
||||
}
|
||||
|
||||
return $moved;
|
||||
}
|
||||
|
||||
public static function response(Ticket $ticket, int $index): StreamedResponse
|
||||
{
|
||||
$location = self::locate(self::entries($ticket)[$index] ?? null);
|
||||
|
||||
abort_if($location === null, 404);
|
||||
|
||||
[$disk, $path] = $location;
|
||||
|
||||
return Storage::disk($disk)->response($path, null, ['Cache-Control' => 'private, max-age=3600']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private static function entries(Ticket $ticket): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
(array) ($ticket->gallery ?? []),
|
||||
fn ($entry) => is_string($entry) && $entry !== '',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}|null
|
||||
*/
|
||||
private static function locate(?string $entry): ?array
|
||||
{
|
||||
if ($entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$disk, $path] = [self::DISK, $entry];
|
||||
|
||||
if (preg_match('#^https?://#i', $entry)) {
|
||||
$urlPath = (string) parse_url($entry, PHP_URL_PATH);
|
||||
$offset = strpos($urlPath, self::LEGACY_URL_SEGMENT);
|
||||
|
||||
if ($offset === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$disk, $path] = [self::LEGACY_DISK, rawurldecode(substr($urlPath, $offset + strlen(self::LEGACY_URL_SEGMENT)))];
|
||||
}
|
||||
|
||||
if (str_contains($path, '..') || ! Storage::disk($disk)->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$disk, $path];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user