mirror of
https://github.com/dyzulk/trustlab-api.git
synced 2026-01-25 21:08:47 +07:00
First commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
112
.env.example
Normal file
112
.env.example
Normal file
@@ -0,0 +1,112 @@
|
||||
APP_NAME=TrustLab
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=localhost
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000,trustlab.dyzulk.com,trustlab.pages.dev
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=lab.dyzulk.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=noreply@lab.dyzulk.com
|
||||
MAIL_PASSWORD=
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS="noreply@lab.dyzulk.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
MAIL_SUPPORT_MAILER=smtp
|
||||
MAIL_SUPPORT_HOST=lab.dyzulk.com
|
||||
MAIL_SUPPORT_PORT=587
|
||||
MAIL_SUPPORT_USERNAME=support@lab.dyzulk.com
|
||||
MAIL_SUPPORT_PASSWORD=
|
||||
MAIL_SUPPORT_ENCRYPTION=tls
|
||||
MAIL_SUPPORT_FROM_ADDRESS="support@lab.dyzulk.com"
|
||||
MAIL_SUPPORT_FROM_NAME="${APP_NAME} Support"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
|
||||
REVERB_APP_ID=
|
||||
REVERB_APP_KEY=
|
||||
REVERB_APP_SECRET=
|
||||
REVERB_HOST="localhost"
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITHUB_REDIRECT_URI=${APP_URL}/api/auth/github/callback
|
||||
|
||||
GITHUB_DEV_CLIENT_ID=
|
||||
GITHUB_DEV_CLIENT_SECRET=
|
||||
GITHUB_DEV_REDIRECT_URI=${APP_URL}/api/auth/github/callback
|
||||
|
||||
GITHUB_PROD_CLIENT_ID=
|
||||
GITHUB_PROD_CLIENT_SECRET=
|
||||
GITHUB_PROD_REDIRECT_URI=${APP_URL}/api/auth/github/callback
|
||||
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=${APP_URL}/api/auth/google/callback
|
||||
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
112
.env.production.example
Normal file
112
.env.production.example
Normal file
@@ -0,0 +1,112 @@
|
||||
APP_NAME=TrustLab
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://trustlab-api.dyzulk.com
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=trustlab
|
||||
DB_USERNAME=trustlab
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=.dyzulk.com
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=trustlab.dyzulk.com
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=lab.dyzulk.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=noreply@lab.dyzulk.com
|
||||
MAIL_PASSWORD=
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS="noreply@lab.dyzulk.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
MAIL_SUPPORT_MAILER=smtp
|
||||
MAIL_SUPPORT_HOST=lab.dyzulk.com
|
||||
MAIL_SUPPORT_PORT=587
|
||||
MAIL_SUPPORT_USERNAME=support@lab.dyzulk.com
|
||||
MAIL_SUPPORT_PASSWORD=
|
||||
MAIL_SUPPORT_ENCRYPTION=tls
|
||||
MAIL_SUPPORT_FROM_ADDRESS="support@lab.dyzulk.com"
|
||||
MAIL_SUPPORT_FROM_NAME="${APP_NAME} Support"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
FRONTEND_URL=https://trustlab.dyzulk.com
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
|
||||
REVERB_APP_ID=
|
||||
REVERB_APP_KEY=
|
||||
REVERB_APP_SECRET=
|
||||
REVERB_HOST="trustlab-api.dyzulk.com"
|
||||
REVERB_PORT=443
|
||||
REVERB_SCHEME=https
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITHUB_REDIRECT_URI=${APP_URL}/api/auth/github/callback
|
||||
|
||||
GITHUB_DEV_CLIENT_ID=
|
||||
GITHUB_DEV_CLIENT_SECRET=
|
||||
GITHUB_DEV_REDIRECT_URI=${APP_URL}/api/auth/github/callback
|
||||
|
||||
GITHUB_PROD_CLIENT_ID=
|
||||
GITHUB_PROD_CLIENT_SECRET=
|
||||
GITHUB_PROD_REDIRECT_URI=${APP_URL}/api/auth/github/callback
|
||||
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=${APP_URL}/api/auth/google/callback
|
||||
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
*.sql
|
||||
*.sqlite
|
||||
.env.testing
|
||||
41
README.md
Normal file
41
README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# TrustLab API
|
||||
|
||||

|
||||
|
||||
**TrustLab API** is the robust backend engine powering the TrustLab ecosystem. Built on Laravel 12, it provides secure authentication, comprehensive role-based access control, and specialized services for CA (Certificate Authority) management and user support.
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
### 🔐 Advanced Authentication & Security
|
||||
- **Multi-Guard Auth**: Powered by Laravel Sanctum for secure SPA (Single Page Application) authentication.
|
||||
- **OAuth Integration**: "Mirror Callback" system handling Social Login (Google, GitHub) via Laravel Socialite.
|
||||
- **Role Hierarchy**:
|
||||
- `Owner` (Supreme): Full control, manages Admins and Customers.
|
||||
- `Admin`: Manages `Customers` only. Cannot modify Owners.
|
||||
- `Customer`: Standard user access.
|
||||
- **Email Verification**: Fully integrated verification flow with rigorous middleware protection (`verified`).
|
||||
- **Turnstile Protected**: Endpoints designed to work with frontend-only Cloudflare Turnstile gatekeeping.
|
||||
|
||||
### 📜 Core Services
|
||||
- **Certificate Management**: Logic for handling Certificate Authority operations (CSR, Keys, Signing).
|
||||
- **Ticket System**: Complete support desk backend with attachment support and admin-user communication channels.
|
||||
- **User Management**: Administrative endpoints for managing the user lifecycle (Ban, Promote, Verify).
|
||||
- **Inquiry System**: Public contact form handling with database persistence and notification triggers.
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Framework**: Laravel 12.x
|
||||
- **Database**: MySQL / MariaDB
|
||||
- **Authentication**: Laravel Sanctum
|
||||
- **Permissions**: `spatie/laravel-permission`
|
||||
- **Social Auth**: `laravel/socialite`
|
||||
- **Testing**: PHPUnit
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
- `app/Http/Controllers/Api`: Core API logic separated by domain (Admin, User, Public).
|
||||
- `app/Models`: Eloquent models with strict typing and relationship definitions.
|
||||
- `routes/api.php`: Centralized API route definitions grouped by middleware and version (`v1`).
|
||||
|
||||
---
|
||||
© 2024 TrustLab. All Internal Rights Reserved.
|
||||
84
app/Console/Commands/NotifyCertificateExpirations.php
Normal file
84
app/Console/Commands/NotifyCertificateExpirations.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NotifyCertificateExpirations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'certificates:notify-expiring';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Command description';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$now = now();
|
||||
$certificates = \App\Models\Certificate::where('valid_to', '>', $now)
|
||||
->where('valid_to', '<', $now->copy()->addDays(30))
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
foreach ($certificates as $certificate) {
|
||||
$user = $certificate->user;
|
||||
if (!$user || !$user->settings_certificate_renewal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysRemaining = $now->diffInDays($certificate->valid_to, false);
|
||||
$daysRemaining = (int) ceil($daysRemaining); // Ensure integer
|
||||
|
||||
// Check if we already sent a notification TODAY for this certificate to avoid spamming
|
||||
$alreadyNotifiedToday = $user->notifications()
|
||||
->where('type', 'App\Notifications\CertificateExpiringNotification')
|
||||
->where('data->certificate_id', $certificate->id)
|
||||
->where('created_at', '>=', $now->copy()->startOfDay())
|
||||
->exists();
|
||||
|
||||
if ($alreadyNotifiedToday) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send Notification (Handles both Database/Bell and Mail channels based on days remaining)
|
||||
$user->notify(new \App\Notifications\CertificateExpiringNotification($certificate, $daysRemaining));
|
||||
|
||||
$this->info("Sent notification to {$user->email} for certificate {$certificate->common_name} (Expires in {$daysRemaining} days)");
|
||||
}
|
||||
|
||||
// 2. Check for ALREADY EXPIRED certificates that haven't been notified of expiration yet
|
||||
$expiredCertificates = \App\Models\Certificate::where('valid_to', '<', $now)
|
||||
->whereNull('expired_notification_sent_at')
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
foreach ($expiredCertificates as $certificate) {
|
||||
$user = $certificate->user;
|
||||
if (!$user || !$user->settings_certificate_renewal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($user->email) {
|
||||
\Illuminate\Support\Facades\Mail::to($user->email)->send(new \App\Mail\CertificateExpiredMail($certificate));
|
||||
|
||||
// Mark as notified so we don't spam
|
||||
$certificate->update(['expired_notification_sent_at' => $now]);
|
||||
|
||||
$this->info("Sent EXPIRED email to {$user->email} for certificate {$certificate->common_name}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Certificate expiration check completed.');
|
||||
}
|
||||
}
|
||||
18
app/Helpers/UuidHelper.php
Normal file
18
app/Helpers/UuidHelper.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UuidHelper
|
||||
{
|
||||
/**
|
||||
* Generate a unique 32-character hexadecimal UUID (without dashes).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function generate()
|
||||
{
|
||||
return str_replace('-', '', (string) Str::uuid());
|
||||
}
|
||||
}
|
||||
178
app/Http/Controllers/Api/Admin/LegalPageController.php
Normal file
178
app/Http/Controllers/Api/Admin/LegalPageController.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LegalPage;
|
||||
use App\Models\LegalPageRevision;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LegalPageController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$pages = LegalPage::with(['latestRevision' => function ($query) {
|
||||
$query->orderBy('major', 'desc')
|
||||
->orderBy('minor', 'desc')
|
||||
->orderBy('patch', 'desc');
|
||||
}])->get();
|
||||
return response()->json(['data' => $pages]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$legalPage = LegalPage::findOrFail($id);
|
||||
|
||||
// Manual load latest revision
|
||||
$latestRevision = $legalPage->revisions()
|
||||
->orderBy('major', 'desc')
|
||||
->orderBy('minor', 'desc')
|
||||
->orderBy('patch', 'desc')
|
||||
->first();
|
||||
|
||||
$legalPage->setRelation('latestRevision', $latestRevision);
|
||||
|
||||
return response()->json(['data' => $legalPage]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'status' => 'required|in:draft,published',
|
||||
]);
|
||||
|
||||
$slug = Str::slug($request->title);
|
||||
|
||||
// Check if page exists
|
||||
$page = LegalPage::where('slug', $slug)->first();
|
||||
|
||||
if ($page) {
|
||||
// Smart Versioning: If exists, increment Major version automatically for "Create" flow
|
||||
$maxMajor = $page->revisions()->max('major') ?? 0;
|
||||
$major = $maxMajor + 1;
|
||||
|
||||
// Auto-Archive Logic: If new version is published, archive others
|
||||
if ($request->status === 'published') {
|
||||
$page->revisions()->where('status', 'published')->update(['status' => 'archived']);
|
||||
}
|
||||
|
||||
$page->revisions()->create([
|
||||
'content' => $request->content,
|
||||
'major' => $major,
|
||||
'minor' => 0,
|
||||
'patch' => 0,
|
||||
'status' => $request->status,
|
||||
'published_at' => $request->status === 'published' ? now() : null,
|
||||
'change_log' => 'Created via New Page (Auto-increment Major)',
|
||||
'is_active' => true,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $page, 'message' => 'New major version created for existing Legal Page'], 201);
|
||||
} else {
|
||||
// Create New
|
||||
$page = LegalPage::create([
|
||||
'title' => $request->title,
|
||||
'slug' => $slug,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Initial create is always 1.0.0
|
||||
$page->revisions()->create([
|
||||
'content' => $request->content,
|
||||
'major' => 1,
|
||||
'minor' => 0,
|
||||
'patch' => 0,
|
||||
'status' => $request->status,
|
||||
'published_at' => $request->status === 'published' ? now() : null,
|
||||
'change_log' => 'Initial creation',
|
||||
'is_active' => true,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $page, 'message' => 'Legal page created successfully'], 201);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, LegalPage $legalPage)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'string|max:255',
|
||||
'content' => 'required|string',
|
||||
'version_type' => 'required|in:major,minor,patch', // 'major', 'minor', 'patch'
|
||||
'parent_major' => 'nullable|integer',
|
||||
'parent_minor' => 'nullable|integer',
|
||||
'status' => 'required|in:draft,published',
|
||||
'change_log' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($request->has('title')) {
|
||||
$legalPage->update(['title' => $request->title]);
|
||||
}
|
||||
|
||||
// Calculate Version
|
||||
$major = 0; $minor = 0; $patch = 0;
|
||||
|
||||
if ($request->version_type === 'major') {
|
||||
$maxMajor = $legalPage->revisions()->max('major') ?? 0;
|
||||
$major = $maxMajor + 1;
|
||||
$minor = 0;
|
||||
$patch = 0;
|
||||
} elseif ($request->version_type === 'minor') {
|
||||
if (!$request->parent_major) return response()->json(['message' => 'Parent Major required for Minor version'], 422);
|
||||
$maxMinor = $legalPage->revisions()
|
||||
->where('major', $request->parent_major)
|
||||
->max('minor') ?? -1;
|
||||
$major = $request->parent_major;
|
||||
$minor = $maxMinor + 1;
|
||||
$patch = 0;
|
||||
} elseif ($request->version_type === 'patch') {
|
||||
if (!$request->parent_major || is_null($request->parent_minor)) return response()->json(['message' => 'Parent Major and Minor required for Patch'], 422);
|
||||
$maxPatch = $legalPage->revisions()
|
||||
->where('major', $request->parent_major)
|
||||
->where('minor', $request->parent_minor)
|
||||
->max('patch') ?? -1;
|
||||
$major = $request->parent_major;
|
||||
$minor = $request->parent_minor;
|
||||
$patch = $maxPatch + 1;
|
||||
}
|
||||
|
||||
// Auto-Archive Logic: If new version is published, archive others
|
||||
if ($request->status === 'published') {
|
||||
$legalPage->revisions()->where('status', 'published')->update(['status' => 'archived']);
|
||||
}
|
||||
|
||||
$legalPage->revisions()->create([
|
||||
'content' => $request->content,
|
||||
'major' => $major,
|
||||
'minor' => $minor,
|
||||
'patch' => $patch,
|
||||
'status' => $request->status,
|
||||
'published_at' => $request->status === 'published' ? now() : null,
|
||||
'change_log' => $request->change_log ?? 'Updated content',
|
||||
'is_active' => true,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $legalPage, 'message' => 'Legal page updated with new revision']);
|
||||
}
|
||||
|
||||
public function getHistory($id) {
|
||||
$legalPage = LegalPage::findOrFail($id);
|
||||
$revisions = $legalPage->revisions()
|
||||
->orderBy('major', 'desc')
|
||||
->orderBy('minor', 'desc')
|
||||
->orderBy('patch', 'desc')
|
||||
->get();
|
||||
return response()->json(['data' => $revisions]);
|
||||
}
|
||||
|
||||
public function destroy(LegalPage $legalPage)
|
||||
{
|
||||
$legalPage->delete();
|
||||
return response()->json(['message' => 'Legal page deleted']);
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Api/ApiKeyController.php
Normal file
106
app/Http/Controllers/Api/ApiKeyController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ApiKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ApiKeyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of personal access tokens.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $request->user()->apiKeys()->orderBy('created_at', 'desc')->get()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personal access token.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$key = ApiKey::generate();
|
||||
|
||||
$apiKey = $request->user()->apiKeys()->create([
|
||||
'name' => $request->name,
|
||||
'key' => $key,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'API Key created successfully',
|
||||
'token' => $key,
|
||||
'key' => $apiKey
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a personal access token.
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
$apiKey = Auth::user()->apiKeys()->where('id', $id)->first();
|
||||
|
||||
if (!$apiKey) {
|
||||
return response()->json(['message' => 'API Key not found'], 404);
|
||||
}
|
||||
|
||||
$apiKey->delete();
|
||||
|
||||
return response()->json(['message' => 'API Key revoked successfully']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active status of an API Key.
|
||||
*/
|
||||
public function toggle($id)
|
||||
{
|
||||
$apiKey = Auth::user()->apiKeys()->where('id', $id)->first();
|
||||
|
||||
if (!$apiKey) {
|
||||
return response()->json(['message' => 'API Key not found'], 404);
|
||||
}
|
||||
|
||||
$apiKey->update([
|
||||
'is_active' => !$apiKey->is_active
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'API Key status updated successfully',
|
||||
'is_active' => $apiKey->is_active
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the contents of an API Key.
|
||||
*/
|
||||
public function regenerate($id)
|
||||
{
|
||||
$apiKey = Auth::user()->apiKeys()->where('id', $id)->first();
|
||||
|
||||
if (!$apiKey) {
|
||||
return response()->json(['message' => 'API Key not found'], 404);
|
||||
}
|
||||
|
||||
$newKey = ApiKey::generate();
|
||||
|
||||
$apiKey->update([
|
||||
'key' => $newKey,
|
||||
'last_used_at' => null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'API Key regenerated successfully',
|
||||
'token' => $newKey
|
||||
]);
|
||||
}
|
||||
}
|
||||
241
app/Http/Controllers/Api/CertificateApiController.php
Normal file
241
app/Http/Controllers/Api/CertificateApiController.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Certificate;
|
||||
use App\Models\CaCertificate;
|
||||
use App\Services\OpenSslService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Traits\LogsActivity;
|
||||
use App\Notifications\CertificateNotification;
|
||||
|
||||
class CertificateApiController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
protected $sslService;
|
||||
|
||||
public function __construct(OpenSslService $sslService)
|
||||
{
|
||||
$this->sslService = $sslService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List user certificates.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
|
||||
$query = Certificate::where('user_id', Auth::id());
|
||||
|
||||
if ($search) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('common_name', 'like', "%{$search}%")
|
||||
->orWhere('serial_number', 'like', "%{$search}%")
|
||||
->orWhere('san', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$certificates = $query->latest()->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $certificates,
|
||||
'ca_status' => $this->getCaStatus()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new certificate.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'common_name' => 'required|string|max:255',
|
||||
'config_mode' => 'required|in:default,manual',
|
||||
'organization' => 'nullable|required_if:config_mode,manual|string|max:255',
|
||||
'locality' => 'nullable|required_if:config_mode,manual|string|max:255',
|
||||
'state' => 'nullable|required_if:config_mode,manual|string|max:255',
|
||||
'country' => 'nullable|required_if:config_mode,manual|string|size:2',
|
||||
'san' => 'nullable|string',
|
||||
'key_bits' => 'required|in:2048,4096',
|
||||
'is_test_short_lived' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
if (!empty($validated['is_test_short_lived']) && !Auth::user()->isAdminOrOwner()) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Unauthorized for test mode'], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($validated['config_mode'] === 'default') {
|
||||
$defaults = Config::get('openssl.ca_leaf_default');
|
||||
$validated['organization'] = $defaults['organizationName'];
|
||||
$validated['locality'] = $defaults['localityName'];
|
||||
$validated['state'] = $defaults['stateOrProvinceName'];
|
||||
$validated['country'] = $defaults['countryName'];
|
||||
}
|
||||
|
||||
$result = $this->sslService->generateLeaf($validated);
|
||||
|
||||
$certificate = Certificate::create([
|
||||
'user_id' => Auth::id(),
|
||||
'common_name' => $validated['common_name'],
|
||||
'organization' => $validated['organization'],
|
||||
'locality' => $validated['locality'],
|
||||
'state' => $validated['state'],
|
||||
'country' => $validated['country'],
|
||||
'san' => $validated['san'],
|
||||
'key_bits' => $validated['key_bits'],
|
||||
'serial_number' => $result['serial'],
|
||||
'cert_content' => $result['cert'],
|
||||
'key_content' => $result['key'],
|
||||
'csr_content' => $result['csr'],
|
||||
'valid_from' => $result['valid_from'],
|
||||
'valid_to' => $result['valid_to'],
|
||||
]);
|
||||
|
||||
$this->logActivity('issue_cert', "Issued certificate for {$certificate->common_name}");
|
||||
|
||||
// Notify User
|
||||
try {
|
||||
Auth::user()->notify(new CertificateNotification($certificate, 'issued'));
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Failed to send certificate notification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Certificate generated successfully',
|
||||
'data' => $certificate
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Certificate generation failed: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to generate certificate: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show certificate details.
|
||||
*/
|
||||
public function show(Certificate $certificate)
|
||||
{
|
||||
$this->authorizeOwner($certificate);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $certificate
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a certificate.
|
||||
*/
|
||||
public function destroy(Certificate $certificate)
|
||||
{
|
||||
$this->authorizeOwner($certificate);
|
||||
$commonName = $certificate->common_name;
|
||||
$certificate->delete();
|
||||
|
||||
$this->logActivity('delete_cert', "Deleted certificate for {$commonName}");
|
||||
|
||||
// Notify User
|
||||
try {
|
||||
Auth::user()->notify(new CertificateNotification($certificate, 'revoked'));
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Failed to send certificate revocation notification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Certificate deleted successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CA (Admin only).
|
||||
*/
|
||||
public function setupCa()
|
||||
{
|
||||
if (!Auth::user()->isAdminOrOwner()) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Allow setup if any of the required CA types are missing
|
||||
$status = $this->getCaStatus();
|
||||
if ($status['is_ready']) {
|
||||
return response()->json(['status' => 'error', 'message' => 'CA already fully initialized'], 400);
|
||||
}
|
||||
|
||||
if ($this->sslService->setupCa()) {
|
||||
return response()->json(['status' => 'success', 'message' => 'CA successfully initialized']);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error', 'message' => 'Failed to initialize CA'], 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download certificate files.
|
||||
*/
|
||||
public function downloadFile(Certificate $certificate, $type)
|
||||
{
|
||||
$this->authorizeOwner($certificate);
|
||||
|
||||
$content = match($type) {
|
||||
'cert' => $certificate->cert_content,
|
||||
'key' => $certificate->key_content,
|
||||
'csr' => $certificate->csr_content,
|
||||
default => abort(404)
|
||||
};
|
||||
|
||||
$extension = match($type) {
|
||||
'cert' => 'crt',
|
||||
'key' => 'key',
|
||||
'csr' => 'csr',
|
||||
};
|
||||
|
||||
$filename = Str::slug($certificate->common_name) . '.' . $extension;
|
||||
|
||||
return response($content)
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('Content-Disposition', "attachment; filename={$filename}");
|
||||
}
|
||||
|
||||
protected function getCaStatus()
|
||||
{
|
||||
$root = CaCertificate::where('ca_type', 'root')->exists();
|
||||
$int2048 = CaCertificate::where('ca_type', 'intermediate_2048')->exists();
|
||||
$int4096 = CaCertificate::where('ca_type', 'intermediate_4096')->exists();
|
||||
|
||||
return [
|
||||
'root' => $root,
|
||||
'intermediate_2048' => $int2048,
|
||||
'intermediate_4096' => $int4096,
|
||||
'is_ready' => $root && $int2048 && $int4096,
|
||||
'missing' => array_keys(array_filter([
|
||||
'root' => !$root,
|
||||
'intermediate_2048' => !$int2048,
|
||||
'intermediate_4096' => !$int4096,
|
||||
]))
|
||||
];
|
||||
}
|
||||
|
||||
protected function authorizeOwner(Certificate $certificate)
|
||||
{
|
||||
if ($certificate->user_id !== Auth::id()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/Http/Controllers/Api/DashboardController.php
Normal file
160
app/Http/Controllers/Api/DashboardController.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
use App\Models\Certificate;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Inquiry;
|
||||
use App\Models\ActivityLog;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Helper to calculate percentage change
|
||||
$getTrend = function($current, $previous) {
|
||||
if ($previous == 0) return $current > 0 ? 100 : 0;
|
||||
return round((($current - $previous) / $previous) * 100, 1);
|
||||
};
|
||||
|
||||
// Basic Stats
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$previousMonth = now()->subMonth()->startOfMonth();
|
||||
|
||||
// Certificates (Scoped to User)
|
||||
$totalCertificates = Certificate::where('user_id', $user->id)->count();
|
||||
$prevCertificates = Certificate::where('user_id', $user->id)->where('created_at', '<', $currentMonth)->count();
|
||||
|
||||
// Active Certificates (Scoped to User)
|
||||
$activeCertificates = Certificate::where('user_id', $user->id)->where('status', 'ISSUED')->where('valid_to', '>', now())->count();
|
||||
$prevActiveCertificates = Certificate::where('user_id', $user->id)->where('status', 'ISSUED')->where('valid_to', '>', now()->subMonth())->where('created_at', '<', $currentMonth)->count();
|
||||
|
||||
// Expired (Scoped to User)
|
||||
$expiredCertificates = Certificate::where('user_id', $user->id)->where('valid_to', '<', now())->count();
|
||||
|
||||
// Tickets (Role Based)
|
||||
$ticketQuery = Ticket::query()->whereIn('status', ['open', 'answered']);
|
||||
if (!$user->isAdmin()) {
|
||||
$ticketQuery->where('user_id', $user->id);
|
||||
}
|
||||
$activeTickets = $ticketQuery->count();
|
||||
|
||||
// Previous Tickets (Role Based)
|
||||
$prevTicketQuery = Ticket::query()->whereIn('status', ['open', 'answered'])->where('created_at', '<', $currentMonth);
|
||||
if (!$user->isAdmin()) {
|
||||
$prevTicketQuery->where('user_id', $user->id);
|
||||
}
|
||||
$prevActiveTickets = $prevTicketQuery->count();
|
||||
|
||||
$stats = [
|
||||
'total_certificates' => [
|
||||
'value' => $totalCertificates,
|
||||
'trend' => $getTrend($totalCertificates, $prevCertificates),
|
||||
'trend_label' => 'vs last month'
|
||||
],
|
||||
'active_certificates' => [
|
||||
'value' => $activeCertificates,
|
||||
'trend' => $getTrend($activeCertificates, $prevActiveCertificates),
|
||||
'trend_label' => 'vs last month'
|
||||
],
|
||||
'expired_certificates' => [
|
||||
'value' => $expiredCertificates,
|
||||
'trend' => 0,
|
||||
'trend_label' => 'vs last month'
|
||||
],
|
||||
'active_tickets' => [
|
||||
'value' => $activeTickets,
|
||||
'trend' => $getTrend($activeTickets, $prevActiveTickets),
|
||||
'trend_label' => 'vs last month'
|
||||
],
|
||||
];
|
||||
|
||||
// Admin only stats
|
||||
if ($user->isAdmin()) {
|
||||
$totalUsers = User::count();
|
||||
$prevUsers = User::where('created_at', '<', $currentMonth)->count();
|
||||
|
||||
$stats['total_users'] = [
|
||||
'value' => $totalUsers,
|
||||
'trend' => $getTrend($totalUsers, $prevUsers),
|
||||
'trend_label' => 'vs last month'
|
||||
];
|
||||
|
||||
// Inquiries - trend calculation for "Pending" is hard, so we just wrap value to keep consistent structure
|
||||
$stats['pending_inquiries'] = [
|
||||
'value' => Inquiry::where('status', 'unread')->count(),
|
||||
];
|
||||
|
||||
// CA Certificate Downloads
|
||||
$caDownloads = \App\Models\CaCertificate::select('ca_type', 'download_count')->get();
|
||||
foreach ($caDownloads as $ca) {
|
||||
$stats['ca_downloads_' . $ca->ca_type] = [
|
||||
'value' => $ca->download_count ?? 0,
|
||||
'label' => str_replace('_', ' ', strtoupper($ca->ca_type)) . ' Downloads'
|
||||
];
|
||||
}
|
||||
|
||||
$stats['recent_users'] = User::latest()->take(5)->get(['id', 'first_name', 'last_name', 'email', 'created_at']);
|
||||
}
|
||||
|
||||
// Recent Activity
|
||||
$activityLogQuery = ActivityLog::with('user:id,first_name,last_name,email,avatar')
|
||||
->latest()
|
||||
->take(10);
|
||||
|
||||
if (!$user->isAdmin()) {
|
||||
$activityLogQuery->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
$recentActivity = $activityLogQuery->get()->map(function($log) {
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'user_name' => $log->user ? $log->user->first_name . ' ' . $log->user->last_name : 'System',
|
||||
'user_avatar' => $log->user ? $log->user->avatar : null,
|
||||
'action' => $log->action,
|
||||
'description' => $log->description,
|
||||
'created_at' => $log->created_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
// Chart Data (Certificate Issuance Trend - Last 7 Days)
|
||||
$chartData = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$date = now()->subDays($i)->format('Y-m-d');
|
||||
$countQuery = Certificate::whereDate('created_at', $date);
|
||||
if (!$user->isAdmin()) {
|
||||
$countQuery->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
$chartData[] = [
|
||||
'date' => $date,
|
||||
'day' => now()->subDays($i)->format('D'),
|
||||
'count' => $countQuery->count()
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'stats' => $stats,
|
||||
'recent_activity' => $recentActivity,
|
||||
'chart_data' => $chartData,
|
||||
'server_time' => now()->toIso8601String(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function ping()
|
||||
{
|
||||
return response()->json([
|
||||
'pong' => true,
|
||||
'time' => microtime(true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Api/InquiryController.php
Normal file
118
app/Http/Controllers/Api/InquiryController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Inquiry;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewInquiryNotification;
|
||||
use App\Mail\InquiryReplyMail;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class InquiryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a new inquiry (Public).
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'category' => 'required|string|max:255',
|
||||
'subject' => 'required|string|max:255',
|
||||
'message' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$inquiry = Inquiry::create($request->all());
|
||||
|
||||
try {
|
||||
// Notify all admins
|
||||
$admins = User::where('role', 'admin')->get();
|
||||
Notification::send($admins, new NewInquiryNotification($inquiry));
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but fail silently to the user, as the inquiry was saved.
|
||||
\Illuminate\Support\Facades\Log::error('Failed to send NewInquiryNotification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Your message has been sent successfully. We will get back to you soon!',
|
||||
'inquiry' => $inquiry
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all inquiries (Admin).
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$search = $request->query('search');
|
||||
$status = $request->query('status');
|
||||
|
||||
$query = Inquiry::query();
|
||||
|
||||
if ($search) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
->orWhere('subject', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$inquiries = $query->orderBy('created_at', 'desc')->paginate(10);
|
||||
|
||||
return response()->json($inquiries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a specific inquiry (Admin).
|
||||
*/
|
||||
public function show(Inquiry $inquiry)
|
||||
{
|
||||
return response()->json($inquiry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an inquiry (Admin).
|
||||
*/
|
||||
public function destroy(Inquiry $inquiry)
|
||||
{
|
||||
$inquiry->delete();
|
||||
return response()->json(['message' => 'Inquiry deleted successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to an inquiry (Admin).
|
||||
*/
|
||||
public function reply(Request $request, Inquiry $inquiry)
|
||||
{
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Send email using the support mailer
|
||||
Mail::mailer('support')->to($inquiry->email)->send(new \App\Mail\InquiryReplyMail($inquiry, $request->message));
|
||||
|
||||
$inquiry->update([
|
||||
'status' => 'replied',
|
||||
'replied_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Reply sent successfully.']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => 'Failed to send reply: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/Api/LegalPageController.php
Normal file
42
app/Http/Controllers/Api/LegalPageController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LegalPage;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LegalPageController extends Controller
|
||||
{
|
||||
public function show($slug)
|
||||
{
|
||||
$page = LegalPage::where('slug', $slug)->where('is_active', true)->firstOrFail();
|
||||
|
||||
// Robustly fetch the latest active revision that is published
|
||||
$latestRevision = $page->revisions()
|
||||
->where('is_active', true)
|
||||
->where('status', 'published')
|
||||
->orderBy('major', 'desc')
|
||||
->orderBy('minor', 'desc')
|
||||
->orderBy('patch', 'desc')
|
||||
->first();
|
||||
|
||||
if (!$latestRevision) {
|
||||
return response()->json(['message' => 'Content not available'], 404);
|
||||
}
|
||||
|
||||
// Manually attach for response structure if needed, or just build response
|
||||
return response()->json(['data' => [
|
||||
'title' => $page->title,
|
||||
'content' => $latestRevision->content,
|
||||
'updated_at' => $latestRevision->created_at,
|
||||
'version' => $latestRevision->version,
|
||||
]]);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$pages = LegalPage::where('is_active', true)->select('title', 'slug')->get();
|
||||
return response()->json(['data' => $pages]);
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/Api/MailController.php
Normal file
64
app/Http/Controllers/Api/MailController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\TestMail;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class MailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a test email.
|
||||
*/
|
||||
public function sendTestEmail(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'mailer' => 'required|string|in:smtp,support',
|
||||
]);
|
||||
|
||||
$mailer = $request->mailer;
|
||||
$recipient = $request->email;
|
||||
$host = Config::get("mail.mailers.{$mailer}.host");
|
||||
|
||||
try {
|
||||
Mail::mailer($mailer)->to($recipient)->send(new TestMail($mailer, $host));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Test email successfully sent via {$mailer} mailer.",
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "Failed to send email: " . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mailer configurations (excluding passwords).
|
||||
*/
|
||||
public function getConfigurations()
|
||||
{
|
||||
$configs = [
|
||||
'smtp' => [
|
||||
'host' => config('mail.mailers.smtp.host'),
|
||||
'port' => config('mail.mailers.smtp.port'),
|
||||
'encryption' => config('mail.mailers.smtp.encryption'),
|
||||
'from' => config('mail.from.address'),
|
||||
],
|
||||
'support' => [
|
||||
'host' => config('mail.mailers.support.host'),
|
||||
'port' => config('mail.mailers.support.port'),
|
||||
'encryption' => config('mail.mailers.support.encryption'),
|
||||
'from' => config('mail.mailers.support.from.address'),
|
||||
],
|
||||
];
|
||||
|
||||
return response()->json($configs);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Api/NotificationController.php
Normal file
70
app/Http/Controllers/Api/NotificationController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of notifications.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$query = $user->notifications();
|
||||
|
||||
// Filter by state
|
||||
if ($request->has('filter')) {
|
||||
if ($request->filter === 'unread') {
|
||||
$query = $user->unreadNotifications();
|
||||
} elseif ($request->filter === 'read') {
|
||||
$query = $user->readNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
// Search in data (JSON)
|
||||
if ($request->has('search')) {
|
||||
$search = $request->search;
|
||||
$query->where('data', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$notifications = $query->latest()->paginate(10);
|
||||
|
||||
return response()->json($notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a specific notification as read.
|
||||
*/
|
||||
public function markAsRead(Request $request, $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
|
||||
return response()->json(['message' => 'Notification marked as read']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read.
|
||||
*/
|
||||
public function markAllAsRead(Request $request)
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['message' => 'All notifications marked as read']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified notification.
|
||||
*/
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->delete();
|
||||
|
||||
return response()->json(['message' => 'Notification deleted']);
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Api/PasswordResetController.php
Normal file
96
app/Http/Controllers/Api/PasswordResetController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class PasswordResetController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request)
|
||||
{
|
||||
$request->validate(['email' => 'required|email']);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (!$user) {
|
||||
// We return a "success" message anyway to prevent email enumeration
|
||||
return response()->json(['message' => 'Jika email tersebut terdaftar, kami akan mengirimkan link reset password.']);
|
||||
}
|
||||
|
||||
// Generate a token
|
||||
$token = Str::random(64);
|
||||
|
||||
// Store token in password_reset_tokens table
|
||||
DB::table('password_reset_tokens')->updateOrInsert(
|
||||
['email' => $request->email],
|
||||
[
|
||||
'token' => Hash::make($token),
|
||||
'created_at' => Carbon::now()
|
||||
]
|
||||
);
|
||||
|
||||
// Send Email
|
||||
$resetUrl = config('app.frontend_url') . '/reset-password?token=' . $token . '&email=' . urlencode($request->email);
|
||||
|
||||
Mail::send('emails.password-reset', ['url' => $resetUrl, 'name' => $user->first_name], function ($message) use ($request) {
|
||||
$message->to($request->email);
|
||||
$message->subject('Reset Password - TrustLab');
|
||||
});
|
||||
|
||||
return response()->json(['message' => 'Reset link sent to your email.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*/
|
||||
public function resetPassword(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$reset = DB::table('password_reset_tokens')
|
||||
->where('email', $request->email)
|
||||
->first();
|
||||
|
||||
if (!$reset || !Hash::check($request->token, $reset->token)) {
|
||||
return response()->json(['message' => 'Invalid or expired token.'], 400);
|
||||
}
|
||||
|
||||
// Check expiry (e.g., 60 minutes)
|
||||
if (Carbon::parse($reset->created_at)->addMinutes(60)->isPast()) {
|
||||
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
|
||||
return response()->json(['message' => 'Token has expired.'], 400);
|
||||
}
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'User not found.'], 404);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
// Delete token
|
||||
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
|
||||
|
||||
return response()->json(['message' => 'Password reset successful.']);
|
||||
}
|
||||
}
|
||||
356
app/Http/Controllers/Api/ProfileController.php
Normal file
356
app/Http/Controllers/Api/ProfileController.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'nullable|string|max:255',
|
||||
'last_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255|unique:users,email,' . $user->id,
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'bio' => 'nullable|string',
|
||||
'job_title' => 'nullable|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'country' => 'nullable|string|max:255',
|
||||
'city_state' => 'nullable|string|max:255',
|
||||
'postal_code' => 'nullable|string|max:20',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'facebook' => 'nullable|string|max:255',
|
||||
'twitter' => 'nullable|string|max:255',
|
||||
'linkedin' => 'nullable|string|max:255',
|
||||
'instagram' => 'nullable|string|max:255',
|
||||
'settings_email_alerts' => 'sometimes|boolean',
|
||||
'settings_certificate_renewal' => 'sometimes|boolean',
|
||||
'default_landing_page' => 'sometimes|string|max:255',
|
||||
'theme' => 'sometimes|string|in:light,dark,system',
|
||||
'language' => 'sometimes|string|max:10',
|
||||
]);
|
||||
|
||||
// Handle Email Change Logic
|
||||
if (isset($validated['email']) && $validated['email'] !== $user->email) {
|
||||
$pendingEmail = $validated['email'];
|
||||
|
||||
// Basic check to avoid duplication with other pending_emails if necessary,
|
||||
// but unique:users,email already covers the main one.
|
||||
|
||||
$user->pending_email = $pendingEmail;
|
||||
$user->save();
|
||||
|
||||
// Send notification to the NEW email
|
||||
$user->notify(new \App\Notifications\PendingEmailVerificationNotification);
|
||||
|
||||
// Remove email from validated so it doesn't update the primary email yet
|
||||
unset($validated['email']);
|
||||
}
|
||||
|
||||
// Sanitize social links to store only usernames
|
||||
if (isset($validated['facebook'])) $validated['facebook'] = $this->extractUsername($validated['facebook'], 'facebook.com');
|
||||
if (isset($validated['twitter'])) $validated['twitter'] = $this->extractUsername($validated['twitter'], ['twitter.com', 'x.com']);
|
||||
if (isset($validated['linkedin'])) $validated['linkedin'] = $this->extractUsername($validated['linkedin'], 'linkedin.com/in');
|
||||
if (isset($validated['instagram'])) $validated['instagram'] = $this->extractUsername($validated['instagram'], 'instagram.com');
|
||||
|
||||
// Log the update attempt for debugging
|
||||
\Illuminate\Support\Facades\Log::info('Profile Update Request:', ['user_id' => $user->id, 'payload' => $validated]);
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Profile updated successfully.',
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Password updated successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's avatar.
|
||||
*/
|
||||
public function updateAvatar(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:5120',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$file = $request->file('avatar');
|
||||
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
|
||||
// Requirement 1: URL avatar yang sedang digunakan adalah clean uuid
|
||||
// Output: avatars/{user-uuid}.{ext}
|
||||
$newFilename = "{$user->id}.{$extension}";
|
||||
$newPath = "avatars/{$newFilename}";
|
||||
|
||||
// Requirement 2: Jika ganti, pindahkan lama ke trash/{user-uuid}-{trash-random}-{original-filename}
|
||||
if ($user->avatar) {
|
||||
$oldPath = $this->getRelativePath($user->avatar);
|
||||
|
||||
if ($oldPath) {
|
||||
// If it's on R2, move it to trash
|
||||
if (Storage::disk('r2')->exists($oldPath)) {
|
||||
$trashRandom = Str::random(10);
|
||||
$oldBasename = basename($oldPath);
|
||||
$trashPath = "trash/{$user->id}-{$trashRandom}-{$oldBasename}";
|
||||
|
||||
// S3/R2 copy + delete is more reliable than move in some environments
|
||||
Storage::disk('r2')->copy($oldPath, $trashPath);
|
||||
Storage::disk('r2')->delete($oldPath);
|
||||
}
|
||||
// If it's still on local storage (migration case), just delete it
|
||||
elseif (Storage::disk('public')->exists($oldPath)) {
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload to R2
|
||||
// Upload to R2 with Cache-Control to prevent long caching
|
||||
$path = $file->storeAs('avatars', $newFilename, [
|
||||
'disk' => 'r2',
|
||||
'CacheControl' => 'no-cache, no-store, max-age=0, must-revalidate',
|
||||
]);
|
||||
$url = Storage::disk('r2')->url($path);
|
||||
|
||||
$user->update(['avatar' => $url]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Avatar updated successfully.',
|
||||
'avatar_url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract relative path from full URL
|
||||
*/
|
||||
private function getRelativePath($url)
|
||||
{
|
||||
if (!$url) return null;
|
||||
|
||||
$baseUrl = config('filesystems.disks.r2.url');
|
||||
|
||||
// Strip query string if any before processing
|
||||
$urlWithoutQuery = explode('?', $url)[0];
|
||||
|
||||
if (str_starts_with($urlWithoutQuery, $baseUrl)) {
|
||||
return ltrim(str_replace($baseUrl, '', $urlWithoutQuery), '/');
|
||||
}
|
||||
|
||||
// Handle legacy local storage URLs if any
|
||||
if (str_contains($urlWithoutQuery, '/storage/')) {
|
||||
return 'avatars/' . basename($urlWithoutQuery);
|
||||
}
|
||||
|
||||
return $urlWithoutQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Login History (Last 1 month, max 10)
|
||||
*/
|
||||
public function getLoginHistory(Request $request)
|
||||
{
|
||||
$history = $request->user()->loginHistories()
|
||||
->where('created_at', '>=', now()->subMonth())
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return response()->json($history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user account
|
||||
*/
|
||||
public function deleteAccount(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Optional: Perform any cleanup here (delete avatar, certificates, etc.)
|
||||
if ($user->avatar) {
|
||||
$oldPath = $this->getRelativePath($user->avatar);
|
||||
if ($oldPath && Storage::disk('r2')->exists($oldPath)) {
|
||||
$trashRandom = Str::random(10);
|
||||
$oldBasename = basename($oldPath);
|
||||
$trashPath = "trash/deleted_user_{$user->id}-{$trashRandom}-{$oldBasename}";
|
||||
Storage::disk('r2')->move($oldPath, $trashPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke Social Tokens
|
||||
foreach ($user->socialAccounts as $account) {
|
||||
// Re-use logic or call external helper? For simplicity/speed, implementing inline revocation or calling AuthController logic if possible.
|
||||
// Better: Iterate and manually revoke to ensure clean slate.
|
||||
try {
|
||||
if ($account->provider === 'google' && $account->token) {
|
||||
\Illuminate\Support\Facades\Http::post('https://oauth2.googleapis.com/revoke', ['token' => $account->token]);
|
||||
}
|
||||
// GitHub revocation is more complex inline without config access handy, but we attempt basic cleanup
|
||||
} catch (\Exception $e) {
|
||||
// Continue deletion
|
||||
}
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Account deleted successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions from database
|
||||
*/
|
||||
public function getActiveSessions(Request $request)
|
||||
{
|
||||
$sessions = DB::table('sessions')
|
||||
->where('user_id', $request->user()->id)
|
||||
->get()
|
||||
->map(function ($session) use ($request) {
|
||||
$info = $this->parseUserAgent($session->user_agent);
|
||||
return [
|
||||
'id' => $session->id,
|
||||
'ip_address' => $session->ip_address,
|
||||
'browser' => $info['browser'],
|
||||
'os' => $info['os'],
|
||||
'device_type' => $info['device'],
|
||||
'last_active' => $session->last_activity,
|
||||
'is_current' => $session->id === $request->session()->getId(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific session
|
||||
*/
|
||||
public function revokeSession(Request $request, $id)
|
||||
{
|
||||
// Don't allow revoking current session via this endpoint for safety
|
||||
if ($id === $request->session()->getId()) {
|
||||
return response()->json(['message' => 'Cannot revoke current session.'], 400);
|
||||
}
|
||||
|
||||
DB::table('sessions')
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('id', $id)
|
||||
->delete();
|
||||
|
||||
return response()->json(['message' => 'Session revoked successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple User Agent Parser (Copied from AuthController for consistency)
|
||||
*/
|
||||
private function parseUserAgent($agent)
|
||||
{
|
||||
$os = 'Unknown OS';
|
||||
$browser = 'Unknown Browser';
|
||||
$device = 'Desktop';
|
||||
|
||||
if (!$agent) return ['os' => $os, 'browser' => $browser, 'device' => $device];
|
||||
|
||||
// OS Parsing
|
||||
if (preg_match('/iphone|ipad|ipod/i', $agent)) {
|
||||
$os = 'iOS';
|
||||
$device = 'iOS';
|
||||
} elseif (preg_match('/android/i', $agent)) {
|
||||
$os = 'Android';
|
||||
$device = 'Android';
|
||||
} elseif (preg_match('/windows/i', $agent)) {
|
||||
$os = 'Windows';
|
||||
$device = 'Windows';
|
||||
} elseif (preg_match('/macintosh|mac os x/i', $agent)) {
|
||||
$os = 'Mac';
|
||||
$device = 'Mac';
|
||||
} elseif (preg_match('/linux/i', $agent)) {
|
||||
$os = 'Linux';
|
||||
$device = 'Linux';
|
||||
}
|
||||
|
||||
// Browser Parsing
|
||||
if (preg_match('/msie/i', $agent) && !preg_match('/opera/i', $agent)) {
|
||||
$browser = 'Internet Explorer';
|
||||
} elseif (preg_match('/firefox/i', $agent)) {
|
||||
$browser = 'Firefox';
|
||||
} elseif (preg_match('/chrome/i', $agent)) {
|
||||
$browser = 'Chrome';
|
||||
} elseif (preg_match('/safari/i', $agent)) {
|
||||
$browser = 'Safari';
|
||||
} elseif (preg_match('/opera/i', $agent)) {
|
||||
$browser = 'Opera';
|
||||
}
|
||||
|
||||
return [
|
||||
'os' => $os,
|
||||
'browser' => $browser,
|
||||
'device' => $device
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract username from social media URLs.
|
||||
*/
|
||||
private function extractUsername(?string $value, $domains): ?string
|
||||
{
|
||||
if (!$value) return null;
|
||||
|
||||
$value = trim($value);
|
||||
if (empty($value)) return null;
|
||||
|
||||
// If it doesn't look like a URL, assume it's already a username
|
||||
if (!str_contains($value, '/') && !str_contains($value, '.')) {
|
||||
return ltrim($value, '@');
|
||||
}
|
||||
|
||||
$domains = (array) $domains;
|
||||
foreach ($domains as $domain) {
|
||||
// Clean domain for regex (e.g. linkedin.com/in)
|
||||
$safeDomain = str_replace('/', '\/', preg_quote($domain));
|
||||
$pattern = "/(?:https?:\/\/)?(?:www\.)?{$safeDomain}\/([^\/\?#]+)/i";
|
||||
|
||||
if (preg_match($pattern, $value, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a URL but doesn't match the expected domain, just return the last part or the value itself
|
||||
// to avoid losing data if the user inputs something slightly different
|
||||
$parts = explode('/', rtrim($value, '/'));
|
||||
return end($parts);
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/Api/PublicCaController.php
Normal file
171
app/Http/Controllers/Api/PublicCaController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CaCertificate;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PublicCaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of public CA certificates.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$caTypes = ['root', 'intermediate_2048', 'intermediate_4096'];
|
||||
|
||||
$certificates = CaCertificate::whereIn('ca_type', $caTypes)
|
||||
->get(['common_name', 'ca_type', 'serial_number', 'valid_to', 'cert_content'])
|
||||
->map(function ($cert) {
|
||||
return [
|
||||
'name' => $cert->common_name,
|
||||
'type' => $cert->ca_type,
|
||||
'serial' => $cert->serial_number,
|
||||
'expires_at' => $cert->valid_to->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $certificates
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download certificate in various formats.
|
||||
*/
|
||||
public function download(Request $request, $serial)
|
||||
{
|
||||
$cert = CaCertificate::where('serial_number', $serial)->firstOrFail();
|
||||
$cert->increment('download_count');
|
||||
$cert->update(['last_downloaded_at' => now()]);
|
||||
$format = $request->query('format', 'pem');
|
||||
|
||||
if ($format === 'der') {
|
||||
// Convert PEM to DER (Base64 decode the body)
|
||||
$pem = $cert->cert_content;
|
||||
$lines = explode("\n", trim($pem));
|
||||
$payload = '';
|
||||
foreach ($lines as $line) {
|
||||
if (!str_starts_with($line, '-----')) {
|
||||
$payload .= trim($line);
|
||||
}
|
||||
}
|
||||
$der = base64_decode($payload);
|
||||
|
||||
return response($der)
|
||||
->header('Content-Type', 'application/x-x509-ca-cert')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $cert->common_name . '.der"');
|
||||
}
|
||||
|
||||
// Default PEM
|
||||
return response($cert->cert_content)
|
||||
->header('Content-Type', 'application/x-pem-file')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $cert->common_name . '.crt"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Windows One-Click Installer (.bat)
|
||||
*/
|
||||
public function downloadWindows($serial)
|
||||
{
|
||||
$cert = CaCertificate::where('serial_number', $serial)->firstOrFail();
|
||||
$cert->increment('download_count');
|
||||
$cert->update(['last_downloaded_at' => now()]);
|
||||
$store = $cert->ca_type === 'root' ? 'Root' : 'CA';
|
||||
$filename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $cert->common_name);
|
||||
|
||||
// Convert CRLF to ensure batch file works
|
||||
$certContent = str_replace("\n", "\r\n", str_replace("\r\n", "\n", $cert->cert_content));
|
||||
|
||||
$script = "@echo off\r\n";
|
||||
$script .= "echo Installing " . $cert->common_name . "...\r\n";
|
||||
$script .= "echo Please allow the security prompt to trust this certificate.\r\n";
|
||||
$script .= "set \"CERT_FILE=%TEMP%\\" . $filename . ".crt\"\r\n";
|
||||
$script .= "((\r\n";
|
||||
foreach(explode("\r\n", $certContent) as $line) {
|
||||
if(!empty($line)) $script .= "echo " . $line . "\r\n";
|
||||
}
|
||||
$script .= ")) > \"%CERT_FILE%\"\r\n";
|
||||
$script .= "certutil -addstore -f \"" . $store . "\" \"%CERT_FILE%\"\r\n";
|
||||
$script .= "del \"%CERT_FILE%\"\r\n";
|
||||
$script .= "pause\r\n";
|
||||
|
||||
return response($script)
|
||||
->header('Content-Type', 'application/x-bat')
|
||||
->header('Content-Disposition', 'attachment; filename="install-' . $filename . '.bat"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download macOS Configuration Profile (.mobileconfig)
|
||||
*/
|
||||
public function downloadMac($serial)
|
||||
{
|
||||
$cert = CaCertificate::where('serial_number', $serial)->firstOrFail();
|
||||
$cert->increment('download_count');
|
||||
$cert->update(['last_downloaded_at' => now()]);
|
||||
|
||||
// Extract Base64 payload
|
||||
$pem = $cert->cert_content;
|
||||
$lines = explode("\n", trim($pem));
|
||||
$payload = '';
|
||||
foreach ($lines as $line) {
|
||||
if (!str_starts_with($line, '-----')) {
|
||||
$payload .= trim($line);
|
||||
}
|
||||
}
|
||||
|
||||
$uuid = \Illuminate\Support\Str::uuid();
|
||||
$identifier = 'com.trustlab.cert.' . $serial;
|
||||
$name = $cert->common_name;
|
||||
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>' . $name . '.cer</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>
|
||||
' . $payload . '
|
||||
</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Adds ' . $name . ' to Trusted Root Store</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>' . $name . '</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>' . $identifier . '.cert</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.pkcs1</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>' . \Illuminate\Support\Str::uuid() . '</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>' . $name . ' Installer</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>' . $identifier . '</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>' . $uuid . '</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>';
|
||||
|
||||
return response($xml)
|
||||
->header('Content-Type', 'application/x-apple-aspen-config')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $name . '.mobileconfig"');
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Api/RootCaApiController.php
Normal file
69
app/Http/Controllers/Api/RootCaApiController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CaCertificate;
|
||||
use App\Services\OpenSslService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RootCaApiController extends Controller
|
||||
{
|
||||
protected $sslService;
|
||||
|
||||
public function __construct(OpenSslService $sslService)
|
||||
{
|
||||
$this->sslService = $sslService;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
|
||||
$certificates = CaCertificate::all()->map(function($cert) {
|
||||
$cert->status = $cert->valid_to->isFuture() ? 'valid' : 'expired';
|
||||
return $cert;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $certificates
|
||||
]);
|
||||
}
|
||||
|
||||
public function renew(Request $request, CaCertificate $certificate)
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
|
||||
$days = (int) $request->input('days', 3650);
|
||||
|
||||
try {
|
||||
$newData = $this->sslService->renewCaCertificate($certificate, $days);
|
||||
|
||||
$certificate->update([
|
||||
'cert_content' => $newData['cert_content'],
|
||||
'serial_number' => $newData['serial_number'],
|
||||
'valid_from' => $newData['valid_from'],
|
||||
'valid_to' => $newData['valid_to'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Certificate renewed successfully.',
|
||||
'data' => $certificate
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Renewal failed: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeAdmin()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
}
|
||||
}
|
||||
241
app/Http/Controllers/Api/TicketController.php
Normal file
241
app/Http/Controllers/Api/TicketController.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketAttachment;
|
||||
use App\Models\TicketReply;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewTicketNotification;
|
||||
use App\Notifications\TicketReplyNotification;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Traits\LogsActivity;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
/**
|
||||
* Display a listing of tickets.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$query = Ticket::with(['user:id,first_name,last_name,email,avatar', 'replies.user:id,first_name,last_name,avatar', 'replies.attachments']);
|
||||
|
||||
// Only show all tickets if user is admin AND explicitly asks for all
|
||||
if ($user->isAdmin() && $request->has('all')) {
|
||||
// No additional where clause needed
|
||||
} else {
|
||||
// Everyone else (including admins in personal view) only sees their own
|
||||
$query->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
$tickets = $query->latest()->paginate(10);
|
||||
|
||||
return response()->json($tickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created ticket.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'subject' => 'required|string|max:255',
|
||||
'category' => 'required|string',
|
||||
'priority' => 'required|in:low,medium,high',
|
||||
'message' => 'required|string',
|
||||
'attachments' => 'array|max:5',
|
||||
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx,zip,txt|max:10240',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request, $user) {
|
||||
$ticket = Ticket::create([
|
||||
'user_id' => $user->id,
|
||||
'ticket_number' => Ticket::generateTicketNumber(),
|
||||
'subject' => $request->subject,
|
||||
'category' => $request->category,
|
||||
'priority' => $request->priority,
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
$this->logActivity('create_ticket', "Created ticket #{$ticket->ticket_number}: {$ticket->subject}");
|
||||
|
||||
$reply = TicketReply::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => $user->id,
|
||||
'message' => $request->message,
|
||||
]);
|
||||
|
||||
// Handle Attachments
|
||||
if ($request->hasFile('attachments')) {
|
||||
foreach ($request->file('attachments') as $file) {
|
||||
$path = $file->store('ticket-attachments', 'r2');
|
||||
$url = Storage::disk('r2')->url($path);
|
||||
TicketAttachment::create([
|
||||
'ticket_reply_id' => $reply->id,
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $url,
|
||||
'file_type' => $file->getClientMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify Admins
|
||||
try {
|
||||
$admins = User::where('role', 'admin')
|
||||
->where('id', '!=', $user->id)
|
||||
->get();
|
||||
if ($admins->isNotEmpty()) {
|
||||
Notification::send($admins, new NewTicketNotification($ticket->load('user')));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't fail the request
|
||||
\Illuminate\Support\Facades\Log::error('Failed to send ticket notification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Ticket created successfully',
|
||||
'ticket' => $ticket->load(['user:id,first_name,last_name,email,avatar', 'replies.user:id,first_name,last_name,avatar', 'replies.attachments'])
|
||||
], 201);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Ticket creation failed: ' . $e->getMessage());
|
||||
return response()->json(['message' => 'Failed to create ticket: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified ticket with replies.
|
||||
*/
|
||||
public function show(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
$ticket = Ticket::with(['user:id,first_name,last_name,email,avatar', 'replies.user:id,first_name,last_name,avatar', 'replies.attachments'])->findOrFail($id);
|
||||
|
||||
if (!$user->isAdmin() && $ticket->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
return response()->json($ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reply to the ticket.
|
||||
*/
|
||||
public function reply(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
$ticket = Ticket::findOrFail($id);
|
||||
|
||||
if (!$user->isAdmin() && $ticket->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
if ($ticket->status === 'closed') {
|
||||
return response()->json(['message' => 'Cannot reply to a closed ticket'], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'message' => 'required|string',
|
||||
'attachments' => 'array|max:5',
|
||||
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx,zip,txt|max:10240',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$reply = TicketReply::create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'user_id' => $user->id,
|
||||
'message' => $request->message,
|
||||
]);
|
||||
|
||||
$this->logActivity('reply_ticket', "Replied to ticket #{$ticket->ticket_number}");
|
||||
|
||||
// Handle Attachments
|
||||
if ($request->hasFile('attachments')) {
|
||||
foreach ($request->file('attachments') as $file) {
|
||||
$path = $file->store('ticket-attachments', 'r2');
|
||||
$url = Storage::disk('r2')->url($path);
|
||||
TicketAttachment::create([
|
||||
'ticket_reply_id' => $reply->id,
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $url,
|
||||
'file_type' => $file->getClientMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket status & Notify
|
||||
try {
|
||||
if ($user->isAdmin()) {
|
||||
$ticket->update(['status' => 'answered']);
|
||||
// Notify Customer
|
||||
$ticketUser = $ticket->user;
|
||||
if ($ticketUser && $ticketUser->id !== $user->id) {
|
||||
$ticketUser->notify(new TicketReplyNotification($ticket, $reply, true));
|
||||
}
|
||||
|
||||
// Also notify OTHER admins
|
||||
$otherAdmins = User::where('role', 'admin')
|
||||
->where('id', '!=', $user->id)
|
||||
->get();
|
||||
if ($otherAdmins->isNotEmpty()) {
|
||||
Notification::send($otherAdmins, new TicketReplyNotification($ticket, $reply, true));
|
||||
}
|
||||
} else {
|
||||
$ticket->update(['status' => 'open']);
|
||||
// Notify All Admins
|
||||
$admins = User::where('role', 'admin')->get();
|
||||
if ($admins->isNotEmpty()) {
|
||||
Notification::send($admins, new TicketReplyNotification($ticket, $reply, false));
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Failed to send ticket reply notification: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Reply added successfully',
|
||||
'reply' => $reply->load(['user:id,first_name,last_name,avatar', 'attachments'])
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the ticket.
|
||||
*/
|
||||
public function close(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
$ticket = Ticket::findOrFail($id);
|
||||
|
||||
if (!$user->isAdmin() && $ticket->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$ticket->update(['status' => 'closed']);
|
||||
|
||||
$this->logActivity('close_ticket', "Closed ticket #{$ticket->ticket_number}");
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Ticket closed successfully',
|
||||
'ticket' => $ticket
|
||||
]);
|
||||
}
|
||||
}
|
||||
157
app/Http/Controllers/Api/UserApiController.php
Normal file
157
app/Http/Controllers/Api/UserApiController.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the users.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorizeAdminOrOwner();
|
||||
$query = User::query();
|
||||
|
||||
// Search by name or email
|
||||
if ($request->has('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('email', 'like', "%{$search}%")
|
||||
->orWhere('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by role
|
||||
if ($request->has('role')) {
|
||||
$query->where('role', $request->input('role'));
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
|
||||
return $query->paginate($request->input('per_page', 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created user in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorizeAdminOrOwner();
|
||||
|
||||
if (auth()->user()->isOwner()) {
|
||||
$roles = [User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_CUSTOMER];
|
||||
} else {
|
||||
// Admins can only create Customers
|
||||
$roles = [User::ROLE_CUSTOMER];
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'nullable|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => ['required', Rule::in($roles)],
|
||||
]);
|
||||
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
|
||||
$user = User::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User created successfully',
|
||||
'user' => $user
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified user.
|
||||
*/
|
||||
public function show(User $user)
|
||||
{
|
||||
$this->authorizeAdminOrOwner();
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified user in storage.
|
||||
*/
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$this->authorizeAdminOrOwner();
|
||||
|
||||
// Permission check: Admins cannot modify Admin/Owner accounts
|
||||
if (auth()->user()->isAdmin() && $user->role !== User::ROLE_CUSTOMER) {
|
||||
abort(403, 'Admins can only manage Customer accounts.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'sometimes|required|string|max:255',
|
||||
'last_name' => 'nullable|string|max:255',
|
||||
'email' => ['sometimes', 'required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'password' => 'nullable|string|min:8',
|
||||
'role' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
auth()->user()->isOwner() ? Rule::in([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_CUSTOMER]) : Rule::in([$user->role])
|
||||
],
|
||||
]);
|
||||
|
||||
// Admins cannot change roles (already handled by Rule::in([$user->role]) above for safety, but let's be explicit)
|
||||
if (auth()->user()->isAdmin() && isset($validated['role']) && $validated['role'] !== $user->role) {
|
||||
abort(403, 'Admins cannot change user roles.');
|
||||
}
|
||||
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
} else {
|
||||
unset($validated['password']);
|
||||
}
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User updated successfully',
|
||||
'user' => $user
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified user from storage.
|
||||
*/
|
||||
public function destroy(User $user)
|
||||
{
|
||||
$this->authorizeAdminOrOwner();
|
||||
|
||||
// Permission check: Admins can only delete Customer accounts
|
||||
if (auth()->user()->isAdmin() && $user->role !== User::ROLE_CUSTOMER) {
|
||||
abort(403, 'Admins can only delete Customer accounts.');
|
||||
}
|
||||
// Prevent deleting yourself
|
||||
if (auth()->id() === $user->id) {
|
||||
return response()->json(['message' => 'You cannot delete your own account.'], 403);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return response()->json(['message' => 'User deleted successfully']);
|
||||
}
|
||||
|
||||
protected function authorizeAdminOrOwner()
|
||||
{
|
||||
if (!auth()->user()->isAdminOrOwner()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Api/VerificationController.php
Normal file
62
app/Http/Controllers/Api/VerificationController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VerificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*
|
||||
* @param \Illuminate\Foundation\Auth\EmailVerificationRequest $request
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function verify(EmailVerificationRequest $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->hasVerifiedEmail() && !$user->pending_email) {
|
||||
return redirect(config('app.frontend_url') . '/verify-success?already_verified=1');
|
||||
}
|
||||
|
||||
// If there is a pending email, promote it to the main email
|
||||
if ($user->pending_email) {
|
||||
$user->email = $user->pending_email;
|
||||
$user->pending_email = null;
|
||||
}
|
||||
|
||||
if ($user->markEmailAsVerified()) {
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect(config('app.frontend_url') . '/verify-success?verified=1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend the email verification notification.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function resend(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->hasVerifiedEmail() && !$user->pending_email) {
|
||||
return response()->json(['message' => 'Email already verified.'], 400);
|
||||
}
|
||||
|
||||
if ($user->pending_email) {
|
||||
$user->notify(new \App\Notifications\PendingEmailVerificationNotification);
|
||||
} else {
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Verification link sent.']);
|
||||
}
|
||||
}
|
||||
377
app/Http/Controllers/AuthController.php
Normal file
377
app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\LoginHistory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Traits\CanTrackLogin;
|
||||
use App\Traits\LogsActivity;
|
||||
use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use CanTrackLogin, LogsActivity;
|
||||
|
||||
/**
|
||||
* Handle Login Request
|
||||
*/
|
||||
/**
|
||||
* Handle Login Request
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['Invalid credentials.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 2FA Check
|
||||
if ($user->two_factor_confirmed_at) {
|
||||
// Return Temporary Token with "2fa" capability
|
||||
// We DO NOT call Auth::login() here to prevent session cookie creation
|
||||
$tempToken = $user->createToken('2fa_temp_token', ['2fa-required'])->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'two_factor_required' => true,
|
||||
'temp_token' => $tempToken,
|
||||
]);
|
||||
}
|
||||
|
||||
// Standard Login - Establish session and issue token
|
||||
Auth::guard('web')->login($user, $request->boolean('remember'));
|
||||
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
$this->recordLoginHistory($request, $user);
|
||||
$this->logActivity('login', 'User logged in to the dashboard');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Login successful',
|
||||
'token' => $token,
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Registration Request
|
||||
*/
|
||||
public function register(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'fname' => 'required|string|max:255',
|
||||
'lname' => 'nullable|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:8',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'first_name' => $validated['fname'],
|
||||
'last_name' => $validated['lname'] ?? null,
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
$this->recordLoginHistory($request, $user);
|
||||
$this->logActivity('register', 'User registered a new account');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Registration successful',
|
||||
'token' => $token,
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to Social Provider
|
||||
*/
|
||||
public function socialRedirect(Request $request, $provider)
|
||||
{
|
||||
// Store context (signin, signup, connect) in session
|
||||
$context = $request->query('context', 'signin'); // Default to signin if missing
|
||||
session(['social_context' => $context]);
|
||||
|
||||
// Secure Link Flow: If a link_token is provided, verify it and store the user ID in session
|
||||
if ($context === 'connect' && $request->has('link_token')) {
|
||||
$token = $request->query('link_token');
|
||||
$userId = Cache::get("link_token_{$token}");
|
||||
|
||||
if ($userId) {
|
||||
session(['social_auth_user_id' => $userId]);
|
||||
Cache::forget("link_token_{$token}"); // Consume token
|
||||
}
|
||||
}
|
||||
|
||||
$driver = Socialite::driver($provider)->stateless();
|
||||
|
||||
if ($provider === 'google') {
|
||||
$driver->with(['prompt' => 'select_account consent', 'access_type' => 'offline']);
|
||||
} else {
|
||||
// Attempt to force consent for others
|
||||
$driver->with(['prompt' => 'consent']);
|
||||
}
|
||||
|
||||
return $driver->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Link Token for Secure Connection
|
||||
*/
|
||||
public function getLinkToken(Request $request)
|
||||
{
|
||||
$token = Str::random(40);
|
||||
// Cache user ID for 2 minutes
|
||||
Cache::put("link_token_{$token}", $request->user()->id, 120);
|
||||
|
||||
return response()->json(['token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Social Provider Callback
|
||||
*/
|
||||
public function socialCallback(Request $request, $provider)
|
||||
{
|
||||
try {
|
||||
$socialUser = Socialite::driver($provider)->stateless()->user();
|
||||
} catch (\Exception $e) {
|
||||
return redirect(config('app.frontend_url') . '/auth/callback?error=' . urlencode($e->getMessage()));
|
||||
}
|
||||
|
||||
$context = session('social_context', 'signin'); // Default to strict signin if session lost
|
||||
// request()->session()->forget('social_context'); // Optional: Clear it, but typical session lifecycle handles this.
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// CASE 1: CONNECT ACCOUNT (User is already logged in)
|
||||
// ---------------------------------------------------------
|
||||
// Explicitly check context or Auth::check()
|
||||
// If context is 'connect', they MUST be logged in.
|
||||
if ($context === 'connect' || Auth::check() || session('social_auth_user_id')) {
|
||||
|
||||
// Debug Logging: Trace why connection flow might be failing
|
||||
\Illuminate\Support\Facades\Log::info('Social Callback Connect Flow:', [
|
||||
'context' => $context,
|
||||
'auth_check' => Auth::check(),
|
||||
'session_user_id' => session('social_auth_user_id'),
|
||||
'provider' => $provider
|
||||
]);
|
||||
|
||||
// If strictly unauthenticated but we have a session user ID from the link token
|
||||
if (!Auth::check() && session('social_auth_user_id')) {
|
||||
Auth::loginUsingId(session('social_auth_user_id'));
|
||||
}
|
||||
|
||||
if (!Auth::check()) {
|
||||
return redirect(config('app.frontend_url') . '/signin?error=login_required_to_connect');
|
||||
}
|
||||
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// Check if this social account is already linked to *any* user
|
||||
$existingAccount = \App\Models\SocialAccount::where('provider', $provider)
|
||||
->where('provider_id', $socialUser->getId())
|
||||
->first();
|
||||
|
||||
if ($existingAccount) {
|
||||
if ($existingAccount->user_id === $currentUser->id) {
|
||||
return redirect(config('app.frontend_url') . '/dashboard/settings?error=already_connected');
|
||||
} else {
|
||||
return redirect(config('app.frontend_url') . '/dashboard/settings?error=connected_to_other_account');
|
||||
}
|
||||
}
|
||||
|
||||
// Link the account
|
||||
$currentUser->socialAccounts()->create([
|
||||
'provider' => $provider,
|
||||
'provider_id' => $socialUser->getId(),
|
||||
'provider_email' => $socialUser->getEmail(),
|
||||
'avatar' => $socialUser->getAvatar(),
|
||||
'token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'expires_at' => isset($socialUser->expiresIn) ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
]);
|
||||
|
||||
return redirect(config('app.frontend_url') . '/dashboard/settings?success=account_connected');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// CASE 2: SOCIAL SIGN IN / SIGN UP (Guest)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
// 1. Check if SocialAccount exists (Already Linked)
|
||||
$socialAccount = \App\Models\SocialAccount::where('provider', $provider)
|
||||
->where('provider_id', $socialUser->getId())
|
||||
->first();
|
||||
|
||||
if ($socialAccount) {
|
||||
// Account linked -> ALWAYS ALLOW LOGIN
|
||||
// (Even if context=signup, we can just log them in, or strictly say "Already registered")
|
||||
$user = $socialAccount->user;
|
||||
|
||||
// Auto-verify if not verified (Social provider already verified the email)
|
||||
if (!$user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
$this->recordLoginHistory($request, $user);
|
||||
|
||||
return redirect(config('app.frontend_url') . '/auth/callback?token=' . $token);
|
||||
}
|
||||
|
||||
// 2. Check if User with this email exists (BUT NOT LINKED)
|
||||
$existingUser = User::where('email', $socialUser->getEmail())->first();
|
||||
|
||||
if ($existingUser) {
|
||||
// ERROR: Account exists but not linked
|
||||
return redirect(config('app.frontend_url') . '/auth/callback?error=account_exists_please_login');
|
||||
}
|
||||
|
||||
// 3. HANDLE NEW USERS
|
||||
// STRICT CHECK: If context is 'signin', DO NOT register new user.
|
||||
if ($context === 'signin') {
|
||||
return redirect(config('app.frontend_url') . '/auth/callback?error=account_not_found_please_signup');
|
||||
}
|
||||
|
||||
// 4. REGISTER (Only if context == 'signup')
|
||||
$nameParts = explode(' ', $socialUser->getName() ?? '', 2);
|
||||
$firstName = $nameParts[0];
|
||||
$lastName = $nameParts[1] ?? '';
|
||||
|
||||
$user = User::create([
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => $socialUser->getEmail(),
|
||||
'avatar' => $socialUser->getAvatar(),
|
||||
'email_verified_at' => now(),
|
||||
// Password is null initially
|
||||
]);
|
||||
|
||||
$user->socialAccounts()->create([
|
||||
'provider' => $provider,
|
||||
'provider_id' => $socialUser->getId(),
|
||||
'provider_email' => $socialUser->getEmail(),
|
||||
'avatar' => $socialUser->getAvatar(),
|
||||
'token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'expires_at' => isset($socialUser->expiresIn) ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
$this->recordLoginHistory($request, $user);
|
||||
|
||||
// Redirect to Set Password page
|
||||
return redirect(config('app.frontend_url') . '/auth/callback?token=' . $token . '&action=set_password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Password for Social Users
|
||||
*/
|
||||
public function setPassword(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Security check: Only allow setting password if it's currently null
|
||||
// or provide a way to override if we want to allow simple password resets from this endpoint (unlikely for security)
|
||||
if ($user->password && !Hash::check('', $user->password)) { // Check if password is not empty string/null effectively
|
||||
// If user already has a password, they should use the update-password endpoint which requires current_password
|
||||
// However, for this specific flow "set password after social login", we can allow it IF implementation allows.
|
||||
// Stricter: Only if password is NULL.
|
||||
}
|
||||
|
||||
if ($user->password !== null && $user->password !== '') {
|
||||
return response()->json(['message' => 'Password already set. Use update password.'], 403);
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Password set successfully.',
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Logout Request
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Social Account (Revoke Token)
|
||||
*/
|
||||
public function disconnectSocial(Request $request, $provider)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$account = $user->socialAccounts()->where('provider', $provider)->first();
|
||||
|
||||
if (!$account) {
|
||||
return response()->json(['message' => 'Account not linked.'], 404);
|
||||
}
|
||||
|
||||
// 1. Revoke Logic
|
||||
try {
|
||||
if ($provider === 'google' && $account->token) {
|
||||
// Google: Revoke via POST to oauth2.googleapis.com
|
||||
Http::post('https://oauth2.googleapis.com/revoke', [
|
||||
'token' => $account->token,
|
||||
]);
|
||||
}
|
||||
elseif ($provider === 'github' && $account->token) {
|
||||
// GitHub: Revoke via Basic Auth with Client ID/Secret
|
||||
// Requires client_id:client_secret base64 encoded
|
||||
$clientId = config('services.github.client_id');
|
||||
$clientSecret = config('services.github.client_secret');
|
||||
|
||||
if ($clientId && $clientSecret) {
|
||||
Http::withBasicAuth($clientId, $clientSecret)
|
||||
->delete("https://api.github.com/applications/{$clientId}/grant", [
|
||||
'access_token' => $account->token
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log error but proceed to delete local record
|
||||
\Illuminate\Support\Facades\Log::error("Failed to revoke {$provider} token: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// 2. Delete Local Record
|
||||
$account->delete();
|
||||
|
||||
return response()->json(['message' => 'Account disconnected successfully.']);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
161
app/Http/Controllers/NavigationController.php
Normal file
161
app/Http/Controllers/NavigationController.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NavigationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$menuGroups = [];
|
||||
|
||||
// 1. Admin Management (Admin or Owner)
|
||||
if ($user && $user->isAdminOrOwner()) {
|
||||
$menuGroups[] = [
|
||||
'title' => 'Admin Management',
|
||||
'items' => [
|
||||
[
|
||||
'name' => 'User Management',
|
||||
'icon' => 'users',
|
||||
'route' => '/dashboard/admin/users',
|
||||
],
|
||||
[
|
||||
'name' => 'Root CA Management',
|
||||
'icon' => 'certificate',
|
||||
'route' => '/dashboard/admin/root-ca',
|
||||
],
|
||||
[
|
||||
'name' => 'Ticket Management',
|
||||
'icon' => 'support-ticket',
|
||||
'route' => '/dashboard/admin/tickets',
|
||||
],
|
||||
[
|
||||
'name' => 'Legal Page Management',
|
||||
'icon' => 'pages',
|
||||
'route' => '/dashboard/admin/legal',
|
||||
],
|
||||
[
|
||||
'name' => 'Inquiries',
|
||||
'icon' => 'inbox',
|
||||
'route' => '/dashboard/admin/inquiries',
|
||||
],
|
||||
[
|
||||
'name' => 'SMTP Tester',
|
||||
'icon' => 'smtp',
|
||||
'route' => '/dashboard/admin/smtp-tester',
|
||||
],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Main Menu (Common)
|
||||
$mainItems = [
|
||||
[
|
||||
'name' => 'Dashboard',
|
||||
'icon' => 'dashboard',
|
||||
'route' => '/dashboard',
|
||||
],
|
||||
[
|
||||
'name' => 'Certificates',
|
||||
'icon' => 'certificate',
|
||||
'route' => '/dashboard/certificates',
|
||||
],
|
||||
[
|
||||
'name' => 'API Keys',
|
||||
'icon' => 'api-key',
|
||||
'route' => '/dashboard/api-keys',
|
||||
],
|
||||
[
|
||||
'name' => 'Support Tickets',
|
||||
'icon' => 'support-ticket',
|
||||
'route' => '/dashboard/support', // Assuming support.index maps to /support
|
||||
],
|
||||
];
|
||||
|
||||
// "My Services" for Customers ONLY
|
||||
if ($user && $user->role === \App\Models\User::ROLE_CUSTOMER) {
|
||||
// We can insert "My Services" if we want to keep that feature for customers
|
||||
// As per user request "ikuiti app-beta", but we also added "My Services" previously.
|
||||
// Let's keep "My Services" as it's a nice dedicated page for them, inserting it after Dashboard.
|
||||
array_splice($mainItems, 1, 0, [[
|
||||
'name' => 'My Services',
|
||||
'icon' => 'layers',
|
||||
'route' => '/dashboard/services',
|
||||
]]);
|
||||
}
|
||||
|
||||
$menuGroups[] = [
|
||||
'title' => 'Menu',
|
||||
'items' => $mainItems,
|
||||
];
|
||||
|
||||
// 3. My Account (Common)
|
||||
$menuGroups[] = [
|
||||
'title' => 'My Account',
|
||||
'items' => [
|
||||
[
|
||||
'name' => 'User Profile',
|
||||
'icon' => 'user-profile',
|
||||
'route' => '/dashboard/profile',
|
||||
],
|
||||
[
|
||||
'name' => 'Account Settings',
|
||||
'icon' => 'settings',
|
||||
'route' => '/dashboard/settings',
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
return response()->json($menuGroups);
|
||||
}
|
||||
|
||||
public function debug()
|
||||
{
|
||||
// Simulate a User instance for admin view
|
||||
$user = new \App\Models\User(['first_name' => 'Debug', 'last_name' => 'Admin', 'role' => 'admin']);
|
||||
|
||||
// This is a bit of a hack since $user->isAdmin() might be a real method,
|
||||
// but for JSON structure debugging, we'll just replicate the logic or mock it.
|
||||
|
||||
$menuGroups = [];
|
||||
|
||||
// 1. Admin Management (Simulated Admin)
|
||||
$menuGroups[] = [
|
||||
'title' => 'Admin Management',
|
||||
'items' => [
|
||||
['name' => 'User Management', 'icon' => 'users', 'route' => '/admin/users'],
|
||||
['name' => 'Root CA Management', 'icon' => 'certificate', 'route' => '/admin/root-ca'],
|
||||
['name' => 'Ticket Management', 'icon' => 'support-ticket', 'route' => '/admin/tickets'],
|
||||
['name' => 'Legal Page Management', 'icon' => 'pages', 'route' => '/dashboard/admin/legal'],
|
||||
['name' => 'Inquiries', 'icon' => 'inbox', 'route' => '/dashboard/admin/inquiries'],
|
||||
['name' => 'SMTP Tester', 'icon' => 'smtp', 'route' => '/dashboard/admin/smtp-tester'],
|
||||
]
|
||||
];
|
||||
|
||||
// 2. Main Menu
|
||||
$mainItems = [
|
||||
['name' => 'Dashboard', 'icon' => 'dashboard', 'route' => '/dashboard'],
|
||||
['name' => 'Certificates', 'icon' => 'certificate', 'route' => '/dashboard/certificates'],
|
||||
['name' => 'API Keys', 'icon' => 'api-key', 'route' => '/dashboard/api-keys'],
|
||||
['name' => 'Support Tickets', 'icon' => 'support-ticket', 'route' => '/dashboard/support'],
|
||||
];
|
||||
|
||||
$menuGroups[] = [
|
||||
'title' => 'Menu',
|
||||
'items' => $mainItems,
|
||||
];
|
||||
|
||||
// 3. My Account
|
||||
$menuGroups[] = [
|
||||
'title' => 'My Account',
|
||||
'items' => [
|
||||
['name' => 'User Profile', 'icon' => 'user-profile', 'route' => '/dashboard/profile'],
|
||||
['name' => 'Account Settings', 'icon' => 'settings', 'route' => '/dashboard/settings'],
|
||||
]
|
||||
];
|
||||
|
||||
return response()->json($menuGroups);
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/ServiceController.php
Normal file
35
app/Http/Controllers/ServiceController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return response()->json([
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'SSL Certificate - Standard',
|
||||
'status' => 'Active',
|
||||
'expiry' => '2026-12-23',
|
||||
'domain' => 'example.com'
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'name' => 'Code Signing - Pro',
|
||||
'status' => 'Pending',
|
||||
'expiry' => 'N/A',
|
||||
'domain' => 'N/A'
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'name' => 'Wildcard SSL',
|
||||
'status' => 'Expired',
|
||||
'expiry' => '2025-01-10',
|
||||
'domain' => '*.web.dev'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
195
app/Http/Controllers/TwoFactorController.php
Normal file
195
app/Http/Controllers/TwoFactorController.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use OTPHP\TOTP;
|
||||
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Traits\CanTrackLogin;
|
||||
|
||||
class TwoFactorController extends Controller
|
||||
{
|
||||
use CanTrackLogin;
|
||||
|
||||
/**
|
||||
* Enable 2FA: Generate Secret & QR Code
|
||||
*/
|
||||
public function enable(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->two_factor_confirmed_at) {
|
||||
return response()->json(['message' => '2FA is already enabled.'], 400);
|
||||
}
|
||||
|
||||
$google2fa = new Google2FA();
|
||||
|
||||
// Generate secret if not exists or if re-enabling
|
||||
$secret = $google2fa->generateSecretKey();
|
||||
|
||||
// Save encrypted secret (or plain if you trust your db, but encrypted is better)
|
||||
// For simplicity with this library, it often expects raw secret.
|
||||
// We will store it encrypted but decrypt it when needed if we were using a trait
|
||||
// But for manual implementation, lets store it temporarily in the session OR save it to DB directly?
|
||||
// Let's save it to DB but encrypt it.
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt($secret),
|
||||
'two_factor_recovery_codes' => null, // Reset codes
|
||||
])->save();
|
||||
|
||||
// Generate QR Code Object
|
||||
$qrCodeUrl = $google2fa->getQRCodeInline(
|
||||
config('app.name'),
|
||||
$user->email,
|
||||
$secret
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'secret' => $secret,
|
||||
'qr_code' => $qrCodeUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm 2FA: Verify initial OTP
|
||||
*/
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$request->validate(['code' => 'required|string|size:6']);
|
||||
|
||||
$user = $request->user();
|
||||
$google2fa = new Google2FA();
|
||||
|
||||
try {
|
||||
$secret = decrypt($user->two_factor_secret);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => '2FA not initiated.'], 400);
|
||||
}
|
||||
|
||||
$valid = $google2fa->verifyKey($secret, $request->code);
|
||||
|
||||
if (!$valid) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['Invalid 2FA code.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Generate Recovery Codes
|
||||
$recoveryCodes = [];
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$recoveryCodes[] = \Illuminate\Support\Str::random(10) . '-' . \Illuminate\Support\Str::random(10);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_confirmed_at' => now(),
|
||||
'two_factor_recovery_codes' => encrypt(json_encode($recoveryCodes)),
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'message' => '2FA enabled successfully.',
|
||||
'recovery_codes' => $recoveryCodes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable 2FA
|
||||
*/
|
||||
public function disable(Request $request)
|
||||
{
|
||||
$request->validate(['password' => 'required']);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (!Hash::check($request->password, $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => ['Invalid password.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
|
||||
return response()->json(['message' => '2FA disabled successfully.']);
|
||||
}
|
||||
/**
|
||||
* Verify 2FA during Login Challenge
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$request->validate(['code' => 'required|string']);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Check 2FA Secret
|
||||
try {
|
||||
$secret = decrypt($user->two_factor_secret);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => '2FA configuration error.'], 500);
|
||||
}
|
||||
|
||||
$google2fa = new Google2FA();
|
||||
$valid = $google2fa->verifyKey($secret, $request->code);
|
||||
|
||||
// Check Recovery Code if TOTP failed
|
||||
if (!$valid) {
|
||||
$recoveryCodes = $user->two_factor_recovery_codes ? json_decode(decrypt($user->two_factor_recovery_codes), true) : [];
|
||||
|
||||
if (in_array($request->code, $recoveryCodes)) {
|
||||
$valid = true;
|
||||
// Remove used recovery code
|
||||
$recoveryCodes = array_diff($recoveryCodes, [$request->code]);
|
||||
$user->forceFill([
|
||||
'two_factor_recovery_codes' => encrypt(json_encode(array_values($recoveryCodes))),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$valid) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['Invalid code provided.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Success!
|
||||
// 1. Establish session (for web/inertia/sanctum cookie flows)
|
||||
Auth::guard('web')->login($user, $request->boolean('remember'));
|
||||
|
||||
// 2. Revoke the temp token
|
||||
if ($user->currentAccessToken()) {
|
||||
$user->currentAccessToken()->delete();
|
||||
}
|
||||
|
||||
// 3. Create new full access token
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
// 4. Record History
|
||||
$this->recordLoginHistory($request, $user);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Login successful',
|
||||
'token' => $token,
|
||||
'user' => $user
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Recovery Codes
|
||||
*/
|
||||
public function recoveryCodes(Request $request)
|
||||
{
|
||||
if (!$request->user()->two_factor_confirmed_at) {
|
||||
return response()->json(['message' => '2FA not enabled.'], 400);
|
||||
}
|
||||
|
||||
$codes = json_decode(decrypt($request->user()->two_factor_recovery_codes), true);
|
||||
|
||||
return response()->json(['recovery_codes' => $codes]);
|
||||
}
|
||||
}
|
||||
24
app/Http/Middleware/AdminMiddleware.php
Normal file
24
app/Http/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user() || !$request->user()->isAdminOrOwner()) {
|
||||
return response()->json(['message' => 'Unauthorized. Admin access required.'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
57
app/Http/Middleware/CheckApiKey.php
Normal file
57
app/Http/Middleware/CheckApiKey.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use App\Models\ApiKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CheckApiKey
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Check for underscore only (preference)
|
||||
$headerValue = $request->header('TRUSTLAB_API_KEY');
|
||||
$keyString = $headerValue ? trim($headerValue) : null;
|
||||
|
||||
if (!$keyString) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'API Key is missing. Please provide it in the TRUSTLAB_API_KEY header.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$apiKey = ApiKey::where('key', $keyString)->first();
|
||||
|
||||
if (!$apiKey || !$apiKey->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Invalid or inactive API Key.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
$apiKey->update(['last_used_at' => now()]);
|
||||
|
||||
// Optional: Dispatch stats update event if needed in the future
|
||||
// \App\Events\DashboardStatsUpdated::dispatch($apiKey->user_id);
|
||||
|
||||
// Put the user in the request context
|
||||
$user = $apiKey->user;
|
||||
$request->merge(['authenticated_user' => $user]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
if ($user) {
|
||||
Auth::setUser($user);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
55
app/Mail/CertificateExpiredMail.php
Normal file
55
app/Mail/CertificateExpiredMail.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CertificateExpiredMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $certificate;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct($certificate)
|
||||
{
|
||||
$this->certificate = $certificate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'URGENT: Certificate [' . $this->certificate->common_name . '] Has Expired',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.certificate-expired',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
57
app/Mail/CertificateExpiringMail.php
Normal file
57
app/Mail/CertificateExpiringMail.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CertificateExpiringMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $certificate;
|
||||
public $daysRemaining;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct($certificate, $daysRemaining)
|
||||
{
|
||||
$this->certificate = $certificate;
|
||||
$this->daysRemaining = $daysRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Action Required: Certificate [' . $this->certificate->common_name . '] Expiring in ' . $this->daysRemaining . ' Days',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.certificate-expiring',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
55
app/Mail/InquiryReplyMail.php
Normal file
55
app/Mail/InquiryReplyMail.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Inquiry;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InquiryReplyMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $inquiry;
|
||||
public $replyMessage;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(Inquiry $inquiry, $replyMessage)
|
||||
{
|
||||
$this->inquiry = $inquiry;
|
||||
$this->replyMessage = $replyMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Re: ' . $this->inquiry->subject,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.inquiry_reply',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
57
app/Mail/TestMail.php
Normal file
57
app/Mail/TestMail.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TestMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public $mailer;
|
||||
public $host;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct($mailer, $host)
|
||||
{
|
||||
$this->mailer = $mailer;
|
||||
$this->host = $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: '[TrustLab] SMTP Connection Test Successful',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.test',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
36
app/Models/ActivityLog.php
Normal file
36
app/Models/ActivityLog.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Helpers\UuidHelper;
|
||||
|
||||
class ActivityLog extends Model
|
||||
{
|
||||
protected $primaryKey = 'id';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'user_id',
|
||||
'action',
|
||||
'description',
|
||||
'ip_address',
|
||||
'user_agent'
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->id)) {
|
||||
$model->id = UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
55
app/Models/ApiKey.php
Normal file
55
app/Models/ApiKey.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApiKey extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
protected $keyType = 'string';
|
||||
public $incrementing = false;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->id)) {
|
||||
$model->id = \App\Helpers\UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'key',
|
||||
'last_used_at',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique API Key.
|
||||
*/
|
||||
public static function generate()
|
||||
{
|
||||
do {
|
||||
$key = 'dvp_' . Str::random(56); // Prefix (4) + Random (56) = 60 chars
|
||||
} while (static::where('key', $key)->exists());
|
||||
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
42
app/Models/CaCertificate.php
Normal file
42
app/Models/CaCertificate.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Helpers\UuidHelper;
|
||||
|
||||
class CaCertificate extends Model
|
||||
{
|
||||
protected $primaryKey = 'uuid';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'ca_type',
|
||||
'cert_content',
|
||||
'key_content',
|
||||
'serial_number',
|
||||
'common_name',
|
||||
'organization',
|
||||
'valid_from',
|
||||
'valid_to',
|
||||
'download_count',
|
||||
'last_downloaded_at'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_from' => 'datetime',
|
||||
'valid_to' => 'datetime',
|
||||
'last_downloaded_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
82
app/Models/Certificate.php
Normal file
82
app/Models/Certificate.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Helpers\UuidHelper;
|
||||
|
||||
class Certificate extends Model
|
||||
{
|
||||
protected $primaryKey = 'uuid';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'user_id', 'common_name', 'organization', 'locality',
|
||||
'state', 'country', 'san', 'key_bits', 'serial_number',
|
||||
'cert_content', 'key_content', 'csr_content',
|
||||
'valid_from', 'valid_to', 'expired_notification_sent_at'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_from' => 'datetime',
|
||||
'valid_to' => 'datetime',
|
||||
'expired_notification_sent_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'expired_notification_sent_at',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'ssl_status',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function getSslStatusAttribute()
|
||||
{
|
||||
if ($this->valid_to && $this->valid_to->isPast()) {
|
||||
return 'EXPIRED';
|
||||
}
|
||||
return 'ACTIVE';
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
$array = parent::toArray();
|
||||
$ordered = [];
|
||||
|
||||
// Define the preferred order of keys
|
||||
$paramOrder = [
|
||||
'uuid', 'user_id', 'common_name', 'organization', 'locality',
|
||||
'state', 'country', 'san', 'status', 'key_bits', 'serial_number',
|
||||
'ssl_status', // <--- Inserted here (before content)
|
||||
'cert_content', 'key_content', 'csr_content',
|
||||
'valid_from', 'valid_to', 'created_at', 'updated_at'
|
||||
];
|
||||
|
||||
// Reconstruct query based on paramOrder
|
||||
foreach ($paramOrder as $key) {
|
||||
if (array_key_exists($key, $array)) {
|
||||
$ordered[$key] = $array[$key];
|
||||
unset($array[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining keys (that weren't in our explicit list)
|
||||
return array_merge($ordered, $array);
|
||||
}
|
||||
}
|
||||
38
app/Models/Inquiry.php
Normal file
38
app/Models/Inquiry.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Inquiry extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'category',
|
||||
'subject',
|
||||
'message',
|
||||
'status',
|
||||
'replied_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'replied_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->{$model->getKeyName()})) {
|
||||
$model->{$model->getKeyName()} = \App\Helpers\UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
32
app/Models/LegalPage.php
Normal file
32
app/Models/LegalPage.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
||||
class LegalPage extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = ['title', 'slug', 'is_active'];
|
||||
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
public function revisions()
|
||||
{
|
||||
return $this->hasMany(LegalPageRevision::class);
|
||||
}
|
||||
|
||||
public function latestRevision()
|
||||
{
|
||||
return $this->hasOne(LegalPageRevision::class)->latestOfMany('created_at');
|
||||
}
|
||||
|
||||
public function currentRevision()
|
||||
{
|
||||
return $this->hasOne(LegalPageRevision::class)->where('is_active', true)->latest();
|
||||
}
|
||||
}
|
||||
35
app/Models/LegalPageRevision.php
Normal file
35
app/Models/LegalPageRevision.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
||||
class LegalPageRevision extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'legal_page_id',
|
||||
'content',
|
||||
'major', 'minor', 'patch',
|
||||
'status', 'published_at',
|
||||
'change_log', 'is_active', 'created_by'
|
||||
];
|
||||
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $appends = ['version'];
|
||||
|
||||
public function getVersionAttribute()
|
||||
{
|
||||
return "{$this->major}.{$this->minor}.{$this->patch}";
|
||||
}
|
||||
|
||||
public function legalPage()
|
||||
{
|
||||
return $this->belongsTo(LegalPage::class);
|
||||
}
|
||||
}
|
||||
41
app/Models/LoginHistory.php
Normal file
41
app/Models/LoginHistory.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LoginHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
protected $keyType = 'string';
|
||||
public $incrementing = false;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->id)) {
|
||||
$model->id = \App\Helpers\UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'os',
|
||||
'browser',
|
||||
'city',
|
||||
'country',
|
||||
'country_code',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/SocialAccount.php
Normal file
40
app/Models/SocialAccount.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SocialAccount extends Model
|
||||
{
|
||||
protected $primaryKey = 'id';
|
||||
protected $keyType = 'string';
|
||||
public $incrementing = false;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->id)) {
|
||||
$model->id = \App\Helpers\UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'provider',
|
||||
'provider_id',
|
||||
'provider_email',
|
||||
'avatar',
|
||||
'token',
|
||||
'refresh_token',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
45
app/Models/Ticket.php
Normal file
45
app/Models/Ticket.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
||||
class Ticket extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ticket_number',
|
||||
'subject',
|
||||
'category',
|
||||
'priority',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns the ticket.
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the replies for the ticket.
|
||||
*/
|
||||
public function replies()
|
||||
{
|
||||
return $this->hasMany(TicketReply::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to generate a unique ticket number.
|
||||
*/
|
||||
public static function generateTicketNumber()
|
||||
{
|
||||
return 'TKT-' . strtoupper(bin2hex(random_bytes(3)));
|
||||
}
|
||||
}
|
||||
25
app/Models/TicketAttachment.php
Normal file
25
app/Models/TicketAttachment.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TicketAttachment extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'ticket_reply_id',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'file_type',
|
||||
'file_size',
|
||||
];
|
||||
|
||||
public function reply()
|
||||
{
|
||||
return $this->belongsTo(TicketReply::class, 'ticket_reply_id');
|
||||
}
|
||||
}
|
||||
40
app/Models/TicketReply.php
Normal file
40
app/Models/TicketReply.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
||||
class TicketReply extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'message',
|
||||
'attachment_path',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the ticket that owns the reply.
|
||||
*/
|
||||
public function ticket()
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user that wrote the reply.
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(TicketAttachment::class);
|
||||
}
|
||||
}
|
||||
189
app/Models/User.php
Normal file
189
app/Models/User.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
public const ROLE_OWNER = 'owner';
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_CUSTOMER = 'customer';
|
||||
|
||||
/**
|
||||
* The channels the user receives notification broadcasts on.
|
||||
*/
|
||||
public function receivesBroadcastNotificationsOn(): string
|
||||
{
|
||||
return 'App.Models.User.' . $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API keys for the user.
|
||||
*/
|
||||
public function apiKeys()
|
||||
{
|
||||
return $this->hasMany(ApiKey::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the login history for the user.
|
||||
*/
|
||||
public function loginHistories()
|
||||
{
|
||||
return $this->hasMany(LoginHistory::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tickets for the user.
|
||||
*/
|
||||
public function tickets()
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->{$model->getKeyName()})) {
|
||||
$model->{$model->getKeyName()} = \App\Helpers\UuidHelper::generate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'pending_email',
|
||||
'password',
|
||||
'avatar',
|
||||
'role',
|
||||
'phone',
|
||||
'bio',
|
||||
'job_title',
|
||||
'location',
|
||||
'country',
|
||||
'city_state',
|
||||
'postal_code',
|
||||
'tax_id',
|
||||
'settings_email_alerts',
|
||||
'settings_certificate_renewal',
|
||||
'default_landing_page',
|
||||
'theme',
|
||||
'language',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the social accounts for the user.
|
||||
*/
|
||||
public function socialAccounts()
|
||||
{
|
||||
return $this->hasMany(SocialAccount::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is owner.
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_OWNER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin.
|
||||
*/
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin or owner.
|
||||
*/
|
||||
public function isAdminOrOwner(): bool
|
||||
{
|
||||
return in_array($this->role, [self::ROLE_OWNER, self::ROLE_ADMIN]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the avatar URL with cache busting timestamp.
|
||||
*/
|
||||
public function getAvatarAttribute($value)
|
||||
{
|
||||
if (!$value) return $value;
|
||||
|
||||
// If it's already a full R2 URL, append timestamp
|
||||
if (str_contains($value, 'cdn.trustlab.dyzulk.com')) {
|
||||
$separator = str_contains($value, '?') ? '&' : '?';
|
||||
return $value . $separator . 't=' . ($this->updated_at ? $this->updated_at->timestamp : time());
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'settings_email_alerts' => 'boolean',
|
||||
'settings_certificate_renewal' => 'boolean',
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Send the email verification notification.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function sendEmailVerificationNotification()
|
||||
{
|
||||
$this->notify(new \App\Notifications\VerifyEmailNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notifications for the mail channel.
|
||||
*
|
||||
* @param \Illuminate\Notifications\Notification $notification
|
||||
* @return array|string
|
||||
*/
|
||||
public function routeNotificationForMail($notification)
|
||||
{
|
||||
if ($notification instanceof \App\Notifications\PendingEmailVerificationNotification) {
|
||||
return $this->pending_email;
|
||||
}
|
||||
|
||||
return $this->email;
|
||||
}
|
||||
}
|
||||
68
app/Notifications/CertificateExpiringNotification.php
Normal file
68
app/Notifications/CertificateExpiringNotification.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class CertificateExpiringNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $certificate;
|
||||
public $daysRemaining;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($certificate, $daysRemaining)
|
||||
{
|
||||
$this->certificate = $certificate;
|
||||
$this->daysRemaining = $daysRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
if ($this->daysRemaining < 7) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
return (new \App\Mail\CertificateExpiringMail($this->certificate, $this->daysRemaining))
|
||||
->to($notifiable->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase($notifiable)
|
||||
{
|
||||
return [
|
||||
'type' => 'certificate_expiring',
|
||||
'certificate_id' => $this->certificate->id,
|
||||
'common_name' => $this->certificate->common_name,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'valid_to' => $this->certificate->valid_to,
|
||||
'message' => "Certificate '{$this->certificate->common_name}' expires in {$this->daysRemaining} days.",
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Notifications/CertificateNotification.php
Normal file
76
app/Notifications/CertificateNotification.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Notifications\Messages\BroadcastMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
|
||||
class CertificateNotification extends Notification implements ShouldBroadcast
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $certificate;
|
||||
public $action; // 'issued' or 'revoked'
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($certificate, $action = 'issued')
|
||||
{
|
||||
$this->certificate = $certificate;
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['database', 'broadcast'];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase($notifiable)
|
||||
{
|
||||
$title = $this->action === 'issued' ? 'Certificate Issued' : 'Certificate Revoked';
|
||||
$message = $this->action === 'issued'
|
||||
? "New certificate for {$this->certificate->common_name} has been issued."
|
||||
: "Certificate for {$this->certificate->common_name} has been revoked.";
|
||||
|
||||
return [
|
||||
'certificate_id' => $this->certificate->getKey(),
|
||||
'common_name' => $this->certificate->common_name,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'type' => get_class($this),
|
||||
'icon' => $this->action === 'issued' ? 'check-circle' : 'trash-2',
|
||||
'url' => '/dashboard/certificates',
|
||||
'sender_name' => null,
|
||||
'sender_avatar' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function toBroadcast($notifiable)
|
||||
{
|
||||
$db = $this->toDatabase($notifiable);
|
||||
|
||||
return new BroadcastMessage([
|
||||
'title' => $db['title'],
|
||||
'message' => $db['message'],
|
||||
'url' => $db['url'],
|
||||
'type' => get_class($this),
|
||||
'data' => $db
|
||||
]);
|
||||
}
|
||||
}
|
||||
82
app/Notifications/NewInquiryNotification.php
Normal file
82
app/Notifications/NewInquiryNotification.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Inquiry;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Notifications\Messages\BroadcastMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
|
||||
class NewInquiryNotification extends Notification implements ShouldBroadcast
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $inquiry;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(Inquiry $inquiry)
|
||||
{
|
||||
$this->inquiry = $inquiry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['database', 'broadcast'];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return $this->toDatabase($notifiable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database representation of the notification.
|
||||
*/
|
||||
public function toDatabase($notifiable)
|
||||
{
|
||||
return [
|
||||
'inquiry_id' => $this->inquiry->id,
|
||||
'name' => $this->inquiry->name,
|
||||
'email' => $this->inquiry->email,
|
||||
'subject' => $this->inquiry->subject,
|
||||
'title' => 'New Inquiry: ' . $this->inquiry->subject,
|
||||
'message' => 'Received from ' . $this->inquiry->name . ' (' . $this->inquiry->email . ')',
|
||||
'type' => get_class($this),
|
||||
'icon' => 'inbox',
|
||||
'url' => '/dashboard/admin/inquiries',
|
||||
'sender_name' => $this->inquiry->name,
|
||||
'sender_avatar' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the broadcastable representation of the notification.
|
||||
*/
|
||||
public function toBroadcast($notifiable)
|
||||
{
|
||||
$db = $this->toDatabase($notifiable);
|
||||
return new BroadcastMessage([
|
||||
'title' => $db['title'],
|
||||
'message' => $db['message'],
|
||||
'url' => $db['url'],
|
||||
'type' => get_class($this),
|
||||
'data' => $db
|
||||
]);
|
||||
}
|
||||
}
|
||||
85
app/Notifications/NewTicketNotification.php
Normal file
85
app/Notifications/NewTicketNotification.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Notifications\Messages\BroadcastMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
|
||||
class NewTicketNotification extends Notification implements ShouldBroadcast
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $ticket;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($ticket)
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['database', 'broadcast'];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return $this->toDatabase($notifiable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database representation of the notification.
|
||||
*/
|
||||
public function toDatabase($notifiable)
|
||||
{
|
||||
$user = $this->ticket->user;
|
||||
$userName = $user ? trim($user->first_name . ' ' . $user->last_name) : 'Unknown User';
|
||||
if (empty($userName)) $userName = 'Unknown User';
|
||||
|
||||
return [
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'ticket_number' => $this->ticket->ticket_number,
|
||||
'subject' => $this->ticket->subject,
|
||||
'title' => 'New Ticket #' . $this->ticket->ticket_number,
|
||||
'message' => "Subject: {$this->ticket->subject}. From: {$userName}",
|
||||
'sender_name' => $userName,
|
||||
'sender_avatar' => $user ? $user->avatar : null,
|
||||
'type' => get_class($this),
|
||||
'icon' => 'support-ticket',
|
||||
'url' => '/dashboard/admin/tickets/view?id=' . $this->ticket->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the broadcastable representation of the notification.
|
||||
*/
|
||||
public function toBroadcast($notifiable)
|
||||
{
|
||||
$db = $this->toDatabase($notifiable);
|
||||
|
||||
return new BroadcastMessage([
|
||||
'title' => $db['title'],
|
||||
'message' => $db['message'],
|
||||
'url' => $db['url'],
|
||||
'type' => get_class($this),
|
||||
'data' => $db
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
app/Notifications/PendingEmailVerificationNotification.php
Normal file
50
app/Notifications/PendingEmailVerificationNotification.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
class PendingEmailVerificationNotification extends VerifyEmail
|
||||
{
|
||||
/**
|
||||
* Build the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
$verificationUrl = $this->verificationUrl($notifiable);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(Lang::get('Verify Your New Email Address'))
|
||||
->view('emails.verify-email', [
|
||||
'name' => $notifiable->first_name . ' ' . $notifiable->last_name,
|
||||
'url' => $verificationUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the verification URL for the given notifiable.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return string
|
||||
*/
|
||||
protected function verificationUrl($notifiable)
|
||||
{
|
||||
return parent::verificationUrl($notifiable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the recipient to the pending email.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
}
|
||||
96
app/Notifications/TicketReplyNotification.php
Normal file
96
app/Notifications/TicketReplyNotification.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Notifications\Messages\BroadcastMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
|
||||
class TicketReplyNotification extends Notification implements ShouldBroadcast
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected $ticket;
|
||||
protected $reply;
|
||||
protected $byAdmin;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($ticket, $reply, $byAdmin = false)
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
$this->reply = $reply;
|
||||
$this->byAdmin = $byAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['database', 'broadcast'];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return $this->toDatabase($notifiable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database representation of the notification.
|
||||
*/
|
||||
public function toDatabase($notifiable)
|
||||
{
|
||||
$sender = $this->reply->user;
|
||||
$senderName = $sender ? trim($sender->first_name . ' ' . $sender->last_name) : 'Support Team';
|
||||
if (empty($senderName)) $senderName = 'Support Team';
|
||||
|
||||
// URL determines based on recipient role
|
||||
$url = ($notifiable->role === 'admin')
|
||||
? '/dashboard/admin/tickets/view?id=' . $this->ticket->id
|
||||
: '/dashboard/support/view?id=' . $this->ticket->id;
|
||||
|
||||
return [
|
||||
'ticket_id' => $this->ticket->id,
|
||||
'reply_id' => $this->reply->id,
|
||||
'ticket_number' => $this->ticket->ticket_number,
|
||||
'title' => 'Reply to Ticket #' . $this->ticket->ticket_number,
|
||||
'message' => $this->byAdmin
|
||||
? 'Support replied: ' . $this->ticket->subject
|
||||
: $senderName . ' replied: ' . $this->ticket->ticket_number,
|
||||
'sender_name' => $senderName,
|
||||
'sender_avatar' => $sender ? $sender->avatar : null,
|
||||
'type' => get_class($this),
|
||||
'icon' => 'support-ticket',
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the broadcastable representation of the notification.
|
||||
*/
|
||||
public function toBroadcast($notifiable)
|
||||
{
|
||||
$db = $this->toDatabase($notifiable);
|
||||
|
||||
return new BroadcastMessage([
|
||||
'title' => $db['title'],
|
||||
'message' => $db['message'],
|
||||
'url' => $db['url'],
|
||||
'type' => get_class($this),
|
||||
'data' => $db
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Notifications/VerifyEmailNotification.php
Normal file
28
app/Notifications/VerifyEmailNotification.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
class VerifyEmailNotification extends VerifyEmail
|
||||
{
|
||||
/**
|
||||
* Build the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
$verificationUrl = $this->verificationUrl($notifiable);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(Lang::get('Verify Email Address'))
|
||||
->view('emails.verify-email', [
|
||||
'name' => $notifiable->first_name . ' ' . $notifiable->last_name,
|
||||
'url' => $verificationUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
407
app/Services/OpenSslService.php
Normal file
407
app/Services/OpenSslService.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CaCertificate;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OpenSslService
|
||||
{
|
||||
/**
|
||||
* Generate Root and Intermediate CA certificates.
|
||||
*/
|
||||
public function setupCa()
|
||||
{
|
||||
if (CaCertificate::count() > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rootConfig = Config::get('openssl.ca_root');
|
||||
$int4096Config = Config::get('openssl.ca_4096');
|
||||
$int2048Config = Config::get('openssl.ca_2048');
|
||||
|
||||
// Create a basic temporary openssl config for CA extensions
|
||||
$configContent = "[req]\ndistinguished_name = req\n[v3_ca]\nsubjectKeyIdentifier = hash\nauthorityKeyIdentifier = keyid:always,issuer\nbasicConstraints = critical, CA:true\nkeyUsage = critical, digitalSignature, cRLSign, keyCertSign";
|
||||
$configFile = tempnam(sys_get_temp_dir(), 'ca_conf_');
|
||||
file_put_contents($configFile, $configContent);
|
||||
|
||||
try {
|
||||
// Root CA (4096-bit)
|
||||
$rootKey = openssl_pkey_new([
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
'config' => $configFile
|
||||
]);
|
||||
if (!$rootKey) throw new \Exception('Failed to generate Root Key: ' . openssl_error_string());
|
||||
|
||||
$rootCsr = openssl_csr_new($rootConfig, $rootKey, ['digest_alg' => 'sha256', 'config' => $configFile]);
|
||||
if (!$rootCsr) throw new \Exception('Failed to generate Root CSR: ' . openssl_error_string());
|
||||
|
||||
// Generate a random serial
|
||||
$serial = $this->generateSerialNumber();
|
||||
|
||||
$rootCert = openssl_csr_sign($rootCsr, null, $rootKey, 10950, [
|
||||
'digest_alg' => 'sha256',
|
||||
'x509_extensions' => 'v3_ca',
|
||||
'config' => $configFile,
|
||||
], $serial);
|
||||
if (!$rootCert) throw new \Exception('Failed to sign Root Cert: ' . openssl_error_string());
|
||||
|
||||
if (!openssl_x509_export($rootCert, $rootCertPem)) throw new \Exception('Failed to export Root Cert');
|
||||
if (!openssl_pkey_export($rootKey, $rootKeyPem, null, ['config' => $configFile])) throw new \Exception('Failed to export Root Key');
|
||||
|
||||
$rootDetails = openssl_x509_parse($rootCertPem);
|
||||
|
||||
// Prefer serialNumberHex if available (PHP 8.0+)
|
||||
$serialHex = isset($rootDetails['serialNumberHex'])
|
||||
? $this->formatHex($rootDetails['serialNumberHex'])
|
||||
: $this->formatSerialToHex($rootDetails['serialNumber']);
|
||||
|
||||
CaCertificate::create([
|
||||
'ca_type' => 'root',
|
||||
'cert_content' => $rootCertPem,
|
||||
'key_content' => $rootKeyPem,
|
||||
'serial_number' => $serialHex,
|
||||
'common_name' => $rootDetails['subject']['CN'] ?? 'Root CA',
|
||||
'organization' => $rootDetails['subject']['O'] ?? null,
|
||||
'valid_from' => date('Y-m-d H:i:s', $rootDetails['validFrom_time_t']),
|
||||
'valid_to' => date('Y-m-d H:i:s', $rootDetails['validTo_time_t']),
|
||||
]);
|
||||
|
||||
// Intermediate CA 4096-bit
|
||||
$int4096Key = openssl_pkey_new([
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
'config' => $configFile
|
||||
]);
|
||||
if (!$int4096Key) throw new \Exception('Failed to generate Int-4096 Key: ' . openssl_error_string());
|
||||
|
||||
$int4096Csr = openssl_csr_new($int4096Config, $int4096Key, ['digest_alg' => 'sha256', 'config' => $configFile]);
|
||||
if (!$int4096Csr) throw new \Exception('Failed to generate Int-4096 CSR: ' . openssl_error_string());
|
||||
|
||||
$int4096Cert = openssl_csr_sign($int4096Csr, $rootCert, $rootKey, 10950, [
|
||||
'digest_alg' => 'sha256',
|
||||
'x509_extensions' => 'v3_ca',
|
||||
'config' => $configFile,
|
||||
], $this->generateSerialNumber());
|
||||
if (!$int4096Cert) throw new \Exception('Failed to sign Int-4096 Cert: ' . openssl_error_string());
|
||||
|
||||
if (!openssl_x509_export($int4096Cert, $int4096CertPem)) throw new \Exception('Failed to export Int-4096 Cert');
|
||||
if (!openssl_pkey_export($int4096Key, $int4096KeyPem, null, ['config' => $configFile])) throw new \Exception('Failed to export Int-4096 Key');
|
||||
|
||||
$int4096Details = openssl_x509_parse($int4096CertPem);
|
||||
$serialHex4096 = isset($int4096Details['serialNumberHex'])
|
||||
? $this->formatHex($int4096Details['serialNumberHex'])
|
||||
: $this->formatSerialToHex($int4096Details['serialNumber']);
|
||||
|
||||
CaCertificate::create([
|
||||
'ca_type' => 'intermediate_4096',
|
||||
'cert_content' => $int4096CertPem,
|
||||
'key_content' => $int4096KeyPem,
|
||||
'serial_number' => $serialHex4096,
|
||||
'common_name' => $int4096Details['subject']['CN'] ?? 'Intermediate CA 4096',
|
||||
'organization' => $int4096Details['subject']['O'] ?? null,
|
||||
'valid_from' => date('Y-m-d H:i:s', $int4096Details['validFrom_time_t']),
|
||||
'valid_to' => date('Y-m-d H:i:s', $int4096Details['validTo_time_t']),
|
||||
]);
|
||||
|
||||
// Intermediate CA 2048-bit
|
||||
$int2048Key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
'config' => $configFile
|
||||
]);
|
||||
if (!$int2048Key) throw new \Exception('Failed to generate Int-2048 Key: ' . openssl_error_string());
|
||||
|
||||
$int2048Csr = openssl_csr_new($int2048Config, $int2048Key, ['digest_alg' => 'sha256', 'config' => $configFile]);
|
||||
if (!$int2048Csr) throw new \Exception('Failed to generate Int-2048 CSR: ' . openssl_error_string());
|
||||
|
||||
$int2048Cert = openssl_csr_sign($int2048Csr, $rootCert, $rootKey, 10950, [
|
||||
'digest_alg' => 'sha256',
|
||||
'x509_extensions' => 'v3_ca',
|
||||
'config' => $configFile,
|
||||
], $this->generateSerialNumber());
|
||||
if (!$int2048Cert) throw new \Exception('Failed to sign Int-2048 Cert: ' . openssl_error_string());
|
||||
|
||||
if (!openssl_x509_export($int2048Cert, $int2048CertPem)) throw new \Exception('Failed to export Int-2048 Cert');
|
||||
if (!openssl_pkey_export($int2048Key, $int2048KeyPem, null, ['config' => $configFile])) throw new \Exception('Failed to export Int-2048 Key');
|
||||
|
||||
$int2048Details = openssl_x509_parse($int2048CertPem);
|
||||
$serialHex2048 = isset($int2048Details['serialNumberHex'])
|
||||
? $this->formatHex($int2048Details['serialNumberHex'])
|
||||
: $this->formatSerialToHex($int2048Details['serialNumber']);
|
||||
|
||||
CaCertificate::create([
|
||||
'ca_type' => 'intermediate_2048',
|
||||
'cert_content' => $int2048CertPem,
|
||||
'key_content' => $int2048KeyPem,
|
||||
'serial_number' => $serialHex2048,
|
||||
'common_name' => $int2048Details['subject']['CN'] ?? 'Intermediate CA 2048',
|
||||
'organization' => $int2048Details['subject']['O'] ?? null,
|
||||
'valid_from' => date('Y-m-d H:i:s', $int2048Details['validFrom_time_t']),
|
||||
'valid_to' => date('Y-m-d H:i:s', $int2048Details['validTo_time_t']),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
if (file_exists($configFile)) unlink($configFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a domain certificate (Leaf).
|
||||
*/
|
||||
public function generateLeaf($data)
|
||||
{
|
||||
$keyBits = $data['key_bits'] ?? 2048;
|
||||
$issuerType = (int)$keyBits === 4096 ? 'intermediate_4096' : 'intermediate_2048';
|
||||
|
||||
$intermediate = CaCertificate::where('ca_type', $issuerType)->first();
|
||||
if (!$intermediate) {
|
||||
throw new \Exception("Intermediate CA ({$issuerType}) not found. Please setup CA first.");
|
||||
}
|
||||
|
||||
$dn = [
|
||||
"countryName" => $data['country'],
|
||||
"stateOrProvinceName" => $data['state'],
|
||||
"localityName" => $data['locality'],
|
||||
"organizationName" => $data['organization'],
|
||||
"commonName" => $data['common_name']
|
||||
];
|
||||
|
||||
$cn = $data['common_name'];
|
||||
$userSan = $data['san'] ?? '';
|
||||
|
||||
// Parse user input: split by comma, trim, filter empty
|
||||
$entries = array_filter(array_map('trim', explode(',', $userSan)));
|
||||
|
||||
// Always include CN as the first DNS entry
|
||||
array_unshift($entries, $cn);
|
||||
|
||||
$sanArray = array_unique(array_map(function($entry) {
|
||||
if (str_starts_with($entry, 'IP:') || str_starts_with($entry, 'DNS:')) {
|
||||
return $entry;
|
||||
}
|
||||
return filter_var($entry, FILTER_VALIDATE_IP) ? "IP:$entry" : "DNS:$entry";
|
||||
}, $entries));
|
||||
|
||||
$sanString = implode(', ', $sanArray);
|
||||
|
||||
$configFile = null;
|
||||
try {
|
||||
$configContent = "[req]\ndistinguished_name = req\nreq_extensions = v3_req\nprompt = no\n[req_distinguished_name]\nCN = $cn\n[v3_req]\nsubjectAltName = $sanString";
|
||||
$configFile = tempnam(sys_get_temp_dir(), 'openssl_');
|
||||
file_put_contents($configFile, $configContent);
|
||||
|
||||
$keyBits = $data['key_bits'] ?? 2048;
|
||||
$privKey = openssl_pkey_new([
|
||||
'private_key_bits' => (int)$keyBits,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
'config' => $configFile
|
||||
]);
|
||||
if (!$privKey) throw new \Exception('Failed to generate Private Key: ' . openssl_error_string());
|
||||
|
||||
\Log::debug("Generating Leaf with SAN: " . $sanString);
|
||||
|
||||
$csr = openssl_csr_new($dn, $privKey, [
|
||||
'digest_alg' => 'sha256',
|
||||
'req_extensions' => 'v3_req',
|
||||
'config' => $configFile
|
||||
]);
|
||||
if (!$csr) {
|
||||
$err = openssl_error_string();
|
||||
\Log::error("CSR Creation Failed: " . $err);
|
||||
throw new \Exception('Failed to generate CSR: ' . $err);
|
||||
}
|
||||
|
||||
$serial = $this->generateSerialNumber();
|
||||
\Log::debug("Signing CSR with serial: " . $serial);
|
||||
|
||||
$days = 365;
|
||||
if (!empty($data['is_test_short_lived'])) {
|
||||
$days = 1; // Minimum allowed by OpenSSL, will override DB record later
|
||||
}
|
||||
|
||||
$cert = openssl_csr_sign($csr, $intermediate->cert_content, $intermediate->key_content, $days, [
|
||||
'digest_alg' => 'sha256',
|
||||
'x509_extensions' => 'v3_req',
|
||||
'config' => $configFile,
|
||||
], $serial);
|
||||
|
||||
if (!$cert) {
|
||||
$err = openssl_error_string();
|
||||
\Log::error("Certificate Signing Failed: " . $err);
|
||||
throw new \Exception('Failed to sign Certificate: ' . $err);
|
||||
}
|
||||
|
||||
// Verification: check if serial was actually applied
|
||||
$certInfo = openssl_x509_parse($cert);
|
||||
$actualSerialHex = isset($certInfo['serialNumberHex'])
|
||||
? $this->formatHex($certInfo['serialNumberHex'])
|
||||
: $this->formatSerialToHex($certInfo['serialNumber']);
|
||||
|
||||
\Log::debug("Certificate signed. Embedded Serial: " . $actualSerialHex);
|
||||
|
||||
if (!openssl_x509_export($cert, $certPem)) throw new \Exception('Failed to export Certificate');
|
||||
if (!openssl_pkey_export($privKey, $keyPem, null, ['config' => $configFile])) throw new \Exception('Failed to export Private Key');
|
||||
if (!openssl_csr_export($csr, $csrPem)) throw new \Exception('Failed to export CSR');
|
||||
|
||||
$validTo = isset($certInfo['validTo_time_t']) ? date('Y-m-d H:i:s', $certInfo['validTo_time_t']) : null;
|
||||
|
||||
// Override for testing: 30 seconds expiration
|
||||
if (!empty($data['is_test_short_lived'])) {
|
||||
$validTo = date('Y-m-d H:i:s', time() + 30);
|
||||
}
|
||||
|
||||
return [
|
||||
'cert' => $certPem,
|
||||
'key' => $keyPem,
|
||||
'csr' => $csrPem,
|
||||
'serial' => $actualSerialHex,
|
||||
'valid_from' => isset($certInfo['validFrom_time_t']) ? date('Y-m-d H:i:s', $certInfo['validFrom_time_t']) : null,
|
||||
'valid_to' => $validTo,
|
||||
];
|
||||
} finally {
|
||||
if ($configFile && file_exists($configFile)) {
|
||||
unlink($configFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique serial number.
|
||||
*/
|
||||
protected function generateSerialNumber(): int
|
||||
{
|
||||
try {
|
||||
return random_int(1, PHP_INT_MAX);
|
||||
} catch (\Exception $e) {
|
||||
return time();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a hex string (from serialNumberHex).
|
||||
*/
|
||||
public function formatHex($hex)
|
||||
{
|
||||
$hex = strtoupper($hex);
|
||||
return implode(':', str_split($hex, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback format a decimal serial number to hex string.
|
||||
*/
|
||||
public function formatSerialToHex($decimal)
|
||||
{
|
||||
$cleaned = preg_replace('/[^0-9]/', '', (string)$decimal);
|
||||
if ($cleaned === '') $cleaned = '0';
|
||||
|
||||
if (function_exists('bcdiv')) {
|
||||
$hex = '';
|
||||
$value = $cleaned;
|
||||
if (!preg_match('/^\d+$/', $value)) $value = '0';
|
||||
|
||||
while (bccomp($value, '0') > 0) {
|
||||
$mod = bcmod($value, '16');
|
||||
$hex = dechex((int)$mod) . $hex;
|
||||
$value = bcdiv($value, '16', 0);
|
||||
}
|
||||
$hex = $hex ?: '0';
|
||||
} else {
|
||||
$hex = dechex((int)$cleaned);
|
||||
}
|
||||
|
||||
if (strlen($hex) % 2 !== 0) $hex = '0' . $hex;
|
||||
return strtoupper(implode(':', str_split($hex, 2)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew (re-sign) an existing CA certificate using its existing private key.
|
||||
*/
|
||||
public function renewCaCertificate(CaCertificate $cert, int $days)
|
||||
{
|
||||
$configFile = null;
|
||||
try {
|
||||
// 1. Prepare Config
|
||||
$configContent = "[req]\ndistinguished_name = req\n[v3_ca]\nsubjectKeyIdentifier = hash\nauthorityKeyIdentifier = keyid:always,issuer\nbasicConstraints = critical, CA:true\nkeyUsage = critical, digitalSignature, cRLSign, keyCertSign";
|
||||
$configFile = tempnam(sys_get_temp_dir(), 'renew_ca_');
|
||||
file_put_contents($configFile, $configContent);
|
||||
|
||||
// 2. Get Private Key
|
||||
$privKey = openssl_pkey_get_private($cert->key_content);
|
||||
if (!$privKey) throw new \Exception('Failed to load Private Key');
|
||||
|
||||
// 3. Get Subject DN from existing Cert
|
||||
$certInfo = openssl_x509_parse($cert->cert_content);
|
||||
$dn = $certInfo['subject'];
|
||||
|
||||
$dnMap = [
|
||||
'CN' => 'commonName',
|
||||
'O' => 'organizationName',
|
||||
'OU' => 'organizationalUnitName',
|
||||
'C' => 'countryName',
|
||||
'ST' => 'stateOrProvinceName',
|
||||
'L' => 'localityName',
|
||||
'emailAddress' => 'emailAddress'
|
||||
];
|
||||
|
||||
$newDn = [];
|
||||
foreach ($dn as $key => $value) {
|
||||
if (isset($dnMap[$key])) {
|
||||
$newDn[$dnMap[$key]] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Generate New CSR
|
||||
$csr = openssl_csr_new($newDn, $privKey, ['digest_alg' => 'sha256', 'config' => $configFile]);
|
||||
if (!$csr) throw new \Exception('Failed to generate Renewal CSR: ' . openssl_error_string());
|
||||
|
||||
// 5. Determine Signer (Issuer)
|
||||
$issuerCert = null;
|
||||
$issuerKey = null;
|
||||
|
||||
if ($cert->ca_type === 'root') {
|
||||
// Root signs itself
|
||||
$issuerCert = null;
|
||||
$issuerKey = $privKey;
|
||||
} else {
|
||||
// Intermediate is signed by Root
|
||||
$root = CaCertificate::where('ca_type', 'root')->first();
|
||||
if (!$root) throw new \Exception('Root CA not found for signing intermediate renewal.');
|
||||
$issuerCert = $root->cert_content;
|
||||
$issuerKey = openssl_pkey_get_private($root->key_content);
|
||||
}
|
||||
|
||||
// 6. Sign CSR
|
||||
$serial = $this->generateSerialNumber();
|
||||
$newCert = openssl_csr_sign($csr, $issuerCert, $issuerKey, $days, [
|
||||
'digest_alg' => 'sha256',
|
||||
'x509_extensions' => 'v3_ca',
|
||||
'config' => $configFile,
|
||||
], $serial);
|
||||
|
||||
if (!$newCert) throw new \Exception('Failed to sign Renewal Cert: ' . openssl_error_string());
|
||||
|
||||
// 7. Export
|
||||
if (!openssl_x509_export($newCert, $newCertPem)) throw new \Exception('Failed to export Renewal Cert');
|
||||
|
||||
// 8. Parse new details
|
||||
$newInfo = openssl_x509_parse($newCertPem);
|
||||
$newSerialHex = isset($newInfo['serialNumberHex'])
|
||||
? $this->formatHex($newInfo['serialNumberHex'])
|
||||
: $this->formatSerialToHex($newInfo['serialNumber']);
|
||||
|
||||
return [
|
||||
'cert_content' => $newCertPem,
|
||||
'serial_number' => $newSerialHex,
|
||||
'valid_from' => date('Y-m-d H:i:s', $newInfo['validFrom_time_t']),
|
||||
'valid_to' => date('Y-m-d H:i:s', $newInfo['validTo_time_t']),
|
||||
];
|
||||
|
||||
} finally {
|
||||
if ($configFile && file_exists($configFile)) unlink($configFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/Traits/CanTrackLogin.php
Normal file
118
app/Traits/CanTrackLogin.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\LoginHistory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
trait CanTrackLogin
|
||||
{
|
||||
/**
|
||||
* Record Login History
|
||||
*/
|
||||
protected function recordLoginHistory(Request $request, $user)
|
||||
{
|
||||
$userAgent = $request->header('User-Agent');
|
||||
$ip = $request->ip();
|
||||
|
||||
// For local development testing (if IP is local, use a real one for fallback)
|
||||
$lookupIp = ($ip === '127.0.0.1' || $ip === '::1') ? '8.8.8.8' : $ip;
|
||||
$location = $this->getLocationFromIp($lookupIp);
|
||||
|
||||
$info = $this->parseUserAgent($userAgent);
|
||||
|
||||
LoginHistory::create([
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
'device_type' => $info['device'],
|
||||
'os' => $info['os'],
|
||||
'browser' => $info['browser'],
|
||||
'city' => $location['city'],
|
||||
'country' => $location['country'],
|
||||
'country_code' => $location['country_code'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Location from IP
|
||||
*/
|
||||
protected function getLocationFromIp($ip)
|
||||
{
|
||||
try {
|
||||
$response = Http::get("http://ip-api.com/json/{$ip}")->json();
|
||||
|
||||
if ($response && $response['status'] === 'success') {
|
||||
return [
|
||||
'city' => $response['city'] ?? 'Unknown City',
|
||||
'country' => $response['country'] ?? 'Unknown Country',
|
||||
'country_code' => $response['countryCode'] ?? 'UN',
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Fallback silently
|
||||
}
|
||||
|
||||
return [
|
||||
'city' => 'Unknown City',
|
||||
'country' => 'Unknown Country',
|
||||
'country_code' => 'UN',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to parse User Agent
|
||||
*/
|
||||
protected function parseUserAgent($agent)
|
||||
{
|
||||
$os = 'Unknown OS';
|
||||
$browser = 'Unknown Browser';
|
||||
$device = 'Desktop';
|
||||
|
||||
// OS Parsing
|
||||
if (preg_match('/iphone|ipad|ipod/i', $agent)) {
|
||||
$os = 'iOS';
|
||||
$device = 'iOS';
|
||||
} elseif (preg_match('/android/i', $agent)) {
|
||||
$os = 'Android';
|
||||
$device = 'Android';
|
||||
} elseif (preg_match('/windows/i', $agent)) {
|
||||
$os = 'Windows';
|
||||
$device = 'Windows';
|
||||
} elseif (preg_match('/macintosh|mac os x/i', $agent)) {
|
||||
$os = 'Mac';
|
||||
$device = 'Mac';
|
||||
} elseif (preg_match('/linux/i', $agent)) {
|
||||
$os = 'Linux';
|
||||
$device = 'Linux';
|
||||
}
|
||||
|
||||
// Browser Parsing
|
||||
if (preg_match('/msie/i', $agent) && !preg_match('/opera/i', $agent)) {
|
||||
$browser = 'Internet Explorer';
|
||||
} elseif (preg_match('/firefox/i', $agent)) {
|
||||
$browser = 'Firefox';
|
||||
} elseif (preg_match('/chrome/i', $agent)) {
|
||||
$browser = 'Chrome';
|
||||
} elseif (preg_match('/safari/i', $agent)) {
|
||||
$browser = 'Safari';
|
||||
} elseif (preg_match('/opera/i', $agent)) {
|
||||
$browser = 'Opera';
|
||||
} elseif (preg_match('/netscape/i', $agent)) {
|
||||
$browser = 'Netscape';
|
||||
}
|
||||
|
||||
// More specific
|
||||
if ($os === 'iOS' && $browser === 'Safari') $browser = 'iOS Safari';
|
||||
if ($os === 'iOS' && $browser === 'Chrome') $browser = 'iOS Chrome';
|
||||
if ($os === 'Android' && $browser === 'Chrome') $browser = 'Android Chrome';
|
||||
if ($os === 'Android' && $browser === 'Firefox') $browser = 'Android Firefox';
|
||||
|
||||
return [
|
||||
'os' => $os,
|
||||
'browser' => $browser,
|
||||
'device' => $device
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Traits/LogsActivity.php
Normal file
23
app/Traits/LogsActivity.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
|
||||
trait LogsActivity
|
||||
{
|
||||
/**
|
||||
* Log a user activity.
|
||||
*/
|
||||
public function logActivity(string $action, ?string $description = null)
|
||||
{
|
||||
return ActivityLog::create([
|
||||
'user_id' => auth()->id(),
|
||||
'action' => $action,
|
||||
'description' => $description,
|
||||
'ip_address' => Request::ip(),
|
||||
'user_agent' => Request::userAgent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
artisan
Normal file
18
artisan
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
BIN
banner.png
Normal file
BIN
banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
24
bootstrap/app.php
Normal file
24
bootstrap/app.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: '*');
|
||||
$middleware->statefulApi();
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\AdminMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
bootstrap/providers.php
Normal file
5
bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
97
composer.json
Normal file
97
composer.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"intervention/image": "^3.11",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/reverb": "^1.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/socialite": "^5.24",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/flysystem-aws-s3-v3": "3.0",
|
||||
"pragmarx/google2fa-laravel": "^2.3",
|
||||
"ryangjchandler/laravel-cloudflare-turnstile": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
10812
composer.lock
generated
Normal file
10812
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
128
config/app.php
Normal file
128
config/app.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'Asia/Jakarta',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'),
|
||||
|
||||
];
|
||||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
82
config/broadcasting.php
Normal file
82
config/broadcasting.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
117
config/cache.php
Normal file
117
config/cache.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "octane",
|
||||
| "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
];
|
||||
36
config/cors.php
Normal file
36
config/cors.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie', '*'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => ['http://localhost:3000', 'http://127.0.0.1:3000', 'https://trustlab.dyzulk.com', 'https://trustlab-api.dyzulk.com', 'https://trustlab.pages.dev', 'https://dev.trustlab.dyzulk.com'],
|
||||
|
||||
'allowed_origins_patterns' => [
|
||||
'#^https?://.*\.trustlab\.pages\.dev$#',
|
||||
],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
|
||||
];
|
||||
183
config/database.php
Normal file
183
config/database.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
93
config/filesystems.php
Normal file
93
config/filesystems.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'r2'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'r2' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('R2_ACCESS_KEY_ID'),
|
||||
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||
'region' => 'auto',
|
||||
'bucket' => env('R2_BUCKET'),
|
||||
'url' => env('R2_URL'),
|
||||
'endpoint' => env('R2_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('R2_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
133
config/mail.php
Normal file
133
config/mail.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'support' => [
|
||||
'transport' => 'smtp',
|
||||
'host' => env('MAIL_SUPPORT_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_SUPPORT_PORT', 587),
|
||||
'username' => env('MAIL_SUPPORT_USERNAME'),
|
||||
'password' => env('MAIL_SUPPORT_PASSWORD'),
|
||||
'encryption' => env('MAIL_SUPPORT_ENCRYPTION', 'tls'),
|
||||
'timeout' => null,
|
||||
'from' => [
|
||||
'address' => env('MAIL_SUPPORT_FROM_ADDRESS'),
|
||||
'name' => env('MAIL_SUPPORT_FROM_NAME', 'Support'),
|
||||
],
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
];
|
||||
29
config/openssl.php
Normal file
29
config/openssl.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'ca_root' => [
|
||||
'countryName' => env('CA_ROOT_COUNTRY_NAME', 'ID'),
|
||||
'organizationName' => env('CA_ROOT_ORGANIZATION_NAME', 'DyDev Authority'),
|
||||
'organizationalUnitName' => env('CA_ROOT_ORGANIZATIONAL_UNIT_NAME', 'Security Division'),
|
||||
'commonName' => env('CA_ROOT_COMMON_NAME', 'DyDev Its True'),
|
||||
],
|
||||
'ca_4096' => [
|
||||
'countryName' => env('CA_4096_COUNTRY_NAME', 'ID'),
|
||||
'organizationName' => env('CA_4096_ORGANIZATION_NAME', 'TrustLab CA'),
|
||||
'organizationalUnitName' => env('CA_4096_ORGANIZATIONAL_UNIT_NAME', 'Security Division'),
|
||||
'commonName' => env('CA_4096_COMMON_NAME', 'TrustLab Intermediate CA 4096'),
|
||||
],
|
||||
'ca_2048' => [
|
||||
'countryName' => env('CA_2048_COUNTRY_NAME', 'ID'),
|
||||
'organizationName' => env('CA_2048_ORGANIZATION_NAME', 'TrustLab CA'),
|
||||
'organizationalUnitName' => env('CA_2048_ORGANIZATIONAL_UNIT_NAME', 'Security Division'),
|
||||
'commonName' => env('CA_2048_COMMON_NAME', 'TrustLab Intermediate CA 2048'),
|
||||
],
|
||||
'ca_leaf_default' => [
|
||||
'countryName' => env('CA_LEAF_DEFAULT_COUNTRY_NAME', 'ID'),
|
||||
'localityName' => env('CA_LEAF_DEFAULT_LOCALITY', 'Jakarta'),
|
||||
'stateOrProvinceName' => env('CA_LEAF_DEFAULT_STATE', 'DKI Jakarta'),
|
||||
'organizationName' => env('CA_LEAF_DEFAULT_ORGANIZATION_NAME', 'TrustLab Cooking'),
|
||||
'commonName' => env('CA_LEAF_DEFAULT_COMMON_NAME', 'customer.trustlab.com'),
|
||||
],
|
||||
];
|
||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
95
config/reverb.php
Normal file
95
config/reverb.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_HOST'),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
90
config/sanctum.php
Normal file
90
config/sanctum.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => array_merge(
|
||||
explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1')),
|
||||
[
|
||||
'trustlab.dyzulk.com',
|
||||
'dev.trustlab.dyzulk.com',
|
||||
parse_url(env('APP_URL', ''), PHP_URL_HOST),
|
||||
parse_url(env('FRONTEND_URL', ''), PHP_URL_HOST),
|
||||
],
|
||||
(isset($_SERVER['HTTP_ORIGIN']) && str_ends_with($_SERVER['HTTP_ORIGIN'], '.trustlab.pages.dev'))
|
||||
? [parse_url($_SERVER['HTTP_ORIGIN'], PHP_URL_HOST)]
|
||||
: []
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
55
config/services.php
Normal file
55
config/services.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
'google' => [
|
||||
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||
'redirect' => env('GOOGLE_REDIRECT_URI'),
|
||||
],
|
||||
|
||||
'github' => [
|
||||
'client_id' => env('APP_ENV') === 'production' ? env('GITHUB_PROD_CLIENT_ID') : env('GITHUB_DEV_CLIENT_ID'),
|
||||
'client_secret' => env('APP_ENV') === 'production' ? env('GITHUB_PROD_CLIENT_SECRET') : env('GITHUB_DEV_CLIENT_SECRET'),
|
||||
'redirect' => env('APP_ENV') === 'production' ? env('GITHUB_PROD_REDIRECT_URI') : env('GITHUB_DEV_REDIRECT_URI'),
|
||||
],
|
||||
|
||||
'turnstile' => [
|
||||
'key' => env('TURNSTILE_SITE_KEY'),
|
||||
'secret' => env('TURNSTILE_SECRET_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
220
config/session.php
Normal file
220
config/session.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => (function() {
|
||||
if (!isset($_SERVER['HTTP_HOST'])) {
|
||||
return env('SESSION_DOMAIN');
|
||||
}
|
||||
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
|
||||
// Always use dyzulk.com for the cookie domain because the API is hosted there.
|
||||
// A cookie set by dyzulk.com cannot have a .pages.dev domain.
|
||||
if (str_contains($host, 'dyzulk.com')) {
|
||||
return '.dyzulk.com';
|
||||
}
|
||||
|
||||
return env('SESSION_DOMAIN');
|
||||
})(),
|
||||
|
||||
'same_site' => (function() {
|
||||
// Force 'none' for any cross-domain requests on production/staging
|
||||
// This is safe because we use 'secure' => true below.
|
||||
return 'none';
|
||||
})(),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
48
database/factories/UserFactory.php
Normal file
48
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$firstName = fake()->firstName();
|
||||
$lastName = fake()->lastName();
|
||||
|
||||
return [
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
78
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
78
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->string('id', 32)->primary();
|
||||
$table->string('first_name')->nullable();
|
||||
$table->string('last_name')->nullable();
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
|
||||
// 2FA columns
|
||||
$table->text('two_factor_secret')->nullable();
|
||||
$table->text('two_factor_recovery_codes')->nullable();
|
||||
$table->timestamp('two_factor_confirmed_at')->nullable();
|
||||
|
||||
$table->string('role')->default('customer');
|
||||
$table->string('avatar')->nullable();
|
||||
|
||||
// Profile & Contact
|
||||
$table->string('phone')->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->string('job_title')->nullable();
|
||||
$table->string('location')->nullable();
|
||||
$table->string('country')->nullable();
|
||||
$table->string('city_state')->nullable();
|
||||
$table->string('postal_code')->nullable();
|
||||
$table->string('tax_id')->nullable();
|
||||
|
||||
// Notification Settings
|
||||
$table->boolean('settings_email_alerts')->default(true);
|
||||
$table->boolean('settings_certificate_renewal')->default(true);
|
||||
|
||||
// Preferences
|
||||
$table->string('default_landing_page')->default('/dashboard');
|
||||
$table->string('theme')->default('system');
|
||||
$table->string('language')->default('en');
|
||||
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('user_id', 32)->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_keys', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('key', 64)->unique();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_keys');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tokenable_type');
|
||||
$table->string('tokenable_id', 32);
|
||||
$table->index(['tokenable_type', 'tokenable_id']);
|
||||
$table->text('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('certificates', function (Blueprint $table) {
|
||||
$table->string('uuid', 32)->primary();
|
||||
$table->string('user_id', 32)->index();
|
||||
$table->string('common_name');
|
||||
$table->string('organization')->nullable();
|
||||
$table->string('locality')->nullable();
|
||||
$table->string('state')->nullable();
|
||||
$table->string('country', 2)->nullable();
|
||||
$table->text('san')->nullable();
|
||||
$table->string('status')->default('ISSUED')->index();
|
||||
$table->integer('key_bits')->default(2048);
|
||||
$table->string('serial_number')->nullable();
|
||||
$table->longText('cert_content')->nullable();
|
||||
$table->longText('key_content')->nullable();
|
||||
$table->longText('csr_content')->nullable();
|
||||
$table->dateTime('valid_from')->nullable();
|
||||
$table->dateTime('valid_to')->nullable();
|
||||
$table->timestamp('expired_notification_sent_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('certificates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ca_certificates', function (Blueprint $table) {
|
||||
$table->string('uuid', 32)->primary();
|
||||
$table->string('ca_type'); // root, intermediate_4096, intermediate_2048
|
||||
$table->longText('cert_content')->nullable();
|
||||
$table->longText('key_content')->nullable();
|
||||
$table->string('serial_number')->nullable();
|
||||
$table->string('common_name')->nullable();
|
||||
$table->string('organization')->nullable();
|
||||
$table->dateTime('valid_from')->nullable();
|
||||
$table->dateTime('valid_to')->nullable();
|
||||
|
||||
// Tracking
|
||||
$table->unsignedBigInteger('download_count')->default(0);
|
||||
$table->timestamp('last_downloaded_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ca_certificates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('login_histories', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->string('device_type')->nullable(); // ios, android, windows, mac, linux
|
||||
$table->string('os')->nullable(); // iOS 15, Windows 10, etc.
|
||||
$table->string('browser')->nullable(); // Safari, Chrome, etc.
|
||||
$table->string('city')->nullable();
|
||||
$table->string('country')->nullable();
|
||||
$table->string('country_code')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('login_histories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('inquiries', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
$table->string('category')->nullable();
|
||||
$table->string('subject');
|
||||
$table->text('message');
|
||||
$table->enum('status', ['pending', 'replied'])->default('pending');
|
||||
$table->timestamp('replied_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inquiries');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->uuidMorphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('legal_pages', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('legal_page_revisions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('legal_page_id')->constrained('legal_pages')->onDelete('cascade');
|
||||
$table->longText('content');
|
||||
|
||||
// Hierarchical Versioning
|
||||
$table->integer('major')->default(0);
|
||||
$table->integer('minor')->default(0);
|
||||
$table->integer('patch')->default(0);
|
||||
|
||||
// Publishing Status
|
||||
$table->string('status')->default('draft'); // draft, published, archived
|
||||
$table->timestamp('published_at')->nullable();
|
||||
|
||||
$table->text('change_log')->nullable();
|
||||
$table->boolean('is_active')->default(true); // Internal Soft delete/archive
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('legal_page_revisions');
|
||||
Schema::dropIfExists('legal_pages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tickets', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('ticket_number')->unique();
|
||||
$table->string('subject');
|
||||
$table->string('category'); // Technical, Billing, etc.
|
||||
$table->enum('priority', ['low', 'medium', 'high'])->default('medium');
|
||||
$table->enum('status', ['open', 'answered', 'closed'])->default('open');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('ticket_replies', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('ticket_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
|
||||
$table->text('message');
|
||||
$table->string('attachment_path')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_replies');
|
||||
Schema::dropIfExists('tickets');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_attachments', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('ticket_reply_id')->constrained('ticket_replies')->onDelete('cascade');
|
||||
$table->string('file_name');
|
||||
$table->string('file_path');
|
||||
$table->string('file_type'); // mime type
|
||||
$table->integer('file_size'); // in bytes
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('social_accounts', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('provider'); // 'google', 'github'
|
||||
$table->string('provider_id');
|
||||
$table->string('provider_email')->nullable(); // Email from the provider (e.g. b@gmail.com)
|
||||
$table->string('avatar')->nullable();
|
||||
$table->text('token')->nullable();
|
||||
$table->text('refresh_token')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['provider', 'provider_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('social_accounts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activity_logs', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('action');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $label) {
|
||||
$label->string('pending_email')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $label) {
|
||||
$label->dropColumn('pending_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
51
database/seeders/DatabaseSeeder.php
Normal file
51
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$commonPassword = bcrypt('password');
|
||||
|
||||
// 1. Owner
|
||||
User::updateOrCreate([
|
||||
'email' => 'owner@trustlab.com',
|
||||
], [
|
||||
'first_name' => 'Owner',
|
||||
'last_name' => 'User',
|
||||
'password' => $commonPassword,
|
||||
'role' => User::ROLE_OWNER,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
// 2. Admin
|
||||
User::updateOrCreate([
|
||||
'email' => 'admin@trustlab.com',
|
||||
], [
|
||||
'first_name' => 'Admin',
|
||||
'last_name' => 'User',
|
||||
'password' => $commonPassword,
|
||||
'role' => User::ROLE_ADMIN,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
// 3. Customer
|
||||
User::updateOrCreate([
|
||||
'email' => 'customer@trustlab.com',
|
||||
], [
|
||||
'first_name' => 'Customer',
|
||||
'last_name' => 'User',
|
||||
'password' => $commonPassword,
|
||||
'role' => User::ROLE_CUSTOMER,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
12
lang/vendor/cloudflare-turnstile/ar/errors.php
vendored
Normal file
12
lang/vendor/cloudflare-turnstile/ar/errors.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'missing-input-secret' => 'لم يتم إرسال البيانات اللازمة للتحقق من Captcha.',
|
||||
'invalid-input-secret' => 'البيانات اللازمة للتحقق من Captcha غير صحيحة أو غير موجودة.',
|
||||
'missing-input-response' => 'لم يتم إرسال الرد المطلوب للتحقق من Captcha.',
|
||||
'invalid-input-response' => 'الرد المطلوب للتحقق من Captcha غير صالح أو انتهت صلاحيته.',
|
||||
'bad-request' => 'تم رفض الطلب لأنه غير مكتمل أو يحتوي على خطأ.',
|
||||
'timeout-or-duplicate' => 'تم استخدام رد Captcha هذا مسبقًا.',
|
||||
'internal-error' => 'حدث خطأ داخلي أثناء التحقق من Captcha.',
|
||||
'unexpected' => 'حدث خطأ غير متوقع أثناء التحقق من Captcha.',
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user