Bootstrap Ladill Link from QR Plus extract template.
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
APP_NAME="Ladill QR Plus"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://qrplus.ladill.com
|
||||
|
||||
PLATFORM_URL=https://ladill.com
|
||||
PLATFORM_DOMAIN=ladill.com
|
||||
AUTH_DOMAIN=auth.ladill.com
|
||||
ACCOUNT_DOMAIN=account.ladill.com
|
||||
QR_DOMAIN=qrplus.ladill.com
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ladill_qr_plus
|
||||
DB_USERNAME=ladill_qr_plus
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_DOMAIN=.ladill.com
|
||||
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
LADILL_SSO_CLIENT_SECRET=
|
||||
|
||||
BILLING_API_URL=https://ladill.com/api/billing
|
||||
BILLING_API_KEY_QR=
|
||||
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_QR=
|
||||
|
||||
AFIA_ENABLED=true
|
||||
AFIA_PRODUCT=qr
|
||||
AFIA_PROVIDER=openai
|
||||
AFIA_MODEL=gpt-4o-mini
|
||||
AFIA_API_KEY=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Deploy Ladill QR Plus
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-qr-plus
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy
|
||||
env:
|
||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||
NODE_VERSION: "22.14.0"
|
||||
RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-qr-plus
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
|
||||
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||
rm -rf "$WORKSPACE"
|
||||
mkdir -p "$WORKSPACE"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
git \
|
||||
-c credential.helper= \
|
||||
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
|
||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||
"$REPO_URL" "$WORKSPACE"
|
||||
|
||||
- name: Setup Node.js
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||
rm -rf "$NODE_ROOT"
|
||||
mkdir -p "$NODE_ROOT"
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) NODE_ARCH="x64" ;;
|
||||
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||
fi
|
||||
"$NODE_ROOT/bin/node" -v
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- name: Build release archive
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
printf '%s\n' "${{ gitea.sha }}" > REVISION
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
tar -czf "$RELEASE_ARCHIVE" \
|
||||
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
|
||||
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
|
||||
|
||||
- name: Deploy release
|
||||
shell: bash {0}
|
||||
env:
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
bash "$WORKSPACE/deploy/deploy.sh"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: bash {0}
|
||||
run: |
|
||||
rm -rf "$WORKSPACE"
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
*.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
|
||||
@@ -0,0 +1,87 @@
|
||||
# Ladill QR Plus — deploy & cutover runbook
|
||||
|
||||
Standalone app for **utility QR codes** at `qrplus.ladill.com` (URL, WiFi, link
|
||||
list, business, app download). **vCard** is a separate future product
|
||||
(`vcard.ladill.com`). Commerce types (shop, menu, event, give, …) stay on the
|
||||
platform until Merchant/Events/Give extract.
|
||||
|
||||
Trusts `auth.ladill.com` for SSO and bills the one platform UserWallet via the
|
||||
Billing API. Public scans stay at `ladill.com/q/<code>` (nginx proxies to this
|
||||
app for Plus codes after cutover).
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
| Secret | Where |
|
||||
|---|---|
|
||||
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
|
||||
| `BILLING_API_KEY_QR` | platform `.env` + this app |
|
||||
| `IDENTITY_API_KEY_QR` | platform `.env` + this app |
|
||||
|
||||
## 1. Gitea repo + CI
|
||||
|
||||
1. Repo: **http://161.97.138.149:3000/isaacclad/ladill-qr-plus**
|
||||
2. Push to `main` triggers `.gitea/workflows/deploy.yml`.
|
||||
3. App root: `/var/www/ladill-qr-plus`
|
||||
|
||||
## 2. Server app-slot + database
|
||||
|
||||
```bash
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-qr-plus
|
||||
sudo mysql -e "CREATE DATABASE ladill_qr_plus CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
sudo mysql -e "CREATE USER 'ladill_qr_plus'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||
sudo mysql -e "GRANT ALL ON ladill_qr_plus.* TO 'ladill_qr_plus'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
## 3. Register OIDC client (platform)
|
||||
|
||||
```bash
|
||||
php artisan passport:client \
|
||||
--name="Ladill QR Plus" \
|
||||
--redirect_uri="https://qrplus.ladill.com/sso/callback"
|
||||
```
|
||||
|
||||
## 4. Platform integration
|
||||
|
||||
```env
|
||||
BILLING_API_KEY_QR=<same>
|
||||
IDENTITY_API_KEY_QR=<same>
|
||||
RP_QR_FRONTCHANNEL_LOGOUT=https://qrplus.ladill.com/sso/logout-frontchannel
|
||||
LADILL_QR_APP_URL=https://qrplus.ladill.com
|
||||
```
|
||||
|
||||
## 5. nginx + TLS
|
||||
|
||||
```bash
|
||||
sudo deployment/setup-service-subdomain-nginx.sh qrplus --app /var/www/ladill-qr-plus/current
|
||||
```
|
||||
|
||||
Proxy `ladill.com/q/*` to this app after import (see platform nginx docs).
|
||||
|
||||
## 6. First deploy
|
||||
|
||||
```bash
|
||||
cd /var/www/ladill-qr-plus/current
|
||||
php artisan migrate --force
|
||||
php artisan config:cache route:cache view:cache
|
||||
```
|
||||
|
||||
## 7. Data migration
|
||||
|
||||
```bash
|
||||
# platform
|
||||
php artisan qr-plus:export --out=/tmp/qr-plus-export.json
|
||||
|
||||
# qr app (include --storage-source so PDF files copy across)
|
||||
php artisan qr-plus:import /tmp/qr-plus-export.json \
|
||||
--storage-source=/var/www/ladill/current/storage/app/private/qr \
|
||||
--commit
|
||||
```
|
||||
|
||||
## 8. Smoke test
|
||||
|
||||
- `https://qrplus.ladill.com/up` → 200
|
||||
- SSO login → create URL QR → wallet debited
|
||||
- `https://ladill.com/q/<code>` resolves (after nginx proxy)
|
||||
- `account.ladill.com/qr-codes` redirects here
|
||||
@@ -0,0 +1,59 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||
*/
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
public function connect(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->query('redirect', route('qr.dashboard'));
|
||||
|
||||
if (Auth::check()) {
|
||||
return $this->safeRedirect($intended, route('qr.dashboard'));
|
||||
}
|
||||
|
||||
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||
return $this->safeRedirect($intended, route('qr.dashboard'));
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
|
||||
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||
if ($loginHint !== '') {
|
||||
$query['login_hint'] = $loginHint;
|
||||
}
|
||||
|
||||
if (! $request->boolean('interactive')) {
|
||||
$query['prompt'] = 'none';
|
||||
}
|
||||
|
||||
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
|
||||
|
||||
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
|
||||
$request->session()->put('sso.popup', true);
|
||||
|
||||
return view('auth.sso-signing-in', [
|
||||
'authorizeUrl' => $authorizeUrl,
|
||||
'intended' => $intended,
|
||||
'fallbackUrl' => route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->away($authorizeUrl);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->session()->get('sso.intended', route('qr.dashboard'));
|
||||
|
||||
$popup = (bool) $request->session()->get('sso.popup');
|
||||
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
|
||||
}
|
||||
|
||||
if (! $request->filled('code')
|
||||
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'code' => (string) $request->query('code'),
|
||||
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||
]);
|
||||
if ($tokenRes->failed()) {
|
||||
return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
if (! $user) {
|
||||
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
|
||||
}
|
||||
|
||||
QrTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return $this->finishCallback($request, $intended, null, $popup);
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Per-app sign-out: end only this app's session and keep the platform
|
||||
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently
|
||||
// without a password. Full "sign out of all Ladill apps" lives on account.
|
||||
return redirect()->away($this->defaultSignedOutUrl());
|
||||
}
|
||||
|
||||
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||
public function platformSignedOut(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => (string) $request->query('redirect', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutBridge(Request $request): View
|
||||
{
|
||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
|
||||
'embedded' => 1,
|
||||
'return' => $return,
|
||||
]);
|
||||
|
||||
return view('auth.sso-logout-bridge', [
|
||||
'hubUrl' => $hubUrl,
|
||||
'return' => $return,
|
||||
'authOrigin' => 'https://'.config('app.auth_domain'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if ($this->shouldLogoutForMailbox($request)) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
|
||||
$return = (string) $request->query('return', '');
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$host = parse_url($return, PHP_URL_HOST);
|
||||
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($return);
|
||||
}
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
private function attemptSilentRefresh(Request $request, string $intended): bool
|
||||
{
|
||||
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||
if ($refreshToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'scope' => 'openid profile email',
|
||||
]);
|
||||
|
||||
if ($tokenRes->failed()) {
|
||||
$request->session()->forget('sso.refresh_token');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||
{
|
||||
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||
if ($refreshToken !== '') {
|
||||
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = (string) ($claims->json('email') ?: '');
|
||||
if ($email !== '') {
|
||||
$request->session()->put('sso.login_hint', $email);
|
||||
}
|
||||
|
||||
return User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldLogoutForMailbox(Request $request): bool
|
||||
{
|
||||
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
|
||||
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower((string) $user->email) !== $mailbox;
|
||||
}
|
||||
|
||||
private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View
|
||||
{
|
||||
if ($popup) {
|
||||
return view('auth.sso-popup-done', [
|
||||
'intended' => $intended,
|
||||
'error' => $error,
|
||||
'appOrigin' => rtrim((string) config('app.url'), '/'),
|
||||
'fallbackUrl' => route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->safeRedirect($intended, route('qr.dashboard'));
|
||||
}
|
||||
|
||||
private function defaultSignedOutUrl(): string
|
||||
{
|
||||
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
|
||||
if (str_ends_with($name, '.signed-out')) {
|
||||
return route($name);
|
||||
}
|
||||
}
|
||||
|
||||
return 'https://'.config('app.platform_domain');
|
||||
}
|
||||
|
||||
private function safeReturnUrl(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $this->defaultSignedOutUrl();
|
||||
}
|
||||
|
||||
private function safeRedirect(string $url, string $fallback): RedirectResponse
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return redirect()->away($fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function unread(Request $request): JsonResponse
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->unreadNotifications()
|
||||
->latest()
|
||||
->take(10)
|
||||
->get()
|
||||
->map(fn ($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => class_basename($n->type),
|
||||
'title' => $n->data['title'] ?? 'Notification',
|
||||
'message' => $n->data['message'] ?? '',
|
||||
'icon' => $n->data['icon'] ?? 'bell',
|
||||
'url' => $n->data['url'] ?? null,
|
||||
'created_at' => $n->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notification = $request->user()
|
||||
->notifications()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($notification) {
|
||||
$notification->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse|View
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
|
||||
|
||||
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
return view('search.index', [
|
||||
'query' => $q,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
|
||||
private function results(string $q): array
|
||||
{
|
||||
$like = '%'.$q.'%';
|
||||
|
||||
return ladill_account()->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::plusTypes())
|
||||
->where(function ($query) use ($like): void {
|
||||
$query->where('label', 'like', $like)
|
||||
->orWhere('short_code', 'like', $like)
|
||||
->orWhere('destination_url', 'like', $like);
|
||||
})
|
||||
->orderByDesc('updated_at')
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(fn (QrCode $code): array => [
|
||||
'type' => 'qr',
|
||||
'title' => $code->label,
|
||||
'subtitle' => $code->typeLabel().' · '.$code->publicUrl(),
|
||||
'url' => route('user.qr-codes.show', $code),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Lightweight wallet balance for the avatar dropdown widget.
|
||||
*/
|
||||
class WalletBalanceController extends Controller
|
||||
{
|
||||
public function balance(Request $request, BillingClient $billing): JsonResponse
|
||||
{
|
||||
$publicId = (string) $request->user()->public_id;
|
||||
|
||||
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
|
||||
try {
|
||||
return $billing->balanceMinor($publicId);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if ($minor === null) {
|
||||
return response()->json(['available' => false]);
|
||||
}
|
||||
|
||||
$currency = (string) config('billing.currency', 'GHS');
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'balance_minor' => $minor,
|
||||
'currency' => $currency,
|
||||
'formatted' => $currency.' '.number_format($minor / 100, 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* In-app wallet top-up. Posts to the central identity API to start a Paystack
|
||||
* checkout against the one Ladill wallet, then hands the user to checkout. This
|
||||
* backs the two-step "Add funds" modal so users never leave for the wallet page
|
||||
* when they hit an insufficient-balance action.
|
||||
*/
|
||||
class WalletTopupController extends Controller
|
||||
{
|
||||
public function store(Request $request, BillingClient $billing): JsonResponse|RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:5', 'max:5000'],
|
||||
'return_url' => ['nullable', 'url', 'max:2048'],
|
||||
]);
|
||||
|
||||
$returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: url('/'));
|
||||
|
||||
try {
|
||||
$checkoutUrl = $billing->topup(
|
||||
(string) $request->user()->public_id,
|
||||
(float) $validated['amount'],
|
||||
$returnUrl,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$checkoutUrl = '';
|
||||
}
|
||||
|
||||
if ($checkoutUrl === '') {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => 'Unable to start payment. Please try again.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Unable to start payment. Please try again.');
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['checkout_url' => $checkoutUrl]);
|
||||
}
|
||||
|
||||
return redirect()->away($checkoutUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* App sessions are subordinate to the platform (auth.ladill.com) session.
|
||||
* When the platform session ends, clear this app's local session too.
|
||||
*/
|
||||
class EnsurePlatformSession
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$authDomain = trim((string) config('app.auth_domain', ''));
|
||||
if ($authDomain === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||
if ($cookieHeader === '') {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||
->timeout(3)
|
||||
->get('https://'.$authDomain.'/sso/ping');
|
||||
} catch (\Throwable) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function clearAppSession(Request $request): Response
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
|
||||
* pages. The splash shows once per browser session while the app loads, then
|
||||
* fades out — masking the client-side boot so the app feels instant.
|
||||
*/
|
||||
class InjectBootSplash
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (! $request->isMethod('GET') || ! Auth::check()) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
if (! is_string($content)
|
||||
|| stripos($content, '<body') === false
|
||||
|| str_contains($content, 'id="ladill-boot"')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$splash = View::make('partials.boot-splash')->render();
|
||||
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
|
||||
|
||||
if (is_string($content)) {
|
||||
$response->setContent($content);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetActingAccount
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($user = $request->user()) {
|
||||
if ($request->is('api/*')) {
|
||||
$accountId = (int) ($request->header('X-Ladill-Account') ?: $user->id);
|
||||
} else {
|
||||
$accountId = (int) $request->session()->get('ladill_account', $user->id);
|
||||
}
|
||||
|
||||
if (! $user->canAccessAccount($accountId)) {
|
||||
$accountId = $user->id;
|
||||
if (! $request->is('api/*')) {
|
||||
$request->session()->put('ladill_account', $accountId);
|
||||
}
|
||||
}
|
||||
|
||||
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
|
||||
|
||||
$request->attributes->set('actingAccount', $account);
|
||||
|
||||
if (! $request->is('api/*')) {
|
||||
View::share('actingAccount', $account);
|
||||
View::share('accessibleAccounts', $user->accessibleAccounts());
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* Thin local mirror of the platform identity (auth.ladill.com owns users).
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
|
||||
}
|
||||
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrTeamMember::class, 'user_id')
|
||||
->where('status', QrTeamMember::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function canAccessAccount(int $accountId): bool
|
||||
{
|
||||
return $accountId === $this->id
|
||||
|| $this->memberships()->where('account_id', $accountId)->exists();
|
||||
}
|
||||
|
||||
/** @return Collection<int, User> */
|
||||
public function accessibleAccounts(): Collection
|
||||
{
|
||||
$ids = $this->memberships()->pluck('account_id')->all();
|
||||
|
||||
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
|
||||
}
|
||||
|
||||
public function qrWallet(): HasOne
|
||||
{
|
||||
return $this->hasOne(QrWallet::class);
|
||||
}
|
||||
|
||||
public function qrCodes(): HasMany
|
||||
{
|
||||
return $this->hasMany(QrCode::class);
|
||||
}
|
||||
|
||||
public function qrSetting(): HasOne
|
||||
{
|
||||
return $this->hasOne(QrSetting::class);
|
||||
}
|
||||
|
||||
public function getOrCreateQrSetting(): QrSetting
|
||||
{
|
||||
return $this->qrSetting()->firstOrCreate([]);
|
||||
}
|
||||
|
||||
public function getOrCreateQrWallet(): QrWallet
|
||||
{
|
||||
return $this->qrWallet()->firstOrCreate(
|
||||
[],
|
||||
['credit_balance' => 0, 'qr_codes_total' => 0, 'scans_total' => 0, 'status' => QrWallet::STATUS_ACTIVE],
|
||||
);
|
||||
}
|
||||
|
||||
public function avatarUrl(): ?string
|
||||
{
|
||||
$url = trim((string) $this->avatar_url);
|
||||
|
||||
return $url !== '' ? $url : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class DomainVerifiedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Domain $domain
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("Domain Verified: {$this->domain->host} is now active!")
|
||||
->view('mail.notifications.domain-verified', [
|
||||
'domain' => $this->domain,
|
||||
'manageUrl' => route('user.domains.show', $this->domain),
|
||||
'emailUrl' => route('user.mailboxes.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => 'Domain Verified',
|
||||
'message' => "Your domain {$this->domain->host} has been verified and is now active.",
|
||||
'icon' => 'success',
|
||||
'url' => route('user.domains.show', $this->domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class HostingActivatedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $planName,
|
||||
private readonly string $activationDate,
|
||||
private readonly ?string $domainName = null,
|
||||
private readonly ?string $manageUrl = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('Your hosting is now active!')
|
||||
->view('mail.notifications.hosting-activated', [
|
||||
'planName' => $this->planName,
|
||||
'activationDate' => $this->activationDate,
|
||||
'domainName' => $this->domainName,
|
||||
'manageUrl' => $this->manageUrl ?? route('hosting.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$message = "Your {$this->planName} hosting plan is now active";
|
||||
if ($this->domainName) {
|
||||
$message .= " for {$this->domainName}";
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'Hosting Activated',
|
||||
'message' => $message . '.',
|
||||
'icon' => 'hosting',
|
||||
'url' => $this->manageUrl ?? route('hosting.index'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingDeveloperAddedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $accountLabels
|
||||
*/
|
||||
public function __construct(
|
||||
private string $ownerName,
|
||||
private array $accountLabels,
|
||||
private ?string $setupUrl = null
|
||||
) {}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('You were added to a Ladill hosting team')
|
||||
->view('mail.notifications.hosting-developer-added', [
|
||||
'developer' => $notifiable,
|
||||
'ownerName' => $this->ownerName,
|
||||
'accountLabels' => $this->accountLabels,
|
||||
'setupUrl' => $this->setupUrl,
|
||||
'loginUrl' => route('login'),
|
||||
'dashboardUrl' => route('dashboard'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$accountCount = count($this->accountLabels);
|
||||
$scope = $accountCount === 1 ? $this->accountLabels[0] : $accountCount.' hosting accounts';
|
||||
|
||||
return [
|
||||
'title' => 'Hosting team access granted',
|
||||
'message' => "You were added by {$this->ownerName} to {$scope}.",
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.single-domain'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingExpiringNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly HostingAccount $account,
|
||||
private readonly int $daysRemaining,
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject($this->subjectLine())
|
||||
->view('mail.notifications.hosting-expiring', [
|
||||
'account' => $this->account,
|
||||
'daysRemaining' => $this->daysRemaining,
|
||||
'renewUrl' => route('hosting.accounts.show', $this->account),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$label = $this->account->primary_domain ?: $this->account->username;
|
||||
|
||||
return [
|
||||
'title' => $this->headline(),
|
||||
'message' => "Hosting for {$label} expires in {$this->daysRemaining} days.",
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.accounts.show', $this->account),
|
||||
];
|
||||
}
|
||||
|
||||
private function subjectLine(): string
|
||||
{
|
||||
$label = $this->account->primary_domain ?: $this->account->username;
|
||||
|
||||
return match (true) {
|
||||
$this->daysRemaining <= 1 => "Hosting expires tomorrow: {$label}",
|
||||
$this->daysRemaining <= 7 => "Urgent: hosting for {$label} expires in {$this->daysRemaining} days",
|
||||
default => "Hosting renewal reminder: {$label}",
|
||||
};
|
||||
}
|
||||
|
||||
private function headline(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->daysRemaining <= 1 => 'Hosting expires soon',
|
||||
$this->daysRemaining <= 7 => 'Urgent hosting renewal',
|
||||
default => 'Hosting renewal reminder',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingResourceWarningNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly HostingAccount $account,
|
||||
private readonly string $subjectLine,
|
||||
private readonly string $messageBody
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject($this->subjectLine)
|
||||
->greeting('Hosting resource update')
|
||||
->line($this->messageBody)
|
||||
->line('Domain: ' . ($this->account->primary_domain ?: $this->account->username))
|
||||
->line('Resource state: ' . ucfirst((string) ($this->account->resource_status ?: 'active')))
|
||||
->action('Manage Hosting', route('hosting.accounts.show', $this->account));
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->subjectLine,
|
||||
'message' => $this->messageBody,
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.accounts.show', $this->account),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingSuspendedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly HostingAccount $account,
|
||||
private readonly string $reason,
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('Hosting suspended: '.($this->account->primary_domain ?: $this->account->username))
|
||||
->view('mail.notifications.hosting-suspended', [
|
||||
'account' => $this->account,
|
||||
'reason' => $this->reason,
|
||||
'dashboardUrl' => route('hosting.accounts.show', $this->account),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$label = $this->account->primary_domain ?: $this->account->username;
|
||||
|
||||
return [
|
||||
'title' => 'Hosting suspended',
|
||||
'message' => "Hosting for {$label} has been suspended. {$this->reason}",
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.accounts.show', $this->account),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class SslExpiringNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Domain $domain,
|
||||
private readonly int $daysUntilExpiry
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("SSL Certificate Expiring Soon: {$this->domain->host}")
|
||||
->view('mail.notifications.ssl-expiring', [
|
||||
'domain' => $this->domain,
|
||||
'daysUntilExpiry' => $this->daysUntilExpiry,
|
||||
'expiresAt' => $this->domain->ssl_expires_at,
|
||||
'manageUrl' => route('user.domains.show', $this->domain),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => 'SSL Certificate Expiring',
|
||||
'message' => "Your SSL certificate for {$this->domain->host} expires in {$this->daysUntilExpiry} days.",
|
||||
'icon' => 'warning',
|
||||
'url' => route('user.domains.show', $this->domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class SslProvisionedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Domain $domain
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("SSL Certificate Active: {$this->domain->host}")
|
||||
->view('mail.notifications.ssl-provisioned', [
|
||||
'domain' => $this->domain,
|
||||
'expiresAt' => $this->domain->ssl_expires_at,
|
||||
'websiteUrl' => "https://{$this->domain->host}",
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => 'SSL Certificate Active',
|
||||
'message' => "Your SSL certificate for {$this->domain->host} is now active. Your site is secure!",
|
||||
'icon' => 'ssl',
|
||||
'url' => route('user.domains.show', $this->domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Policies\QrCodePolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Support\MobileTopbar;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(QrCode::class, QrCodePolicy::class);
|
||||
View::composer(['partials.topbar', 'partials.topbar-qr'], function ($view) {
|
||||
$view->with(MobileTopbar::resolve());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Afia;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Afia — Ladill in-app AI assistant scoped to a product (QR Plus).
|
||||
*/
|
||||
class AfiaService
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{role:string,text:string}> $history
|
||||
* @param array<string,mixed> $context
|
||||
*/
|
||||
public function chat(string $message, array $history, array $context): string
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
throw new RuntimeException('Afia is not configured.');
|
||||
}
|
||||
|
||||
$provider = (string) config('afia.provider', 'openai');
|
||||
$model = (string) config('afia.model', 'gpt-4o-mini');
|
||||
$apiKey = (string) config('afia.api_key');
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
|
||||
foreach (array_slice($history, -8) as $turn) {
|
||||
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
|
||||
$text = trim((string) ($turn['text'] ?? ''));
|
||||
if ($text !== '') {
|
||||
$messages[] = ['role' => $role, 'content' => $text];
|
||||
}
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => $message];
|
||||
|
||||
return $provider === 'anthropic'
|
||||
? $this->viaAnthropic($model, $apiKey, $messages)
|
||||
: $this->viaOpenAi($model, $apiKey, $messages);
|
||||
}
|
||||
|
||||
private function viaOpenAi(string $model, string $apiKey, array $messages): string
|
||||
{
|
||||
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
|
||||
->post('https://api.openai.com/v1/chat/completions', [
|
||||
'model' => $model,
|
||||
'temperature' => 0.3,
|
||||
'max_tokens' => 600,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('OpenAI request failed: '.$res->status());
|
||||
}
|
||||
|
||||
return trim((string) $res->json('choices.0.message.content', ''));
|
||||
}
|
||||
|
||||
private function viaAnthropic(string $model, string $apiKey, array $messages): string
|
||||
{
|
||||
$system = $messages[0]['content'];
|
||||
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
|
||||
|
||||
$res = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => $model,
|
||||
'max_tokens' => 600,
|
||||
'system' => $system,
|
||||
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException('Anthropic request failed: '.$res->status());
|
||||
}
|
||||
|
||||
return trim((string) $res->json('content.0.text', ''));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context */
|
||||
private function systemPrompt(array $context): string
|
||||
{
|
||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||
|
||||
return match ((string) config('afia.product', 'qr')) {
|
||||
'qr' => $this->qrSystemPrompt($ctx),
|
||||
default => $this->qrSystemPrompt($ctx),
|
||||
};
|
||||
}
|
||||
|
||||
private function qrSystemPrompt(string $ctx): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
|
||||
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
|
||||
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
|
||||
|
||||
What Ladill QR Plus does and where things live:
|
||||
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
|
||||
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
|
||||
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
|
||||
- Dynamic codes: destination can change after printing — the printed QR always points to ladill.com/q/{code}.
|
||||
- Business QR: name, tagline, contact, hours, logo, cover, social links — shown as a mobile landing page.
|
||||
- WiFi QR: guests scan to join; network name and password are encoded in the code.
|
||||
- PDF QR: hosts a PDF with optional download button on the viewer.
|
||||
- Billing: each new code debits the Ladill wallet (account portal → Wallet). Top up at account.ladill.com/wallet.
|
||||
- Team: invite teammates to manage codes together (sidebar → Team).
|
||||
- Developers: API tokens for programmatic code creation (sidebar → Developers).
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill QR Plus — code types, styling, downloads, scans, wallet billing, team, and API.
|
||||
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
|
||||
- Never invent prices, short codes, or wallet balances — use the user context below or tell them where to check.
|
||||
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
|
||||
|
||||
Current user context:
|
||||
{$ctx}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
* platform; Bird only consumes it. Amounts are integer minor units; the user is
|
||||
* identified by public_id. Authenticates with the per-consumer config('billing.service') key.
|
||||
* Contract validated in the monolith (smtp-extraction-runbook §4-A).
|
||||
*/
|
||||
class BillingClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('billing.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('billing.api_key') ?? '');
|
||||
}
|
||||
|
||||
private function get(string $path, array $query): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().$path, $query);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function balanceMinor(string $publicId): int
|
||||
{
|
||||
return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Paystack checkout to top up the central wallet; returns the
|
||||
* checkout URL. Backs the in-app two-step "Add funds" modal.
|
||||
*/
|
||||
public function topup(string $publicId, float $amount, string $returnUrl): string
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/topup', [
|
||||
'user' => $publicId,
|
||||
'amount' => $amount,
|
||||
'return_url' => $returnUrl,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return (string) ($res->json()['checkout_url'] ?? '');
|
||||
}
|
||||
|
||||
public function canAfford(string $publicId, int $amountMinor): bool
|
||||
{
|
||||
return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false);
|
||||
}
|
||||
|
||||
public function serviceLedger(string $publicId, string $service): array
|
||||
{
|
||||
return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||
* (HTTP 402). Idempotent by $reference.
|
||||
*/
|
||||
public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => $service,
|
||||
'source' => $source,
|
||||
'reference' => $reference,
|
||||
'service_id' => $serviceId,
|
||||
'description' => $description,
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
return false;
|
||||
}
|
||||
$res->throw();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => $service,
|
||||
'source' => $source,
|
||||
'reference' => $reference,
|
||||
'service_id' => $serviceId,
|
||||
'description' => $description,
|
||||
], static fn ($v) => $v !== null));
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PlatformSetting;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class PaystackService
|
||||
{
|
||||
public function publicKey(): string
|
||||
{
|
||||
return (string) $this->settingValue('paystack_public_key', config('services.paystack.public_key', ''));
|
||||
}
|
||||
|
||||
public function initializeTransaction(array $payload): array
|
||||
{
|
||||
return $this->initializeTransactionWithCredentials($payload);
|
||||
}
|
||||
|
||||
public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||
{
|
||||
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||
->post('/transaction/initialize', $payload);
|
||||
|
||||
return $this->extractData($response, 'Failed to initialize Paystack transaction.');
|
||||
}
|
||||
|
||||
public function verifyTransaction(string $reference): array
|
||||
{
|
||||
return $this->verifyTransactionWithCredentials($reference);
|
||||
}
|
||||
|
||||
public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array
|
||||
{
|
||||
$response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl)
|
||||
->get('/transaction/verify/' . urlencode($reference));
|
||||
|
||||
return $this->extractData($response, 'Failed to verify Paystack transaction.');
|
||||
}
|
||||
|
||||
public function verifyWebhookSignature(string $rawBody, ?string $signature): bool
|
||||
{
|
||||
$secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret'));
|
||||
if ($secret === '') {
|
||||
$secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'));
|
||||
}
|
||||
if ($secret === '' || !$signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$computed = hash_hmac('sha512', $rawBody, $secret);
|
||||
return hash_equals($computed, $signature);
|
||||
}
|
||||
|
||||
protected function request(?string $secretKey = null, ?string $baseUrl = null)
|
||||
{
|
||||
$resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/');
|
||||
$secret = $this->normalizeSecretKey(
|
||||
(string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')))
|
||||
);
|
||||
|
||||
return Http::baseUrl($resolvedBaseUrl)
|
||||
->withToken($secret)
|
||||
->acceptJson()
|
||||
->asJson();
|
||||
}
|
||||
|
||||
protected function settingValue(string $key, mixed $fallback = null): mixed
|
||||
{
|
||||
try {
|
||||
if (!Schema::hasTable('platform_settings')) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$setting = PlatformSetting::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
return $setting ? ($setting->value['value'] ?? $fallback) : $fallback;
|
||||
} catch (\Throwable) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
protected function extractData(Response $response, string $message): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (!is_array($json) || !($json['status'] ?? false)) {
|
||||
throw new RuntimeException((string) ($json['message'] ?? $message));
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List supported banks / mobile money providers.
|
||||
* @param string $country e.g. 'ghana', 'nigeria'
|
||||
* @param string $type 'nuban', 'mobile_money', or '' for all
|
||||
*/
|
||||
public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array
|
||||
{
|
||||
$query = ['perPage' => 200];
|
||||
if ($currency !== null && $currency !== '') {
|
||||
$query['currency'] = strtoupper($currency);
|
||||
} else {
|
||||
$query['country'] = $country;
|
||||
}
|
||||
if ($type !== '') {
|
||||
$query['type'] = $type;
|
||||
}
|
||||
$response = $this->request()->get('/bank', $query);
|
||||
$json = $response->json();
|
||||
return is_array($json['data'] ?? null) ? $json['data'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transfer recipient (bank account or mobile money).
|
||||
* @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload
|
||||
*/
|
||||
public function createTransferRecipient(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transferrecipient', $payload);
|
||||
return $this->extractData($response, 'Failed to create transfer recipient.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a transfer to a recipient.
|
||||
* @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload
|
||||
*/
|
||||
public function initiateTransfer(array $payload): array
|
||||
{
|
||||
$response = $this->request()->post('/transfer', $payload);
|
||||
return $this->extractData($response, 'Failed to initiate transfer.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a transfer by reference.
|
||||
*/
|
||||
public function verifyTransfer(string $reference): array
|
||||
{
|
||||
$response = $this->request()->get('/transfer/verify/' . urlencode($reference));
|
||||
return $this->extractData($response, 'Failed to verify transfer.');
|
||||
}
|
||||
|
||||
protected function normalizeSecretKey(string $secret): string
|
||||
{
|
||||
$normalized = trim($secret);
|
||||
|
||||
if (str_starts_with(strtolower($normalized), 'bearer ')) {
|
||||
$normalized = trim(substr($normalized, 7));
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Identity;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class IdentityClient
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function mailboxLinkStatus(string $publicId): array
|
||||
{
|
||||
$response = $this->request()->get($this->url('/identity/mailbox-link'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function linkMailbox(string $publicId, string $mailboxAddress): array
|
||||
{
|
||||
$response = $this->request()->put($this->url('/identity/mailbox-link'), [
|
||||
'user' => $publicId,
|
||||
'mailbox_address' => $mailboxAddress,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function unlinkMailbox(string $publicId): array
|
||||
{
|
||||
$response = $this->request()->delete($this->url('/identity/mailbox-link'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
private function request()
|
||||
{
|
||||
return Http::withToken((string) config('identity.api_key'))
|
||||
->acceptJson()
|
||||
->timeout(15);
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return rtrim((string) config('identity.api_url'), '/').$path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class CrmPrefillCodec
|
||||
{
|
||||
/** @return array<string, mixed>|null */
|
||||
public static function decode(?string $token): ?array
|
||||
{
|
||||
if (! is_string($token) || $token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$padded = $token.str_repeat('=', (4 - strlen($token) % 4) % 4);
|
||||
$json = base64_decode(strtr($padded, '-_', '+/'), true);
|
||||
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Services\Domain\DomainPricingService;
|
||||
|
||||
class DomainConfig
|
||||
{
|
||||
private const PRIORITY_TLDS = [
|
||||
'com', 'org', 'net', 'ai', 'io', 'co', 'app', 'dev',
|
||||
'store', 'shop', 'online', 'site', 'tech', 'cloud', 'pro', 'me',
|
||||
'xyz', 'club', 'live', 'com.ng', 'com.gh', 'co.za', 'co.uk', 'info', 'biz',
|
||||
];
|
||||
|
||||
private const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Get featured TLDs for initial display (lightweight).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function featuredTlds(): array
|
||||
{
|
||||
$allTlds = self::allTlds();
|
||||
$featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds));
|
||||
|
||||
if ($featured !== []) {
|
||||
return $featured;
|
||||
}
|
||||
|
||||
return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available TLDs sorted by priority.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function allTlds(): array
|
||||
{
|
||||
$current = self::normalizeTlds(config('domain.search_tlds', []));
|
||||
$legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', []));
|
||||
|
||||
$tlds = $legacy !== [] ? $legacy : $current;
|
||||
$livePricedTlds = self::livePricedTlds();
|
||||
|
||||
if ($livePricedTlds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$livePricedLookup = array_flip($livePricedTlds);
|
||||
$tlds = array_values(array_filter(
|
||||
$tlds,
|
||||
static fn (string $tld): bool => isset($livePricedLookup[$tld])
|
||||
));
|
||||
|
||||
return self::sortWithPriority($tlds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated TLDs (excluding featured ones).
|
||||
*
|
||||
* @return array{tlds: list<string>, hasMore: bool, total: int}
|
||||
*/
|
||||
public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
|
||||
{
|
||||
$allTlds = self::allTlds();
|
||||
$featured = self::featuredTlds();
|
||||
|
||||
$remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true)));
|
||||
$total = count($remaining);
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$tlds = array_slice($remaining, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'tlds' => $tlds,
|
||||
'hasMore' => ($offset + $perPage) < $total,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use featuredTlds() or allTlds() instead
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function searchTlds(): array
|
||||
{
|
||||
return self::featuredTlds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the TLD list and query term for a search page request.
|
||||
*
|
||||
* @return array{query: string, tlds: list<string>, hasMore: bool, exact_domain: string|null}
|
||||
*/
|
||||
public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array
|
||||
{
|
||||
$query = strtolower(trim($query));
|
||||
$allTlds = self::allTlds();
|
||||
|
||||
if ($allTlds === []) {
|
||||
return [
|
||||
'query' => $query,
|
||||
'tlds' => [],
|
||||
'hasMore' => false,
|
||||
'exact_domain' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$exactDomain = self::normalizeExactDomain($query);
|
||||
|
||||
if ($exactDomain === null) {
|
||||
if ($page === 1) {
|
||||
$featured = self::featuredTlds();
|
||||
$paginatedData = self::paginatedTlds(1, $perPage);
|
||||
|
||||
return [
|
||||
'query' => $query,
|
||||
'tlds' => $featured,
|
||||
'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0,
|
||||
'exact_domain' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$paginatedData = self::paginatedTlds($page - 1, $perPage);
|
||||
|
||||
return [
|
||||
'query' => $query,
|
||||
'tlds' => $paginatedData['tlds'],
|
||||
'hasMore' => $paginatedData['hasMore'],
|
||||
'exact_domain' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$exactTld = self::extractTld($exactDomain);
|
||||
$keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain;
|
||||
$firstPageTlds = array_values(array_unique(array_filter([
|
||||
$exactTld,
|
||||
...self::featuredTlds(),
|
||||
])));
|
||||
$remainingTlds = array_values(array_filter(
|
||||
$allTlds,
|
||||
static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true)
|
||||
));
|
||||
|
||||
if ($page === 1) {
|
||||
return [
|
||||
'query' => $keyword,
|
||||
'tlds' => $firstPageTlds,
|
||||
'hasMore' => $remainingTlds !== [],
|
||||
'exact_domain' => $exactDomain,
|
||||
];
|
||||
}
|
||||
|
||||
$offset = max(0, ($page - 2) * $perPage);
|
||||
|
||||
return [
|
||||
'query' => $keyword,
|
||||
'tlds' => array_slice($remainingTlds, $offset, $perPage),
|
||||
'hasMore' => ($offset + $perPage) < count($remainingTlds),
|
||||
'exact_domain' => $exactDomain,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort domain search results by TLD priority.
|
||||
*
|
||||
* @param array<int, array{domain: string, ...}> $results
|
||||
* @return array<int, array{domain: string, ...}>
|
||||
*/
|
||||
public static function sortResultsByTldPriority(array $results, ?string $query = null): array
|
||||
{
|
||||
$priorityMap = array_flip(self::PRIORITY_TLDS);
|
||||
$exactDomain = self::normalizeExactDomain((string) $query);
|
||||
|
||||
usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int {
|
||||
$aDomain = strtolower((string) ($a['domain'] ?? ''));
|
||||
$bDomain = strtolower((string) ($b['domain'] ?? ''));
|
||||
|
||||
$aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1;
|
||||
$bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1;
|
||||
|
||||
if ($aExact !== $bExact) {
|
||||
return $aExact <=> $bExact;
|
||||
}
|
||||
|
||||
$aPremium = self::isPremiumResult($a) ? 0 : 1;
|
||||
$bPremium = self::isPremiumResult($b) ? 0 : 1;
|
||||
|
||||
if ($aPremium !== $bPremium) {
|
||||
return $aPremium <=> $bPremium;
|
||||
}
|
||||
|
||||
$aAvailable = ! empty($a['available']) ? 0 : 1;
|
||||
$bAvailable = ! empty($b['available']) ? 0 : 1;
|
||||
|
||||
if ($aAvailable !== $bAvailable) {
|
||||
return $aAvailable <=> $bAvailable;
|
||||
}
|
||||
|
||||
$aTld = self::extractTld($a['domain'] ?? '');
|
||||
$bTld = self::extractTld($b['domain'] ?? '');
|
||||
|
||||
$aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX;
|
||||
$bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX;
|
||||
|
||||
if ($aPriority !== $bPriority) {
|
||||
return $aPriority <=> $bPriority;
|
||||
}
|
||||
|
||||
return $aDomain <=> $bDomain;
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract TLD from a domain name.
|
||||
*/
|
||||
private static function extractTld(string $domain): string
|
||||
{
|
||||
$parts = explode('.', strtolower($domain), 2);
|
||||
|
||||
return $parts[1] ?? '';
|
||||
}
|
||||
|
||||
private static function normalizeExactDomain(string $query): ?string
|
||||
{
|
||||
$query = strtolower(trim($query));
|
||||
|
||||
if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1
|
||||
? $query
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private static function isPremiumResult(array $result): bool
|
||||
{
|
||||
$premium = $result['premium'] ?? false;
|
||||
|
||||
if (is_bool($premium)) {
|
||||
return $premium;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tlds
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function sortWithPriority(array $tlds): array
|
||||
{
|
||||
$priorityMap = array_flip(self::PRIORITY_TLDS);
|
||||
$pricingMap = self::pricingMapForTlds($tlds);
|
||||
|
||||
usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int {
|
||||
$aPriority = $priorityMap[$a] ?? PHP_INT_MAX;
|
||||
$bPriority = $priorityMap[$b] ?? PHP_INT_MAX;
|
||||
|
||||
if ($aPriority !== $bPriority) {
|
||||
return $aPriority <=> $bPriority;
|
||||
}
|
||||
|
||||
$aPrice = (int) ($pricingMap[$a]['register'] ?? 0);
|
||||
$bPrice = (int) ($pricingMap[$b]['register'] ?? 0);
|
||||
|
||||
if ($aPrice !== $bPrice) {
|
||||
return $bPrice <=> $aPrice;
|
||||
}
|
||||
|
||||
return $a <=> $b;
|
||||
});
|
||||
|
||||
return $tlds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function resellerClubTldProductKeys(): array
|
||||
{
|
||||
return array_replace(
|
||||
self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])),
|
||||
self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', []))
|
||||
);
|
||||
}
|
||||
|
||||
public static function resellerClubProductKeyCacheTtlSeconds(): int
|
||||
{
|
||||
$ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400);
|
||||
|
||||
return $ttl > 0 ? $ttl : 86400;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeTlds(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'),
|
||||
$value
|
||||
))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function normalizeProductKeyMap(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($value as $tld => $productKey) {
|
||||
$normalizedTld = ltrim(strtolower(trim((string) $tld)), '.');
|
||||
$normalizedProductKey = trim((string) $productKey);
|
||||
|
||||
if ($normalizedTld === '' || $normalizedProductKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$normalizedTld] = $normalizedProductKey;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function livePricedTlds(): array
|
||||
{
|
||||
try {
|
||||
return app(DomainPricingService::class)->getLivePricedTlds();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tlds
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
|
||||
*/
|
||||
private static function pricingMapForTlds(array $tlds): array
|
||||
{
|
||||
try {
|
||||
return app(DomainPricingService::class)->getPricingForTlds($tlds);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class DomainGlobeIcon
|
||||
{
|
||||
public const VIEW_BOX = '0 0 14 14';
|
||||
|
||||
/** 14×14 globe for UI icons (inherits color via currentColor). */
|
||||
public const ICON_ASSET = 'images/ladill-icons/domain.svg';
|
||||
|
||||
public static function paths(): string
|
||||
{
|
||||
return '<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
.'<path d="M0.5 7H13.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
.'<path d="M9.5 7C9.3772 9.37699 8.50168 11.6533 7 13.5C5.49832 11.6533 4.6228 9.37699 4.5 7C4.6228 4.62301 5.49832 2.34665 7 0.5C8.50168 2.34665 9.3772 4.62301 9.5 7V7Z" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||
}
|
||||
|
||||
public static function svg(string $class = 'h-5 w-5'): string
|
||||
{
|
||||
return sprintf(
|
||||
'<svg class="%s" viewBox="%s" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">%s</svg>',
|
||||
e($class),
|
||||
self::VIEW_BOX,
|
||||
self::paths()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/** Storage-tier pricing for mailboxes (config/email.php quota_tiers). */
|
||||
class MailboxPricing
|
||||
{
|
||||
/** @return array<int,array{mb:int,price_minor:int,label:string}> */
|
||||
public static function tiers(): array
|
||||
{
|
||||
return array_map(fn ($t) => [
|
||||
'mb' => (int) $t['mb'],
|
||||
'price_minor' => (int) $t['price_minor'],
|
||||
'label' => self::label((int) $t['mb']),
|
||||
], (array) config('email.quota_tiers', []));
|
||||
}
|
||||
|
||||
public static function isValidQuota(int $mb): bool
|
||||
{
|
||||
foreach (self::tiers() as $t) {
|
||||
if ($t['mb'] === $mb) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function priceMinorFor(int $mb): int
|
||||
{
|
||||
foreach (self::tiers() as $t) {
|
||||
if ($t['mb'] === $mb) {
|
||||
return $t['price_minor'];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** A tier is free when its monthly price is zero (the 1 GB plan). */
|
||||
public static function isFree(int $mb): bool
|
||||
{
|
||||
return self::priceMinorFor($mb) === 0;
|
||||
}
|
||||
|
||||
/** The smallest free tier's quota (the default for new mailboxes). */
|
||||
public static function freeQuotaMb(): int
|
||||
{
|
||||
foreach (self::tiers() as $t) {
|
||||
if ($t['price_minor'] === 0) {
|
||||
return $t['mb'];
|
||||
}
|
||||
}
|
||||
|
||||
return self::tiers()[0]['mb'] ?? 1024;
|
||||
}
|
||||
|
||||
public static function defaultQuotaMb(): int
|
||||
{
|
||||
$default = (int) config('email.default_quota_mb', 1024);
|
||||
|
||||
return self::isValidQuota($default) ? $default : self::freeQuotaMb();
|
||||
}
|
||||
|
||||
public static function label(int $mb): string
|
||||
{
|
||||
return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class MobileTopbar
|
||||
{
|
||||
public static function resolve(): array
|
||||
{
|
||||
$appName = config('mobile-topbar.app_name', 'Ladill');
|
||||
|
||||
$title = $appName === 'Ladill'
|
||||
? 'Ladill'
|
||||
: "Ladill {$appName}";
|
||||
|
||||
return [
|
||||
'mobileTopbarTitle' => $title,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Generates ZPL II label code for Zebra-style badge/label printers.
|
||||
* One ^XA…^XZ label block per attendee, sized to the chosen badge size at 203 dpi.
|
||||
*/
|
||||
class EventBadgeZpl
|
||||
{
|
||||
/** @param Collection<int, \App\Models\QrEventRegistration> $registrations */
|
||||
public static function forRegistrations(QrCode $qrCode, Collection $registrations): string
|
||||
{
|
||||
$content = $qrCode->content();
|
||||
$eventName = self::sanitize($content['name'] ?? $qrCode->label);
|
||||
$size = $content['badge_size'] ?? '4x3';
|
||||
|
||||
// Label dimensions in dots @ 203 dpi.
|
||||
[$widthDots, $heightDots] = match ($size) {
|
||||
'4x6' => [812, 1218],
|
||||
'cr80' => [685, 431],
|
||||
default => [812, 609], // 4x3
|
||||
};
|
||||
|
||||
$labels = [];
|
||||
foreach ($registrations as $reg) {
|
||||
$name = self::sanitize($reg->attendee_name);
|
||||
$tier = self::sanitize($reg->tier_name);
|
||||
$extra = collect($reg->badge_fields ?? [])
|
||||
->map(fn ($v, $k) => self::sanitize($k . ': ' . $v))
|
||||
->implode(' | ');
|
||||
|
||||
$zpl = "^XA\n";
|
||||
$zpl .= "^PW{$widthDots}\n";
|
||||
$zpl .= "^LL{$heightDots}\n";
|
||||
$zpl .= "^CI28\n"; // UTF-8
|
||||
// Event name (top)
|
||||
$zpl .= "^FO40,40^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$eventName}^FS\n";
|
||||
// Attendee name (large, centered)
|
||||
$zpl .= "^FO40,150^A0N,80,80^FB" . ($widthDots - 80) . ",2,0,C^FD{$name}^FS\n";
|
||||
// Tier
|
||||
$zpl .= "^FO40,320^A0N,40,40^FB" . ($widthDots - 80) . ",1,0,C^FD{$tier}^FS\n";
|
||||
// Extra fields
|
||||
if ($extra !== '') {
|
||||
$zpl .= "^FO40,375^A0N,28,28^FB" . ($widthDots - 80) . ",2,0,C^FD{$extra}^FS\n";
|
||||
}
|
||||
// QR of the badge code (bottom)
|
||||
$qrX = (int) (($widthDots / 2) - 70);
|
||||
$zpl .= "^FO{$qrX}," . ($heightDots - 200) . "^BQN,2,5^FDLA,{$reg->badge_code}^FS\n";
|
||||
// Badge code text
|
||||
$zpl .= "^FO40," . ($heightDots - 60) . "^A0N,34,34^FB" . ($widthDots - 80) . ",1,0,C^FD{$reg->badge_code}^FS\n";
|
||||
$zpl .= "^XZ\n";
|
||||
|
||||
$labels[] = $zpl;
|
||||
}
|
||||
|
||||
return implode("\n", $labels);
|
||||
}
|
||||
|
||||
private static function sanitize(string $value): string
|
||||
{
|
||||
// Escape ZPL control chars (^ and ~) and collapse whitespace.
|
||||
$value = str_replace(['^', '~'], [' ', ' '], $value);
|
||||
|
||||
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrCornerStyleCatalog
|
||||
{
|
||||
/** @return array<string, array{label: string}> */
|
||||
public static function outerStyles(): array
|
||||
{
|
||||
return [
|
||||
'square' => ['label' => 'Square eye'],
|
||||
'rounded' => ['label' => 'Rounded'],
|
||||
'circle' => ['label' => 'Circular'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string}> */
|
||||
public static function innerStyles(): array
|
||||
{
|
||||
return [
|
||||
'square' => ['label' => 'Square'],
|
||||
'rounded' => ['label' => 'Rounded'],
|
||||
'dot' => ['label' => 'Dot'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function isValidOuter(string $style): bool
|
||||
{
|
||||
return isset(self::outerStyles()[$style]);
|
||||
}
|
||||
|
||||
public static function isValidInner(string $style): bool
|
||||
{
|
||||
return isset(self::innerStyles()[$style]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrCoverImageSpec
|
||||
{
|
||||
/** Wide hero banners (business, church, event, itinerary, menu, shop). */
|
||||
public const BANNER = '1920×1080 px (16:9 landscape)';
|
||||
|
||||
/** Book product cover on the landing page. */
|
||||
public const BOOK = '800×1067 px (3:4 portrait)';
|
||||
|
||||
public static function label(string $variant = 'banner'): string
|
||||
{
|
||||
return match ($variant) {
|
||||
'book' => self::BOOK,
|
||||
default => self::BANNER,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class QrDateFormatter
|
||||
{
|
||||
public static function forInput(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public static function forDisplay(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
try {
|
||||
return Carbon::createFromFormat('Y-m-d', $value)->format('D, j M Y');
|
||||
} catch (\Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function normalize(?string $value, int $maxLength = 60): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return mb_substr($value, 0, $maxLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrFrameStyleCatalog
|
||||
{
|
||||
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'none' => [
|
||||
'label' => 'None',
|
||||
'description' => 'Code only',
|
||||
'border_px' => 0,
|
||||
'mode' => 'none',
|
||||
],
|
||||
'thin' => [
|
||||
'label' => 'Border',
|
||||
'description' => 'Simple white sticker edge',
|
||||
'border_px' => 8,
|
||||
'mode' => 'border',
|
||||
],
|
||||
'bold' => [
|
||||
'label' => 'Wide border',
|
||||
'description' => 'Legacy wide border',
|
||||
'border_px' => 16,
|
||||
'mode' => 'border',
|
||||
'visible' => false,
|
||||
],
|
||||
'scan_me' => [
|
||||
'label' => 'Scan me',
|
||||
'description' => 'CTA sticker below code',
|
||||
'border_px' => 14,
|
||||
'mode' => 'label',
|
||||
'cta' => 'SCAN ME',
|
||||
],
|
||||
'tap_to_scan' => [
|
||||
'label' => 'Tap to scan',
|
||||
'description' => 'Rounded CTA sticker',
|
||||
'border_px' => 14,
|
||||
'mode' => 'pill',
|
||||
'cta' => 'TAP TO SCAN',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, border_px: int, mode: string, cta?: string, visible?: bool}> */
|
||||
public static function visible(): array
|
||||
{
|
||||
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::all());
|
||||
}
|
||||
|
||||
public static function isValid(string $style): bool
|
||||
{
|
||||
return isset(self::all()[$style]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrModuleStyleCatalog
|
||||
{
|
||||
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'square' => [
|
||||
'label' => 'Standard',
|
||||
'description' => 'Traditional square QR modules',
|
||||
'circular' => false,
|
||||
'circle_radius' => 0.4,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'low',
|
||||
],
|
||||
'soft' => [
|
||||
'label' => 'Rounded',
|
||||
'description' => 'Soft rounded modules',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.36,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'medium',
|
||||
'visible' => false,
|
||||
],
|
||||
'dots' => [
|
||||
'label' => 'Dots',
|
||||
'description' => 'Round dot modules',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.45,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'medium',
|
||||
],
|
||||
'bubble' => [
|
||||
'label' => 'Large dots',
|
||||
'description' => 'Bolder round dot modules',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.52,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'high',
|
||||
'visible' => false,
|
||||
],
|
||||
'fluid' => [
|
||||
'label' => 'Fluid',
|
||||
'description' => 'Legacy decorative style',
|
||||
'circular' => true,
|
||||
'circle_radius' => 0.38,
|
||||
'connect_paths' => true,
|
||||
'scan_risk' => 'high',
|
||||
'visible' => false,
|
||||
],
|
||||
'bold' => [
|
||||
'label' => 'Bold',
|
||||
'description' => 'Legacy thick square modules',
|
||||
'circular' => false,
|
||||
'circle_radius' => 0.4,
|
||||
'connect_paths' => false,
|
||||
'scan_risk' => 'low',
|
||||
'visible' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, circular: bool, circle_radius: float, connect_paths: bool, scan_risk: string, visible?: bool}> */
|
||||
public static function visible(): array
|
||||
{
|
||||
return array_filter(self::all(), fn (array $style): bool => ($style['visible'] ?? true) === true);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::all());
|
||||
}
|
||||
|
||||
public static function isValid(string $style): bool
|
||||
{
|
||||
return isset(self::all()[$style]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function optionsFor(string $style): array
|
||||
{
|
||||
return self::all()[$style] ?? self::all()['square'];
|
||||
}
|
||||
|
||||
public static function isDecorative(string $style): bool
|
||||
{
|
||||
return in_array(self::optionsFor($style)['scan_risk'], ['medium', 'high'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrScanReliability
|
||||
{
|
||||
/** @param array<string, mixed> $style */
|
||||
public static function contrastRatio(array $style): float
|
||||
{
|
||||
$fg = self::relativeLuminance((string) ($style['foreground'] ?? '#000000'));
|
||||
$bg = self::relativeLuminance((string) ($style['background'] ?? '#ffffff'));
|
||||
|
||||
$lighter = max($fg, $bg);
|
||||
$darker = min($fg, $bg);
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $style
|
||||
* @return array{level: string, messages: list<string>}
|
||||
*/
|
||||
public static function assess(array $style): array
|
||||
{
|
||||
$style = QrStyleDefaults::merge($style);
|
||||
$messages = [];
|
||||
$level = 'good';
|
||||
|
||||
$contrast = self::contrastRatio($style);
|
||||
$isClassic = self::isClassicBlackOnWhite($style);
|
||||
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||
$moduleMeta = QrModuleStyleCatalog::optionsFor($moduleStyle);
|
||||
|
||||
if ($moduleMeta['scan_risk'] === 'medium') {
|
||||
$messages[] = 'This module style may not scan on every phone camera. Square is the safest choice.';
|
||||
$level = 'fair';
|
||||
}
|
||||
|
||||
if ($moduleMeta['scan_risk'] === 'high') {
|
||||
$messages[] = 'Decorative styles like ' . $moduleMeta['label'] . ' often fail on Samsung Camera. Use square for print and signage.';
|
||||
$level = 'poor';
|
||||
}
|
||||
|
||||
if (! $isClassic) {
|
||||
$messages[] = 'Colored QR codes often fail on Samsung Camera and other basic scanners. Black on white works everywhere.';
|
||||
$level = $level === 'good' ? 'fair' : 'poor';
|
||||
}
|
||||
|
||||
if ($contrast < 4.5) {
|
||||
$messages[] = sprintf(
|
||||
'Contrast is low (%.1f:1). Aim for at least 4.5:1 — dark foreground, white background.',
|
||||
$contrast,
|
||||
);
|
||||
$level = 'poor';
|
||||
}
|
||||
|
||||
if ($moduleMeta['scan_risk'] !== 'low' && ! $isClassic) {
|
||||
$level = 'poor';
|
||||
}
|
||||
|
||||
if ($messages === []) {
|
||||
$messages[] = 'Black square modules on white give the best compatibility with Samsung, iPhone, and printed codes.';
|
||||
}
|
||||
|
||||
return ['level' => $level, 'messages' => $messages];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
public static function isClassicBlackOnWhite(array $style): bool
|
||||
{
|
||||
$fg = strtolower(ltrim((string) ($style['foreground'] ?? ''), '#'));
|
||||
$bg = strtolower(ltrim((string) ($style['background'] ?? ''), '#'));
|
||||
|
||||
$fg = strlen($fg) === 3 ? $fg[0] . $fg[0] . $fg[1] . $fg[1] . $fg[2] . $fg[2] : $fg;
|
||||
$bg = strlen($bg) === 3 ? $bg[0] . $bg[0] . $bg[1] . $bg[1] . $bg[2] . $bg[2] : $bg;
|
||||
|
||||
return in_array($fg, ['000000', '0f172a', '111111', '1a1a1a'], true)
|
||||
&& in_array($bg, ['ffffff', 'fff'], true);
|
||||
}
|
||||
|
||||
private static function relativeLuminance(string $hex): float
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$channels = [];
|
||||
foreach ([0, 2, 4] as $offset) {
|
||||
$value = hexdec(substr($hex, $offset, 2)) / 255;
|
||||
$channels[] = $value <= 0.03928
|
||||
? $value / 12.92
|
||||
: (($value + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrStyleDefaults
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'foreground' => '#000000',
|
||||
'background' => '#ffffff',
|
||||
'error_correction' => 'M',
|
||||
'margin' => 4,
|
||||
'module_style' => 'square',
|
||||
'finder_outer' => 'square',
|
||||
'finder_inner' => 'square',
|
||||
'frame_style' => 'none',
|
||||
'frame_text' => '',
|
||||
'frame_color' => '#000000',
|
||||
'scale' => 8,
|
||||
'logo_path' => null,
|
||||
'gradient_type' => 'none',
|
||||
'gradient_color1' => '#000000',
|
||||
'gradient_color2' => '#7c3aed',
|
||||
'gradient_rotation' => 45,
|
||||
'logo_size' => 0.3,
|
||||
'logo_margin' => 5,
|
||||
'logo_white_bg' => false,
|
||||
'logo_shape' => 'none',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $style */
|
||||
public static function merge(?array $style): array
|
||||
{
|
||||
$merged = array_merge(self::defaults(), $style ?? []);
|
||||
|
||||
if (! in_array($merged['error_correction'], ['L', 'M', 'Q', 'H'], true)) {
|
||||
$merged['error_correction'] = 'M';
|
||||
}
|
||||
|
||||
if (! in_array($merged['module_style'], QrModuleStyleCatalog::keys(), true)) {
|
||||
$merged['module_style'] = 'square';
|
||||
}
|
||||
|
||||
if (! QrCornerStyleCatalog::isValidOuter((string) ($merged['finder_outer'] ?? 'square'))) {
|
||||
$merged['finder_outer'] = 'square';
|
||||
}
|
||||
|
||||
if (! QrCornerStyleCatalog::isValidInner((string) ($merged['finder_inner'] ?? 'square'))) {
|
||||
$merged['finder_inner'] = 'square';
|
||||
}
|
||||
|
||||
if (! QrFrameStyleCatalog::isValid((string) ($merged['frame_style'] ?? 'none'))) {
|
||||
$merged['frame_style'] = 'none';
|
||||
}
|
||||
|
||||
$merged['frame_text'] = substr(trim((string) ($merged['frame_text'] ?? '')), 0, 100);
|
||||
$frameColor = trim((string) ($merged['frame_color'] ?? '#000000'));
|
||||
$merged['frame_color'] = preg_match('/^#[0-9a-fA-F]{6}$/', $frameColor) ? $frameColor : '#000000';
|
||||
|
||||
if (! in_array($merged['gradient_type'], ['none', 'linear', 'radial'], true)) {
|
||||
$merged['gradient_type'] = 'none';
|
||||
}
|
||||
|
||||
$merged['logo_size'] = max(0.1, min(0.4, (float) ($merged['logo_size'] ?? 0.3)));
|
||||
$merged['logo_margin'] = max(0, min(15, (int) ($merged['logo_margin'] ?? 5)));
|
||||
if (! in_array($merged['logo_shape'], ['none', 'rounded', 'circle'], true)) {
|
||||
$merged['logo_shape'] = 'none';
|
||||
}
|
||||
$merged['logo_white_bg'] = (bool) ($merged['logo_white_bg'] ?? false);
|
||||
|
||||
if ($merged['module_style'] === 'bold') {
|
||||
$merged['scale'] = min(16, (int) $merged['scale'] + 2);
|
||||
}
|
||||
|
||||
$merged['margin'] = max(0, min(10, (int) $merged['margin']));
|
||||
$merged['scale'] = max(4, min(16, (int) $merged['scale']));
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $style */
|
||||
public static function mergeForRender(?array $style): array
|
||||
{
|
||||
return QrStyleNormalizer::normalize(self::merge($style));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
public static function recommendedEcc(array $style): string
|
||||
{
|
||||
$ecc = (string) ($style['error_correction'] ?? 'M');
|
||||
$order = ['L' => 0, 'M' => 1, 'Q' => 2, 'H' => 3];
|
||||
$minimum = 'M';
|
||||
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||
|
||||
if (in_array($moduleStyle, ['bubble', 'fluid'], true)) {
|
||||
$minimum = 'H';
|
||||
} elseif (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
|
||||
$minimum = 'Q';
|
||||
}
|
||||
|
||||
if (! empty($style['logo_path'])) {
|
||||
$minimum = 'H';
|
||||
}
|
||||
|
||||
if (QrScanReliability::contrastRatio($style) < 4.5) {
|
||||
$minimum = 'H';
|
||||
}
|
||||
|
||||
return ($order[$minimum] ?? 1) > ($order[$ecc] ?? 1) ? $minimum : $ecc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrStyleNormalizer
|
||||
{
|
||||
/**
|
||||
* Silently tune styles so more phone cameras (including Samsung) can read the code.
|
||||
*
|
||||
* @param array<string, mixed> $style
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function normalize(array $style): array
|
||||
{
|
||||
$style = self::ensureContrast($style);
|
||||
$style = self::ensureQuietZone($style);
|
||||
$style = self::ensureRenderScale($style);
|
||||
|
||||
$style['error_correction'] = QrStyleDefaults::recommendedEcc($style);
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private static function ensureContrast(array $style): array
|
||||
{
|
||||
$target = 4.5;
|
||||
$attempts = 0;
|
||||
|
||||
while (QrScanReliability::contrastRatio($style) < $target && $attempts < 16) {
|
||||
$fg = self::hexToRgb((string) $style['foreground']);
|
||||
$bg = self::hexToRgb((string) $style['background']);
|
||||
|
||||
if (self::relativeLuminance($fg) > self::relativeLuminance($bg)) {
|
||||
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.18));
|
||||
} else {
|
||||
$style['foreground'] = self::rgbToHex(self::darkenToward($fg, 0.15));
|
||||
$style['background'] = self::rgbToHex(self::lightenToward($bg, 0.15));
|
||||
}
|
||||
|
||||
$attempts++;
|
||||
}
|
||||
|
||||
if (QrScanReliability::contrastRatio($style) < $target) {
|
||||
$style['foreground'] = '#000000';
|
||||
$style['background'] = '#ffffff';
|
||||
}
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private static function ensureQuietZone(array $style): array
|
||||
{
|
||||
$margin = (int) ($style['margin'] ?? 4);
|
||||
$moduleStyle = (string) ($style['module_style'] ?? 'square');
|
||||
|
||||
if (QrModuleStyleCatalog::isDecorative($moduleStyle)) {
|
||||
$margin = max($margin, 5);
|
||||
}
|
||||
|
||||
$style['margin'] = max(4, min(10, $margin));
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $style */
|
||||
private static function ensureRenderScale(array $style): array
|
||||
{
|
||||
$scale = (int) ($style['scale'] ?? 8);
|
||||
$module = QrModuleStyleCatalog::optionsFor((string) ($style['module_style'] ?? 'square'));
|
||||
|
||||
if ($module['circular'] ?? false) {
|
||||
$scale = max($scale, 10);
|
||||
}
|
||||
|
||||
if (! empty($style['logo_path'])) {
|
||||
$scale = max($scale, 10);
|
||||
}
|
||||
|
||||
$style['scale'] = max(6, min(16, $scale));
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
/** @return array{0: int, 1: int, 2: int} */
|
||||
private static function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if (strlen($hex) !== 6 || ! ctype_xdigit($hex)) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
return [hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))];
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function rgbToHex(array $rgb): string
|
||||
{
|
||||
return sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function darkenToward(array $rgb, float $amount): array
|
||||
{
|
||||
return [
|
||||
(int) max(0, $rgb[0] * (1 - $amount)),
|
||||
(int) max(0, $rgb[1] * (1 - $amount)),
|
||||
(int) max(0, $rgb[2] * (1 - $amount)),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function lightenToward(array $rgb, float $amount): array
|
||||
{
|
||||
return [
|
||||
(int) min(255, $rgb[0] + ((255 - $rgb[0]) * $amount)),
|
||||
(int) min(255, $rgb[1] + ((255 - $rgb[1]) * $amount)),
|
||||
(int) min(255, $rgb[2] + ((255 - $rgb[2]) * $amount)),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array{0: int, 1: int, 2: int} $rgb */
|
||||
private static function relativeLuminance(array $rgb): float
|
||||
{
|
||||
$channels = [];
|
||||
foreach ($rgb as $value) {
|
||||
$v = $value / 255;
|
||||
$channels[] = $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
return (0.2126 * $channels[0]) + (0.7152 * $channels[1]) + (0.0722 * $channels[2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
use App\Models\QrCode;
|
||||
|
||||
/**
|
||||
* QR Plus utility types only. vCard and commerce types live in other products.
|
||||
*/
|
||||
class QrTypeCatalog
|
||||
{
|
||||
/** @return list<string> */
|
||||
public static function plusTypes(): array
|
||||
{
|
||||
return [
|
||||
QrCode::TYPE_URL,
|
||||
QrCode::TYPE_DOCUMENT,
|
||||
QrCode::TYPE_LINK_LIST,
|
||||
QrCode::TYPE_BUSINESS,
|
||||
QrCode::TYPE_WIFI,
|
||||
QrCode::TYPE_APP,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array{label: string, description: string, category: string, icon: string}> */
|
||||
public static function all(): array
|
||||
{
|
||||
$catalog = [
|
||||
QrCode::TYPE_URL => [
|
||||
'label' => 'Link',
|
||||
'description' => 'Redirect to any URL',
|
||||
'category' => 'basic',
|
||||
'icon' => 'website.svg',
|
||||
],
|
||||
QrCode::TYPE_DOCUMENT => [
|
||||
'label' => 'PDF',
|
||||
'description' => 'Hosted PDF reader',
|
||||
'category' => 'basic',
|
||||
'icon' => 'terms.svg',
|
||||
],
|
||||
QrCode::TYPE_LINK_LIST => [
|
||||
'label' => 'List of Links',
|
||||
'description' => 'Landing page with multiple links',
|
||||
'category' => 'basic',
|
||||
'icon' => 'list.svg',
|
||||
],
|
||||
QrCode::TYPE_BUSINESS => [
|
||||
'label' => 'Business',
|
||||
'description' => 'Business profile page',
|
||||
'category' => 'contact',
|
||||
'icon' => 'business-profile.svg',
|
||||
],
|
||||
QrCode::TYPE_WIFI => [
|
||||
'label' => 'WiFi',
|
||||
'description' => 'Network name and password',
|
||||
'category' => 'contact',
|
||||
'icon' => 'wifi.svg',
|
||||
],
|
||||
QrCode::TYPE_APP => [
|
||||
'label' => 'App',
|
||||
'description' => 'App Store and Play Store links',
|
||||
'category' => 'business',
|
||||
'icon' => 'app.svg',
|
||||
],
|
||||
];
|
||||
|
||||
return array_intersect_key($catalog, array_flip(self::plusTypes()));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::all());
|
||||
}
|
||||
|
||||
public static function label(string $type): string
|
||||
{
|
||||
return self::all()[$type]['label'] ?? ucfirst(str_replace('_', ' ', $type));
|
||||
}
|
||||
|
||||
public static function isValid(string $type): bool
|
||||
{
|
||||
return isset(self::all()[$type]);
|
||||
}
|
||||
|
||||
public static function iconUrl(string $icon): string
|
||||
{
|
||||
$path = public_path('images/qr-icons/'.$icon);
|
||||
$url = '/images/qr-icons/'.$icon;
|
||||
|
||||
if (is_file($path)) {
|
||||
return $url.'?v='.filemtime($path);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Qr;
|
||||
|
||||
class QrWifiPayload
|
||||
{
|
||||
/**
|
||||
* Build a WiFi QR payload (ZXing MECARD format) for one-tap network join on scan.
|
||||
*
|
||||
* @param array<string, mixed> $content
|
||||
*/
|
||||
public static function encode(array $content): string
|
||||
{
|
||||
$encryption = strtoupper(trim((string) ($content['encryption'] ?? 'WPA')));
|
||||
$auth = $encryption === 'NOPASS' ? 'nopass' : $encryption;
|
||||
if (! in_array($auth, ['WPA', 'WEP', 'nopass'], true)) {
|
||||
$auth = 'WPA';
|
||||
}
|
||||
|
||||
$ssid = self::escape(trim((string) ($content['ssid'] ?? '')));
|
||||
$payload = 'WIFI:T:' . $auth . ';S:' . $ssid;
|
||||
|
||||
if ($auth !== 'nopass') {
|
||||
$password = trim((string) ($content['password'] ?? ''));
|
||||
if ($password !== '') {
|
||||
$payload .= ';P:' . self::escape($password);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter_var($content['hidden'] ?? false, FILTER_VALIDATE_BOOL)) {
|
||||
$payload .= ';H:true';
|
||||
}
|
||||
|
||||
return $payload . ';;';
|
||||
}
|
||||
|
||||
private static function escape(string $value): string
|
||||
{
|
||||
return str_replace(
|
||||
['\\', ';', ',', '"', ':'],
|
||||
['\\\\', '\\;', '\\,', '\\"', '\\:'],
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\DomainOrder;
|
||||
use App\Models\RcServiceOrder;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* ResellerClub is legacy-only for new sales. Renewals and management remain available
|
||||
* for customers with existing ResellerClub services.
|
||||
*/
|
||||
final class ResellerClubLegacy
|
||||
{
|
||||
public static function integrationEnabled(): bool
|
||||
{
|
||||
return (bool) config('hosting.legacy.rc_enabled', true);
|
||||
}
|
||||
|
||||
public static function newOrdersEnabled(): bool
|
||||
{
|
||||
return self::integrationEnabled()
|
||||
&& (bool) config('hosting.legacy.rc_new_orders_enabled', false);
|
||||
}
|
||||
|
||||
public static function renewalsEnabled(): bool
|
||||
{
|
||||
return self::integrationEnabled()
|
||||
&& (bool) config('hosting.legacy.rc_renewals_enabled', true);
|
||||
}
|
||||
|
||||
public static function isRenewalRcServiceOrder(RcServiceOrder $order): bool
|
||||
{
|
||||
if ($order->order_type === RcServiceOrder::TYPE_RENEWAL) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) data_get($order->meta, 'is_renewal', false);
|
||||
}
|
||||
|
||||
public static function isRenewalDomainOrder(DomainOrder $order): bool
|
||||
{
|
||||
return $order->order_type === DomainOrder::TYPE_RENEWAL;
|
||||
}
|
||||
|
||||
public static function canAutomateFulfillment(RcServiceOrder $order): bool
|
||||
{
|
||||
if (! self::integrationEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::newOrdersEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, RcServiceOrder>|iterable<int, RcServiceOrder> $items
|
||||
*/
|
||||
public static function assertCartCheckoutAllowed(iterable $items): void
|
||||
{
|
||||
$items = $items instanceof Collection ? $items : collect($items);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
throw new RuntimeException('Your cart is empty.');
|
||||
}
|
||||
|
||||
if (self::newOrdersEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::renewalsEnabled()) {
|
||||
throw new RuntimeException(self::renewalsDisabledMessage());
|
||||
}
|
||||
|
||||
if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) {
|
||||
throw new RuntimeException(
|
||||
'ResellerClub checkout is limited to renewals for existing services. '
|
||||
.'New product purchases use our current domain and hosting products instead.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertNewSaleAllowed(): void
|
||||
{
|
||||
if (self::newOrdersEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'ResellerClub is no longer available for new product purchases. '
|
||||
.'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.'
|
||||
);
|
||||
}
|
||||
|
||||
public static function assertRenewalsAllowed(): void
|
||||
{
|
||||
if (! self::renewalsEnabled()) {
|
||||
throw new RuntimeException(self::renewalsDisabledMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use assertNewSaleAllowed() */
|
||||
public static function assertNewOrdersAllowed(): void
|
||||
{
|
||||
self::assertNewSaleAllowed();
|
||||
}
|
||||
|
||||
public static function renewalsDisabledMessage(): string
|
||||
{
|
||||
return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.';
|
||||
}
|
||||
|
||||
public static function fulfillmentDisabledMessage(): string
|
||||
{
|
||||
return 'Automated ResellerClub fulfillment for new purchases is no longer available. '
|
||||
.'Open a support ticket if you need help with a legacy order.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class UserProfileMenu
|
||||
{
|
||||
/**
|
||||
* Standard signed-in profile links for Ladill product apps.
|
||||
* Not used by Ladill Mail (mail.ladill.com) — that app keeps a mailbox-specific menu.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function items(?Authenticatable $user = null): array
|
||||
{
|
||||
$user ??= auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
foreach ([
|
||||
['label' => 'Home', 'path' => '', 'host' => 'home'],
|
||||
['label' => 'Profile', 'path' => 'profile'],
|
||||
['label' => 'Account Settings', 'path' => 'account-settings'],
|
||||
['label' => 'Dashboard', 'path' => 'dashboard'],
|
||||
['label' => 'Billing', 'path' => 'billing'],
|
||||
] as $link) {
|
||||
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
|
||||
|
||||
if (self::isCurrentMenuLink($link, $href)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'type' => 'link',
|
||||
'label' => $link['label'],
|
||||
'href' => $href,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
if (Route::has((string) config("billing.wallet_balance_route", "wallet.balance"))) {
|
||||
$items[] = ["type" => "wallet"];
|
||||
}
|
||||
|
||||
if (self::userIsAdmin($user)) {
|
||||
$adminHref = self::platformUrl('account', 'admin');
|
||||
|
||||
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
|
||||
$items[] = [
|
||||
'type' => 'link',
|
||||
'label' => 'Admin',
|
||||
'href' => $adminHref,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (Route::has('logout')) {
|
||||
$items[] = [
|
||||
'type' => 'logout',
|
||||
'label' => 'Logout',
|
||||
'action' => route('logout'),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function platformUrl(string $host, string $path = ''): string
|
||||
{
|
||||
if ($host === 'home') {
|
||||
if (function_exists('ladill_home_url')) {
|
||||
return ladill_home_url($path);
|
||||
}
|
||||
|
||||
$domain = config('app.home_domain');
|
||||
if (! $domain) {
|
||||
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
|
||||
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
|
||||
}
|
||||
|
||||
return self::absoluteUrl((string) $domain, $path);
|
||||
}
|
||||
|
||||
if (function_exists('ladill_account_url')) {
|
||||
return ladill_account_url($path);
|
||||
}
|
||||
|
||||
return self::absoluteUrl((string) config('app.account_domain'), $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a menu link when the signed-in user is already on that destination.
|
||||
*
|
||||
* @param array{label?: string, path?: string, host?: string} $link
|
||||
*/
|
||||
private static function isCurrentMenuLink(array $link, string $href): bool
|
||||
{
|
||||
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request = request();
|
||||
if (! $request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
|
||||
$currentHost = strtolower($request->getHost());
|
||||
|
||||
if ($linkHost === '' || $linkHost !== $currentHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentPath = trim($request->getPathInfo(), '/');
|
||||
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
|
||||
|
||||
if (($link['host'] ?? 'account') === 'home') {
|
||||
return $currentPath === '';
|
||||
}
|
||||
|
||||
if (($link['path'] ?? '') === 'dashboard') {
|
||||
return in_array($currentPath, ['dashboard', 'account'], true)
|
||||
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
|
||||
}
|
||||
|
||||
return self::pathMatchesSection($currentPath, $targetPath);
|
||||
}
|
||||
|
||||
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
|
||||
{
|
||||
if ($targetPath === '') {
|
||||
return $currentPath === '';
|
||||
}
|
||||
|
||||
return $currentPath === $targetPath
|
||||
|| str_starts_with($currentPath, $targetPath.'/');
|
||||
}
|
||||
|
||||
private static function absoluteUrl(string $domain, string $path = ''): string
|
||||
{
|
||||
$base = 'https://'.trim($domain, '/');
|
||||
|
||||
if ($path === '') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $base.'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private static function userIsAdmin(Authenticatable $user): bool
|
||||
{
|
||||
if (method_exists($user, 'isAdmin')) {
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
return (bool) ($user->is_admin ?? false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
if (! function_exists('ladill_account')) {
|
||||
/**
|
||||
* The account the current request acts within — the owner User. Defaults to
|
||||
* the authenticated user; a team member who switched accounts gets the owner.
|
||||
* Set by the SetActingAccount middleware.
|
||||
*/
|
||||
function ladill_account(): ?User
|
||||
{
|
||||
$request = request();
|
||||
|
||||
return $request->attributes->get('actingAccount') ?? $request->user();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('ladill_domains_url')) {
|
||||
function ladill_domains_url(string $path = ''): string
|
||||
{
|
||||
return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('ladill_account_url')) {
|
||||
function ladill_account_url(string $path = ''): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('ladill_servers_url')) {
|
||||
function ladill_servers_url(string $path = ''): string
|
||||
{
|
||||
return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserLayout extends Component
|
||||
{
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.user');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
]));
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\InjectBootSplash::class,
|
||||
\App\Http\Middleware\SetActingAccount::class,
|
||||
]);
|
||||
$middleware->alias([
|
||||
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "ladill/qr-plus",
|
||||
"type": "project",
|
||||
"description": "Ladill QR Plus — utility QR codes at qrplus.ladill.com",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"chillerlan/php-qrcode": "^5.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"phpseclib/phpseclib": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"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.50"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"files": [
|
||||
"app/Support/helpers.php"
|
||||
]
|
||||
},
|
||||
"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 --timeout=0\" \"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
|
||||
}
|
||||
Generated
+8858
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Afia — in-app AI assistant scoped to Ladill QR Plus.
|
||||
'product' => env('AFIA_PRODUCT', 'qr'),
|
||||
'enabled' => (bool) env('AFIA_ENABLED', true),
|
||||
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
|
||||
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
||||
'api_key' => env('AFIA_API_KEY'),
|
||||
];
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<?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', 'Ladill QR Plus'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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'),
|
||||
],
|
||||
|
||||
// Platform identity surfaces (Zoho One model). Ladill QR Plus (qrplus.ladill.com).
|
||||
'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'),
|
||||
'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||
'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||
'qr_domain' => env('QR_DOMAIN', parse_url((string) env('APP_URL', 'https://qrplus.ladill.com'), PHP_URL_HOST) ?: 'qrplus.ladill.com'),
|
||||
'servers_domain' => env('SERVERS_DOMAIN', 'servers.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||
'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')),
|
||||
|
||||
];
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
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', 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),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||
'api_key' => env('BILLING_API_KEY_QR'),
|
||||
'service' => 'qr',
|
||||
'wallet_balance_route' => 'wallet.balance',
|
||||
'currency' => 'GHS',
|
||||
];
|
||||
@@ -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-'),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
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 ? 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 ? 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' => env('DB_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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('DOMAIN_API_URL', 'https://ladill.com/api/domains'),
|
||||
'api_key' => env('DOMAIN_API_KEY_HOSTING'),
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
| Email product plans. Mailboxes are priced purely by the storage tier chosen
|
||||
| at creation (and changeable later via Upgrade). The 1 GB tier is FREE,
|
||||
| forever, for every mailbox; larger tiers are billed monthly from the one
|
||||
| Ladill wallet (§4-A). Money is in minor units (pesewas).
|
||||
*/
|
||||
'currency' => env('EMAIL_CURRENCY', 'GHS'),
|
||||
'default_quota_mb' => (int) env('EMAIL_DEFAULT_QUOTA_MB', 1024), // new mailboxes start on the free 1 GB tier
|
||||
|
||||
// Storage tiers: quota (MB) → monthly price (minor units). 1 GB is free.
|
||||
'quota_tiers' => [
|
||||
['mb' => 1024, 'price_minor' => 0], // 1 GB — Free forever
|
||||
['mb' => 5120, 'price_minor' => 1000], // 5 GB — GHS 10
|
||||
['mb' => 10240, 'price_minor' => 2000], // 10 GB — GHS 20
|
||||
['mb' => 25600, 'price_minor' => 3000], // 25 GB — GHS 30
|
||||
['mb' => 51200, 'price_minor' => 6000], // 50 GB — GHS 60
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Consumer config for the shared Email-Domain verification API (§4-D).
|
||||
'api_url' => env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'),
|
||||
'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'),
|
||||
];
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => rtrim(env('APP_URL', 'http://localhost'), '/').'/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,
|
||||
],
|
||||
|
||||
'qr' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private/qr'),
|
||||
'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'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,703 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Contabo API Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Credentials for the Contabo Compute Management API.
|
||||
| Used for provisioning VPS and VDS instances.
|
||||
|
|
||||
*/
|
||||
'contabo' => [
|
||||
'client_id' => env('CONTABO_CLIENT_ID'),
|
||||
'client_secret' => env('CONTABO_CLIENT_SECRET'),
|
||||
'api_user' => env('CONTABO_API_USER'),
|
||||
'api_password' => env('CONTABO_API_PASSWORD'),
|
||||
'product_catalog_endpoint' => env('CONTABO_PRODUCT_CATALOG_ENDPOINT'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Pricing Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Dynamic pricing settings for VPS/Dedicated servers.
|
||||
| Base prices from Contabo are in USD, converted to GHS using exchange rate.
|
||||
|
|
||||
*/
|
||||
'pricing' => [
|
||||
'base_currency' => 'USD',
|
||||
'display_currency' => 'GHS',
|
||||
|
||||
// Fallback rate used when live API fails
|
||||
'fallback_usd_to_ghs_rate' => env('FALLBACK_USD_TO_GHS_RATE', 15.50),
|
||||
|
||||
// Profit margins (percentage on top of converted Contabo USD price)
|
||||
'margins' => [
|
||||
'vps' => env('VPS_PROFIT_MARGIN', 45),
|
||||
'dedicated' => env('DEDICATED_PROFIT_MARGIN', 30),
|
||||
],
|
||||
|
||||
'contabo_price_cache_ttl' => env('CONTABO_PRICE_CACHE_TTL', 3600),
|
||||
|
||||
'term_discounts' => [
|
||||
'quarterly' => 5,
|
||||
'semiannual' => 10,
|
||||
'yearly' => 20,
|
||||
],
|
||||
|
||||
'setup_fee_rules' => [
|
||||
// VPS 10 only — monthly and semiannual cycles carry a setup fee equal to 1× the monthly price
|
||||
[
|
||||
'product_ids' => ['V91'],
|
||||
'billing_cycles' => ['monthly', 'semiannual'],
|
||||
'monthly_price_multiplier' => 1,
|
||||
'label' => 'One-time setup fee',
|
||||
],
|
||||
// Dedicated servers — fixed EUR fee that decreases with longer commitment; waived on yearly
|
||||
[
|
||||
'product_ids' => ['amd-ryzen-12-cores', 'amd-genoa-24-cores'],
|
||||
'billing_cycles' => ['monthly', 'quarterly', 'semiannual'],
|
||||
'fixed_eur_by_cycle' => [
|
||||
'monthly' => 39.99,
|
||||
'quarterly' => 29.99,
|
||||
'semiannual' => 19.99,
|
||||
],
|
||||
'label' => 'One-time setup fee',
|
||||
],
|
||||
],
|
||||
|
||||
// Fallback Contabo base prices in USD. Live API/feed prices are preferred.
|
||||
// Keep these values as a fail-safe for API downtime.
|
||||
'contabo_base_prices' => [
|
||||
'V91' => ['monthly' => 4.99, 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75],
|
||||
'V94' => ['monthly' => 7.00, 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100],
|
||||
'V97' => ['monthly' => 14.00, 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200],
|
||||
'V100' => ['monthly' => 25.00, 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250],
|
||||
'amd-ryzen-12-cores' => ['monthly' => 104.64, 'name' => 'AMD Ryzen 12 Cores', 'cpu' => 12, 'ram_gb' => 64, 'disk_gb' => 1000],
|
||||
'amd-genoa-24-cores' => ['monthly' => 184.21, 'name' => 'AMD Genoa 24 Cores', 'cpu' => 24, 'ram_gb' => 128, 'disk_gb' => 2000],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Server Order Options
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Surcharges shown here are Contabo-side monthly USD add-on costs before
|
||||
| the Ladill exchange-rate conversion and product margin are applied.
|
||||
| Update these values whenever Contabo changes its commercial pricing.
|
||||
|
|
||||
*/
|
||||
'server_order' => [
|
||||
'regions' => [
|
||||
'EU' => ['label' => 'Europe (Germany)', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'US-central' => ['label' => 'US Central', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'US-east' => ['label' => 'US East', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'US-west' => ['label' => 'US West', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'UK' => ['label' => 'United Kingdom', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'SIN' => ['label' => 'Singapore', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'AUS' => ['label' => 'Australia', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
'JPN' => ['label' => 'Japan', 'monthly_usd' => 0.00, 'automated' => true],
|
||||
],
|
||||
'image_pricing_rules' => [
|
||||
[
|
||||
'key' => 'windows',
|
||||
'label' => 'Windows Server',
|
||||
'monthly_usd' => 9.30,
|
||||
'match' => ['windows'],
|
||||
'os_family' => 'windows',
|
||||
'default_user' => 'administrator',
|
||||
'requires_custom_image_addon' => false,
|
||||
'automated' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'ubuntu',
|
||||
'label' => 'Ubuntu',
|
||||
'monthly_usd' => 0.00,
|
||||
'match' => ['ubuntu'],
|
||||
'os_family' => 'linux',
|
||||
'default_user' => 'root',
|
||||
'requires_custom_image_addon' => false,
|
||||
'automated' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'rhel',
|
||||
'label' => 'RHEL Variants',
|
||||
'monthly_usd' => 0.00,
|
||||
'match' => ['alma', 'rocky', 'rhel', 'centos'],
|
||||
'os_family' => 'linux',
|
||||
'default_user' => 'root',
|
||||
'requires_custom_image_addon' => false,
|
||||
'automated' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'custom',
|
||||
'label' => 'Custom Images',
|
||||
'monthly_usd' => 0.00,
|
||||
'custom_image' => true,
|
||||
'os_family' => 'linux',
|
||||
'default_user' => 'root',
|
||||
'requires_custom_image_addon' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'linux',
|
||||
'label' => 'Linux',
|
||||
'monthly_usd' => 0.00,
|
||||
'match' => ['debian', 'fedora', 'linux', 'bsd'],
|
||||
'os_family' => 'linux',
|
||||
'default_user' => 'root',
|
||||
'requires_custom_image_addon' => false,
|
||||
'automated' => true,
|
||||
],
|
||||
],
|
||||
'fallback_images' => [
|
||||
[
|
||||
'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
|
||||
'label' => 'Ubuntu 22.04',
|
||||
'description' => 'Fallback Ubuntu image',
|
||||
'os_family' => 'linux',
|
||||
'monthly_usd' => 0.00,
|
||||
'default_user' => 'root',
|
||||
'requires_custom_image_addon' => false,
|
||||
'automated' => true,
|
||||
],
|
||||
],
|
||||
'managed_stack_supported_images' => [
|
||||
[
|
||||
'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
|
||||
'label' => 'Ubuntu 22.04 LTS',
|
||||
'match' => ['ubuntu 22.04'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'Ubuntu 24.04 LTS',
|
||||
'match' => ['ubuntu 24.04'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'Debian 12',
|
||||
'match' => ['debian 12', 'bookworm'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'AlmaLinux',
|
||||
'match' => ['almalinux', 'alma linux', 'alma'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'Rocky Linux',
|
||||
'match' => ['rocky linux', 'rocky'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'Fedora',
|
||||
'match' => ['fedora'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'openSUSE Leap',
|
||||
'match' => ['opensuse leap', 'open suse leap', 'opensuse', 'leap'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
[
|
||||
'label' => 'CentOS',
|
||||
'match' => ['centos'],
|
||||
'os_family' => 'linux',
|
||||
],
|
||||
],
|
||||
'licenses' => [
|
||||
'none' => [
|
||||
'label' => 'Remote Login Only',
|
||||
'description' => 'No hosting panel or commercial control-panel license.',
|
||||
'monthly_usd' => 0.00,
|
||||
'license' => null,
|
||||
'panel' => null,
|
||||
'automated' => true,
|
||||
],
|
||||
'ladill_panel' => [
|
||||
'label' => 'Ladill Server Manager',
|
||||
'description' => 'Manage power, status, and server details from the Ladill server manager.',
|
||||
'monthly_usd' => 0.00,
|
||||
'license' => null,
|
||||
'panel' => 'ladill',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'requires_managed_stack_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'plesk_host' => [
|
||||
'label' => 'Plesk + Linux',
|
||||
'description' => 'Plesk host edition on Linux.',
|
||||
'monthly_usd' => 15.00,
|
||||
'license' => 'PleskHost',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'plesk_windows' => [
|
||||
'label' => 'Plesk + Windows',
|
||||
'description' => 'Plesk on Windows Server.',
|
||||
'monthly_usd' => 22.70,
|
||||
'license' => 'PleskHost',
|
||||
'compatible_os_families' => ['windows'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_5' => [
|
||||
'label' => 'cPanel 5',
|
||||
'description' => 'cPanel license for up to 5 accounts.',
|
||||
'monthly_usd' => 35.99,
|
||||
'license' => 'cPanel5',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_30' => [
|
||||
'label' => 'cPanel 30',
|
||||
'description' => 'cPanel license for up to 30 accounts.',
|
||||
'monthly_usd' => 53.99,
|
||||
'license' => 'cPanel30',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_50' => [
|
||||
'label' => 'cPanel 50',
|
||||
'description' => 'cPanel license for up to 50 accounts.',
|
||||
'monthly_usd' => 53.99 + (0.49 * 20),
|
||||
'license' => 'cPanel50',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_100' => [
|
||||
'label' => 'cPanel 100',
|
||||
'description' => 'cPanel license for up to 100 accounts.',
|
||||
'monthly_usd' => 69.99,
|
||||
'license' => 'cPanel100',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_150' => [
|
||||
'label' => 'cPanel 150',
|
||||
'description' => 'cPanel license for up to 150 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 50),
|
||||
'license' => 'cPanel150',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_200' => [
|
||||
'label' => 'cPanel 200',
|
||||
'description' => 'cPanel license for up to 200 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 100),
|
||||
'license' => 'cPanel200',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_250' => [
|
||||
'label' => 'cPanel 250',
|
||||
'description' => 'cPanel license for up to 250 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 150),
|
||||
'license' => 'cPanel250',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_300' => [
|
||||
'label' => 'cPanel 300',
|
||||
'description' => 'cPanel license for up to 300 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 200),
|
||||
'license' => 'cPanel300',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_350' => [
|
||||
'label' => 'cPanel 350',
|
||||
'description' => 'cPanel license for up to 350 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 250),
|
||||
'license' => 'cPanel350',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_400' => [
|
||||
'label' => 'cPanel 400',
|
||||
'description' => 'cPanel license for up to 400 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 300),
|
||||
'license' => 'cPanel400',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_450' => [
|
||||
'label' => 'cPanel 450',
|
||||
'description' => 'cPanel license for up to 450 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 350),
|
||||
'license' => 'cPanel450',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_500' => [
|
||||
'label' => 'cPanel 500',
|
||||
'description' => 'cPanel license for up to 500 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 400),
|
||||
'license' => 'cPanel500',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_550' => [
|
||||
'label' => 'cPanel 550',
|
||||
'description' => 'cPanel license for up to 550 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 450),
|
||||
'license' => 'cPanel550',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_600' => [
|
||||
'label' => 'cPanel 600',
|
||||
'description' => 'cPanel license for up to 600 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 500),
|
||||
'license' => 'cPanel600',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_650' => [
|
||||
'label' => 'cPanel 650',
|
||||
'description' => 'cPanel license for up to 650 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 550),
|
||||
'license' => 'cPanel650',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_700' => [
|
||||
'label' => 'cPanel 700',
|
||||
'description' => 'cPanel license for up to 700 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 600),
|
||||
'license' => 'cPanel700',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_750' => [
|
||||
'label' => 'cPanel 750',
|
||||
'description' => 'cPanel license for up to 750 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 650),
|
||||
'license' => 'cPanel750',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_800' => [
|
||||
'label' => 'cPanel 800',
|
||||
'description' => 'cPanel license for up to 800 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 700),
|
||||
'license' => 'cPanel800',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_850' => [
|
||||
'label' => 'cPanel 850',
|
||||
'description' => 'cPanel license for up to 850 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 750),
|
||||
'license' => 'cPanel850',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_900' => [
|
||||
'label' => 'cPanel 900',
|
||||
'description' => 'cPanel license for up to 900 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 800),
|
||||
'license' => 'cPanel900',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_950' => [
|
||||
'label' => 'cPanel 950',
|
||||
'description' => 'cPanel license for up to 950 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 850),
|
||||
'license' => 'cPanel950',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'cpanel_1000' => [
|
||||
'label' => 'cPanel 1000',
|
||||
'description' => 'cPanel license for up to 1000 accounts.',
|
||||
'monthly_usd' => 69.99 + (0.49 * 900),
|
||||
'license' => 'cPanel1000',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
],
|
||||
'applications' => [
|
||||
'none' => [
|
||||
'label' => 'No Preinstalled App',
|
||||
'description' => 'Provision the server without an extra preinstalled application.',
|
||||
'monthly_usd' => 0.00,
|
||||
'automated' => true,
|
||||
],
|
||||
'webmin' => [
|
||||
'label' => 'Webmin',
|
||||
'description' => 'Free Webmin server panel installed automatically on Linux.',
|
||||
'monthly_usd' => 0.00,
|
||||
'cloud_init_preset' => 'webmin',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'webmin_lamp' => [
|
||||
'label' => 'Webmin + LAMP',
|
||||
'description' => 'Free Webmin plus Apache, MariaDB, and PHP on Linux.',
|
||||
'monthly_usd' => 0.00,
|
||||
'cloud_init_preset' => 'webmin_lamp',
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'ipfs_node' => [
|
||||
'label' => 'IPFS Node',
|
||||
'description' => 'Contabo application profile for IPFS nodes.',
|
||||
'monthly_usd' => 0.00,
|
||||
'application_id' => env('CONTABO_APP_ID_IPFS_NODE'),
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'flux_node' => [
|
||||
'label' => 'Flux Node',
|
||||
'description' => 'Contabo application profile for Flux nodes.',
|
||||
'monthly_usd' => 0.00,
|
||||
'application_id' => env('CONTABO_APP_ID_FLUX_NODE'),
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'horizon_node' => [
|
||||
'label' => 'Horizen Node',
|
||||
'description' => 'Contabo application profile for Horizen nodes.',
|
||||
'monthly_usd' => 0.00,
|
||||
'application_id' => env('CONTABO_APP_ID_HORIZON_NODE'),
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'ethereum_node' => [
|
||||
'label' => 'Ethereum Node',
|
||||
'description' => 'Contabo application profile for Ethereum 2.0 nodes.',
|
||||
'monthly_usd' => 0.00,
|
||||
'application_id' => null,
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
'bitcoin_node' => [
|
||||
'label' => 'Bitcoin Full Node',
|
||||
'description' => 'Contabo application profile for Bitcoin full nodes.',
|
||||
'monthly_usd' => 0.00,
|
||||
'application_id' => null,
|
||||
'compatible_os_families' => ['linux'],
|
||||
'requires_image' => true,
|
||||
'automated' => true,
|
||||
],
|
||||
],
|
||||
'additional_ip' => [
|
||||
'none' => [
|
||||
'label' => '1 IP Address',
|
||||
'description' => 'Default primary IP only.',
|
||||
'monthly_usd' => 0.00,
|
||||
'add_on' => null,
|
||||
'automated' => true,
|
||||
],
|
||||
'one_extra' => [
|
||||
'label' => '1 Additional IP',
|
||||
'description' => 'Adds one extra IPv4 address.',
|
||||
'monthly_usd' => 4.50,
|
||||
'add_on' => 'additionalIps',
|
||||
'automated' => true,
|
||||
],
|
||||
],
|
||||
'private_networking' => [
|
||||
'disabled' => [
|
||||
'label' => 'No Private Networking',
|
||||
'description' => 'Do not enable the private networking add-on.',
|
||||
'monthly_usd' => 0.00,
|
||||
'add_on' => null,
|
||||
'automated' => true,
|
||||
],
|
||||
'enabled' => [
|
||||
'label' => 'Private Networking Enabled',
|
||||
'description' => 'Purchases the private networking add-on.',
|
||||
'monthly_usd' => 2.99,
|
||||
'add_on' => 'privateNetworking',
|
||||
'automated' => true,
|
||||
],
|
||||
],
|
||||
'storage_types' => [
|
||||
'included' => [
|
||||
'label' => 'Included Storage',
|
||||
'description' => 'Use the plan default storage.',
|
||||
'monthly_usd' => 0.00,
|
||||
'add_on' => null,
|
||||
'automated' => true,
|
||||
],
|
||||
'ssd_300' => [
|
||||
'label' => '300 GB SSD',
|
||||
'description' => 'Higher SSD storage tier.',
|
||||
'monthly_usd' => 1.95,
|
||||
'add_on' => 'extraStorage',
|
||||
'automated' => false,
|
||||
],
|
||||
'nvme_150' => [
|
||||
'label' => '150 GB NVMe',
|
||||
'description' => 'Higher NVMe storage tier.',
|
||||
'monthly_usd' => 2.30,
|
||||
'add_on' => 'extraStorage',
|
||||
'automated' => false,
|
||||
],
|
||||
],
|
||||
'object_storage' => [
|
||||
'none' => [
|
||||
'label' => 'No Object Storage',
|
||||
'description' => 'Do not order object storage with this server.',
|
||||
'monthly_usd' => 0.00,
|
||||
'automated' => false,
|
||||
],
|
||||
'eu_250' => [
|
||||
'label' => '250 GB Object Storage (EU)',
|
||||
'description' => 'S3-compatible object storage in the European Union.',
|
||||
'monthly_usd' => 2.99,
|
||||
'automated' => false,
|
||||
],
|
||||
'eu_500' => [
|
||||
'label' => '500 GB Object Storage (EU)',
|
||||
'description' => 'S3-compatible object storage in the European Union.',
|
||||
'monthly_usd' => 5.98,
|
||||
'automated' => false,
|
||||
],
|
||||
'eu_750' => [
|
||||
'label' => '750 GB Object Storage (EU)',
|
||||
'description' => 'S3-compatible object storage in the European Union.',
|
||||
'monthly_usd' => 8.97,
|
||||
'automated' => false,
|
||||
],
|
||||
'eu_1024' => [
|
||||
'label' => '1 TB Object Storage (EU)',
|
||||
'description' => 'S3-compatible object storage in the European Union.',
|
||||
'monthly_usd' => 11.96,
|
||||
'automated' => false,
|
||||
],
|
||||
],
|
||||
'linux_default_users' => [
|
||||
'root' => ['label' => 'root'],
|
||||
'admin' => ['label' => 'admin'],
|
||||
],
|
||||
'windows_default_users' => [
|
||||
'admin' => ['label' => 'admin'],
|
||||
'administrator' => ['label' => 'administrator'],
|
||||
],
|
||||
],
|
||||
|
||||
'server_agent' => [
|
||||
'release_version' => env('SERVER_AGENT_RELEASE_VERSION', '0.2.0'),
|
||||
'signed_release_ttl_minutes' => (int) env('SERVER_AGENT_SIGNED_RELEASE_TTL_MINUTES', 10080),
|
||||
'heartbeat_interval_seconds' => 15,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Shared Hosting Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Settings for the shared hosting nodes.
|
||||
|
|
||||
*/
|
||||
'shared' => [
|
||||
'default_php_version' => '8.2',
|
||||
'available_php_versions' => ['8.0', '8.1', '8.2', '8.3'],
|
||||
'default_document_root' => 'public_html',
|
||||
'max_upload_size_mb' => 64,
|
||||
'max_execution_time' => 300,
|
||||
'memory_limit_mb' => 256,
|
||||
'phpmyadmin_url' => env('HOSTING_PHPMYADMIN_URL', ''),
|
||||
'phpmyadmin_sso_secret' => env('HOSTING_PHPMYADMIN_SSO_SECRET', ''),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| VPS Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Default settings for VPS instances.
|
||||
|
|
||||
*/
|
||||
'vps' => [
|
||||
'default_region' => 'EU',
|
||||
'default_image' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', // Ubuntu 22.04
|
||||
'available_regions' => ['EU', 'US-central', 'US-east', 'US-west', 'SIN', 'UK', 'AUS', 'JPN'],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| App Installer Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Settings for the one-click app installer.
|
||||
|
|
||||
*/
|
||||
'apps' => [
|
||||
'enabled' => ['wordpress', 'joomla', 'drupal', 'opencart'],
|
||||
'magento_enabled' => false, // Requires higher resource plans
|
||||
'wordpress' => [
|
||||
'default_version' => '6.4',
|
||||
'wp_cli_path' => '/usr/local/bin/wp',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Provisioning Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| General provisioning configuration.
|
||||
|
|
||||
*/
|
||||
'provisioning' => [
|
||||
'max_retries' => 3,
|
||||
'retry_delay_minutes' => 5,
|
||||
'timeout_minutes' => 30,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Legacy ResellerClub Integration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Keep RC integration active for existing customers.
|
||||
|
|
||||
*/
|
||||
'legacy' => [
|
||||
// Keep RC data/API available for customers with existing ResellerClub services.
|
||||
'rc_enabled' => (bool) env('LADILL_RC_LEGACY_ENABLED', true),
|
||||
// New sales and automated fulfillment use Ladill (Dynadot domains, native hosting).
|
||||
'rc_new_orders_enabled' => (bool) env('LADILL_RC_NEW_ORDERS_ENABLED', false),
|
||||
// Renew existing ResellerClub services (hosting, domains, VPS, etc.).
|
||||
'rc_renewals_enabled' => (bool) env('LADILL_RC_RENEWALS_ENABLED', true),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'),
|
||||
'api_key' => env('IDENTITY_API_KEY_HOSTING'),
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'product_slug' => 'qrplus',
|
||||
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/qrplus'),
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ladill App Launcher — SHARED, IDENTICAL across every app/service repo
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
|
||||
| launcher in a new app, copy these three things verbatim into that repo:
|
||||
| - config/ladill_launcher.php (this file)
|
||||
| - resources/views/partials/launcher.blade.php
|
||||
| - public/images/launcher-icons/* (the icon set)
|
||||
|
|
||||
| When a service becomes FULLY extracted: add one row below AND drop its icon in
|
||||
| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed.
|
||||
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
||||
|
|
||||
| URLs are absolute (built from the platform root) so this file is byte-identical
|
||||
| in every app; the blade omits whichever row matches the app's APP_URL host.
|
||||
| Icons are served from /images/launcher-icons/<icon>.
|
||||
*/
|
||||
|
||||
$root = config('app.platform_domain', 'ladill.com');
|
||||
|
||||
return [
|
||||
'apps' => [
|
||||
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
|
||||
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
|
||||
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
|
||||
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
|
||||
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
|
||||
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
||||
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
|
||||
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
|
||||
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
|
||||
],
|
||||
];
|
||||
@@ -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', env('APP_NAME', 'Laravel')),
|
||||
'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'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<?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'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'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', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
$platformHost = parse_url((string) env('APP_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com';
|
||||
|
||||
return [
|
||||
'account_url' => env(
|
||||
'LADILL_ACCOUNT_URL',
|
||||
'https://'.env('ACCOUNT_DOMAIN', 'account.'.$platformHost)
|
||||
),
|
||||
|
||||
'brands' => [
|
||||
'ladill' => [
|
||||
'name' => 'Ladill',
|
||||
'logo' => 'ladill-logo-white.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('APP_URL', 'https://ladill.com'),
|
||||
'footer_account' => 'an account with Ladill',
|
||||
'dashboard_path' => '/dashboard',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'ladill.com',
|
||||
],
|
||||
'hosting' => [
|
||||
'name' => 'Ladill Hosting',
|
||||
'logo' => 'ladillhosting-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_HOSTING_APP_URL', 'https://hosting.ladill.com'),
|
||||
'footer_account' => 'a Ladill Hosting account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'hosting.ladill.com',
|
||||
],
|
||||
'domains' => [
|
||||
'name' => 'Ladill Domains',
|
||||
'logo' => 'ladilldomains-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_DOMAINS_APP_URL', 'https://domains.ladill.com'),
|
||||
'footer_account' => 'a Ladill Domains account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'domains.ladill.com',
|
||||
],
|
||||
'bird' => [
|
||||
'name' => 'Ladill Bird',
|
||||
'logo' => 'ladillbird-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_BIRD_APP_URL', 'https://bird.ladill.com'),
|
||||
'footer_account' => 'a Ladill Bird account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'bird.ladill.com',
|
||||
],
|
||||
'mail' => [
|
||||
'name' => 'Ladill Mail',
|
||||
'logo' => 'ladillmail-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'),
|
||||
'footer_account' => 'a Ladill Mail account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'mail.ladill.com',
|
||||
],
|
||||
'email' => [
|
||||
'name' => 'Ladill Email',
|
||||
'logo' => 'ladillemail-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_EMAIL_APP_URL', 'https://email.ladill.com'),
|
||||
'footer_account' => 'a Ladill Email account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'email.ladill.com',
|
||||
],
|
||||
'qrplus' => [
|
||||
'name' => 'Ladill QR Plus',
|
||||
'logo' => 'ladillqrplus-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_QR_APP_URL', 'https://qrplus.ladill.com'),
|
||||
'footer_account' => 'a Ladill QR Plus account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'qrplus.ladill.com',
|
||||
],
|
||||
'events' => [
|
||||
'name' => 'Ladill Events',
|
||||
'logo' => 'ladillevents-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_EVENTS_APP_URL', 'https://events.ladill.com'),
|
||||
'footer_account' => 'a Ladill Events account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'events.ladill.com',
|
||||
],
|
||||
'transfer' => [
|
||||
'name' => 'Ladill Transfer',
|
||||
'logo' => 'ladilltransfer-logo-email.png',
|
||||
'logo_class' => 'email-logo email-logo-transfer',
|
||||
'app_url' => env('LADILL_TRANSFER_APP_URL', 'https://transfer.ladill.com'),
|
||||
'footer_account' => 'a Ladill Transfer account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'transfer.ladill.com',
|
||||
],
|
||||
'mini' => [
|
||||
'name' => 'Ladill Mini',
|
||||
'logo' => 'ladillmini-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_MINI_APP_URL', 'https://mini.ladill.com'),
|
||||
'footer_account' => 'a Ladill Mini account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'mini.ladill.com',
|
||||
],
|
||||
'servers' => [
|
||||
'name' => 'Ladill Servers',
|
||||
'logo' => 'ladillservers-logo-email.png',
|
||||
'logo_class' => 'email-logo',
|
||||
'app_url' => env('LADILL_SERVERS_APP_URL', 'https://servers.ladill.com'),
|
||||
'footer_account' => 'a Ladill Servers account',
|
||||
'dashboard_path' => '/',
|
||||
'support_path' => '/support',
|
||||
'home_label' => 'servers.ladill.com',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Consumer config for the platform Mailbox provisioning API (§4). Mail infra
|
||||
// (Postfix/Dovecot via mail DB + pools) lives on the platform; this app
|
||||
// provisions through the API. The user is identified by public_id.
|
||||
'api_url' => env('MAILBOX_API_URL', 'https://ladill.com/api/mailboxes'),
|
||||
'api_key' => env('MAILBOX_API_KEY_EMAIL'),
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'app_name' => 'QR Plus',
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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.
|
||||
|
|
||||
*/
|
||||
|
||||
/*
|
||||
| Ladill SSO (OIDC) — this app is a first-party client of the platform
|
||||
| identity provider at auth.ladill.com (Authorization Code + PKCE). Mirrors
|
||||
| the monolith's reference client; redirect points back at this app.
|
||||
*/
|
||||
'ladill_sso' => [
|
||||
'issuer' => 'https://'.config('app.auth_domain'),
|
||||
'client_id' => env('LADILL_SSO_CLIENT_ID'),
|
||||
'client_secret' => env('LADILL_SSO_CLIENT_SECRET'),
|
||||
'redirect' => rtrim((string) env('APP_URL', 'https://qrplus.ladill.com'), '/').'/sso/callback',
|
||||
],
|
||||
|
||||
'ladill_webmail' => [
|
||||
'url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'),
|
||||
],
|
||||
|
||||
'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'),
|
||||
],
|
||||
],
|
||||
|
||||
// Paystack — guest checkout (and the card fallback for logged-in buyers).
|
||||
'paystack' => [
|
||||
'base_url' => env('PAYSTACK_BASE_URL', 'https://api.paystack.co'),
|
||||
'public_key' => env('PAYSTACK_PUBLIC_KEY'),
|
||||
'secret_key' => env('PAYSTACK_SECRET_KEY'),
|
||||
'webhook_secret' => env('PAYSTACK_WEBHOOK_SECRET', env('PAYSTACK_SECRET_KEY')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,217 @@
|
||||
<?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' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Ladill QR Plus',
|
||||
'logo' => 'images/logo/ladillqrplus-logo.svg',
|
||||
'description' => 'Your Ladill QR Plus session has ended. Sign in again to manage dynamic QR codes.',
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<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
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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
|
||||
{
|
||||
// Local mirror of the platform identity, keyed by public_id (OIDC sub).
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('public_id')->unique();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->unique();
|
||||
$table->string('avatar_url')->nullable();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$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->foreignId('user_id')->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');
|
||||
}
|
||||
};
|
||||
@@ -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')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -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,33 @@
|
||||
<?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->morphs('tokenable');
|
||||
$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,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Database\Seeders\HostingProductSeeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
HostingProductSeeder::class,
|
||||
]);
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class HostingNodeSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
HostingNode::firstOrCreate(
|
||||
['provider' => 'local', 'ip_address' => '127.0.0.1'],
|
||||
[
|
||||
'name' => 'Primary Server',
|
||||
'hostname' => gethostname() ?: 'localhost',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'ipv6_address' => '::1',
|
||||
'type' => 'shared',
|
||||
'segment' => 'general',
|
||||
'provider' => 'local',
|
||||
'cpu_cores' => 4,
|
||||
'ram_mb' => 8192,
|
||||
'disk_gb' => 150,
|
||||
'oversell_ratio' => 3,
|
||||
'bandwidth_tb' => 10,
|
||||
'max_accounts' => 100,
|
||||
'current_accounts' => 0,
|
||||
'status' => 'active',
|
||||
'ssh_port' => 22,
|
||||
'features' => ['php', 'mysql', 'nginx', 'ssl'],
|
||||
'installed_software' => [
|
||||
'php' => ['8.1', '8.2', '8.3'],
|
||||
'mysql' => '8.0',
|
||||
'nginx' => '1.24',
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF' >&2
|
||||
Usage:
|
||||
ladill-hosting-admin self-check
|
||||
ladill-hosting-admin mkdir <path>
|
||||
ladill-hosting-admin chown <owner:group> <path>
|
||||
ladill-hosting-admin chmod <mode> <path>
|
||||
ladill-hosting-admin write-file <path> <base64-content>
|
||||
ladill-hosting-admin symlink <target> <link>
|
||||
ladill-hosting-admin nginx-test
|
||||
ladill-hosting-admin reload-nginx
|
||||
ladill-hosting-admin certbot-webroot <email> <domain> <webroot>
|
||||
ladill-hosting-admin certbot-renew
|
||||
ladill-hosting-admin run-cmd <command>
|
||||
ladill-hosting-admin read-file <path>
|
||||
EOF
|
||||
exit 64
|
||||
}
|
||||
|
||||
require_abs_path() {
|
||||
local path="${1:-}"
|
||||
|
||||
if [[ -z "$path" || "${path#/}" == "$path" ]]; then
|
||||
echo "Expected an absolute path, got: $path" >&2
|
||||
exit 64
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_domain() {
|
||||
local value="${1:-}"
|
||||
|
||||
if [[ ! "$value" =~ ^[A-Za-z0-9.-]+$ ]]; then
|
||||
echo "Invalid domain: $value" >&2
|
||||
exit 64
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_owner_group() {
|
||||
local value="${1:-}"
|
||||
|
||||
if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_-]*:[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||
echo "Invalid owner:group value: $value" >&2
|
||||
exit 64
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_mode() {
|
||||
local value="${1:-}"
|
||||
|
||||
if [[ ! "$value" =~ ^[0-7]{3,4}$ && ! "$value" =~ ^[ugoa,+-=rwxX]+$ ]]; then
|
||||
echo "Invalid chmod mode: $value" >&2
|
||||
exit 64
|
||||
fi
|
||||
}
|
||||
|
||||
command="${1:-}"
|
||||
|
||||
case "$command" in
|
||||
self-check)
|
||||
exit 0
|
||||
;;
|
||||
mkdir)
|
||||
[[ $# -eq 2 ]] || usage
|
||||
require_abs_path "$2"
|
||||
exec mkdir -p "$2"
|
||||
;;
|
||||
chown)
|
||||
[[ $# -eq 3 ]] || usage
|
||||
ensure_owner_group "$2"
|
||||
require_abs_path "$3"
|
||||
exec chown -R "$2" "$3"
|
||||
;;
|
||||
chmod)
|
||||
[[ $# -eq 3 ]] || usage
|
||||
ensure_mode "$2"
|
||||
require_abs_path "$3"
|
||||
exec chmod "$2" "$3"
|
||||
;;
|
||||
write-file)
|
||||
[[ $# -eq 3 ]] || usage
|
||||
require_abs_path "$2"
|
||||
tmpfile="$(mktemp)"
|
||||
trap 'rm -f "$tmpfile"' EXIT
|
||||
printf '%s' "$3" | base64 --decode > "$tmpfile"
|
||||
install -m 0644 "$tmpfile" "$2"
|
||||
rm -f "$tmpfile"
|
||||
trap - EXIT
|
||||
;;
|
||||
symlink)
|
||||
[[ $# -eq 3 ]] || usage
|
||||
require_abs_path "$2"
|
||||
require_abs_path "$3"
|
||||
exec ln -sfn "$2" "$3"
|
||||
;;
|
||||
nginx-test)
|
||||
exec nginx -t
|
||||
;;
|
||||
reload-nginx)
|
||||
exec systemctl reload nginx
|
||||
;;
|
||||
certbot-webroot)
|
||||
[[ $# -eq 4 ]] || usage
|
||||
ensure_domain "$3"
|
||||
require_abs_path "$4"
|
||||
exec certbot certonly --webroot --non-interactive --agree-tos --no-eff-email -m "$2" -w "$4" -d "$3" -d "www.$3"
|
||||
;;
|
||||
certbot-renew)
|
||||
exec certbot renew --quiet --no-random-sleep-on-renew
|
||||
;;
|
||||
run-cmd)
|
||||
[[ $# -ge 2 ]] || usage
|
||||
shift
|
||||
exec bash -c "$*"
|
||||
;;
|
||||
read-file)
|
||||
[[ $# -eq 2 ]] || usage
|
||||
require_abs_path "$2"
|
||||
exec cat "$2"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: ladill-hosting-user <username> <base64-command>" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[[ $# -eq 2 ]] || usage
|
||||
|
||||
username="$1"
|
||||
payload_b64="$2"
|
||||
|
||||
if [[ ! "$username" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||
echo "Invalid username: $username" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
command="$(printf '%s' "$payload_b64" | base64 --decode)"
|
||||
|
||||
exec runuser -u "$username" -- bash -lc "$command"
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh).
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-qr-plus}"
|
||||
RELEASES_DIR="$APP_ROOT/releases"
|
||||
SHARED_DIR="$APP_ROOT/shared"
|
||||
CURRENT_LINK="$APP_ROOT/current"
|
||||
RELEASE_ARCHIVE="${LADILL_RELEASE_ARCHIVE:-/tmp/ladill-release.tgz}"
|
||||
KEEP_RELEASES="${LADILL_KEEP_RELEASES:-5}"
|
||||
|
||||
STAMP="$(date +%Y%m%d%H%M%S)"
|
||||
NEW_RELEASE="$RELEASES_DIR/$STAMP"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
|
||||
}
|
||||
|
||||
bootstrap_shared_env() {
|
||||
local candidate=""
|
||||
|
||||
if [ -f "$SHARED_DIR/.env" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in "$CURRENT_LINK/.env" "$APP_ROOT/.env"; do
|
||||
if [ -f "$candidate" ]; then
|
||||
cp -fL "$candidate" "$SHARED_DIR/.env"
|
||||
log "Bootstrapped shared .env from $candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_writable_paths() {
|
||||
local paths=("$@")
|
||||
|
||||
[ "${#paths[@]}" -gt 0 ] || return 0
|
||||
|
||||
chmod -R ug+rwX "${paths[@]}" 2>/dev/null || true
|
||||
|
||||
if command -v find >/dev/null 2>&1; then
|
||||
find "${paths[@]}" -type d -exec chmod ug+rwx {} + 2>/dev/null || true
|
||||
find "${paths[@]}" -type d -exec chmod g+s {} + 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_shared_permissions() {
|
||||
local owner="${LADILL_DEPLOY_USER:-deploy}"
|
||||
local group="${LADILL_DEPLOY_GROUP:-www-data}"
|
||||
local shared_paths=("$SHARED_DIR/storage" "$SHARED_DIR/bootstrap-cache")
|
||||
|
||||
id -u "$owner" >/dev/null 2>&1 || return 0
|
||||
getent group "$group" >/dev/null 2>&1 || return 0
|
||||
|
||||
if chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null; then
|
||||
:
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chown -R "$owner:$group" "${shared_paths[@]}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ensure_writable_paths "${shared_paths[@]}"
|
||||
}
|
||||
|
||||
run_artisan() {
|
||||
local command="$1"
|
||||
|
||||
if [ -f "$NEW_RELEASE/artisan" ]; then
|
||||
(cd "$NEW_RELEASE" && php artisan ${command})
|
||||
fi
|
||||
}
|
||||
|
||||
uses_sqlite() {
|
||||
[ -f "$SHARED_DIR/.env" ] && grep -Eq '^DB_CONNECTION=sqlite([[:space:]]*)$' "$SHARED_DIR/.env"
|
||||
}
|
||||
|
||||
sqlite_database_setting() {
|
||||
[ -f "$SHARED_DIR/.env" ] || return 1
|
||||
|
||||
grep -E '^DB_DATABASE=' "$SHARED_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//"
|
||||
}
|
||||
|
||||
prepare_sqlite_database() {
|
||||
local configured_path=""
|
||||
local db_name=""
|
||||
local shared_sqlite_path=""
|
||||
local current_sqlite_path=""
|
||||
local release_sqlite_path=""
|
||||
|
||||
configured_path="$(sqlite_database_setting || true)"
|
||||
[ -n "$configured_path" ] || configured_path="database/database.sqlite"
|
||||
|
||||
if [[ "$configured_path" = /* ]]; then
|
||||
shared_sqlite_path="$configured_path"
|
||||
mkdir -p "$(dirname "$shared_sqlite_path")"
|
||||
else
|
||||
db_name="$(basename "$configured_path")"
|
||||
shared_sqlite_path="$SHARED_DIR/database/$db_name"
|
||||
current_sqlite_path="$CURRENT_LINK/$configured_path"
|
||||
release_sqlite_path="$NEW_RELEASE/$configured_path"
|
||||
|
||||
mkdir -p "$SHARED_DIR/database" "$(dirname "$release_sqlite_path")"
|
||||
|
||||
if [ ! -f "$shared_sqlite_path" ]; then
|
||||
if [ -f "$current_sqlite_path" ]; then
|
||||
cp -fL "$current_sqlite_path" "$shared_sqlite_path"
|
||||
log "Bootstrapped shared sqlite database from $current_sqlite_path"
|
||||
elif [ -f "$APP_ROOT/$configured_path" ]; then
|
||||
cp -fL "$APP_ROOT/$configured_path" "$shared_sqlite_path"
|
||||
log "Bootstrapped shared sqlite database from $APP_ROOT/$configured_path"
|
||||
else
|
||||
touch "$shared_sqlite_path"
|
||||
log "Created shared sqlite database at $shared_sqlite_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$release_sqlite_path"
|
||||
ln -sfn "$shared_sqlite_path" "$release_sqlite_path"
|
||||
ensure_writable_paths "$SHARED_DIR/database"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$shared_sqlite_path" ]; then
|
||||
touch "$shared_sqlite_path"
|
||||
log "Created shared sqlite database at $shared_sqlite_path"
|
||||
fi
|
||||
|
||||
ensure_writable_paths "$(dirname "$shared_sqlite_path")"
|
||||
}
|
||||
|
||||
log "Preparing release $STAMP"
|
||||
[ -f "$RELEASE_ARCHIVE" ] || { echo "Release archive not found: $RELEASE_ARCHIVE" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$NEW_RELEASE"
|
||||
tar -xzf "$RELEASE_ARCHIVE" -C "$NEW_RELEASE"
|
||||
chmod -R u+rwX "$NEW_RELEASE" 2>/dev/null || true
|
||||
|
||||
log "Linking shared paths"
|
||||
mkdir -p "$SHARED_DIR/storage/"{app/public,app/private,framework/{cache/data,sessions,testing,views},logs}
|
||||
mkdir -p "$SHARED_DIR/bootstrap-cache"
|
||||
normalize_shared_permissions
|
||||
|
||||
if bootstrap_shared_env; then
|
||||
ln -sfn "$SHARED_DIR/.env" "$NEW_RELEASE/.env"
|
||||
else
|
||||
echo "Missing deployment environment file. Expected $SHARED_DIR/.env or an existing $CURRENT_LINK/.env / $APP_ROOT/.env to bootstrap from." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$NEW_RELEASE/storage"
|
||||
ln -sfn "$SHARED_DIR/storage" "$NEW_RELEASE/storage"
|
||||
mkdir -p "$NEW_RELEASE/bootstrap"
|
||||
rm -rf "$NEW_RELEASE/bootstrap/cache"
|
||||
ln -sfn "$SHARED_DIR/bootstrap-cache" "$NEW_RELEASE/bootstrap/cache"
|
||||
normalize_shared_permissions
|
||||
|
||||
if uses_sqlite; then
|
||||
log "Preparing shared sqlite database"
|
||||
prepare_sqlite_database
|
||||
if id -u www-data >/dev/null 2>&1; then
|
||||
chgrp www-data "$SHARED_DIR/database" 2>/dev/null || true
|
||||
find "$SHARED_DIR/database" -type f -exec chgrp www-data {} + 2>/dev/null || true
|
||||
find "$SHARED_DIR/database" -type d -exec chgrp www-data {} + 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Installing PHP dependencies"
|
||||
if [ -f "$NEW_RELEASE/composer.json" ]; then
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
export COMPOSER_ALLOW_SUPERUSER=1
|
||||
fi
|
||||
export COMPOSER_HOME="${COMPOSER_HOME:-/home/deploy/.composer}"
|
||||
(
|
||||
cd "$NEW_RELEASE"
|
||||
composer install \
|
||||
--no-dev \
|
||||
--prefer-dist \
|
||||
--no-interaction \
|
||||
--no-progress \
|
||||
--optimize-autoloader \
|
||||
--classmap-authoritative
|
||||
)
|
||||
fi
|
||||
|
||||
log "Running migrations"
|
||||
if ! run_artisan "migrate --force"; then
|
||||
log "Migration failed — clearing shared bootstrap cache"
|
||||
run_artisan "optimize:clear" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
switch_current_release() {
|
||||
if ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Unable to update current release symlink at $CURRENT_LINK" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
log "Switching current release"
|
||||
if ! switch_current_release; then
|
||||
echo "Failed to point $CURRENT_LINK at $NEW_RELEASE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LIVE_REV="$(cat "$CURRENT_LINK/REVISION" 2>/dev/null || echo unknown)"
|
||||
log "Live release: $STAMP (revision $LIVE_REV)"
|
||||
|
||||
log "Optimizing Laravel"
|
||||
if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then
|
||||
(
|
||||
cd "$CURRENT_LINK"
|
||||
php artisan storage:link || true
|
||||
php artisan optimize:clear || true
|
||||
php artisan config:cache || true
|
||||
php artisan route:cache || true
|
||||
php artisan view:cache || true
|
||||
)
|
||||
fi
|
||||
|
||||
log "Reloading services"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload php8.4-fpm 2>/dev/null || sudo -n systemctl reload php8.4-fpm
|
||||
systemctl reload nginx 2>/dev/null || sudo -n systemctl reload nginx
|
||||
fi
|
||||
|
||||
log "Restarting queues"
|
||||
if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then
|
||||
(cd "$CURRENT_LINK" && php artisan queue:restart) || true
|
||||
fi
|
||||
if command -v supervisorctl >/dev/null 2>&1; then
|
||||
supervisorctl restart ladill-qr-plus-worker:* 2>/dev/null || true
|
||||
fi
|
||||
|
||||
log "Cleaning old releases"
|
||||
mapfile -t OLD_RELEASES < <(ls -1dt "$RELEASES_DIR"/* 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" || true)
|
||||
if [ "${#OLD_RELEASES[@]}" -gt 0 ]; then
|
||||
chmod -R u+rwX "${OLD_RELEASES[@]}" 2>/dev/null || true
|
||||
rm -rf "${OLD_RELEASES[@]}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
log "Deploy completed: $STAMP"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Replace `www-data` with the actual PHP-FPM/web user that runs the app.
|
||||
# Install as `/etc/sudoers.d/ladill-hosting` with mode `0440`.
|
||||
#
|
||||
# These helpers are root-owned fixed entry points installed by the deploy script.
|
||||
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-admin *
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-user *
|
||||
Generated
+2558
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alpinejs/collapse": "^3.15.12",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"alpinejs": "^3.15.12",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"qrcode-generator": "^2.0.4"
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_KEY" value="base64:2fl+KtvkdphvQyEfm3qXz2Bdm5fH3IOx4EoZ5/R4eJE="/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #7660e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #a8b621;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M249.9.2c-13,0-26,.2-39,0-12.5-.2-21.6,4.9-27.6,16-10.8,20.1-21.8,40-32.7,60C106.8,156.5,63.3,236.9,19,317.1c-10.9,19.7.4,43.1,25.5,42.9,25.8-.1,51.6,0,77.4,0,12.5,0,21.8-5.3,27.9-16.7,29.7-54.7,59.6-109.3,89.4-163.9,24.7-45.3,49-90.9,74.3-135.9,12.3-21.9-2.6-45-25.9-43.4-12.5.8-25.2.2-37.7.2Z"/>
|
||||
<path class="cls-2" d="M209.1,290.9c0,38.1,30.1,69.2,68.4,68.8,38.1-.4,68.1-28.6,68.6-68.2.4-42-33.9-69.2-68.2-69-38.5.3-68.7,29.9-68.7,68.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 821 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #7660e6;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16b5d7;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M61.4,0h29.9c9.7,0,16.5,2.3,21.3,7.7,8.2,9.6,16.8,19.2,25.1,28.7,33.7,38.6,67.3,77.3,101.4,115.9,8.2,9.6,0,20.8-19.4,20.6h-59.5c-9.7,0-16.8-2.6-21.7-7.9-22.8-26.4-46-52.6-68.8-78.7C50.6,64.5,31.9,42.6,12.4,20.8,3.1,10.3,14.7-.7,32.6,0,42.3.5,52.1,0,61.8,0h-.4Z"/>
|
||||
<path class="cls-2" d="M190.1,192.4c-7.7,9.7-15.8,19.5-23.5,29.2-31.6,39.2-63.3,78.5-95.2,117.7-7.7,9.7,0,21.1,18.3,20.9h55.9c9.1,0,15.8-2.6,20.4-8.1,19.8-24.8,39.9-49.4,59.8-73.9,1.4-1,2.7-2.2,3.9-3.6,21.4-26.8,43.2-53.4,64.7-80,17.9-22.1,35.5-44.4,53.8-66.4,8.8-10.7-2.1-21.8-19-21.1-9.1.5-18.3,0-27.4,0h-27.8c-9.1,0-15.5,2.4-20,7.8-7.7,9.7-15.8,19.5-23.5,29.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user