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:
baodev0
2026-10-16 17:00:55 +07:00
parent 0cdf224309
commit 0b08689ea4
52 changed files with 1421 additions and 118 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Exports;
use App\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
final class TicketsExport implements BrandedReport
{
public function __construct(private readonly Builder $query) {}
public function title(): string
{
return 'Danh sách ticket hỗ trợ';
}
public function query(): Builder
{
return $this->query;
}
public function moneyColumns(): array
{
return [];
}
public function headings(): array
{
return [
'Mã ticket', 'Tiêu đề', 'Khách hàng', 'Email', 'Phòng ban', 'Mức ưu tiên',
'Trạng thái', 'Số phản hồi', 'Ngày tạo', 'Cập nhật lần cuối',
];
}
/**
* @param Ticket $ticket
*/
public function map($ticket): array
{
$priority = $ticket->priority();
return [
$ticket->ticket_num,
$ticket->subject,
$ticket->customer?->fullname,
$ticket->customer?->email,
$ticket->roomLabel(),
$priority ? (Ticket::PRIORITY_LABELS[$priority] ?? $priority) : null,
Ticket::STATUS_LABELS[$ticket->status] ?? $ticket->status,
(int) $ticket->reply_count,
$ticket->created_at?->format('d/m/Y H:i'),
$ticket->updated_at?->format('d/m/Y H:i'),
];
}
}
@@ -6,27 +6,36 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateProfileRequest;
use App\Http\Requests\UpdateAdminPasswordRequest;
use App\Http\Resources\Api\Admin\AdminUserResource;
use App\Services\Api\Admin\AdminRefreshTokenService;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
class AuthController extends Controller
{
private const LOGIN_MAX_ATTEMPTS = 5;
private const LOGIN_DECAY_SECONDS = 60;
private const REMEMBER_TTL_SECONDS = 2592000;
private const REFRESH_COOKIE = 'admin_refresh_token';
private const REFRESH_COOKIE_PATH = '/api/v1/administrator';
public function __construct(private readonly ActivityLogger $activityLogger) {}
public function __construct(
private readonly ActivityLogger $activityLogger,
private readonly AdminRefreshTokenService $refreshTokens,
) {}
public function login(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'password' => 'required|string',
'remember' => 'sometimes|boolean',
]);
$throttleKey = 'admin-login:' . strtolower($request->string('username')->toString()) . '|' . $request->ip();
@@ -38,7 +47,10 @@ class AuthController extends Controller
], 429);
}
$token = Auth::guard('admin')->attempt($request->only('username', 'password'));
$guard = Auth::guard('admin');
$remember = $request->boolean('remember');
$credentials = $request->only('username', 'password');
$token = $guard->attempt($credentials);
if (!$token) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
@@ -61,15 +73,26 @@ class AuthController extends Controller
$this->activityLogger->logAuth('login', 'admin', $user->id, $user->username ?? $user->email);
return response()->json([
$data = [
'token' => $token,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'user' => new AdminUserResource($user),
];
if ($remember) {
$refreshToken = $this->refreshTokens->create($user->id, now()->addDays(30));
$data['refresh_expires_in'] = self::REMEMBER_TTL_SECONDS;
}
$response = response()->json([
'error' => false,
'data' => [
'token' => $token,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'user' => new AdminUserResource($user),
],
'data' => $data,
]);
return $remember
? $response->withCookie($this->refreshCookie($refreshToken, self::REMEMBER_TTL_SECONDS))
: $response->withCookie($this->forgetRefreshCookie());
}
public function me(): JsonResponse
@@ -116,31 +139,55 @@ class AuthController extends Controller
]);
}
public function refresh(): JsonResponse
public function refresh(Request $request): JsonResponse
{
try {
$newToken = JWTAuth::parseToken()->refresh();
$user = JWTAuth::setToken($newToken)->toUser();
return response()->json([
'error' => false,
'data' => [
'token' => $newToken,
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'user' => new AdminUserResource($user),
],
]);
} catch (JWTException) {
if (! $request->ajax()) {
return response()->json([
'error' => true,
'message' => 'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.',
], 401);
'message' => 'Yêu cầu làm mới phiên không hợp lệ.',
], 403);
}
$request->validate([
'refresh_token' => ['prohibited'],
]);
$refreshToken = $request->cookie(self::REFRESH_COOKIE);
if (! is_string($refreshToken) || $refreshToken === '') {
return response()->json([
'error' => true,
'message' => 'Phiên ghi nhớ đăng nhập không tồn tại.',
], 401)->withCookie($this->forgetRefreshCookie());
}
try {
$result = $this->refreshTokens->rotate($refreshToken);
} catch (ValidationException) {
return response()->json([
'error' => true,
'message' => 'Refresh token không hợp lệ hoặc đã hết hạn.',
], 401)->withCookie($this->forgetRefreshCookie());
}
return response()->json([
'error' => false,
'data' => [
'token' => $result['token'],
'token_type' => 'bearer',
'expires_in' => config('jwt.ttl') * 60,
'refresh_expires_in' => $result['refresh_expires_in'],
'user' => new AdminUserResource($result['user']),
],
])->withCookie($this->refreshCookie($result['refresh_token'], $result['refresh_expires_in']));
}
public function logout(): JsonResponse
{
$refreshToken = request()->cookie(self::REFRESH_COOKIE);
if (is_string($refreshToken) && $refreshToken !== '') {
$this->refreshTokens->revoke($refreshToken);
}
$user = Auth::guard('admin')->user();
if ($user) {
$this->activityLogger->logAuth('logout', 'admin', $user->id, $user->username ?? $user->email);
@@ -151,6 +198,26 @@ class AuthController extends Controller
return response()->json([
'error' => false,
'message' => 'Đã đăng xuất thành công.',
]);
])->withCookie($this->forgetRefreshCookie());
}
private function refreshCookie(string $token, int $maxAgeSeconds): Cookie
{
return cookie(
self::REFRESH_COOKIE,
$token,
(int) ceil($maxAgeSeconds / 60),
self::REFRESH_COOKIE_PATH,
null,
true,
true,
false,
app()->isLocal() ? 'none' : 'strict',
);
}
private function forgetRefreshCookie(): Cookie
{
return cookie()->forget(self::REFRESH_COOKIE, self::REFRESH_COOKIE_PATH);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\IpPool\Concerns;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\IpPoolLog;
use App\Services\IpPool\IpAllocationService;
use App\Support\MailAttachments;
use Illuminate\Http\JsonResponse;
@@ -55,6 +56,45 @@ trait ManagesIpPoolAllocation
]);
}
public function allLogs(Request $request): JsonResponse
{
$data = $request->validate([
'filter' => 'nullable|string|max:255',
'action' => 'nullable|array',
'action.*' => 'in:' . implode(',', [IpPoolLog::ALLOCATED, IpPoolLog::REVOKED, IpPoolLog::VM_ASSIGNED, IpPoolLog::VM_RELEASED]),
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
'per_page' => 'nullable|integer|min:1|max:100',
]);
$paginator = IpPoolLog::with('user:id,username,fullname')
->when($data['filter'] ?? null, function ($query, string $filter) {
$like = '%' . addcslashes($filter, '%_\\') . '%';
$query->where(fn ($q) => $q->where('ip_address', 'like', $like)
->orWhere('customer_name', 'like', $like)
->orWhere('service_name', 'like', $like)
->orWhere('vm_name', 'like', $like)
->orWhere('email_to', 'like', $like)
->orWhere('email_cc', 'like', $like)
->orWhere('email_bcc', 'like', $like));
})
->when($data['action'] ?? null, fn ($query, array $actions) => $query->whereIn('action', $actions))
->when($data['date_from'] ?? null, fn ($query, string $from) => $query->whereDate('created_at', '>=', $from))
->when($data['date_to'] ?? null, fn ($query, string $to) => $query->whereDate('created_at', '<=', $to))
->latest('id')
->paginate($data['per_page'] ?? 20);
return response()->json([
'error' => false,
'data' => [
'items' => $paginator->items(),
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
],
]);
}
private function validateAllocation(Request $request): array
{
return $request->validate([
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\IpPool\Concerns;
use App\Models\IpPool;
use App\Models\ModuleNetwork;
use App\Services\IpPool\IpPoolDashboard;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
@@ -33,6 +34,11 @@ trait ReadsIpPoolUsage
return response()->json(['error' => false, 'data' => $used]);
}
public function dashboard(IpPoolDashboard $dashboard): JsonResponse
{
return response()->json(['error' => false, 'data' => $dashboard->build()]);
}
public function networks(): JsonResponse
{
$networks = ModuleNetwork::select(['id', 'name', 'type', 'network', 'module_host_id'])
@@ -15,12 +15,36 @@ use App\Support\Turnstile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
class SettingController extends Controller
{
public function verifyPassword(Request $request): JsonResponse
{
$data = $request->validate([
'password' => ['required', 'string'],
], [
'password.required' => 'Vui lòng nhập mật khẩu hiện tại.',
]);
$user = $request->user('admin');
if (!$user || !Hash::check($data['password'], $user->password)) {
return response()->json([
'error' => true,
'message' => 'Mật khẩu hiện tại không chính xác.',
], 422);
}
return response()->json([
'error' => false,
'message' => 'Xác thực thành công.',
]);
}
/**
* GET /administrator/settings
* Returns all setting keys: setting, smtp.
@@ -2,6 +2,8 @@
namespace App\Http\Controllers\Api\Admin\Ticket;
use App\Exports\BrandedReportWriter;
use App\Exports\TicketsExport;
use App\Http\Controllers\Controller;
use App\Events\TicketReplyCreated;
use App\Http\Resources\Api\Ticket\TicketResource;
@@ -9,7 +11,10 @@ use App\Http\Resources\Api\Ticket\TicketReplyResource;
use App\Models\Notification;
use App\Models\Ticket;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use App\Support\TicketGallery;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class TicketController extends Controller
{
@@ -17,29 +22,19 @@ class TicketController extends Controller
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
private const FILTER_RULES = [
'status' => ['nullable', 'array'],
'status.*' => ['string', 'in:open,pending,resolved,closed'],
'room' => ['nullable', 'string', 'max:50'],
'customer_id' => ['nullable', 'integer'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'search' => ['nullable', 'string', 'max:255'],
];
public function index(Request $request)
{
$filters = $request->validate([
'status' => ['nullable', 'array'],
'status.*' => ['string', 'in:open,pending,resolved,closed'],
'room' => ['nullable', 'string', 'max:50'],
'customer_id' => ['nullable', 'integer'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'search' => ['nullable', 'string', 'max:255'],
]);
$query = Ticket::query()
->with(['customer'])
->latest('id')
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room))
->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->when($filters['search'] ?? null, fn ($q, $search) => $q->where(
fn ($q) => $q->where('subject', 'like', "%{$search}%")->orWhere('ticket_num', 'like', "%{$search}%")
));
$query = $this->filteredQuery($request->validate(self::FILTER_RULES))->with(['customer']);
$paginated = $query->paginate($request->input('per_page', 15));
@@ -58,6 +53,39 @@ class TicketController extends Controller
/**
* Show a ticket with its replies and customer info.
*/
public function export(Request $request, BrandedReportWriter $writer): BinaryFileResponse
{
$filters = $request->validate(self::FILTER_RULES + ['filter' => ['nullable', 'string', 'max:255']]);
$filters['search'] ??= $filters['filter'] ?? null;
$query = $this->filteredQuery($filters)->with(['customer'])->withCount('reply');
return $writer->download(new TicketsExport($query), 'ticket-' . now()->format('Y-m-d-His') . '.xlsx');
}
private function filteredQuery(array $filters): Builder
{
return Ticket::query()
->latest('id')
->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status))
->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room))
->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id))
->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to))
->when($filters['search'] ?? null, function ($q, $search) {
$keyword = '%' . addcslashes($search, '%_\\') . '%';
$q->where(fn ($q) => $q->where('subject', 'like', $keyword)->orWhere('ticket_num', 'like', $keyword));
});
}
public function pendingCount()
{
return response()->json([
'error' => false,
'data' => ['count' => Ticket::whereIn('status', Ticket::UNRESOLVED_STATUSES)->count()],
]);
}
public function show(string $ticket_num)
{
$ticket = Ticket::query()
@@ -74,7 +102,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'data' => new TicketResource($ticket),
'data' => [...(new TicketResource($ticket))->resolve(), 'status_history' => $ticket->statusHistory()],
]);
}
@@ -162,4 +190,9 @@ class TicketController extends Controller
'data' => new TicketReplyResource($reply),
], 201);
}
public function image(string $ticket, int $index)
{
return TicketGallery::response(Ticket::where('ticket_num', $ticket)->firstOrFail(), $index);
}
}
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Api\Client\Ticket;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Ticket\TicketResource;
use App\Http\Resources\Api\Ticket\TicketReplyResource;
use App\Http\Resources\Api\Client\Ticket\ClientTicketReplyResource;
use App\Http\Resources\Api\Client\Ticket\ClientTicketResource;
use App\Jobs\Ticket\TicketJobs;
use App\Mail\TicketCreated;
use App\Mail\Admin\TicketAdminNotification;
@@ -14,9 +14,9 @@ use App\Models\Notification;
use App\Events\TicketReplyCreated;
use App\Repositories\Ticket\TicketRepositoryInterface;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use App\Support\TicketGallery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class TicketController extends Controller
@@ -26,27 +26,38 @@ class TicketController extends Controller
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
/**
* List tickets for the authenticated customer (paginated, filterable by status).
*/
public function index(Request $request)
{
$filters = $request->validate([
'status' => ['nullable', 'string', 'max:20'],
'room' => ['nullable', 'string', 'max:50'],
'search' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$customer = auth('client')->user();
$query = Ticket::query()
->where('customer_id', $customer->id)
->latest('id');
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
foreach (['status', 'room'] as $column) {
if (filled($filters[$column] ?? null)) {
$query->where($column, $filters[$column]);
}
}
$paginated = $query->paginate(15);
if (filled($filters['search'] ?? null)) {
$keyword = '%' . addcslashes($filters['search'], '%_\\') . '%';
$query->where(fn ($q) => $q->where('subject', 'like', $keyword)->orWhere('ticket_num', 'like', $keyword));
}
$paginated = $query->paginate($filters['per_page'] ?? 15);
return response()->json([
'error' => false,
'data' => [
'items' => TicketResource::collection($paginated->items()),
'items' => ClientTicketResource::collection($paginated->items()),
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
@@ -73,11 +84,7 @@ class TicketController extends Controller
$ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5));
$gallery = [];
foreach ($request->file('images', []) as $image) {
$path = $image->store('tickets', 'public');
$gallery[] = Storage::disk('public')->url($path);
}
$gallery = TicketGallery::store($request->file('images', []), $customer->id);
$ticket = $this->ticketRepository->create([
'uuid' => (string) Str::uuid(),
@@ -103,7 +110,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'message' => 'Ticket đã được khởi tạo.',
'data' => new TicketResource($ticket),
'data' => new ClientTicketResource($ticket),
], 201);
}
@@ -129,7 +136,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'data' => new TicketResource($ticket),
'data' => new ClientTicketResource($ticket),
]);
}
@@ -193,7 +200,7 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'message' => 'Phản hồi đã được gửi.',
'data' => new TicketReplyResource($reply),
'data' => new ClientTicketReplyResource($reply),
], 201);
}
@@ -228,7 +235,17 @@ class TicketController extends Controller
return response()->json([
'error' => false,
'message' => 'Ticket đã được đóng.',
'data' => new TicketResource($ticket->fresh()),
'data' => new ClientTicketResource($ticket->fresh()),
]);
}
public function image(string $ticket_num, int $index)
{
$ticket = Ticket::query()
->where('ticket_num', $ticket_num)
->where('customer_id', auth('client')->id())
->firstOrFail();
return TicketGallery::response($ticket, $index);
}
}
@@ -314,7 +314,7 @@ class ManualDepositController extends Controller
Mail::to($adminEmail)->send(new ManualDepositPendingNotification([
'company_name' => $settingValue['company'] ?? config('app.name', 'Portal Admin'),
'customer_name' => $customer->fullname ?: trim(($customer->firstname ?? '') . ' ' . ($customer->lastname ?? '')),
'customer_name' => (string) $customer->fullname,
'customer_email' => $customer->email,
'customer_phone' => $customer->phone ?? null,
'client_ip' => $request->ip(),
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources\Api\Client\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ClientTicketReplyResource extends JsonResource
{
public function toArray(Request $request): array
{
$fromStaff = $this->user_id !== null;
return [
'message' => $this->message,
'sender_type' => $fromStaff ? 'staff' : 'client',
'sender_name' => $fromStaff ? (optional($this->user)->name ?: 'Nhân viên hỗ trợ') : 'Khách hàng',
'sender_role' => $fromStaff ? 'Nhân viên hỗ trợ' : 'Khách hàng',
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Api\Client\Ticket;
use App\Support\TicketGallery;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ClientTicketResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'ticket_num' => $this->ticket_num,
'subject' => $this->subject,
'description' => $this->description,
'status' => $this->status,
'room' => $this->room,
'priority' => $this->resource->priority(),
'gallery' => TicketGallery::clientUrls($this->resource),
'customer' => $this->whenLoaded('customer', fn () => ['fullname' => $this->customer->fullname]),
'replies' => ClientTicketReplyResource::collection($this->whenLoaded('reply')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Resources\Api\Ticket;
use App\Support\TicketGallery;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -18,7 +19,7 @@ class TicketResource extends JsonResource
'status' => $this->status,
'room' => $this->room,
'data' => $this->data ? (is_string($this->data) ? json_decode($this->data, true) : $this->data) : null,
'gallery' => $this->gallery ?? [],
'gallery' => TicketGallery::adminUrls($this->resource),
'customer_id' => $this->customer_id,
'customer' => $this->whenLoaded('customer', fn() => [
'id' => $this->customer->id,
+1 -1
View File
@@ -117,7 +117,7 @@ class Hosting
])
->acceptJson()
->post("https://$this->url/api/v2/clients", [
'name' => $product_data->Customer->firstname . " " . $product_data->Customer->lastname,
'name' => (string) $product_data->Customer->fullname,
'company' => $product_data->Customer->company,
'login' => $product_data->Customer->email,
'email' => $product_data->Customer->email,
+1 -1
View File
@@ -82,7 +82,7 @@ class Telegram
{
$msg = "🔔 Yêu cầu nạp tiền chờ duyệt!\n";
$msg .= "--------------------------------\n";
$msg .= "👤 Khách: {$customer->firstname} {$customer->lastname}\n";
$msg .= "👤 Khách: {$customer->fullname}\n";
$msg .= "📧 Email: {$customer->email}\n";
$msg .= "💵 Số tiền: " . number_format($transaction->amount, 0, ',', '.') . " VNĐ\n";
$msg .= "🧾 Mã GD: {$transaction->public_code}\n";
+1 -1
View File
@@ -153,7 +153,7 @@ class CreateHostingJobs implements ShouldQueue
$data = [
'company_name' => json_decode($setting->value)->company,
'company_logo' => json_decode($setting->value)->logo,
'client_name' => $product->Customer->firstname . " " . $product->Customer->lastname,
'client_name' => (string) $product->Customer->fullname,
'product_name' => $product->GetInvoiceItem()->GetItemProduct()->name,
'product_billing' => Helper::BillingCycle($product->GetInvoiceItem()->billing),
'product_price' => number_format($product->GetInvoiceItem()->amount,0,',','.'),
+1 -1
View File
@@ -57,7 +57,7 @@ class ExpiryMail implements ShouldQueue
$data = [
'company_name' => json_decode($setting->value)->company,
'company_logo' => json_decode($setting->value)->logo,
'client_name' => $product->Customer->firstname . " " . $product->Customer->lastname,
'client_name' => (string) $product->Customer->fullname,
'product_name' => $product->GetInvoiceItem()->GetItemProduct()->name,
'service_due_exp' => Carbon::parse($product->next_due_date)->format('d/m/Y'),
'service_remaining' => (int) Carbon::now()->diffInDays(Carbon::parse($product->next_due_date), false) + 1
+2 -2
View File
@@ -38,10 +38,10 @@ class TicketJobs implements ShouldQueue
// Gửi thông báo Telegram khi tạo ticket mới
$message = "🆕 Ticket mới được tạo!\n";
$message .= "👤 Khách: {$this->customer->firstname} {$this->customer->lastname} ({$this->customer->email})\n";
$message .= "👤 Khách: {$this->customer->fullname} ({$this->customer->email})\n";
$message .= "🎫 Số ticket: #{$this->ticket->ticket_num}\n";
$message .= "📄 Tiêu đề: {$this->ticket->subject}\n";
$message .= "🏢 Phòng ban: {$this->ticket->room}\n";
$message .= "🏢 Phòng ban: {$this->ticket->roomLabel()}\n";
$message .= "📝 Nội dung: " . $cleanDescription;
// Kiểm tra độ dài message (Telegram giới hạn 4096 ký tự)
@@ -261,7 +261,7 @@ class AssignIpToGuestJob implements ShouldQueue
"🖥️ VM ID: <code>{$this->vmId}</code>\n" .
"📦 Sản phẩm: " . ($product?->name ?? 'N/A') . "\n" .
"🧾 Đơn hàng: #" . ($order?->order_num ?? 'N/A') . "\n" .
"👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" .
"👤 Khách hàng: " . ($customer?->fullname ?: 'N/A') . "\n" .
"📧 Email: " . ($customer?->email ?? 'N/A') . "\n" .
"🔁 Số lần thử: {$maxAttempts}\n" .
"⚡ Trạng thái: VM đã bị đánh dấu inactive\n" .
@@ -237,7 +237,7 @@ class UpdateVmConfigJob implements ShouldQueue
"━━━━━━━━━━━━━━━━━━━━\n" .
"🖥️ VM ID: <code>{$this->vmId}</code>\n" .
"📦 Server: <code>" . ($vm?->name ?? 'N/A') . "</code>\n" .
"👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" .
"👤 Khách hàng: " . ($customer?->fullname ?: 'N/A') . "\n" .
"📧 Email: " . ($customer?->email ?? 'N/A') . "\n" .
"❌ Lỗi: <code>" . htmlspecialchars($e->getMessage()) . "</code>\n" .
"⚡ Trạng thái: VM đã bị đánh dấu inactive\n" .
+4 -3
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
@@ -28,11 +29,11 @@ class TicketAdminNotification extends TemplateMailable implements ShouldQueue
return [
'ticket_num' => $this->ticket->ticket_num,
'subject_text' => $this->ticket->subject,
'room' => $this->ticket->room ?? 'N/A',
'customer_name' => trim($this->customer->firstname . ' ' . $this->customer->lastname) ?: 'N/A',
'room' => $this->ticket->roomLabel() ?? 'N/A',
'customer_name' => $this->customer->fullname ?: 'N/A',
'customer_email'=> $this->customer->email,
'description' => mb_substr($description, 0, 500) . (mb_strlen($description) > 500 ? '…' : ''),
'ticket_url' => config('app.frontend_url') . '/tickets/' . $this->ticket->ticket_num,
'ticket_url' => MailGlobals::frontendUrl() . '/tickets/' . $this->ticket->ticket_num,
];
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ class InvoiceNotificationMail extends TemplateMailable
}
return '<table style="width: 100%; border-collapse: collapse; margin-top: 16px;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>'
. '<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;">'
. '<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #1a1a1a;">'
. '<strong style="color: #374151;">Ghi chú:</strong> '
. htmlspecialchars((string) $this->invoice->note, ENT_QUOTES, 'UTF-8')
. '</td></tr></tbody></table>';
+1 -1
View File
@@ -107,7 +107,7 @@ class IpAllocationMail extends TemplateMailable
->map(fn (string $title) => '<td style="' . self::HEAD_CELL . '">' . $title . '</td>')
->implode('');
return '<table style="border: 1px solid #f6d2b8; border-radius: 10px; border-collapse: separate; overflow: hidden;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
return '<table style="' . EmailTemplateShell::TABLE_STYLE . '" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
. '<tr>' . $head . '</tr>' . $rows
. '</tbody></table>';
}
+7 -11
View File
@@ -3,6 +3,7 @@
namespace App\Mail;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
@@ -28,19 +29,14 @@ class TicketCreated extends TemplateMailable implements ShouldQueue
protected function variables(): array
{
$room = $this->ticket->room ?? null;
$room = $this->ticket->roomLabel();
return [
'full_name' => trim("{$this->customer->firstname} {$this->customer->lastname}") ?: 'Quý khách',
'ticket_num' => $this->ticket->ticket_num,
'subject' => $this->ticket->subject,
'room_row' => $room
? EmailTemplateShell::row('Phòng ban', e($room))
: '',
'ticket_url' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num,
'app_name' => config('app.name', 'Thế Giới Số'),
'support_email' => 'support@tgs.com.vn',
'support_phone' => '(028) 7309. 7379',
'full_name' => $this->customer->fullname ?: 'Quý khách',
'ticket_num' => $this->ticket->ticket_num,
'subject' => $this->ticket->subject,
'room_row' => $room ? EmailTemplateShell::row('Phòng ban', e($room)) : '',
'ticket_url' => MailGlobals::frontendUrl() . '/tickets/' . $this->ticket->ticket_num,
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AdminRefreshToken extends Model
{
protected $fillable = [
'user_id',
'token',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ class IpPoolLog extends Model
protected $fillable = [
'ip_pool_id', 'ip_address', 'action', 'effective_date', 'status_from', 'status_to',
'customer_id', 'customer_name', 'service_name', 'service_id', 'vm_name', 'email_to', 'user_id', 'note',
'customer_id', 'customer_name', 'service_name', 'service_id', 'vm_name', 'email_to', 'email_cc', 'email_bcc', 'user_id', 'note',
];
public static function record(IpPool $pool, string $action, string $statusFrom, array $attributes = []): self
+54
View File
@@ -5,11 +5,32 @@ namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class Ticket extends Model
{
use HasFactory, LogsActivity;
public const UNRESOLVED_STATUSES = ['open', 'pending'];
public const STATUS_LABELS = [
'open' => 'Mở',
'pending' => 'Đang xử lý',
'resolved' => 'Đã giải quyết',
'closed' => 'Đã đóng',
];
public const PRIORITY_LABELS = [
'low' => 'Thấp',
'medium' => 'Trung bình',
'high' => 'Cao',
];
public const ROOM_LABELS = [
'technical' => 'Kỹ thuật',
'sales' => 'Bán hàng',
];
protected $table = 'ticket';
protected $fillable = [
@@ -28,6 +49,39 @@ class Ticket extends Model
'gallery' => 'array',
];
public function priority(): ?string
{
$data = is_string($this->data) ? json_decode($this->data, true) : $this->data;
return is_array($data) ? ($data['priority'] ?? null) : null;
}
/**
* @return Collection<int, array{from: ?string, to: string, causer_type: ?string, causer_name: ?string, created_at: mixed}>
*/
public function statusHistory(): Collection
{
return ActivityLog::query()
->where('subject_type', self::class)
->where('subject_id', $this->id)
->whereIn('event', ['created', 'updated'])
->whereNotNull('new_values->status')
->latest('id')
->get(['old_values', 'new_values', 'causer_type', 'causer_name', 'created_at'])
->map(fn (ActivityLog $log) => [
'from' => $log->old_values['status'] ?? null,
'to' => $log->new_values['status'],
'causer_type' => $log->causer_type,
'causer_name' => $log->causer_name,
'created_at' => $log->created_at,
]);
}
public function roomLabel(): ?string
{
return $this->room ? (self::ROOM_LABELS[$this->room] ?? $this->room) : null;
}
public function customer()
{
return $this->belongsTo(\App\Models\Customer::class);
@@ -0,0 +1,68 @@
<?php
namespace App\Services\Api\Admin;
use App\Models\AdminRefreshToken;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tymon\JWTAuth\Facades\JWTAuth;
class AdminRefreshTokenService
{
public function create(int $userId, CarbonInterface $expiresAt): string
{
$rawToken = Str::random(64);
AdminRefreshToken::create([
'user_id' => $userId,
'token' => hash('sha256', $rawToken),
'expires_at' => $expiresAt,
]);
return $rawToken;
}
public function rotate(string $rawToken): array
{
return DB::transaction(function () use ($rawToken): array {
$record = AdminRefreshToken::where('token', hash('sha256', $rawToken))
->lockForUpdate()
->first();
if (! $record || $record->expires_at->isPast()) {
$record?->delete();
throw ValidationException::withMessages([
'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'],
]);
}
$user = $record->user;
if (! $user) {
$record->delete();
throw ValidationException::withMessages([
'refresh_token' => ['Tài khoản không hợp lệ.'],
]);
}
$expiresAt = $record->expires_at->copy();
$record->delete();
$newRefreshToken = $this->create($user->id, $expiresAt);
return [
'user' => $user,
'token' => JWTAuth::fromUser($user),
'refresh_token' => $newRefreshToken,
'refresh_expires_in' => (int) max(0, now()->diffInSeconds($expiresAt, false)),
];
});
}
public function revoke(string $rawToken): void
{
AdminRefreshToken::where('token', hash('sha256', $rawToken))->delete();
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ class OrderService
AdminOrderNotificationJob::dispatch([
'order_num' => $order->order_num,
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'customer_name' => (string) $customer->fullname,
'customer_email' => $customer->email,
'client_ip' => $order->client_ip ?? request()?->ip(),
'product_name' => $product?->name ?? 'N/A',
+21 -1
View File
@@ -45,6 +45,7 @@ class IpAllocationService
return DB::transaction(function () use ($ids, $customer, $data, $admin, $files) {
$pools = $this->allocatable($this->pools($ids, lock: true));
$copies = $this->emailCopies(IpAllocationMail::ALLOCATED, $data['cc'] ?? null);
foreach ($pools as $pool) {
$from = $pool->status;
@@ -67,6 +68,7 @@ class IpAllocationService
'customer_name' => $customer->fullname,
'service_name' => $pool->service_name,
'email_to' => $data['to'],
...$copies,
'note' => $data['note'] ?? null,
]);
}
@@ -116,6 +118,7 @@ class IpAllocationService
$groups = $this->revocationGroups($this->revocable($this->pools($ids, lock: true)));
return $groups->map(function (Collection $pools, string $key) use ($data, $recipients, $admin) {
$copies = $this->emailCopies(IpAllocationMail::REVOKED, $recipients[$key]['cc'] ?? null);
$mail = $this->revocationMail($pools, $data, $recipients[$key]['subject'] ?? null);
$customerId = $pools->first()->customer_id;
foreach ($pools as $pool) {
@@ -136,6 +139,7 @@ class IpAllocationService
...$before,
'effective_date' => $data['date'],
'email_to' => $recipients[$key]['to'],
...$copies,
'note' => $data['note'] ?? null,
]);
}
@@ -255,19 +259,35 @@ class IpAllocationService
}
}
/**
* @return array{email_cc: string|null, email_bcc: string|null}
*/
private function emailCopies(string $scenario, ?string $cc): array
{
$config = $this->templates->loadTemplate($scenario)->mailConfig();
$join = fn (array $emails) => $emails ? implode(', ', array_values(array_unique($emails))) : null;
return [
'email_cc' => $join([...EmailRecipients::parse($cc), ...($config['cc'] ?? [])]),
'email_bcc' => $join($config['bcc'] ?? []),
];
}
private function send(IpAllocationMail $mail, string $to, ?string $cc, ?int $customerId, User $admin): void
{
AutoEmailGate::markManual();
$template = $this->templates->loadTemplate($mail->type);
$toList = EmailRecipients::parse($to);
$ccList = EmailRecipients::parse($cc);
$copies = $this->emailCopies($mail->type, $cc);
MailLog::create([
'template_id' => $template->id,
'subject' => $mail->envelope()->subject,
'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'),
'to' => implode(', ', $toList),
'cc' => $ccList ? implode(', ', $ccList) : null,
'cc' => $copies['email_cc'],
'bcc' => $copies['email_bcc'],
'customer_id' => $customerId,
'sent_by' => $admin->id,
'message' => (clone $mail)->render(),
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services\IpPool;
use App\Models\Customer;
use App\Models\IpPool;
use Illuminate\Support\Facades\DB;
class IpPoolDashboard
{
public const TOP_CUSTOMERS = 15;
private const ADDON_ROLE = 'ADDON';
public function build(): array
{
$blocks = IpPool::query()
->selectRaw("COALESCE(NULLIF(ip_block, ''), '') as ip_block")
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(ip_role = ?) as addon', [self::ADDON_ROLE])
->selectRaw("SUM(status = 'assigned') as assigned")
->selectRaw("SUM(status = 'free') as free")
->selectRaw("SUM(status = 'reserved') as reserved")
->selectRaw('COALESCE(SUM(monthly_cost), 0) as cost')
->selectRaw("COALESCE(SUM(CASE WHEN status = 'assigned' THEN selling_price END), 0) as revenue")
->groupBy(DB::raw("COALESCE(NULLIF(ip_block, ''), '')"))
->orderBy('ip_block')
->get()
->map(fn ($row) => $this->withRates([
'ip_block' => $row->ip_block ?: null,
'total' => (int) $row->total,
'addon' => (int) $row->addon,
'assigned' => (int) $row->assigned,
'free' => (int) $row->free,
'reserved' => (int) $row->reserved,
'cost' => (float) $row->cost,
'revenue' => (float) $row->revenue,
]));
$summary = collect(['total', 'addon', 'assigned', 'free', 'reserved', 'cost', 'revenue'])
->mapWithKeys(fn (string $key) => [$key => $blocks->sum($key)])
->all();
$summary['primary'] = $summary['total'] - $summary['addon'];
return [
'summary' => $this->withRates($summary),
'blocks' => $blocks->values(),
'top_customers' => $this->topCustomers(),
];
}
private function withRates(array $stats): array
{
$profit = $stats['revenue'] - $stats['cost'];
return [
...$stats,
'usage_rate' => $stats['total'] ? $stats['assigned'] / $stats['total'] : 0,
'profit' => $profit,
'margin' => $stats['revenue'] ? $profit / $stats['revenue'] : null,
];
}
private function topCustomers(): array
{
$rows = IpPool::query()
->where('status', 'assigned')
->where(fn ($q) => $q->whereNotNull('customer_id')->orWhere('customer_name', '!=', ''))
->selectRaw('customer_id, MAX(customer_name) as customer_name')
->selectRaw('COUNT(*) as ips')
->selectRaw('COALESCE(SUM(selling_price), 0) as revenue')
->groupBy('customer_id', DB::raw('CASE WHEN customer_id IS NULL THEN customer_name END'))
->orderByDesc('ips')
->orderBy('customer_name')
->limit(self::TOP_CUSTOMERS)
->get();
$customers = Customer::whereIn('id', $rows->pluck('customer_id')->filter())->get(['id', 'uuid', 'fullname'])->keyBy('id');
return $rows->map(fn ($row) => [
'customer_uuid' => $customers[$row->customer_id]->uuid ?? null,
'customer_name' => $customers[$row->customer_id]->fullname ?? $row->customer_name,
'ips' => (int) $row->ips,
'revenue' => (float) $row->revenue,
])->all();
}
}
@@ -34,7 +34,7 @@ class VmNotificationService
VMCreationFailedJob::dispatch([
'customer_email' => $customer->email,
'customer_name' => trim($customer->firstname . ' ' . $customer->lastname),
'customer_name' => (string) $customer->fullname,
'product_name' => $product?->name ?? 'Unknown Product',
'order_id' => $item->order->id,
'order_num' => $item->order->order_num,
@@ -95,7 +95,7 @@ class VmNotificationService
try {
$order = $item->order;
$customer = $order->customer;
$name = trim($customer->firstname . ' ' . $customer->lastname);
$name = (string) $customer->fullname;
broadcast(new OrderProvisioned(
orderId: $order->id,
+11 -6
View File
@@ -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 &nbsp;-&nbsp; 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 &bull; CLOUD &bull; SERVER &amp; WEBSITE | 15+ năm Phụng Sự🚀</span>', '6px 30px 16px 30px');
+133
View File
@@ -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];
}
}