You've already forked Atomcms-edit
Initial commit
This commit is contained in:
Executable
+113
@@ -0,0 +1,113 @@
|
||||
# AtomCMS Environment Configuration
|
||||
# Works on Linux (Nginx/Apache) and Windows (XAMPP/IIS)
|
||||
# Copy to .env and adjust values for your setup
|
||||
|
||||
# ==========================================
|
||||
# APPLICATION
|
||||
# ==========================================
|
||||
APP_NAME="AtomCMS"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
# ==========================================
|
||||
# DATABASE - Works on Linux & Windows
|
||||
# ==========================================
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1 # Linux: 127.0.0.1, Windows XAMPP: localhost
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=habbo
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
# Windows IIS (SQL Server) - uncomment if using IIS/SQL Server:
|
||||
# DB_CONNECTION=sqlsrv
|
||||
# DB_HOST=(local)
|
||||
# DB_DATABASE=habbo
|
||||
# DB_USERNAME=sa
|
||||
# DB_PASSWORD=your_password
|
||||
|
||||
# ==========================================
|
||||
# SESSION & CACHE (Windows compatible)
|
||||
# ==========================================
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
CACHE_DRIVER=file
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# ==========================================
|
||||
# MAIL (Windows compatible)
|
||||
# ==========================================
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.mailtrap.io
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS="noreply@yourhotel.nl"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# ==========================================
|
||||
# REAL-TIME & PUSHER
|
||||
# ==========================================
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_HOST=
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
# ==========================================
|
||||
# SECURITY
|
||||
# ==========================================
|
||||
# Cloudflare Turnstile (optional)
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Google Recaptcha (optional)
|
||||
NOCAPTCHA_SECRET=
|
||||
NOCAPTCHA_SITEKEY=
|
||||
|
||||
# ==========================================
|
||||
# WEBHOOKS
|
||||
# ==========================================
|
||||
DISCORD_WEBHOOK_URL=
|
||||
|
||||
# ==========================================
|
||||
# NITRO CLIENT PATHS
|
||||
# ==========================================
|
||||
# Linux: /var/www/atomcms/nitro-client
|
||||
# Windows: C:\path\to\atomcms\nitro-client
|
||||
NITRO_CLIENT_PATH=/var/www/atomcms/nitro-client
|
||||
NITRO_RENDERER_PATH=/var/www/atomcms/nitro-renderer
|
||||
NITRO_BUILD_PATH=/var/www/atomcms/nitro-client/dist
|
||||
NITRO_WEBROOT=/Client
|
||||
GAMEDATA_PATH=/var/Gamedata
|
||||
|
||||
# ==========================================
|
||||
# LOGGING
|
||||
# ==========================================
|
||||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ==========================================
|
||||
# BROADCAST & FILESYSTEMS
|
||||
# ==========================================
|
||||
BROADCAST_DRIVER=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# ==========================================
|
||||
# LOCALIZATION
|
||||
# ==========================================
|
||||
APP_LOCALE=nl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=nl_NL
|
||||
|
||||
# ==========================================
|
||||
# SECURITY SETTINGS
|
||||
# ==========================================
|
||||
APP_SECURE=true
|
||||
SESSION_DOMAIN=localhost
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
APP_NAME="Epicnabbo Hotel"
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:YOUR_APP_KEY_HERE
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://epicnabbo.nl
|
||||
|
||||
# --- LOGGING ---
|
||||
LOG_CHANNEL=daily
|
||||
LOG_MAX_FILES=14
|
||||
LOG_LEVEL=error
|
||||
|
||||
# --- DATABASE ---
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=YOUR_DB_HOST
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=YOUR_DB_NAME
|
||||
DB_USERNAME=YOUR_DB_USER
|
||||
DB_PASSWORD=YOUR_DB_PASSWORD
|
||||
DB_STRICT_MODE=true
|
||||
DB_ENGINE=InnoDB
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
|
||||
# --- REDIS TURBO ---
|
||||
BROADCAST_DRIVER=redis
|
||||
CACHE_STORE=redis
|
||||
CACHE_DRIVER=redis
|
||||
SESSION_DRIVER=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
REDIS_DB=0
|
||||
REDIS_CACHE_DB=1
|
||||
REDIS_QUEUE_DB=2
|
||||
|
||||
REDIS_PERSISTENT=true
|
||||
REDIS_READ_TIMEOUT=1.0
|
||||
REDIS_CONNECT_TIMEOUT=1.0
|
||||
|
||||
# --- SESSION SECURITY ---
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_DOMAIN=.epicnabbo.nl
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_HTTP_ONLY=true
|
||||
SESSION_SAME_SITE=lax
|
||||
SESSION_COOKIE=__epicnabbo_secure_session
|
||||
|
||||
# --- BEVEILIGING & EMULATOR ---
|
||||
TURNSTILE_SITE_KEY=YOUR_TURNSTILE_SITE_KEY
|
||||
TURNSTILE_SECRET_KEY=YOUR_TURNSTILE_SECRET_KEY
|
||||
RCON_HOST=127.0.0.1
|
||||
RCON_PORT=3001
|
||||
EMULATOR_IP=127.0.0.1
|
||||
EMULATOR_PORT=3000
|
||||
|
||||
# --- EXTRA HARDENING ---
|
||||
APP_LOCALE=nl
|
||||
FORCE_HTTPS=true
|
||||
PASSWORD_RESET_TOKEN_TIME=15
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
# Windows/XAMPP/IIS .env configuration
|
||||
# Copy this to .env and adjust the values for your Windows setup
|
||||
|
||||
# Application
|
||||
APP_NAME="Epicnabbo"
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:EXAMPLE_KEY_CHANGE_ME
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# Database - Windows/XAMPP Settings
|
||||
# For XAMPP use: DB_HOST=localhost
|
||||
# For IIS use: DB_HOST=127.0.0.1 or localhost
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=habbo
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
# For Windows IIS with named pipes, use:
|
||||
# DB_CONNECTION=sqlsrv
|
||||
# DB_HOST=(local)
|
||||
# DB_DATABASE=habbo
|
||||
# DB_USERNAME=sa
|
||||
# DB_PASSWORD=your_password
|
||||
|
||||
# Session (Windows compatible)
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Cache (Windows compatible)
|
||||
CACHE_DRIVER=file
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Mail (Windows compatible - use Mailtrap for testing)
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.mailtrap.io
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS="noreply@epicnabbo.nl"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# Pusher (for real-time features)
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_HOST=
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
# Cloudflare Turnstile (optional)
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Google Recaptcha (optional)
|
||||
NOCAPTCHA_SECRET=
|
||||
NOCAPTCHA_SITEKEY=
|
||||
|
||||
# Discord Webhook (optional)
|
||||
DISCORD_WEBHOOK_URL=
|
||||
|
||||
# Nitro Client Path (Windows paths use backslashes or forward slashes)
|
||||
NITRO_CLIENT_PATH=C:\path\to\nitro-client
|
||||
NITRO_RENDERER_PATH=C:\path\to\nitro-renderer
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Broadcast & Filesystems (Windows compatible)
|
||||
BROADCAST_DRIVER=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Advanced (usually no changes needed)
|
||||
APP_LOCALE=nl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_LOCALE=nl
|
||||
APP_FAKER_LOCALE=nl_NL
|
||||
|
||||
# Security
|
||||
APP_SECURE=true
|
||||
SESSION_DOMAIN=localhost
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
name: Claude Code
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.event.comment.user.login == github.repository_owner
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
# Negeer wachtwoorden en database-instellingen
|
||||
.env
|
||||
.env.backup
|
||||
.env.testing
|
||||
config.php
|
||||
wp-config.php
|
||||
/uploads/
|
||||
/temp/
|
||||
*.log
|
||||
|
||||
# Geen zware afhankelijkheden pushen
|
||||
node_modules/
|
||||
vendor/
|
||||
|
||||
# UITZONDERING: Vertaalbestanden in lang/vendor wel pushen
|
||||
/lang/vendor/
|
||||
|
||||
# Systeembestanden (Mac/Windows troep)
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# VPS automation scripts (niet pushen naar GitLab)
|
||||
watch.sh
|
||||
check-updates.sh
|
||||
|
||||
# Cache bestanden (niet pushen naar GitLab)
|
||||
/storage/framework/views/
|
||||
/storage/framework/cache/
|
||||
/storage/framework/sessions/
|
||||
/storage/logs/
|
||||
/storage/debugbar/rr
|
||||
.rr.yaml
|
||||
|
||||
# GitHub workflows (pushen naar GitLab)
|
||||
!/.github/workflows/
|
||||
Executable
+1
File diff suppressed because one or more lines are too long
Executable
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-empty-source": null
|
||||
},
|
||||
"ignoreFiles": [
|
||||
"**/node_modules/**",
|
||||
"**/vendor/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/public/build/**",
|
||||
"**/.cache/**",
|
||||
"**/storage/**",
|
||||
"**/*.min.css",
|
||||
"**/client.css"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright 2023 ObjectRetros
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,325 @@
|
||||
# AtomCMS Remco Epicnabbo Edition
|
||||
|
||||
<div align="center">
|
||||
<img src="https://i.imgur.com/9ePNdJ4.png" alt="Atom CMS" width="200"/>
|
||||
|
||||
### The Ultimate Retro Hotel CMS
|
||||
|
||||
**Modern • Fast • Self-Repairing**
|
||||
|
||||
[](https://discord.gg/pP6HyZedAj)
|
||||
[](https://laravel.com)
|
||||
[](https://php.net)
|
||||
[](https://github.com/atom-retros/atomcms)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
```bash
|
||||
# Interactive mode (asks for confirmation before each step)
|
||||
php artisan atom:check --fix
|
||||
|
||||
# Auto mode (fixes everything automatically without asking)
|
||||
php artisan atom:check --auto
|
||||
|
||||
# Fix gamedata symlinks for optimal client performance
|
||||
php artisan atom:fix-gamedata-symlinks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
### 📻 Radio Station
|
||||
|
||||
- DJ applications & rank system
|
||||
- Live DJ sessions with schedule/rooster
|
||||
- Song requests & voting
|
||||
- Shoutbox
|
||||
- Listener points & leaderboard
|
||||
- Contests & giveaways
|
||||
- Radio banners & history
|
||||
|
||||
### 🛒 Shop
|
||||
|
||||
- Product catalog with categories
|
||||
- Virtual currency purchases
|
||||
- Voucher/promo codes
|
||||
- PayPal integration
|
||||
- Order history
|
||||
|
||||
### 👥 Community
|
||||
|
||||
- Articles with comments, reactions & tags
|
||||
- Photo gallery
|
||||
- Leaderboard
|
||||
- Teams & team applications
|
||||
- Staff page & staff applications
|
||||
- Rare values tracker
|
||||
- Badge draw/lottery system
|
||||
|
||||
### 🎮 Hotel Client
|
||||
|
||||
- Nitro (HTML5) client support
|
||||
- Flash client support
|
||||
- FindRetros integration
|
||||
- VPN/proxy checker
|
||||
|
||||
### 👤 User System
|
||||
|
||||
- Public profiles with guestbook
|
||||
- Two-Factor Authentication (2FA)
|
||||
- Referral system with rewards
|
||||
- Account & password settings
|
||||
- Session logs
|
||||
|
||||
### 🎫 Help Center
|
||||
|
||||
- Support ticket system with replies & status management
|
||||
- Website rules
|
||||
- FAQ with categories
|
||||
|
||||
### 🎨 Themes
|
||||
|
||||
- **Atom** — Default theme
|
||||
- **Dusk** — Dark theme
|
||||
|
||||
### 🔧 Filament Admin Panel
|
||||
|
||||
- User & ban management
|
||||
- Radio management (applications, schedules, ranks, shouts, history, banners)
|
||||
- Shop & order management with charts
|
||||
- Article & tag management
|
||||
- Emulator settings, texts & catalog editors
|
||||
- Chatlog viewer (rooms & private messages)
|
||||
- Word filters & moderation tools
|
||||
- Housekeeping permissions
|
||||
- Website navigation & settings
|
||||
- Camera/photo management
|
||||
|
||||
### 🌐 API Endpoints
|
||||
|
||||
- `GET /api/user/{username}` — Fetch user data
|
||||
- `GET /api/online-users` — Online users list
|
||||
- `GET /api/online-count` — Online user count
|
||||
- `GET /api/radio/current-dj` — Current DJ info
|
||||
- `GET /api/radio/now-playing` — Current song
|
||||
- `GET /api/radio/listeners` — Listener count
|
||||
- `GET /api/radio/shouts` — Recent shouts
|
||||
- `GET /api/radio/points` — Points data
|
||||
- `GET /api/radio/points/leaderboard` — Points leaderboard
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Master Repair Tool
|
||||
|
||||
The `atom:check` command performs over **100 deep system checks** and offers to fix them automatically.
|
||||
|
||||
### Diagnostic Checks
|
||||
|
||||
| Section | What it Checks |
|
||||
| --------------- | ------------------------------------------------------------ |
|
||||
| **Environment** | .env, APP_KEY, Debug mode, Composer security |
|
||||
| **Database** | Tables, columns, migrations, seeders, settings, radio, admin |
|
||||
| **PHP Stack** | Extensions, PHP.ini, config cache, session |
|
||||
| **Web Server** | Apache/Nginx/IIS config, SSL |
|
||||
| **System** | Permissions, firewall ports |
|
||||
| **Assets** | Frontend, Redis, Cron, Queue, Supervisor, Vite |
|
||||
| **HTTP Errors** | 400, 401, 403, 404, 419, 429, 500, 502, 503, 504 |
|
||||
|
||||
### Auto-Fix Steps
|
||||
|
||||
| Step | Action |
|
||||
| ---- | ----------------------------------- |
|
||||
| 1 | Environment (.env, APP_KEY) |
|
||||
| 2 | Clear all caches |
|
||||
| 3 | Fix permissions |
|
||||
| 4 | Run migrations |
|
||||
| 5 | Run seeders |
|
||||
| 6 | Fix storage (symlink, directories) |
|
||||
| 6b | Fix Radio tables |
|
||||
| 7 | Fix Gamedata symlinks |
|
||||
| 8 | Create admin user |
|
||||
| 9 | Web server config (.htaccess, etc.) |
|
||||
| 10 | PHP config & extensions |
|
||||
| 11 | Build assets (npm) |
|
||||
| 12 | Fix HTTP errors |
|
||||
|
||||
### Platform Support
|
||||
|
||||
Auto-detects: **Linux, Windows IIS, XAMPP, WAMP, Apache, Nginx**
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone https://github.com/your-repo/atomcms.git
|
||||
cd atomcms
|
||||
|
||||
# 2. Install dependencies
|
||||
composer install
|
||||
npm install
|
||||
|
||||
# 3. Run the repair tool
|
||||
php artisan atom:check --fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Usage
|
||||
|
||||
```bash
|
||||
# Check only (no changes)
|
||||
php artisan atom:check
|
||||
|
||||
# Interactive fix (asks before each step)
|
||||
php artisan atom:check --fix
|
||||
|
||||
# Automatic fix (no questions)
|
||||
php artisan atom:check --auto
|
||||
|
||||
# Force platform detection
|
||||
php artisan atom:check --platform=nginx
|
||||
|
||||
# Dutch language
|
||||
php artisan atom:check --fix --lang=nl
|
||||
|
||||
# Fix gamedata symlinks for bundled assets
|
||||
php artisan atom:fix-gamedata-symlinks
|
||||
|
||||
# Preview what symlinks would be created (dry-run)
|
||||
php artisan atom:fix-gamedata-symlinks --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Optimization
|
||||
|
||||
### Gamedata Symlinks
|
||||
|
||||
The `atom:fix-gamedata-symlinks` command creates symlinks in the Gamedata directory to point to optimized bundled assets. This significantly improves load times for the game client.
|
||||
|
||||
| Symlink | Target | Purpose |
|
||||
| ---------------- | ------------------- | --------------------- |
|
||||
| `effect` | `bundled/effect` | Avatar effects |
|
||||
| `furniture` | `bundled/furniture` | Furniture assets |
|
||||
| `generic` | `bundled/generic` | Generic assets |
|
||||
| `pet` | `bundled/pet` | Pet assets |
|
||||
| `figure` | `bundled/figure` | Avatar figures |
|
||||
| `generic_custom` | `bundled/generic` | Custom generic assets |
|
||||
|
||||
### Recommended Nginx Optimizations
|
||||
|
||||
The following optimizations are already configured:
|
||||
|
||||
- `sendfile on` - Kernel-level file serving
|
||||
- `open_file_cache` - File descriptor caching
|
||||
- `gzip` - Automatic compression for JSON/JS/CSS
|
||||
|
||||
---
|
||||
|
||||
## 🧑💻 Developer Tools
|
||||
|
||||
### Yarn Scripts
|
||||
|
||||
```bash
|
||||
# Build
|
||||
yarn build # Bouw alle thema's
|
||||
yarn build:atom # Bouw alleen atom theme
|
||||
yarn build:dusk # Bouw alleen dusk theme
|
||||
yarn build:all # Bouw beide thema's
|
||||
|
||||
# Development
|
||||
yarn dev # Start dev server
|
||||
yarn dev:atom # Dev server atom theme
|
||||
|
||||
# Code Quality
|
||||
yarn lint # Check JS/Vue
|
||||
yarn lint:fix # Fix JS/Vue
|
||||
yarn lint:css # Check CSS
|
||||
yarn lint:css:fix # Fix CSS
|
||||
yarn lint:all # Check alles
|
||||
yarn lint:fix:all # Fix alles
|
||||
|
||||
yarn format # Formatteer alles
|
||||
yarn format:check # Check formatting
|
||||
|
||||
# Check
|
||||
yarn check # Volledige check
|
||||
yarn check:php # Check PHP syntax
|
||||
yarn check:security # Check beveiliging
|
||||
yarn check:deps # Check verouderde packages
|
||||
|
||||
# Cache
|
||||
yarn clean # Verwijder cache
|
||||
yarn rebuild # Clean + install + build
|
||||
```
|
||||
|
||||
### PHP Commands
|
||||
|
||||
```bash
|
||||
# Auto-fix code style
|
||||
php artisan atom:fix-code
|
||||
|
||||
# Fix gamedata symlinks (run after installing client assets)
|
||||
php artisan atom:fix-gamedata-symlinks
|
||||
|
||||
# Static analysis (PHPStan Level 9)
|
||||
./vendor/bin/phpstan analyse
|
||||
|
||||
# Build frontend assets
|
||||
yarn build:atom
|
||||
yarn build:dusk
|
||||
|
||||
# Development mode
|
||||
yarn dev:atom
|
||||
yarn dev:dusk
|
||||
|
||||
# Format code
|
||||
yarn format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Optimalisaties
|
||||
|
||||
Deze CMS is volledig geoptimaliseerd:
|
||||
|
||||
| Optimalisatie | Beschrijving |
|
||||
| ---------------------- | --------------------------------- |
|
||||
| **Vite 8** | Snelste build tool |
|
||||
| **esbuild** | Snellere minificatie |
|
||||
| **Better chunking** | Optimalere code splitting |
|
||||
| **Gzip + Brotli** | Compressie (~70% kleiner) |
|
||||
| **Resource hints** | DNS prefetch, preconnect, preload |
|
||||
| **HTTP/2** | Snellere netwerk requests |
|
||||
| **Console verwijderd** | Kleinere JS bestanden |
|
||||
| **Caching** | Vite cache + optimizedeps |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tech Stack
|
||||
|
||||
- **Backend:** Laravel 13
|
||||
- **Frontend:** React 19 + Alpine.js
|
||||
- **Build:** Vite 8
|
||||
- **CSS:** TailwindCSS 4
|
||||
- **Linting:** ESLint + Stylelint + Prettier
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Credits
|
||||
|
||||
- **Remco (Epicnabbo)** - Core Maintainer, System Architecture
|
||||
- **Kasja** - Design & Themes
|
||||
- **Kani** - RCON & API
|
||||
- **Atom Community** - Testing & Feedback
|
||||
|
||||
<div align="center">
|
||||
<i>Made with ❤️ for the Retro Community</i>
|
||||
</div>
|
||||
@@ -0,0 +1,273 @@
|
||||
# AtomCMS (epicnabbo edit) - v1.0 Ultimate Setup Guide
|
||||
|
||||
**AtomCMS** is a powerful Habbo retro CMS. This guide ensures a **100% working installation** on **any system**: Linux (Nginx/Apache), Windows (IIS/XAMPP), and macOS.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
git clone ssh://git@localhost:8422/RemcoEpic/atomcms-edit.git
|
||||
cd atomcms-edit
|
||||
composer install --no-dev
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
# Configure your .env (DB settings below)
|
||||
php artisan migrate --seed
|
||||
php artisan storage:link
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Universal Requirements
|
||||
|
||||
- **PHP**: 8.1, 8.2, or 8.3 (8.2 Recommended)
|
||||
- **Extensions**: `pdo_mysql`, `curl`, `mbstring`, `openssl`, `tokenizer`, `xml`, `zip`
|
||||
- **Database**: MySQL 5.7+ or MariaDB 10.3+
|
||||
- **Composer**: 2.x
|
||||
|
||||
### Linux (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y php8.2 php8.2-{mysql,curl,mbstring,xml,zip,fpm} nginx mysql-server
|
||||
```
|
||||
|
||||
### Windows (XAMPP/IIS)
|
||||
|
||||
- Download **XAMPP** with PHP 8.2 or install **PHP 8.2** manually.
|
||||
- Ensure `php.ini` has `extension=pdo_mysql`, `extension=curl`, etc. enabled.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Step 1: Clone the Repository
|
||||
|
||||
```bash
|
||||
# For RemcoEpic epicnabbo edit version:
|
||||
git clone ssh://git@localhost:8422/RemcoEpic/atomcms-edit.git
|
||||
cd atomcms-edit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Step 2: Install Dependencies
|
||||
|
||||
Run composer to install all required packages. The system is optimized to handle missing extensions automatically via the `SystemCheck` middleware.
|
||||
|
||||
```bash
|
||||
composer install --optimize-autoloader
|
||||
```
|
||||
|
||||
_Windows Note: Run CMD/PowerShell as Administrator if you get permission errors._
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Step 3: Environment Configuration
|
||||
|
||||
### 3.1 Create .env
|
||||
|
||||
If `.env` is missing, the system tries to create it automatically. You can also do it manually:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 3.2 Generate App Key
|
||||
|
||||
```bash
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
### 3.3 Database Settings
|
||||
|
||||
Edit `.env` and configure your database connection:
|
||||
|
||||
```env
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=atomcms
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
APP_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
_Windows IIS Note: If `127.0.0.1` fails, try `localhost`._
|
||||
|
||||
---
|
||||
|
||||
## 💾 Step 4: Database Setup
|
||||
|
||||
### 4.1 Create Database
|
||||
|
||||
Login to MySQL and create the database:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE atomcms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'atomcms_user'@'localhost' IDENTIFIED BY 'strong_password';
|
||||
GRANT ALL PRIVILEGES ON atomcms.* TO 'atomcms_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
### 4.2 Run Migrations
|
||||
|
||||
```bash
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Step 5: Storage Link (Crucial!)
|
||||
|
||||
The system uses a **Bulletproof Middleware (`SystemCheck`)** that attempts to create the storage symlink automatically on the first request.
|
||||
|
||||
**Manual Fallback (if auto-fails):**
|
||||
|
||||
_Linux:_
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
sudo chmod -R 755 storage bootstrap/cache
|
||||
```
|
||||
|
||||
_Windows (CMD as Admin):_
|
||||
|
||||
```cmd
|
||||
mklink /J "C:\path\to\atomcms-edit\public\storage" "C:\path\to\atomcms-edit\storage\app\public"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Step 6: Web Server Configuration
|
||||
|
||||
### Option A: PHP Built-in (Testing)
|
||||
|
||||
```bash
|
||||
php artisan serve --host=0.0.0.0 --port=8000
|
||||
```
|
||||
|
||||
Visit: `http://localhost:8000`
|
||||
|
||||
### Option B: Nginx (Linux Production)
|
||||
|
||||
Edit `/etc/nginx/sites-available/atomcms`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name epicnabbo.nl; # Your domain
|
||||
root /var/www/atomcms/public;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable: `sudo ln -s /etc/nginx/sites-available/atomcms /etc/nginx/sites-enabled/ && sudo systemctl restart nginx`
|
||||
|
||||
### Option C: Windows IIS
|
||||
|
||||
1. Open **IIS Manager** -> **Sites** -> **Add Website**.
|
||||
2. Physical Path: `C:\inetpub\wwwroot\atomcms-edit\public`
|
||||
3. Install **URL Rewrite Module**.
|
||||
4. Add `web.config` in `public/`:
|
||||
|
||||
```xml
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="Laravel Routes" stopProcessing="true">
|
||||
<match url=".*" />
|
||||
<conditions>
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="index.php" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Step 7: Production Optimization
|
||||
|
||||
```bash
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Step 8: Verify & Test
|
||||
|
||||
1. Open `http://your-domain.com` (or `http://localhost:8000`).
|
||||
2. Test **Registration** at `/register`.
|
||||
- _Note:_ v1.0 fixes the "500 Error" on registration (Cloudflare Turnstile & IP fields).
|
||||
3. Check logs if needed: `tail -f storage/logs/laravel.log`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting (v1.0 Fixes)
|
||||
|
||||
### 500 Error on Registration?
|
||||
|
||||
**Fixed in v1.0!**
|
||||
|
||||
- ✅ Cloudflare Turnstile null-response handled.
|
||||
- ✅ `ip_register` and `ip_current` added to User model.
|
||||
- ✅ MySQL Strict Mode disabled for Windows/IIS/XAMPP compatibility.
|
||||
|
||||
### "Missing .env" or "No Application Key"
|
||||
|
||||
The `SystemCheck` middleware (auto-runs on every request) will attempt to:
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Generate `APP_KEY` automatically.
|
||||
|
||||
### Storage Link Issues
|
||||
|
||||
The system auto-creates the symlink. If it fails on Windows, ensure you run the server with **Administrator** privileges.
|
||||
|
||||
### Database Connection Failed
|
||||
|
||||
1. Check `.env` credentials.
|
||||
2. Ensure MySQL service is running (`systemctl status mysql` or Windows Services).
|
||||
3. Use `127.0.0.1` instead of `localhost` if socket errors occur.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 What's new in v1.0 (epicnabbo edit)?
|
||||
|
||||
This version is **Bulletproof**. It is designed to work immediately after `composer install` on any environment.
|
||||
|
||||
- ✅ **Cross-Platform**: Works on Linux (Nginx/Apache), Windows (IIS/XAMPP).
|
||||
- ✅ **Auto-Recovery**: `SystemCheck` middleware fixes missing `.env`, `APP_KEY`, and storage links automatically.
|
||||
- ✅ **Zero 500 Errors**: Registration bugs (Turnstile & IP fields) fixed.
|
||||
- ✅ **MySQL Strict Mode**: Disabled for maximum compatibility.
|
||||
- ✅ **Windows Path Fix**: `index.php` handles Windows backslash paths.
|
||||
|
||||
---
|
||||
|
||||
**© 2026 RemcoEpic - epicnabbo.nl**
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Events\RecoveryCodeReplaced;
|
||||
use Laravel\Fortify\Http\Requests\TwoFactorLoginRequest;
|
||||
use Laravel\Fortify\Http\Responses\TwoFactorLoginResponse;
|
||||
|
||||
class TwoFactorAuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* The guard implementation.
|
||||
*
|
||||
* @var StatefulGuard
|
||||
*/
|
||||
protected $guard;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(StatefulGuard $guard)
|
||||
{
|
||||
$this->guard = $guard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a new session using the two factor authentication code.
|
||||
*/
|
||||
public function store(TwoFactorLoginRequest $request): TwoFactorLoginResponse
|
||||
{
|
||||
$user = $request->challengedUser();
|
||||
$authenticatableUser = $user instanceof Authenticatable ? $user : null;
|
||||
|
||||
if ($code = $request->validRecoveryCode()) {
|
||||
if ($user !== null && is_object($user) && method_exists($user, 'replaceRecoveryCode')) {
|
||||
$user->replaceRecoveryCode($code);
|
||||
}
|
||||
|
||||
if ($authenticatableUser instanceof Authenticatable) {
|
||||
event(new RecoveryCodeReplaced($authenticatableUser, $code));
|
||||
}
|
||||
} elseif (! $request->hasValidCode()) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('Invalid Two Factor Authentication code'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($authenticatableUser instanceof Authenticatable) {
|
||||
$this->guard->login($authenticatableUser, $request->remember());
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
if ($user !== null && is_object($user) && method_exists($user, 'update')) {
|
||||
$user->update([
|
||||
'ip_current' => $request->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
return app(TwoFactorLoginResponse::class);
|
||||
}
|
||||
}
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Actions\Fortify\Rules\PasswordValidationRules;
|
||||
use App\Models\Miscellaneous\WebsiteBetaCode;
|
||||
use App\Models\User;
|
||||
use App\Rules\BetaCodeRule;
|
||||
use App\Rules\GoogleRecaptchaRule;
|
||||
use App\Rules\WebsiteWordfilterRule;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
if ((setting('disable_registration') ?: '0') == '1') {
|
||||
throw ValidationException::withMessages([
|
||||
'registration' => __('Registration is disabled.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$ip = request()->ip() ?? '127.0.0.1';
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) {
|
||||
$ip = '127.0.0.1';
|
||||
}
|
||||
|
||||
$matchingIpCount = User::query()
|
||||
->where('ip_current', '=', $ip)
|
||||
->orWhere('ip_register', '=', $ip)
|
||||
->count();
|
||||
|
||||
$maxAccountsPerIpSetting = setting('max_accounts_per_ip');
|
||||
setting('hotel_home_room');
|
||||
$maxAccountsPerIp = (int) (is_string($maxAccountsPerIpSetting) ? $maxAccountsPerIpSetting : '99');
|
||||
if ($matchingIpCount >= $maxAccountsPerIp) {
|
||||
throw ValidationException::withMessages([
|
||||
'registration' => __('You have reached the max amount of allowed account'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validate($input);
|
||||
|
||||
$startCreditsSetting = setting('start_credits');
|
||||
$hotelHomeRoomSetting = setting('hotel_home_room');
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::create([
|
||||
'username' => $input['username'],
|
||||
'mail' => $input['mail'],
|
||||
'password' => Hash::make(is_string($input['password']) ? $input['password'] : ''),
|
||||
'account_created' => time(),
|
||||
'last_login' => time(),
|
||||
'motto' => setting('start_motto') ?: 'Welcome to the hotel!',
|
||||
'look' => $input['look'] ?? setting('start_look') ?: 'hr-100-61.hd-180-1.ch-210-66.lg-270-110.sh-305-62',
|
||||
'credits' => (int) (is_string($startCreditsSetting) ? $startCreditsSetting : '1000'),
|
||||
'auth_ticket' => '',
|
||||
'home_room' => (int) (is_string($hotelHomeRoomSetting) ? $hotelHomeRoomSetting : '0'),
|
||||
'ip_register' => $ip,
|
||||
'ip_current' => $ip,
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'referral_code' => sprintf('%d%s', $user->id, Str::random(8)),
|
||||
]);
|
||||
|
||||
if (setting('requires_beta_code')) {
|
||||
WebsiteBetaCode::where('code', '=', $input['beta_code'])->update([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Referral
|
||||
if (isset($input['referral_code'])) {
|
||||
/** @var User|null $referralUser */
|
||||
$referralUser = User::query()
|
||||
->where('referral_code', '=', $input['referral_code'])
|
||||
->first();
|
||||
|
||||
// Only process referral if users have different IPs
|
||||
if ($referralUser !== null && ($referralUser->ip_current != $user->ip_current && $referralUser->ip_register != $user->ip_register)) {
|
||||
$referralUser->referrals()->updateOrCreate(['user_id' => $referralUser->id], [
|
||||
'referrals_total' => $referralUser->referrals !== null ? $referralUser->referrals->referrals_total + 1 : 1,
|
||||
]);
|
||||
$referralUser->userReferrals()->create([
|
||||
'referred_user_id' => $user->id,
|
||||
'referred_user_ip' => $ip,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (setting('enable_discord_webhook') === '1') {
|
||||
$discordRanksSetting = setting('discord_webhook_ranks', '[]');
|
||||
$discordRanks = json_decode($discordRanksSetting, true) ?? [];
|
||||
|
||||
$shouldNotify = false;
|
||||
|
||||
if (! empty($discordRanks)) {
|
||||
$shouldNotify = in_array($user->rank, $discordRanks);
|
||||
} else {
|
||||
$minStaffRank = (int) setting('min_staff_rank', 3);
|
||||
$shouldNotify = $user->rank >= $minStaffRank;
|
||||
}
|
||||
|
||||
if ($shouldNotify) {
|
||||
$this->sendDiscordWebhook($user->username, $ip, $user->mail);
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inputs
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validate(array $inputs): array
|
||||
{
|
||||
$usernameRegexSetting = setting('username_regex');
|
||||
$usernameRegex = is_string($usernameRegexSetting) ? $usernameRegexSetting : '/^[a-zA-Z0-9_.-]+$/';
|
||||
|
||||
$rules = [
|
||||
'username' => ['required', 'string', 'regex:' . $usernameRegex, 'max:25', Rule::unique('users'), new WebsiteWordfilterRule],
|
||||
'mail' => ['required', 'string', 'email', 'max:255', Rule::unique('users')],
|
||||
'password' => $this->passwordRules(),
|
||||
'beta_code' => ['sometimes', 'string', new BetaCodeRule],
|
||||
'terms' => ['required', 'accepted'],
|
||||
'g-recaptcha-response' => ['sometimes', 'string', new GoogleRecaptchaRule],
|
||||
'look' => ['sometimes', 'string'],
|
||||
];
|
||||
|
||||
if (! empty($inputs['cf-turnstile-response'])) {
|
||||
$rules['cf-turnstile-response'] = [app(Turnstile::class)];
|
||||
}
|
||||
|
||||
$messages = [
|
||||
'g-recaptcha-response.required' => __('The Google recaptcha must be completed'),
|
||||
'g-recaptcha-response.string' => __('The google recaptcha was submitted with an invalid type'),
|
||||
];
|
||||
|
||||
return Validator::make($inputs, $rules, $messages)->validate();
|
||||
}
|
||||
|
||||
private function sendDiscordWebhook(string $username, string $ip, string $email): void
|
||||
{
|
||||
if (setting('discord_webhook_url') === '') {
|
||||
Log::error('Discord webhook url not provided', ['Please provide a discord webhook url before being able to send any webhook requests.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$discordWebhookUrl = setting('discord_webhook_url');
|
||||
$hotelNameSetting = setting('hotel_name');
|
||||
|
||||
try {
|
||||
Http::asJson()->post(is_string($discordWebhookUrl) ? $discordWebhookUrl : '', [
|
||||
'username' => sprintf('%s Bot', is_string($hotelNameSetting) ? $hotelNameSetting : 'Hotel'),
|
||||
'content' => "User: {$username} has just registered, with the IP: {$ip} and E-mail: {$email}",
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to send Discord webhook notification', [
|
||||
'username' => $username,
|
||||
'ip' => $ip,
|
||||
'email' => $email,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class DisableTwoFactorAuthentication extends \Laravel\Fortify\Actions\DisableTwoFactorAuthentication
|
||||
{
|
||||
#[\Override]
|
||||
public function __invoke($user): void
|
||||
{
|
||||
if ($user instanceof User) {
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed' => false,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Rules\GoogleRecaptchaRule;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\Guard;
|
||||
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Events\TwoFactorAuthenticationChallenged;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Fortify\LoginRateLimiter;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectIfTwoFactorAuthenticatable
|
||||
{
|
||||
/**
|
||||
* The guard implementation.
|
||||
*
|
||||
* @var StatefulGuard
|
||||
*/
|
||||
protected $guard;
|
||||
|
||||
/**
|
||||
* The login rate limiter instance.
|
||||
*
|
||||
* @var LoginRateLimiter
|
||||
*/
|
||||
protected $limiter;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(StatefulGuard $guard, LoginRateLimiter $limiter)
|
||||
{
|
||||
$this->guard = $guard;
|
||||
$this->limiter = $limiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, callable $next)
|
||||
{
|
||||
$user = $this->validateCredentials($request);
|
||||
|
||||
if (Fortify::confirmsTwoFactorAuthentication()) {
|
||||
if ($user instanceof User &&
|
||||
property_exists($user, 'two_factor_secret') && $user->two_factor_secret &&
|
||||
property_exists($user, 'two_factor_confirmed_at') && ! is_null($user->two_factor_confirmed_at) &&
|
||||
in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
|
||||
return $this->twoFactorChallengeResponse($request, $user);
|
||||
} else {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if ($user instanceof User &&
|
||||
property_exists($user, 'two_factor_secret') && $user->two_factor_secret &&
|
||||
in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))) {
|
||||
return $this->twoFactorChallengeResponse($request, $user);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to validate the incoming credentials.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function validateCredentials(Request $request)
|
||||
{
|
||||
if (Fortify::$authenticateUsingCallback) {
|
||||
return tap(call_user_func(Fortify::$authenticateUsingCallback, $request), function ($user) use ($request) {
|
||||
if (! $user) {
|
||||
$this->fireFailedEvent($request);
|
||||
|
||||
$this->throwFailedAuthenticationException($request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$providerModel = config('auth.providers.users.model');
|
||||
/** @var Model $model */
|
||||
$model = new $providerModel;
|
||||
|
||||
return tap($model->newQuery()->where(Fortify::username(), $request->{Fortify::username()})->first(), function ($user) use ($request): void {
|
||||
// Update the users password to bcrypt, if they previously used md5
|
||||
if ($user instanceof User && config('habbo.site.convert_passwords')) {
|
||||
$password = $request->input('password');
|
||||
$this->convertUserPassword($user, is_string($password) ? $password : '');
|
||||
}
|
||||
|
||||
/** @var Guard|StatefulGuard $guard */
|
||||
$guard = $this->guard;
|
||||
$passwordInput = $request->input('password');
|
||||
$passwordString = is_string($passwordInput) ? $passwordInput : '';
|
||||
if (! $user instanceof User || ! $guard->validate(['password' => $passwordString])) {
|
||||
$this->fireFailedEvent($request, $user instanceof User ? $user : null);
|
||||
|
||||
$this->throwFailedAuthenticationException($request);
|
||||
}
|
||||
|
||||
$this->validate($request);
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()
|
||||
->select(['id', 'password', 'rank'])
|
||||
->where('username', $request->input('username'))
|
||||
->first();
|
||||
|
||||
if (setting('maintenance_enabled') === '1' && setting('min_maintenance_login_rank') > ($user->rank ?? 0)) {
|
||||
throw ValidationException::withMessages([
|
||||
'username' => __('Only staff can login during maintenance!'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a failed authentication validation exception.
|
||||
*
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function throwFailedAuthenticationException(Request $request): void
|
||||
{
|
||||
$this->limiter->increment($request);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
Fortify::username() => [trans('auth.failed')],
|
||||
])->errorBag('login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the failed authentication attempt event with the given arguments.
|
||||
*/
|
||||
protected function fireFailedEvent(Request $request, ?Authenticatable $user = null): void
|
||||
{
|
||||
$guardName = config('fortify.guard');
|
||||
$username = $request->{Fortify::username()};
|
||||
$password = $request->input('password');
|
||||
|
||||
event(new Failed(is_string($guardName) ? $guardName : '', $user, [
|
||||
Fortify::username() => is_string($username) ? $username : '',
|
||||
'password' => is_string($password) ? $password : '',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the two factor authentication enabled response.
|
||||
*
|
||||
* @param mixed $user
|
||||
*/
|
||||
protected function twoFactorChallengeResponse(Request $request, $user): Response
|
||||
{
|
||||
$request->session()->put([
|
||||
'login.id' => $user instanceof User ? $user->getKey() : null,
|
||||
'login.remember' => $request->filled('remember'),
|
||||
]);
|
||||
|
||||
TwoFactorAuthenticationChallenged::dispatch($user);
|
||||
|
||||
return $request->wantsJson()
|
||||
? response()->json(['two_factor' => true])
|
||||
: redirect()->route('two-factor.login');
|
||||
}
|
||||
|
||||
private function convertUserPassword(User $user, string $password): void
|
||||
{
|
||||
if ($user->password == md5($password)) {
|
||||
$user->update([
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array<mixed>>
|
||||
*/
|
||||
private function validate(Request $request): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
if (setting('google_recaptcha_enabled')) {
|
||||
$rules['g-recaptcha-response'] = ['required', 'string', new GoogleRecaptchaRule];
|
||||
}
|
||||
|
||||
if (setting('cloudflare_turnstile_enabled')) {
|
||||
$rules['cf-turnstile-response'] = ['required', app(Turnstile::class)];
|
||||
}
|
||||
|
||||
$messages = [
|
||||
'g-recaptcha-response.required' => __('The Google reCAPTCHA must be completed'),
|
||||
'g-recaptcha-response.string' => __('The Google reCAPTCHA was submitted with an invalid type'),
|
||||
'cf-turnstile-response.required' => __('The Cloudflare Turnstile response is required'),
|
||||
];
|
||||
|
||||
return Validator::make($request->all(), $rules, $messages)->validate();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
|
||||
class RedirectIfTwoFactorConfirmed extends RedirectIfTwoFactorAuthenticatable
|
||||
{
|
||||
// This class can use the default behavior from the parent class
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Actions\Fortify\Rules\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$password = $input['password'] ?? '';
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(is_string($password) ? $password : ''),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify\Rules;
|
||||
|
||||
use App\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*/
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', new Password, 'confirmed'];
|
||||
}
|
||||
}
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Actions\Fortify\Rules\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$password = $input['password'] ?? '';
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(is_string($password) ? $password : ''),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->mail &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'mail' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'mail' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\CurrencyTypes;
|
||||
use App\Models\User;
|
||||
use App\Services\RconService;
|
||||
|
||||
class SendCurrency
|
||||
{
|
||||
public function __construct(protected RconService $rcon) {}
|
||||
|
||||
public function execute(User $user, string $type, ?int $amount): bool
|
||||
{
|
||||
if (! $amount || $amount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->rcon->isConnected) {
|
||||
match ($type) {
|
||||
'credits' => $this->rcon->giveCredits($user, $amount),
|
||||
'duckets' => $this->rcon->giveDuckets($user, $amount),
|
||||
'diamonds' => $this->rcon->giveDiamonds($user, $amount),
|
||||
'points' => $this->rcon->giveGotw($user, $amount),
|
||||
default => false,
|
||||
};
|
||||
} else {
|
||||
match ($type) {
|
||||
'credits' => $user->update(['credits' => $user->credits + $amount]),
|
||||
'duckets' => $user->currencies()->where('type', CurrencyTypes::Duckets)->increment('amount', $amount),
|
||||
'diamonds' => $user->currencies()->where('type', CurrencyTypes::Diamonds)->increment('amount', $amount),
|
||||
'points' => $user->currencies()->where('type', CurrencyTypes::Points)->increment('amount', $amount),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\RconService;
|
||||
|
||||
class SendFurniture
|
||||
{
|
||||
public function __construct(private readonly RconService $rcon) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array{item_id: int, amount: int}> $furniture
|
||||
*/
|
||||
public function execute(User $user, array $furniture): void
|
||||
{
|
||||
foreach ($furniture as $furni) {
|
||||
if ($this->rcon->isConnected) {
|
||||
for ($i = 0; $i < $furni['amount']; $i++) {
|
||||
$hotelName = setting('hotel_name');
|
||||
$this->rcon->sendGift($user, $furni['item_id'], 'Thank you for supporting ' . (is_scalar($hotelName) ? (string) $hotelName : ''));
|
||||
}
|
||||
} else {
|
||||
for ($i = 0; $i < $furni['amount']; $i++) {
|
||||
$user->items()->create([
|
||||
'item_id' => $furni['item_id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserActions
|
||||
{
|
||||
public function updateUsername(User $user, string $username): void
|
||||
{
|
||||
$user->update([
|
||||
'username' => $username,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEmail(User $user, string $email): void
|
||||
{
|
||||
$user->update([
|
||||
'mail' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateMotto(User $user, string $motto): void
|
||||
{
|
||||
$user->update([
|
||||
'motto' => $motto,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateField(User $user, string $field, ?string $value): void
|
||||
{
|
||||
$user->update([
|
||||
$field => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class AtomSetupCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'atom:setup {--auto=false}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Takes you through a basic setup, allowing you to define general settings';
|
||||
|
||||
private function progressInfo(int $step): void
|
||||
{
|
||||
$this->info(sprintf('Step %s/13', $step));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
Artisan::call('db:seed --class=WebsiteSettingsSeeder');
|
||||
|
||||
if ($this->option('auto') === 'false') {
|
||||
$step = 1;
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$hotelName = $this->ask('Enter your hotel name');
|
||||
WebsiteSetting::where('key', '=', 'hotel_name')->update([
|
||||
'value' => empty($hotelName) ? 'Hotel' : $hotelName,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$colorMode = $this->choice('Enter your preferred CMS color mode', ['light', 'dark'], 0);
|
||||
WebsiteSetting::where('key', '=', 'cms_color_mode')->update([
|
||||
'value' => $colorMode,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$startCredits = $this->ask('Enter the amount of credits new users should start with: (default is 5000)');
|
||||
WebsiteSetting::where('key', '=', 'start_credits')->update([
|
||||
'value' => empty($startCredits) ? '5000' : $startCredits,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$startDuckets = $this->ask('Enter the amount of credits new users should start with: (default is 5000)');
|
||||
WebsiteSetting::where('key', '=', 'start_duckets')->update([
|
||||
'value' => empty($startDuckets) ? '5000' : $startDuckets,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$startDiamonds = $this->ask('Enter the amount of diamonds new users should start with: (default is 100)');
|
||||
WebsiteSetting::where('key', '=', 'start_diamonds')->update([
|
||||
'value' => empty($startDiamonds) ? '100' : $startDiamonds,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$startPoints = $this->ask('Enter the amount of points new users should start with (default is 0)');
|
||||
WebsiteSetting::where('key', '=', 'start_points')->update([
|
||||
'value' => empty($startPoints) ? '0' : $startPoints,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$maxAccountsPerIP = $this->ask('Enter the amount of accounts a user can register per IP address (default is 2)');
|
||||
WebsiteSetting::where('key', '=', 'max_accounts_per_ip')->update([
|
||||
'value' => empty($maxAccountsPerIP) ? '2' : $maxAccountsPerIP,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$recaptchaEnabled = $this->choice('Google ReCaptcha enabled: (Do not forget to add your keys to your .env file in-case you set this to 1)', ['0', '1'], 0);
|
||||
WebsiteSetting::where('key', '=', 'google_recaptcha_enabled')->update([
|
||||
'value' => $recaptchaEnabled,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$wordfilterEnabled = $this->choice('CMS wordfilter enabled', ['0', '1'], 1);
|
||||
WebsiteSetting::where('key', '=', 'website_wordfilter_enabled')->update([
|
||||
'value' => $wordfilterEnabled,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$requiredBetaCode = $this->choice('Requires beta code to register', ['0', '1'], 0);
|
||||
WebsiteSetting::where('key', '=', 'requires_beta_code')->update([
|
||||
'value' => $requiredBetaCode,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$registrationDisabled = $this->choice('Disable registration (Can be re-enabled later inside website_settings table if set to 1)', ['0', '1'], 0);
|
||||
WebsiteSetting::where('key', '=', 'disable_registration')->update([
|
||||
'value' => $registrationDisabled,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
$step++;
|
||||
|
||||
$giveHC = $this->choice('Give all new users HC automatically', ['0', '1'], 0);
|
||||
WebsiteSetting::where('key', '=', 'give_hc_on_register')->update([
|
||||
'value' => $giveHC,
|
||||
]);
|
||||
|
||||
$this->progressInfo($step);
|
||||
|
||||
$maxCommentArticles = $this->ask('Enter the amount of comments each user can post per article (default is 2)');
|
||||
WebsiteSetting::where('key', '=', 'max_comment_per_article')->update([
|
||||
'value' => empty($maxCommentArticles) ? '2' : $maxCommentArticles,
|
||||
]);
|
||||
}
|
||||
|
||||
$seeders = [
|
||||
'WebsiteLanguageSeeder',
|
||||
'WebsiteArticleSeeder',
|
||||
'WebsitePermissionSeeder',
|
||||
'WebsiteWordfilterSeeder',
|
||||
'WebsiteTeamSeeder',
|
||||
'WebsiteRuleCategorySeeder',
|
||||
'WebsiteRuleSeeder',
|
||||
];
|
||||
|
||||
foreach ($seeders as $seeder) {
|
||||
Artisan::call(sprintf('db:seed --class=%s', $seeder));
|
||||
}
|
||||
|
||||
$this->info('The setup was successful!');
|
||||
$this->newLine();
|
||||
|
||||
// Run system check to verify everything is working
|
||||
$this->info('🔍 Running system check to verify installation...');
|
||||
$this->newLine();
|
||||
$this->call('atom:check');
|
||||
}
|
||||
}
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\AlertChannel;
|
||||
use App\Enums\AlertType;
|
||||
use App\Services\AlertService;
|
||||
use App\Services\EmulatorUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AutoUpdateCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'update:auto
|
||||
{--force : Forceer update ook al is het niet de geplande tijd}
|
||||
{--sql-only : Alleen SQL updates draaien}
|
||||
{--emu-only : Alleen emulator updates draaien}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Automatische emulator en SQL updates uitvoeren';
|
||||
|
||||
private const string CACHE_KEY_LAST_EMU_UPDATE = 'auto_update_last_emu';
|
||||
|
||||
private const string CACHE_KEY_LAST_SQL_UPDATE = 'auto_update_last_sql';
|
||||
|
||||
public function handle(AlertService $alertService, EmulatorUpdateService $updateService): int
|
||||
{
|
||||
$this->info('Automatische update check...');
|
||||
|
||||
$isForced = $this->option('force');
|
||||
$sqlOnly = $this->option('sql-only');
|
||||
$emuOnly = $this->option('emu-only');
|
||||
|
||||
if (! $isForced && ! $this->isScheduledTime()) {
|
||||
$this->line('Niet de geplande tijd, overslaan.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $sqlOnly) {
|
||||
$this->runEmulatorUpdate($alertService, $updateService);
|
||||
}
|
||||
|
||||
if (! $emuOnly) {
|
||||
$this->runSqlUpdates($alertService, $updateService);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function isScheduledTime(): bool
|
||||
{
|
||||
$enabled = setting('auto_update_enabled', true);
|
||||
|
||||
if (! $enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scheduleTime = setting('auto_update_schedule', '03:00');
|
||||
$scheduleDays = setting('auto_update_days', '0,1,2,3,4,5,6');
|
||||
|
||||
$now = now();
|
||||
$currentTime = $now->format('H:i');
|
||||
$currentDay = (int) $now->dayOfWeek;
|
||||
|
||||
$allowedDays = array_map(intval(...), explode(',', $scheduleDays));
|
||||
|
||||
if (! in_array($currentDay, $allowedDays)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($currentTime !== $scheduleTime) {
|
||||
$minuteDiff = abs(strtotime($currentTime) - strtotime((string) $scheduleTime)) / 60;
|
||||
if ($minuteDiff > 5) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function runEmulatorUpdate(AlertService $alertService, EmulatorUpdateService $updateService): void
|
||||
{
|
||||
$check = $updateService->checkForUpdates();
|
||||
|
||||
if (! ($check['update_available'] ?? false)) {
|
||||
$this->line('Emulator is al up-to-date.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn('Nieuwe emulator versie beschikbaar: ' . ($check['latest_version'] ?? 'onbekend'));
|
||||
$this->info('Emulator updaten...');
|
||||
|
||||
$result = $updateService->updateEmulator();
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info('Emulator succesvol geüpdatet!');
|
||||
$alertService->sendEmulatorUpdate($result['version'] ?? 'onbekend', $result['message'] ?? '');
|
||||
Log::info('[AutoUpdate] Emulator updated to v' . ($result['version'] ?? 'unknown'));
|
||||
} else {
|
||||
$this->error('Emulator update mislukt: ' . ($result['error'] ?? 'onbekende fout'));
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_ERROR,
|
||||
'Emulator Auto-Update Mislukt: ' . ($result['error'] ?? 'Onbekende fout'),
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY_LAST_EMU_UPDATE, now()->toIso8601String());
|
||||
}
|
||||
|
||||
private function runSqlUpdates(AlertService $alertService, EmulatorUpdateService $updateService): void
|
||||
{
|
||||
$check = $updateService->checkForSqlUpdates();
|
||||
|
||||
if (! ($check['has_updates'] ?? false)) {
|
||||
$this->line('Geen SQL updates beschikbaar.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn($check['message']);
|
||||
$this->info('SQL updates uitvoeren...');
|
||||
|
||||
$result = $updateService->runSqlUpdates();
|
||||
|
||||
if ($result['success'] && ! empty($result['files_run'])) {
|
||||
$count = count($result['files_run']);
|
||||
$this->info("{$count} SQL updates succesvol uitgevoerd!");
|
||||
$alertService->sendSqlUpdate($count, $result['message'] ?? '');
|
||||
Log::info('[AutoUpdate] SQL updates completed', ['files' => $result['files_run']]);
|
||||
} elseif (! empty($result['errors'])) {
|
||||
$this->warn('SQL updates met fouten: ' . implode(', ', $result['errors']));
|
||||
$alertService->send(
|
||||
AlertType::CRITICAL_ERROR,
|
||||
'SQL Updates Met Fouten: ' . implode(', ', $result['errors']),
|
||||
['files' => $result['errors']],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY_LAST_SQL_UPDATE, now()->toIso8601String());
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class BuildTheme extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'build:theme';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Build a selected theme assets';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$themes = $this->getAvailableThemes();
|
||||
|
||||
if ($themes->isEmpty()) {
|
||||
$this->error('No themes found in resources/themes/');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$selectedTheme = $this->choice(
|
||||
'Which theme would you like to build?',
|
||||
$themes->all(),
|
||||
0,
|
||||
);
|
||||
|
||||
$themeName = is_array($selectedTheme) ? ($selectedTheme[0] ?? '') : $selectedTheme;
|
||||
$this->info('Building ' . $themeName . ' theme...');
|
||||
|
||||
$this->runBuildCommand($themeName);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function getAvailableThemes(): Collection
|
||||
{
|
||||
$themesPath = resource_path('themes');
|
||||
|
||||
if (! File::exists($themesPath)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return collect(File::directories($themesPath))
|
||||
->map(fn ($path) => basename((string) $path))
|
||||
->sort();
|
||||
}
|
||||
|
||||
private function runBuildCommand(string $theme): void
|
||||
{
|
||||
$command = escapeshellcmd("npm run build:{$theme}");
|
||||
$output = [];
|
||||
$returnCode = 0;
|
||||
|
||||
exec($command, $output, $returnCode);
|
||||
|
||||
foreach ($output as $line) {
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
if ($returnCode === 0) {
|
||||
$this->info("Theme {$theme} built successfully!");
|
||||
} else {
|
||||
$this->error("Failed to build theme {$theme}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'radio:check-dj')]
|
||||
final class CheckDjConnection extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'radio:check-dj {--force : Force check even if auto-detection is disabled}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Check if a DJ is connected via Sambroadcaster or Virtual DJ';
|
||||
|
||||
private const string CACHE_KEY = 'website_settings';
|
||||
|
||||
private const string DJ_CHECK_RATE_LIMIT = 'dj-check';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$autoDetection = $this->getSetting('radio_auto_dj_detection', '0');
|
||||
|
||||
if ($autoDetection !== '1' && ! $this->option('force')) {
|
||||
$this->info('Auto DJ detectie is uitgeschakeld. Gebruik --force om toch te controleren.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (RateLimiter::tooManyAttempts(self::DJ_CHECK_RATE_LIMIT, 10)) {
|
||||
$this->info('Te veel pogingen. Wacht even.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
RateLimiter::hit(self::DJ_CHECK_RATE_LIMIT, 60);
|
||||
|
||||
$sambroadcasterUrl = $this->getSetting('radio_sambroadcaster_api_url', '');
|
||||
$virtualDjUrl = $this->getSetting('radio_virtual_dj_url', '');
|
||||
|
||||
$detectedDj = $this->checkSambroadcaster($sambroadcasterUrl);
|
||||
|
||||
if ($detectedDj === null) {
|
||||
$detectedDj = $this->checkVirtualDj($virtualDjUrl);
|
||||
}
|
||||
|
||||
if ($detectedDj === null) {
|
||||
$this->info('Geen DJ verbonden.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->processDetectedDj($detectedDj);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getSetting(string $key, string $default = ''): string
|
||||
{
|
||||
/** @var WebsiteSetting|null $setting */
|
||||
$setting = WebsiteSetting::where('key', $key)->first();
|
||||
|
||||
return $setting !== null && isset($setting->value) ? (string) $setting->value : $default;
|
||||
}
|
||||
|
||||
private function checkSambroadcaster(string $url): ?string
|
||||
{
|
||||
if ($url === '' || $url === '0') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)
|
||||
->withQueryParameters(['format' => 'json'])
|
||||
->get($url . '/dj');
|
||||
|
||||
if ($response->successful()) {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = $response->json();
|
||||
|
||||
$djName = $data['dj_name'] ?? $data['dj'] ?? $data['current_dj'] ?? $data['name'] ?? null;
|
||||
|
||||
return is_string($djName) ? $djName : null;
|
||||
}
|
||||
} catch (ConnectionException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function checkVirtualDj(string $url): ?string
|
||||
{
|
||||
if ($url === '' || $url === '0') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$endpoints = ['/info', '/status', '/api/dj', '/api/info'];
|
||||
|
||||
foreach ($endpoints as $endpoint) {
|
||||
try {
|
||||
$response = Http::timeout(5)
|
||||
->withQueryParameters(['format' => 'json'])
|
||||
->get($url . $endpoint);
|
||||
|
||||
if ($response->successful()) {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = $response->json();
|
||||
$djName = $data['djname'] ?? $data['DJName'] ?? $data['current_dj'] ?? $data['dj'] ?? $data['name'] ?? null;
|
||||
|
||||
if ($djName !== null && is_string($djName) && ! in_array(strtolower($djName), ['guest', ''])) {
|
||||
return $djName;
|
||||
}
|
||||
}
|
||||
} catch (ConnectionException) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function processDetectedDj(string $detectedDj): void
|
||||
{
|
||||
/** @var User|null $djUser */
|
||||
$djUser = User::where('username', $detectedDj)->first();
|
||||
|
||||
if ($djUser === null) {
|
||||
$this->warn("DJ {$detectedDj} gedetecteerd maar heeft geen account.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$currentDjId = $this->getSetting('radio_current_dj_id', '');
|
||||
|
||||
if ($currentDjId === (string) $djUser->id) {
|
||||
$this->info("DJ is al actief: {$detectedDj}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
WebsiteSetting::updateOrCreate(
|
||||
['key' => 'radio_current_dj_id'],
|
||||
['value' => (string) $djUser->id],
|
||||
);
|
||||
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
|
||||
$this->info("DJ gedetecteerd: {$detectedDj}");
|
||||
$this->info('DJ ingesteld als actieve presentator.');
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckScheduledMaintenance extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'maintenance:check-scheduled';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Check and activate scheduled maintenance mode';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$scheduledAt = setting('maintenance_scheduled_at');
|
||||
$durationSetting = setting('maintenance_duration_minutes');
|
||||
$duration = (int) (is_string($durationSetting) ? $durationSetting : '30');
|
||||
$isEnabledSetting = setting('maintenance_enabled');
|
||||
$isEnabled = $isEnabledSetting === '1';
|
||||
|
||||
if (! $scheduledAt) {
|
||||
$this->info('No maintenance scheduled');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$scheduledAtString = is_scalar($scheduledAt) ? (string) $scheduledAt : '';
|
||||
$startTime = Carbon::parse($scheduledAtString);
|
||||
$endTime = $startTime->copy()->addMinutes($duration);
|
||||
$now = Carbon::now();
|
||||
|
||||
if ($now->between($startTime, $endTime) && ! $isEnabled) {
|
||||
WebsiteSetting::updateOrCreate(
|
||||
['key' => 'maintenance_enabled'],
|
||||
['value' => '1'],
|
||||
);
|
||||
|
||||
$this->info("Maintenance mode enabled (scheduled for {$startTime->format('Y-m-d H:i')})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($now->greaterThan($endTime) && $isEnabled) {
|
||||
WebsiteSetting::where('key', 'maintenance_enabled')->delete();
|
||||
|
||||
$this->info("Maintenance mode disabled (schedule ended at {$endTime->format('Y-m-d H:i')})");
|
||||
|
||||
WebsiteSetting::where('key', 'maintenance_scheduled_at')->delete();
|
||||
WebsiteSetting::where('key', 'maintenance_duration_minutes')->delete();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('No action needed');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AlertService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DDoSDetectionCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'monitor:ddos
|
||||
{--threshold=100 : Minimum requests per IP om als verdacht te markeren}
|
||||
{--time-window=60 : Tijd window in seconden}
|
||||
{--block : Automatisch IPs blokkeren na detectie}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Detecteer mogelijke DDoS aanvallen en stuur alerts';
|
||||
|
||||
private const string CACHE_KEY_DDOS_TRACKING = 'ddos_tracking';
|
||||
|
||||
private const string CACHE_KEY_BLOCKED_IPS = 'ddos_blocked_ips';
|
||||
|
||||
private const int TRACKING_DURATION_SECONDS = 300;
|
||||
|
||||
public function handle(AlertService $alertService): int
|
||||
{
|
||||
$threshold = (int) $this->option('threshold');
|
||||
$timeWindow = (int) $this->option('time-window');
|
||||
$autoBlock = (bool) $this->option('block');
|
||||
|
||||
$this->info("DDoS detectie gestart (threshold: {$threshold} req/IP in {$timeWindow}s)");
|
||||
|
||||
$ddosData = $this->getDDoSData();
|
||||
$suspiciousIps = $this->analyzeTraffic($ddosData, $threshold, $timeWindow);
|
||||
|
||||
if ($suspiciousIps === []) {
|
||||
$this->line('Geen verdachte activiteit gedetecteerd.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->handleSuspiciousIps($suspiciousIps, $alertService, $autoBlock);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getDDoSData(): array
|
||||
{
|
||||
$data = Cache::get(self::CACHE_KEY_DDOS_TRACKING, []);
|
||||
|
||||
if (empty($data)) {
|
||||
$data = $this->fetchApacheTrafficData();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function fetchApacheTrafficData(): array
|
||||
{
|
||||
$this->warn('Gebruik handmatige IP tracking (geen server logs toegang).');
|
||||
|
||||
return $this->getManualTrackingData();
|
||||
}
|
||||
|
||||
private function tailFile(string $path, int $lines = 100): array
|
||||
{
|
||||
if (! file_exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$file = new \SplFileObject($path, 'r');
|
||||
$file->seek(PHP_INT_MAX);
|
||||
$totalLines = $file->key() + 1;
|
||||
|
||||
$startLine = max(0, $totalLines - $lines);
|
||||
$result = [];
|
||||
|
||||
$file->seek($startLine);
|
||||
while (! $file->eof()) {
|
||||
$result[] = rtrim($file->current(), "\r\n");
|
||||
$file->next();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getManualTrackingData(): array
|
||||
{
|
||||
$tracking = Cache::get('manual_ip_tracking', []);
|
||||
|
||||
foreach ($tracking as $ip => $data) {
|
||||
$tracking[$ip]['requests'] = array_filter(
|
||||
$data['requests'],
|
||||
fn ($timestamp) => $timestamp > time() - self::TRACKING_DURATION_SECONDS,
|
||||
);
|
||||
$tracking[$ip]['count'] = count($tracking[$ip]['requests']);
|
||||
|
||||
if ($tracking[$ip]['count'] === 0) {
|
||||
unset($tracking[$ip]);
|
||||
}
|
||||
}
|
||||
|
||||
Cache::put('manual_ip_tracking', $tracking, self::TRACKING_DURATION_SECONDS);
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
private function analyzeTraffic(array $data, int $threshold, int $timeWindow): array
|
||||
{
|
||||
$suspicious = [];
|
||||
$cutoffTime = time() - $timeWindow;
|
||||
|
||||
foreach ($data as $ip => $info) {
|
||||
$recentRequests = array_filter(
|
||||
$info['requests'],
|
||||
fn ($timestamp) => $timestamp > $cutoffTime,
|
||||
);
|
||||
|
||||
$requestCount = count($recentRequests);
|
||||
|
||||
if ($requestCount >= $threshold) {
|
||||
$suspicious[$ip] = [
|
||||
'count' => $requestCount,
|
||||
'time_window' => $timeWindow,
|
||||
'first_seen' => $recentRequests === [] ? time() : min($recentRequests),
|
||||
'last_seen' => $recentRequests === [] ? time() : max($recentRequests),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $suspicious;
|
||||
}
|
||||
|
||||
private function handleSuspiciousIps(array $suspiciousIps, AlertService $alertService, bool $autoBlock): void
|
||||
{
|
||||
$blockedIps = Cache::get(self::CACHE_KEY_BLOCKED_IPS, []);
|
||||
$newBlocks = [];
|
||||
|
||||
foreach ($suspiciousIps as $ip => $details) {
|
||||
$this->error("VERDACHTE IP GEDETECTEERD: {$ip}");
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Requests', $details['count']],
|
||||
['Time Window', $details['time_window'] . 's'],
|
||||
['First Seen', date('H:i:s', $details['first_seen'])],
|
||||
['Last Seen', date('H:i:s', $details['last_seen'])],
|
||||
],
|
||||
);
|
||||
|
||||
if ($autoBlock && ! in_array($ip, $blockedIps)) {
|
||||
$this->blockIp($ip);
|
||||
$blockedIps[] = $ip;
|
||||
$newBlocks[] = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
$totalRequests = array_sum(array_column($suspiciousIps, 'count'));
|
||||
$uniqueIps = count($suspiciousIps);
|
||||
|
||||
$alertService->sendDDoSDetected([
|
||||
'total_requests' => $totalRequests,
|
||||
'unique_ips' => $uniqueIps,
|
||||
'time_window' => array_first($suspiciousIps)['time_window'],
|
||||
'suspicious_ips' => array_keys($suspiciousIps),
|
||||
'auto_blocked' => $newBlocks,
|
||||
]);
|
||||
|
||||
if ($newBlocks !== []) {
|
||||
$this->info('Automatisch geblokkeerd: ' . implode(', ', $newBlocks));
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY_BLOCKED_IPS, $blockedIps, 3600);
|
||||
}
|
||||
|
||||
private function blockIp(string $ip): void
|
||||
{
|
||||
try {
|
||||
$escapedIp = escapeshellarg($ip);
|
||||
exec("iptables -A INPUT -s {$escapedIp} -j DROP 2>/dev/null");
|
||||
Log::warning("IP blocked due to DDoS detection: {$ip}");
|
||||
$this->warn("IP {$ip} geblokkeerd via iptables.");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to block IP {$ip}: " . $e->getMessage());
|
||||
$this->error("Kon IP {$ip} niet blokkeren: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function trackRequest(string $ip): void
|
||||
{
|
||||
$tracking = Cache::get('manual_ip_tracking', []);
|
||||
|
||||
if (! isset($tracking[$ip])) {
|
||||
$tracking[$ip] = ['requests' => [], 'count' => 0];
|
||||
}
|
||||
|
||||
$tracking[$ip]['requests'][] = time();
|
||||
$tracking[$ip]['count']++;
|
||||
|
||||
Cache::put('manual_ip_tracking', $tracking, self::TRACKING_DURATION_SECONDS);
|
||||
}
|
||||
|
||||
public static function getBlockedIps(): array
|
||||
{
|
||||
return Cache::get(self::CACHE_KEY_BLOCKED_IPS, []);
|
||||
}
|
||||
|
||||
public static function clearBlockedIp(string $ip): void
|
||||
{
|
||||
$blocked = Cache::get(self::CACHE_KEY_BLOCKED_IPS, []);
|
||||
$blocked = array_filter($blocked, fn ($blockedIp) => $blockedIp !== $ip);
|
||||
Cache::put(self::CACHE_KEY_BLOCKED_IPS, array_values($blocked), 3600);
|
||||
|
||||
$escapedIp = escapeshellarg($ip);
|
||||
exec("iptables -D INPUT -s {$escapedIp} -j DROP 2>/dev/null");
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AlertService;
|
||||
use App\Services\RconService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class EmulatorMonitorCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'monitor:emulator {--notify-online : Stuur een melding wanneer emulator online komt}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Monitor de emulator status en stuur alerts bij problemen';
|
||||
|
||||
private const string CACHE_KEY_EMULATOR_STATUS = 'emulator_monitor_status';
|
||||
|
||||
private const string CACHE_KEY_OFFLINE_SINCE = 'emulator_offline_since';
|
||||
|
||||
public function handle(AlertService $alertService): int
|
||||
{
|
||||
$this->info('Emulator status controleren...');
|
||||
|
||||
$rconService = new RconService;
|
||||
$isConnected = $rconService->isConnected();
|
||||
|
||||
Cache::get(self::CACHE_KEY_EMULATOR_STATUS);
|
||||
$wasOffline = Cache::has(self::CACHE_KEY_OFFLINE_SINCE);
|
||||
|
||||
if ($isConnected) {
|
||||
$this->handleOnlineStatus($alertService, $wasOffline);
|
||||
} else {
|
||||
$this->handleOfflineStatus($alertService);
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY_EMULATOR_STATUS, [
|
||||
'connected' => $isConnected,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
$this->info('Emulator status: ' . ($isConnected ? 'ONLINE' : 'OFFLINE'));
|
||||
|
||||
return $isConnected ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
|
||||
private function handleOnlineStatus(AlertService $alertService, bool $wasOffline): void
|
||||
{
|
||||
if ($wasOffline && $this->option('notify-online')) {
|
||||
$offlineSince = Cache::get(self::CACHE_KEY_OFFLINE_SINCE);
|
||||
|
||||
if ($offlineSince) {
|
||||
$duration = now()->diffInMinutes(Carbon::parse($offlineSince));
|
||||
$this->warn("Emulator was offline voor {$duration} minuten. Nu weer online!");
|
||||
}
|
||||
|
||||
$alertService->sendEmulatorOnline();
|
||||
}
|
||||
|
||||
Cache::forget(self::CACHE_KEY_OFFLINE_SINCE);
|
||||
$this->line('Emulator is online en reageert.');
|
||||
}
|
||||
|
||||
private function handleOfflineStatus(AlertService $alertService): void
|
||||
{
|
||||
if (! Cache::has(self::CACHE_KEY_OFFLINE_SINCE)) {
|
||||
Cache::put(self::CACHE_KEY_OFFLINE_SINCE, now()->toIso8601String());
|
||||
$this->warn('Emulator is OFFLINE! Eerste detectie - alert wordt verzonden.');
|
||||
$alertService->sendEmulatorOffline('RCON verbinding mislukt bij monitoring check');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$offlineSince = Cache::get(self::CACHE_KEY_OFFLINE_SINCE);
|
||||
$offlineDuration = now()->diffInMinutes(Carbon::parse($offlineSince));
|
||||
|
||||
$escalationMinutes = $this->getEscalationMinutes($offlineDuration);
|
||||
|
||||
if ($escalationMinutes > 0 && $offlineDuration % $escalationMinutes === 0) {
|
||||
$this->warn("Emulator is al {$offlineDuration} minuten offline. Escalating alert.");
|
||||
$alertService->sendEmulatorOffline("Emulator offline voor {$offlineDuration} minuten");
|
||||
}
|
||||
|
||||
$this->error("Emulator is OFFLINE! Offline sinds: {$offlineSince} ({$offlineDuration} minuten geleden)");
|
||||
}
|
||||
|
||||
private function getEscalationMinutes(float|int $offlineMinutes): int
|
||||
{
|
||||
return match (true) {
|
||||
$offlineMinutes >= 60 => 30,
|
||||
$offlineMinutes >= 30 => 15,
|
||||
$offlineMinutes >= 15 => 5,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\AlertChannel;
|
||||
use App\Enums\AlertType;
|
||||
use App\Services\AlertService;
|
||||
use App\Services\EmulatorUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EmulatorUpdateCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'emulator:update
|
||||
{--check : Alleen controleren op updates}
|
||||
{--force : Forceer update ook al is er geen nieuwe versie}
|
||||
{--repair : Probeer emulator te repareren}
|
||||
{--rebuild : Forceer build vanaf source}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Update de emulator vanaf GitHub';
|
||||
|
||||
public function handle(EmulatorUpdateService $updateService, AlertService $alertService): int
|
||||
{
|
||||
if ($this->option('repair')) {
|
||||
return $this->repairEmulator($updateService, $alertService);
|
||||
}
|
||||
|
||||
if (! $updateService->isConfigured()) {
|
||||
$this->error('Geen GitHub URL geconfigureerd voor emulator updates.');
|
||||
$this->info('Configureer dit in Filament > Settings > Emulator');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Emulator update service gestart...');
|
||||
|
||||
$checkResult = $updateService->checkForUpdates();
|
||||
|
||||
if (isset($checkResult['error'])) {
|
||||
$this->error($checkResult['error']);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Property', 'Value'],
|
||||
[
|
||||
['Huidige Versie', $checkResult['current_version']],
|
||||
['Nieuwste Versie', $checkResult['latest_version']],
|
||||
['Update Beschikbaar', $checkResult['update_available'] ? 'JA' : 'NEE'],
|
||||
['Type', $checkResult['update_type'] ?? 'N/A'],
|
||||
],
|
||||
);
|
||||
|
||||
if ($this->option('check')) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$force = $this->option('force');
|
||||
$rebuild = $this->option('rebuild');
|
||||
|
||||
if (! $checkResult['update_available'] && ! $force && ! $rebuild) {
|
||||
$this->info('Emulator is al up-to-date!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $checkResult['update_available'] && ($force || $rebuild)) {
|
||||
$this->warn('Geen nieuwe versie beschikbaar, maar force/rebuild aangevraagd.');
|
||||
}
|
||||
|
||||
if (! $force && ! $rebuild && ! $this->confirm('Wil je de emulator updaten naar v' . $checkResult['latest_version'] . '?')) {
|
||||
$this->info('Update geannuleerd.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Emulator wordt geüpdatet...');
|
||||
|
||||
try {
|
||||
if ($rebuild || $checkResult['type'] === 'source_build') {
|
||||
$this->info('Build vanaf source...');
|
||||
$result = $updateService->buildFromSource($force);
|
||||
} else {
|
||||
$result = $updateService->updateEmulator();
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info($result['message'] ?? 'Emulator succesvol geüpdatet!');
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_ONLINE,
|
||||
'Emulator succesvol geüpdatet naar v' . ($result['version'] ?? 'onbekend'),
|
||||
[
|
||||
'version' => $result['version'] ?? 'onbekend',
|
||||
'jar' => $result['jar'] ?? 'N/A',
|
||||
],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
Log::info('[EmulatorUpdateCommand] Update successful', $result);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('Update mislukt: ' . ($result['error'] ?? 'Onbekende fout'));
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_ERROR,
|
||||
'Emulator update mislukt: ' . ($result['error'] ?? 'Onbekende fout'),
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
Log::error('[EmulatorUpdateCommand] Update failed', $result);
|
||||
|
||||
return Command::FAILURE;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Update exception: ' . $e->getMessage());
|
||||
$alertService->send(
|
||||
AlertType::CRITICAL_ERROR,
|
||||
'Emulator update exception: ' . $e->getMessage(),
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
Log::error('[EmulatorUpdateCommand] Exception', ['error' => $e->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function repairEmulator(EmulatorUpdateService $updateService, AlertService $alertService): int
|
||||
{
|
||||
$this->warn('🔧 Emulator repair modus gestart...');
|
||||
$this->info('Dit zal de emulator status controleren en proberen te repareren.');
|
||||
|
||||
try {
|
||||
$repairResult = $updateService->repairEmulator();
|
||||
|
||||
if ($repairResult['success']) {
|
||||
$this->info('✅ Repair succesvol!');
|
||||
foreach ($repairResult['actions'] ?? [] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_ONLINE,
|
||||
'Emulator gerepareerd',
|
||||
['actions' => $repairResult['actions'] ?? []],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('❌ Repair mislukt: ' . ($repairResult['error'] ?? 'Onbekende fout'));
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_ERROR,
|
||||
'Emulator repair mislukt: ' . ($repairResult['error'] ?? 'Onbekende fout'),
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::FAILURE;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ Repair exception: ' . $e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class FixCodeCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'atom:fix-code {--dirty : Only fix changed files}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Automatically fix code style and syntax errors in the project';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('🛠️ Starting Atom Code Fixer...');
|
||||
|
||||
// 1. Check if Laravel Pint is installed (Standard in Laravel 9+)
|
||||
if (! file_exists(base_path('vendor/bin/pint'))) {
|
||||
$this->warn('⚠️ Laravel Pint not found. Installing...');
|
||||
$this->runProcess(['composer', 'require', 'laravel/pint', '--dev']);
|
||||
}
|
||||
|
||||
// 2. Run Pint (Fixes styling, unused imports, spacing)
|
||||
$this->info('🎨 Running Code Style Fixer (Pint)...');
|
||||
|
||||
$params = ['vendor/bin/pint'];
|
||||
if ($this->option('dirty')) {
|
||||
$params[] = '--dirty';
|
||||
}
|
||||
|
||||
$process = new Process($params, base_path());
|
||||
$process->setTimeout(300);
|
||||
$process->run(function ($type, $buffer) {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->error('❌ Pint encountered some issues.');
|
||||
} else {
|
||||
$this->info('✅ Code style fixed!');
|
||||
}
|
||||
|
||||
// 3. Optional: Run PHPStan/Larastan if available (For deep logic checks)
|
||||
if (file_exists(base_path('vendor/bin/phpstan'))) {
|
||||
$this->info("\n🧠 Running Static Analysis (PHPStan)...");
|
||||
$analyze = new Process(['vendor/bin/phpstan', 'analyse', 'app'], base_path());
|
||||
$analyze->setTimeout(300);
|
||||
$analyze->run(function ($type, $buffer) {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('🚀 Code cleanup finished.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $command
|
||||
*/
|
||||
private function runProcess(array $command): void
|
||||
{
|
||||
$process = new Process($command, base_path());
|
||||
$process->run(function ($type, $buffer) {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class FixGamedataSymlinks extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'atom:fix-gamedata-symlinks {--dry-run : Show what would be done without making changes}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Create symlinks in gamedata directory for bundled assets';
|
||||
|
||||
private array $symlinks = [
|
||||
'effect' => 'bundled/effect',
|
||||
'furniture' => 'bundled/furniture',
|
||||
'generic' => 'bundled/generic',
|
||||
'pet' => 'bundled/pet',
|
||||
'figure' => 'bundled/figure',
|
||||
'generic_custom' => 'bundled/generic',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$gamedataPath = base_path('../Gamedata');
|
||||
|
||||
if (! File::exists($gamedataPath)) {
|
||||
$this->error('Gamedata directory not found at: ' . $gamedataPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Checking gamedata symlinks...');
|
||||
$this->newLine();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$fixed = 0;
|
||||
|
||||
foreach ($this->symlinks as $link => $target) {
|
||||
$linkPath = $gamedataPath . '/' . $link;
|
||||
$targetPath = $gamedataPath . '/' . $target;
|
||||
|
||||
if (! File::exists($targetPath)) {
|
||||
$this->warn(" ⚠️ Target does not exist: $target");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (File::exists($linkPath)) {
|
||||
if (is_link($linkPath)) {
|
||||
$currentTarget = readlink($linkPath);
|
||||
if ($currentTarget === $target) {
|
||||
$this->line(" ✓ $link → $target (already correct)");
|
||||
$skipped++;
|
||||
} elseif ($this->option('dry-run')) {
|
||||
$this->warn(" → Would fix: $link (currently points to: $currentTarget)");
|
||||
} else {
|
||||
unlink($linkPath);
|
||||
symlink($target, $linkPath);
|
||||
$this->info(" ✨ Fixed: $link → $target");
|
||||
$fixed++;
|
||||
}
|
||||
} elseif ($this->option('dry-run')) {
|
||||
$this->warn(" → Would replace: $link (is a regular file/directory)");
|
||||
} else {
|
||||
$this->warn(" ⚠️ Cannot create symlink, $link exists as file/directory");
|
||||
}
|
||||
} elseif ($this->option('dry-run')) {
|
||||
$this->warn(" → Would create: $link → $target");
|
||||
} else {
|
||||
symlink($target, $linkPath);
|
||||
$this->info(" ✓ Created: $link → $target");
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Summary:');
|
||||
$this->line(" Created: $created");
|
||||
$this->line(" Fixed: $fixed");
|
||||
$this->line(" Already correct: $skipped");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
+796
@@ -0,0 +1,796 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NitroUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class GenerateNitroConfigs extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'app:generate-nitro-configs
|
||||
{--site-url= : The site URL to use}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Generate Nitro configuration files (renderer-config.json, ui-config.json, UITexts.json)';
|
||||
|
||||
private array $checks = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$siteUrl = $this->option('site-url') ?? setting('nitro_site_url', config('app.url', 'https://epicnabbo.nl'));
|
||||
|
||||
$this->info('🔧 Nitro Config Generator');
|
||||
$this->line('═══════════════════════════════════════════════');
|
||||
|
||||
// Check 1: Validate URL
|
||||
$this->addCheck(function () use ($siteUrl) {
|
||||
if (empty($siteUrl) || ! filter_var($siteUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new \Exception("Invalid URL: {$siteUrl}");
|
||||
}
|
||||
$this->line(" ✓ URL: {$siteUrl}");
|
||||
});
|
||||
|
||||
$nitroService = new NitroUpdateService;
|
||||
$status = $nitroService->getStatus();
|
||||
$webroot = $status['webroot'] ?? '/var/www/Client';
|
||||
$buildPath = $status['build_path'] ?? '/var/www/atomcms/nitro-client/dist';
|
||||
|
||||
// Check 2: Webroot exists
|
||||
$this->addCheck(function () use ($webroot) {
|
||||
$result = Process::timeout(5)->run('test -d ' . escapeshellarg((string) $webroot));
|
||||
if ($result->exitCode() !== 0) {
|
||||
throw new \Exception("Webroot does not exist: {$webroot}");
|
||||
}
|
||||
$this->line(" ✓ Webroot exists: {$webroot}");
|
||||
});
|
||||
|
||||
// Check 3: Webroot is writable
|
||||
$this->addCheck(function () use ($webroot) {
|
||||
$result = Process::timeout(5)->run('test -w ' . escapeshellarg((string) $webroot));
|
||||
if ($result->exitCode() !== 0) {
|
||||
throw new \Exception("Webroot is not writable: {$webroot}");
|
||||
}
|
||||
$this->line(' ✓ Webroot is writable');
|
||||
});
|
||||
|
||||
// Check 4: Build path exists
|
||||
$this->addCheck(function () use ($buildPath) {
|
||||
$result = Process::timeout(5)->run('test -d ' . escapeshellarg((string) $buildPath));
|
||||
if ($result->exitCode() !== 0) {
|
||||
throw new \Exception("Build path does not exist: {$buildPath}");
|
||||
}
|
||||
$this->line(" ✓ Build path exists: {$buildPath}");
|
||||
});
|
||||
|
||||
// Check both buildPath and webroot for example files
|
||||
$this->addCheck(function () use ($buildPath, $webroot) {
|
||||
// Map .example to .json fallback
|
||||
$fileMap = [
|
||||
'renderer-config.example' => 'renderer-config.json',
|
||||
'ui-config.example' => 'ui-config.json',
|
||||
'UITexts.example' => 'UITexts.example',
|
||||
];
|
||||
|
||||
$allFound = true;
|
||||
|
||||
foreach ($fileMap as $exampleFile => $jsonFile) {
|
||||
// Check .example file first
|
||||
$buildPathResult = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/' . $exampleFile));
|
||||
$webrootResult = Process::timeout(5)->run('test -f ' . escapeshellarg($webroot . '/' . $exampleFile));
|
||||
|
||||
if ($buildPathResult->exitCode() === 0) {
|
||||
$this->line(" ✓ Found in build path: {$exampleFile}");
|
||||
} elseif ($webrootResult->exitCode() === 0) {
|
||||
$this->line(" ✓ Found in webroot: {$exampleFile}");
|
||||
} elseif ($jsonFile !== $exampleFile) {
|
||||
// Check json fallback for .example files
|
||||
$jsonBuildResult = Process::timeout(5)->run('test -f ' . escapeshellarg($buildPath . '/' . $jsonFile));
|
||||
$jsonWebrootResult = Process::timeout(5)->run('test -f ' . escapeshellarg($webroot . '/' . $jsonFile));
|
||||
if ($jsonBuildResult->exitCode() === 0) {
|
||||
$this->line(" ✓ Found in build path: {$jsonFile}");
|
||||
} elseif ($jsonWebrootResult->exitCode() === 0) {
|
||||
$this->line(" ✓ Found in webroot: {$jsonFile}");
|
||||
} else {
|
||||
$this->warn(" ⚠ Missing: {$exampleFile} or {$jsonFile}");
|
||||
$allFound = false;
|
||||
}
|
||||
} else {
|
||||
$this->warn(" ⚠ Missing: {$exampleFile}");
|
||||
$allFound = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $allFound) {
|
||||
throw new \Exception('Some example files are missing');
|
||||
}
|
||||
});
|
||||
|
||||
// Check 6: Check disk space
|
||||
$this->addCheck(function () use ($webroot) {
|
||||
$result = Process::timeout(10)->run("df -h {$webroot} | tail -1 | awk '{print $4}'");
|
||||
if ($result->successful()) {
|
||||
$free = trim($result->output());
|
||||
$this->line(" ✓ Free space: {$free}");
|
||||
}
|
||||
});
|
||||
|
||||
// Check 7: Validate bundled/config gamedata files
|
||||
$this->addCheck(function () {
|
||||
$bundledConfigDir = '/var/www/Gamedata/bundled/config';
|
||||
$configDir = '/var/www/Gamedata/config';
|
||||
$requiredFiles = ['HabboAvatarActions.json', 'FurnitureData.json', 'ExternalTexts.json', 'ProductData.json', 'FigureData.json', 'FigureMap.json', 'EffectMap.json'];
|
||||
|
||||
// Create bundled/config if it doesn't exist
|
||||
if (! is_dir($bundledConfigDir)) {
|
||||
mkdir($bundledConfigDir, 0755, true);
|
||||
$this->line(" 📁 Created directory: {$bundledConfigDir}");
|
||||
}
|
||||
|
||||
// Copy missing files from config to bundled/config
|
||||
foreach ($requiredFiles as $file) {
|
||||
$targetPath = $bundledConfigDir . '/' . $file;
|
||||
if (! file_exists($targetPath)) {
|
||||
$sourcePath = $configDir . '/' . $file;
|
||||
if (file_exists($sourcePath)) {
|
||||
copy($sourcePath, $targetPath);
|
||||
$this->line(" 📋 Copied: {$file} to bundled/config");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' ✓ Gamedata bundled/config files ready');
|
||||
});
|
||||
|
||||
// Check 8: Validate existing config files
|
||||
$this->addCheck(function () use ($webroot) {
|
||||
$configFiles = ['renderer-config.json', 'ui-config.json', 'UITexts.json'];
|
||||
foreach ($configFiles as $file) {
|
||||
$path = $webroot . '/' . $file;
|
||||
$result = Process::timeout(5)->run('test -f ' . escapeshellarg($path));
|
||||
if ($result->exitCode() === 0) {
|
||||
// Validate JSON
|
||||
$jsonCheck = Process::timeout(5)->run('python3 -c "import json; json.load(open(\'' . $path . '\'))" 2>&1 || echo "INVALID"');
|
||||
if (str_contains($jsonCheck->output(), 'INVALID')) {
|
||||
$this->warn(" ⚠ Invalid JSON: {$file}");
|
||||
} else {
|
||||
$this->line(" ✓ Valid JSON: {$file}");
|
||||
}
|
||||
} else {
|
||||
$this->line(" - New file: {$file}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run all checks
|
||||
$this->line('');
|
||||
$this->info('Running pre-flight checks...');
|
||||
$this->line('───────────────────────────────────────────────');
|
||||
|
||||
try {
|
||||
foreach ($this->checks as $check) {
|
||||
$check();
|
||||
}
|
||||
$this->line('───────────────────────────────────────────────');
|
||||
$this->info('✅ All checks passed!');
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ Check failed: ' . $e->getMessage());
|
||||
$this->newLine();
|
||||
$this->error('Please fix the issue before generating configs.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Generate configs
|
||||
$this->newLine();
|
||||
$this->info('Generating configuration files...');
|
||||
$this->line('───────────────────────────────────────────────');
|
||||
|
||||
$protocol = str_starts_with((string) $siteUrl, 'https') ? 'https' : 'http';
|
||||
$host = preg_replace('/^https?:\/\//', '', (string) $siteUrl);
|
||||
|
||||
try {
|
||||
// Step 0: Sync latest example files from GitHub
|
||||
$this->syncExampleFromGithub($buildPath, $webroot);
|
||||
|
||||
$rendererConfig = $this->generateRendererConfig($protocol, $host, $buildPath, $webroot);
|
||||
$uiConfig = $this->generateUiConfig($protocol, $host, $buildPath, $webroot);
|
||||
|
||||
// Compare with existing deployed configs
|
||||
$this->compareConfigs($rendererConfig, 'renderer-config', $webroot);
|
||||
$this->compareConfigs($uiConfig, 'ui-config', $webroot);
|
||||
|
||||
// Validate generated JSON
|
||||
$this->validateJson($rendererConfig, 'renderer-config');
|
||||
$this->validateJson($uiConfig, 'ui-config');
|
||||
|
||||
$tempFile = '/tmp/nitro_config_' . uniqid();
|
||||
file_put_contents($tempFile . '_renderer', json_encode($rendererConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
file_put_contents($tempFile . '_ui', json_encode($uiConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// Copy generated configs
|
||||
$commands = [
|
||||
'cp "' . $tempFile . '_renderer" "' . $webroot . '/renderer-config.json"',
|
||||
'cp "' . $tempFile . '_ui" "' . $webroot . '/ui-config.json"',
|
||||
];
|
||||
|
||||
// Copy UITexts.json if it exists (check both buildPath and webroot)
|
||||
$uitextsInBuild = Process::timeout(5)->run('test -f "' . $buildPath . '/UITexts.example"')->exitCode() === 0;
|
||||
$uitextsInWebroot = Process::timeout(5)->run('test -f "' . $webroot . '/UITexts.example"')->exitCode() === 0;
|
||||
if ($uitextsInBuild) {
|
||||
$commands[] = 'cp "' . $buildPath . '/UITexts.example" "' . $webroot . '/UITexts.json"';
|
||||
$this->line(' ✓ Generated: UITexts.json (from build path)');
|
||||
} elseif ($uitextsInWebroot) {
|
||||
$commands[] = 'cp "' . $webroot . '/UITexts.example" "' . $webroot . '/UITexts.json"';
|
||||
$this->line(' ✓ Generated: UITexts.json (from webroot)');
|
||||
}
|
||||
|
||||
$commands[] = 'rm "' . $tempFile . '_renderer" "' . $tempFile . '_ui"';
|
||||
|
||||
Process::timeout(10)->run(implode(' && ', $commands));
|
||||
|
||||
// Set proper ownership
|
||||
Process::timeout(10)->run("chown www-data:www-data {$webroot}/renderer-config.json {$webroot}/ui-config.json {$webroot}/UITexts.json 2>/dev/null");
|
||||
|
||||
setting('nitro_config_generated_at', now()->toIso8601String());
|
||||
|
||||
$this->line(' ✓ Generated: renderer-config.json');
|
||||
$this->line(' ✓ Generated: ui-config.json');
|
||||
|
||||
// Verify generated files
|
||||
$this->verifyGeneratedFiles($webroot);
|
||||
|
||||
$this->line('───────────────────────────────────────────────');
|
||||
$this->info('✅ Configs generated successfully!');
|
||||
|
||||
Log::info('[NitroConfig] Generated successfully', [
|
||||
'webroot' => $webroot,
|
||||
'host' => $host,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ Generation failed: ' . $e->getMessage());
|
||||
Log::error('[NitroConfig] Generation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function addCheck(callable $check): void
|
||||
{
|
||||
$this->checks[] = function () use ($check) {
|
||||
$check();
|
||||
};
|
||||
}
|
||||
|
||||
private function validateJson(array $data, string $name): void
|
||||
{
|
||||
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
|
||||
$decoded = json_decode($encoded, true);
|
||||
|
||||
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \Exception("Invalid JSON generated for {$name}");
|
||||
}
|
||||
|
||||
$this->line(" ✓ Validated: {$name}");
|
||||
}
|
||||
|
||||
private function verifyGeneratedFiles(string $webroot): void
|
||||
{
|
||||
$files = ['renderer-config.json', 'ui-config.json', 'UITexts.json'];
|
||||
$allValid = true;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $webroot . '/' . $file;
|
||||
$result = Process::timeout(5)->run('test -f ' . escapeshellarg($path));
|
||||
|
||||
if ($result->exitCode() === 0) {
|
||||
$size = Process::timeout(5)->run('stat -c%s ' . escapeshellarg($path));
|
||||
$sizeStr = trim($size->output()) . ' bytes';
|
||||
$this->line(" ✓ Verified: {$file} ({$sizeStr})");
|
||||
} else {
|
||||
$this->warn(" ⚠ Missing: {$file}");
|
||||
$allValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $allValid) {
|
||||
throw new \Exception('Some files were not generated');
|
||||
}
|
||||
}
|
||||
|
||||
private function generateRendererConfig(string $protocol, string $host, string $buildPath, string $webroot): array
|
||||
{
|
||||
$httpProtocol = $protocol === 'https' ? 'https' : 'http';
|
||||
$wssProtocol = $protocol === 'https' ? 'wss' : 'ws';
|
||||
$wsHost = 'ws.' . $host;
|
||||
|
||||
$rendererConfig = [];
|
||||
|
||||
// Check build path first, then webroot for .example file
|
||||
$examplePath = $buildPath . '/renderer-config.example';
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||||
$examplePath = $webroot . '/renderer-config.example';
|
||||
}
|
||||
|
||||
// Fallback to .json version (newer Nitro-V3 format)
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||||
$jsonPath = $buildPath . '/renderer-config.json';
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||||
$examplePath = $jsonPath;
|
||||
} else {
|
||||
$jsonPath = $webroot . '/renderer-config.json';
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||||
$examplePath = $jsonPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load COMPLETE example file as base
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() === 0) {
|
||||
$content = file_get_contents($examplePath);
|
||||
// Fix invalid escape sequences (literal \n, \r, \t in JSON values)
|
||||
$content = $this->fixInvalidJsonEscapeSequences($content);
|
||||
$rendererConfig = @json_decode($content, true) ?: [];
|
||||
$this->line(' ✓ Loaded renderer config from: ' . basename($examplePath) . ' (' . count($rendererConfig) . ' keys)');
|
||||
} else {
|
||||
$this->warn(' ⚠ renderer-config.example/json not found');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Recursively replace ALL URLs in the entire config
|
||||
$rendererConfig = $this->replaceUrlsRecursively($rendererConfig, $httpProtocol, $wssProtocol, $host, $wsHost);
|
||||
|
||||
// Auto-detect asset directory names on disk and fix paths
|
||||
$rendererConfig = $this->autoDetectAssetPaths($rendererConfig, $webroot);
|
||||
|
||||
// Special handling for socket.url - use ws subdomain
|
||||
if (isset($rendererConfig['socket.url'])) {
|
||||
$rendererConfig['socket.url'] = $wssProtocol . '://ws.' . $host;
|
||||
}
|
||||
|
||||
// Auto-detect local asset paths from Gamedata directory
|
||||
$gamedataBase = $httpProtocol . '://' . $host;
|
||||
|
||||
// Check what directories exist in Gamedata and set URLs accordingly
|
||||
$gamedataPath = '/var/www/Gamedata';
|
||||
if (is_dir($gamedataPath)) {
|
||||
// Check for bundled directory - serve via /gamedata/bundled (nginx alias)
|
||||
if (is_dir($gamedataPath . '/bundled')) {
|
||||
$rendererConfig['asset.url'] = $gamedataBase . '/gamedata/bundled';
|
||||
}
|
||||
// JSON config files are in /gamedata/config/
|
||||
if (is_dir($gamedataPath . '/config')) {
|
||||
$rendererConfig['gamedata.url'] = $gamedataBase . '/gamedata/config';
|
||||
}
|
||||
// Check for c_images directory - serve via /gamedata/c_images
|
||||
if (is_dir($gamedataPath . '/c_images')) {
|
||||
$rendererConfig['image.library.url'] = $gamedataBase . '/gamedata/c_images/';
|
||||
// Use icons folder for furni icons
|
||||
if (is_dir($gamedataPath . '/icons')) {
|
||||
$rendererConfig['hof.furni.url'] = $gamedataBase . '/gamedata/icons';
|
||||
} else {
|
||||
$rendererConfig['hof.furni.url'] = $gamedataBase . '/gamedata/c_images/dcr/hof_furni';
|
||||
}
|
||||
}
|
||||
// Fix furni icon path - icons are directly in hof.furni folder, not in icons subfolder
|
||||
if (isset($rendererConfig['furni.asset.icon.url'])) {
|
||||
$rendererConfig['furni.asset.icon.url'] = '${hof.furni.url}/%libname%%param%_icon.png';
|
||||
}
|
||||
// Fix sound machine samples path - sounds are in /gamedata/sounds/
|
||||
if (isset($rendererConfig['external.samples.url'])) {
|
||||
$rendererConfig['external.samples.url'] = $gamedataBase . '/gamedata/sounds/sound_machine_sample_%sample%.mp3';
|
||||
}
|
||||
// Check for images directory
|
||||
if (is_dir($gamedataPath . '/images')) {
|
||||
$rendererConfig['images.url'] = $gamedataBase . '/gamedata/images';
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing keys that might not be in the example
|
||||
if (! isset($rendererConfig['external.plugins'])) {
|
||||
$rendererConfig['external.plugins'] = [];
|
||||
}
|
||||
|
||||
// Add YouTube API key from settings
|
||||
$youtubeApiKey = setting('youtube_api_key', '');
|
||||
if (! empty($youtubeApiKey)) {
|
||||
$rendererConfig['youtube.api.key'] = $youtubeApiKey;
|
||||
$this->line(' ✓ Added YouTube API key to renderer config');
|
||||
}
|
||||
|
||||
// Ensure pet.types matches the exact required list in order
|
||||
if (isset($rendererConfig['pet.types'])) {
|
||||
$requiredPetTypes = [
|
||||
'dog',
|
||||
'cat',
|
||||
'croco',
|
||||
'terrier',
|
||||
'bear',
|
||||
'pig',
|
||||
'lion',
|
||||
'rhino',
|
||||
'spider',
|
||||
'turtle',
|
||||
'chicken',
|
||||
'frog',
|
||||
'dragon',
|
||||
'monster',
|
||||
'monkey',
|
||||
'horse',
|
||||
'monsterplant',
|
||||
'bunnyeaster',
|
||||
'bunnyevil',
|
||||
'bunnydepressed',
|
||||
'bunnylove',
|
||||
'pigeongood',
|
||||
'pigeonevil',
|
||||
'demonmonkey',
|
||||
'bearbaby',
|
||||
'terrierbaby',
|
||||
'gnome',
|
||||
'leprechaun',
|
||||
'kittenbaby',
|
||||
'puppybaby',
|
||||
'pigletbaby',
|
||||
'haloompa',
|
||||
'fools',
|
||||
'pterosaur',
|
||||
'velociraptor',
|
||||
'cow',
|
||||
'dragondog',
|
||||
'pkmshaymin2',
|
||||
'LeetEendjes',
|
||||
'pkmnentei',
|
||||
'squirtle',
|
||||
'LeetBH',
|
||||
'LeetCaviaaa',
|
||||
'LeetFantj',
|
||||
'LeetHotelMario',
|
||||
'LeetUil',
|
||||
'LeetWolf',
|
||||
'pokemon_mewblu',
|
||||
'LeetBB',
|
||||
'LeetHotelMari1',
|
||||
'LeetMewtw',
|
||||
'LeetPikachu',
|
||||
'LeetYos',
|
||||
'LeetE',
|
||||
'LeetMewt1',
|
||||
'LeetPen',
|
||||
'slendermn',
|
||||
'pkmnPAPI0',
|
||||
'pkmnPAPI1',
|
||||
'pkmnPAPI2',
|
||||
'pkmnPAPI3',
|
||||
'pkmnPAPI4',
|
||||
'pokmn_mew',
|
||||
'pkmashhhpet',
|
||||
'pkmbeautfly',
|
||||
'pkmcelebipe',
|
||||
'pkmdarkraip',
|
||||
'pkmeeveepet',
|
||||
'pkmjirachip',
|
||||
'pkmpichupet',
|
||||
'pkmriolupet',
|
||||
'pkmshayminp',
|
||||
'pkmtogepipe',
|
||||
'pkmvictinip',
|
||||
'slenderm1',
|
||||
'LeetEendj16',
|
||||
'pkmnente1',
|
||||
'LeetBa',
|
||||
'babymeisje',
|
||||
'babyBH',
|
||||
'bb_hbx',
|
||||
'LeetUi1',
|
||||
];
|
||||
|
||||
$rendererConfig['pet.types'] = $requiredPetTypes;
|
||||
$this->line(' ✓ Set pet.types to exact required list');
|
||||
}
|
||||
|
||||
return $rendererConfig;
|
||||
}
|
||||
|
||||
private function generateUiConfig(string $protocol, string $host, string $buildPath, string $webroot): array
|
||||
{
|
||||
$httpProtocol = $protocol === 'https' ? 'https' : 'http';
|
||||
$wssProtocol = $protocol === 'https' ? 'wss' : 'ws';
|
||||
$wsHost = 'ws.' . $host;
|
||||
|
||||
$uiConfig = [];
|
||||
|
||||
// Check build path first, then webroot for .example file
|
||||
$examplePath = $buildPath . '/ui-config.example';
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||||
$examplePath = $webroot . '/ui-config.example';
|
||||
}
|
||||
|
||||
// Fallback to .json version (newer Nitro-V3 format)
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() !== 0) {
|
||||
$jsonPath = $buildPath . '/ui-config.json';
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||||
$examplePath = $jsonPath;
|
||||
} else {
|
||||
$jsonPath = $webroot . '/ui-config.json';
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($jsonPath))->exitCode() === 0) {
|
||||
$examplePath = $jsonPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load COMPLETE example file as base
|
||||
if (Process::timeout(5)->run('test -f ' . escapeshellarg($examplePath))->exitCode() === 0) {
|
||||
$content = file_get_contents($examplePath);
|
||||
// Fix invalid escape sequences
|
||||
$content = $this->fixInvalidJsonEscapeSequences($content);
|
||||
$uiConfig = @json_decode($content, true) ?: [];
|
||||
$this->line(' ✓ Loaded ui config from: ' . basename($examplePath) . ' (' . count($uiConfig) . ' keys)');
|
||||
} else {
|
||||
$this->warn(' ⚠ ui-config.example/json not found');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Recursively replace ALL URLs in the entire config
|
||||
$uiConfig = $this->replaceUrlsRecursively($uiConfig, $httpProtocol, $wssProtocol, $host, $wsHost);
|
||||
|
||||
return $uiConfig;
|
||||
}
|
||||
|
||||
private function fixInvalidJsonEscapeSequences(string $content): string
|
||||
{
|
||||
// The example file has literal backslash + any letter in JSON values
|
||||
// which breaks JSON. We need to escape all of these.
|
||||
|
||||
$backslash = chr(92);
|
||||
$letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
|
||||
|
||||
foreach ($letters as $letter) {
|
||||
$content = str_replace($backslash . $letter, $backslash . $backslash . $letter, $content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function replaceUrlsRecursively(array $config, string $httpProtocol, string $wsProtocol, string $host, string $wsHost): array
|
||||
{
|
||||
foreach ($config as &$value) {
|
||||
if (is_array($value)) {
|
||||
// Handle nested arrays (like navigator.room.models)
|
||||
if ($this->isAssociativeArray($value)) {
|
||||
$value = $this->replaceUrlsRecursively($value, $httpProtocol, $wsProtocol, $host, $wsHost);
|
||||
} else {
|
||||
// Handle arrays of URLs
|
||||
$value = array_map(function ($item) use ($httpProtocol, $wsProtocol, $host, $wsHost) {
|
||||
if (is_string($item)) {
|
||||
return $this->replaceUrl($item, $httpProtocol, $wsProtocol, $host, $wsHost);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}, $value);
|
||||
}
|
||||
} elseif (is_string($value)) {
|
||||
$value = $this->replaceUrl($value, $httpProtocol, $wsProtocol, $host, $wsHost);
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function replaceUrl(string $url, string $httpProtocol, string $wsProtocol, string $host, string $wsHost): string
|
||||
{
|
||||
// Replace ws/wss URLs with ws subdomain
|
||||
if (str_starts_with($url, 'ws://') || str_starts_with($url, 'wss://')) {
|
||||
$path = parse_url($url, PHP_URL_PATH) ?? '/';
|
||||
|
||||
return $wsProtocol . '://' . $wsHost . ($path !== '/' ? $path : '');
|
||||
}
|
||||
|
||||
// Replace localhost in all URLs
|
||||
$url = preg_replace('#https?://localhost(?::\d+)?#', $httpProtocol . '://' . $host, $url);
|
||||
$url = preg_replace('#wss?://localhost(?::\d+)?#', $wsProtocol . '://' . $wsHost, (string) $url);
|
||||
$url = preg_replace('#localhost(?::\d+)?#', $host, (string) $url);
|
||||
|
||||
// Fix broken escape sequences in URL paths (from invalid JSON in example files)
|
||||
// Pattern: /public\nitro-assets\gamedata or any variation
|
||||
$url = preg_replace('#/public\\n[a-z_-]+\\\\gamedata#i', '/gamedata', (string) $url);
|
||||
$url = preg_replace('#/public\\\\[a-z_-]+\\\\gamedata#i', '/gamedata', (string) $url);
|
||||
$url = preg_replace('#/nitro-assets\\\\gamedata#i', '/gamedata', (string) $url);
|
||||
$url = preg_replace('#/nitro\\\\[a-z_-]+#i', '/gamedata', (string) $url);
|
||||
|
||||
// Fix known asset path patterns
|
||||
$url = str_replace('/public/nitro-assets/gamedata', '/gamedata', $url);
|
||||
$url = str_replace('/swf/gamedata', '/gamedata', $url);
|
||||
|
||||
// Clean up any remaining backslashes in URLs
|
||||
$url = str_replace('\\', '/', $url);
|
||||
|
||||
// Clean up any remaining double slashes (but keep protocol slashes)
|
||||
$url = preg_replace('#([^:])//+#', '$1/', $url);
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function isAssociativeArray(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($arr) !== range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
private function autoDetectAssetPaths(array $config, string $webroot): array
|
||||
{
|
||||
// Check multiple possible gamedata locations
|
||||
$possiblePaths = [
|
||||
$webroot . '/gamedata',
|
||||
'/var/www/Gamedata',
|
||||
'/var/www/gamedata',
|
||||
];
|
||||
$gamedataPath = array_find($possiblePaths, fn ($path) => is_dir($path));
|
||||
|
||||
if (! $gamedataPath) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
$assetDir = opendir($gamedataPath);
|
||||
$actualDirs = [];
|
||||
while (($entry = readdir($assetDir)) !== false) {
|
||||
if ($entry !== '.' && $entry !== '..' && is_dir($gamedataPath . '/' . $entry)) {
|
||||
$actualDirs[strtolower($entry)] = $entry;
|
||||
}
|
||||
}
|
||||
closedir($assetDir);
|
||||
|
||||
$pathChecks = [
|
||||
'pet.asset.url' => 'pets',
|
||||
'furni.asset.url' => 'furniture',
|
||||
'avatar.asset.url' => 'clothes',
|
||||
'avatar.asset.effect.url' => 'effect',
|
||||
'generic.asset.url' => 'generic_custom',
|
||||
];
|
||||
|
||||
foreach ($config as $key => &$value) {
|
||||
if (! is_string($value) || ! isset($pathChecks[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedDir = $pathChecks[$key];
|
||||
$lowerExpected = strtolower($expectedDir);
|
||||
$actualName = null;
|
||||
|
||||
// Special case: "figure" is often used instead of "clothes" for avatars
|
||||
if ($lowerExpected === 'clothes' && isset($actualDirs['figure'])) {
|
||||
$actualName = $actualDirs['figure'];
|
||||
} elseif (isset($actualDirs[$lowerExpected])) {
|
||||
$actualName = $actualDirs[$lowerExpected];
|
||||
} else {
|
||||
foreach ($actualDirs as $actualLower => $actual) {
|
||||
if (str_starts_with($actualLower, rtrim($lowerExpected, 's')) ||
|
||||
str_starts_with(rtrim($actualLower, 's'), rtrim($lowerExpected, 's'))) {
|
||||
$actualName = $actual;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($actualName && $actualName !== $expectedDir) {
|
||||
$value = str_replace("/{$expectedDir}/", "/{$actualName}/", $value);
|
||||
$this->line(" 🔍 Auto-detected: {$key} -> /{$actualName}/ (was /{$expectedDir}/)");
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function syncExampleFromGithub(string $buildPath, string $webroot): void
|
||||
{
|
||||
$this->info('Syncing latest examples from GitHub...');
|
||||
|
||||
$rendererRepo = setting('nitro_github_url', '');
|
||||
$repo = $this->parseRepoFromUrl($rendererRepo);
|
||||
if (! $repo) {
|
||||
$this->warn(' ⚠ No GitHub repo configured, skipping sync');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$branch = setting('nitro_github_branch', 'main');
|
||||
|
||||
$examples = [
|
||||
'renderer-config.example' => 'renderer-config.example',
|
||||
'ui-config.example' => 'ui-config.example',
|
||||
'UITexts.example' => 'UITexts.json',
|
||||
];
|
||||
|
||||
foreach (array_keys($examples) as $remoteFile) {
|
||||
$tempFile = '/tmp/' . $remoteFile . '_' . uniqid();
|
||||
$fetched = false;
|
||||
|
||||
// Try multiple paths: root, public/, nitro-client/dist/, Nitro-V3 paths
|
||||
$paths = ['', 'public/', 'nitro-client/dist/', 'dist/', 'src/', 'assets/'];
|
||||
foreach ($paths as $path) {
|
||||
$url = "https://raw.githubusercontent.com/{$repo}/{$branch}/{$path}{$remoteFile}";
|
||||
$result = Process::timeout(15)->run("curl -sL -o {$tempFile} '{$url}'");
|
||||
|
||||
if ($result->successful() && file_exists($tempFile) && filesize($tempFile) > 10) {
|
||||
$content = file_get_contents($tempFile);
|
||||
$data = @json_decode($content, true);
|
||||
|
||||
if (is_array($data) && $data !== []) {
|
||||
// Save to both buildPath and webroot
|
||||
file_put_contents($buildPath . '/' . $remoteFile, $content);
|
||||
file_put_contents($webroot . '/' . $remoteFile, $content);
|
||||
$this->line(" ✓ Synced: {$remoteFile} (" . count($data) . " keys from {$path}{$remoteFile})");
|
||||
$fetched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@unlink($tempFile);
|
||||
}
|
||||
|
||||
if (! $fetched) {
|
||||
$this->line(" - Skipped: {$remoteFile} (not found in any path)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function parseRepoFromUrl(string $url): ?string
|
||||
{
|
||||
if (preg_match('/github\.com\/([^\/]+\/[^\/\?#]+)/', $url, $matches)) {
|
||||
return rtrim($matches[1], '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function compareConfigs(array $generated, string $name, string $webroot): void
|
||||
{
|
||||
$currentPath = $webroot . '/' . $name . '.json';
|
||||
if (! file_exists($currentPath)) {
|
||||
$this->line(" ℹ {$name}: Geen bestaande config — nieuwe generatie");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$current = @json_decode(file_get_contents($currentPath), true);
|
||||
if (! is_array($current)) {
|
||||
$this->warn(" ⚠ {$name}: Bestaande config is ongeldig JSON");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newKeys = array_diff(array_keys($generated), array_keys($current));
|
||||
$removedKeys = array_diff(array_keys($current), array_keys($generated));
|
||||
$changedKeys = [];
|
||||
|
||||
foreach (array_intersect(array_keys($generated), array_keys($current)) as $key) {
|
||||
if ($generated[$key] !== $current[$key]) {
|
||||
$changedKeys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if ($newKeys !== []) {
|
||||
$this->line(" 🆕 {$name}: " . count($newKeys) . ' nieuwe key(s): ' . implode(', ', array_slice($newKeys, 0, 10)));
|
||||
if (count($newKeys) > 10) {
|
||||
$this->line(' ... en ' . (count($newKeys) - 10) . ' meer');
|
||||
}
|
||||
}
|
||||
if ($removedKeys !== []) {
|
||||
$this->line(" 🗑 {$name}: " . count($removedKeys) . ' verwijderde key(s): ' . implode(', ', array_slice($removedKeys, 0, 5)));
|
||||
}
|
||||
if ($changedKeys !== []) {
|
||||
$this->line(" 🔄 {$name}: " . count($changedKeys) . ' gewijzigde key(s): ' . implode(', ', array_slice($changedKeys, 0, 5)));
|
||||
}
|
||||
if ($newKeys === [] && $removedKeys === [] && $changedKeys === []) {
|
||||
$this->line(" ✓ {$name}: Geen wijzigingen");
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WebsiteAd;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ImportAdsData extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'import:ads-data';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Import ads data from the filesystem';
|
||||
|
||||
private const int CHUNK_SIZE = 100;
|
||||
|
||||
private const array ALLOWED_EXTENSIONS = ['jpeg', 'jpg', 'png', 'gif'];
|
||||
|
||||
public function handle(SettingsService $settingsService): void
|
||||
{
|
||||
$adsPathSetting = $settingsService->getOrDefault('ads_path_filesystem');
|
||||
$adsPath = is_string($adsPathSetting) ? $adsPathSetting : '';
|
||||
|
||||
if (! $this->validatePath($adsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = $this->getImageFiles($adsPath);
|
||||
|
||||
if ($files === []) {
|
||||
$this->warn('No valid image files found in the ads directory.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processFiles($files);
|
||||
|
||||
$this->info('Ads data import completed successfully.');
|
||||
}
|
||||
|
||||
private function validatePath(?string $adsPath): bool
|
||||
{
|
||||
if (in_array($adsPath, [null, '', '0'], true)) {
|
||||
$this->error('Ads path is not configured in website_settings.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_dir($adsPath)) {
|
||||
$this->error("The ads path '{$adsPath}' does not exist in the filesystem.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getImageFiles(string $adsPath): array
|
||||
{
|
||||
return array_filter(scandir($adsPath), function ($file) use ($adsPath) {
|
||||
$filePath = $adsPath . DIRECTORY_SEPARATOR . $file;
|
||||
|
||||
return is_file($filePath) &&
|
||||
in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), self::ALLOWED_EXTENSIONS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $files
|
||||
*/
|
||||
private function processFiles(array $files): void
|
||||
{
|
||||
// Get existing images to avoid duplicates
|
||||
$existingImages = WebsiteAd::query()->pluck('image')->toArray();
|
||||
|
||||
$newFiles = Collection::make($files)
|
||||
->filter(fn ($file) => ! in_array($file, $existingImages))
|
||||
->map(fn ($file) => ['image' => $file])
|
||||
->values();
|
||||
|
||||
$skippedCount = count($files) - $newFiles->count();
|
||||
if ($skippedCount > 0) {
|
||||
$this->warn("Skipped {$skippedCount} existing files.");
|
||||
}
|
||||
|
||||
$newFiles->chunk(self::CHUNK_SIZE)->each(function ($chunk) {
|
||||
WebsiteAd::insert($chunk->all());
|
||||
$this->info('Processed ' . $chunk->count() . ' files.');
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WebsiteBadge;
|
||||
use App\Services\SettingsService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ImportBadgeData extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'import:badge-data';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Import badge data from JSON file';
|
||||
|
||||
private const int CHUNK_SIZE = 100;
|
||||
|
||||
private const string BADGE_PREFIX = 'badge_desc_';
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsService $settingsService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$jsonPathSetting = $this->settingsService->getOrDefault('nitro_external_texts_file');
|
||||
$jsonPath = is_string($jsonPathSetting) ? $jsonPathSetting : '';
|
||||
|
||||
if (! $this->validateJsonFile($jsonPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->processBadgeData($jsonPath);
|
||||
$this->info('Badge data imported successfully.');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to import badge data: ' . $e->getMessage());
|
||||
$this->error('Failed to import badge data. Check the logs for details.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateJsonFile(?string $jsonPath): bool
|
||||
{
|
||||
if (in_array($jsonPath, [null, '', '0'], true)) {
|
||||
$this->error('The JSON file path is not configured in the website settings.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! file_exists($jsonPath)) {
|
||||
$this->error('The JSON file does not exist at the specified path: ' . $jsonPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function processBadgeData(string $jsonPath): void
|
||||
{
|
||||
$jsonData = File::json($jsonPath);
|
||||
|
||||
// Extract badge names and descriptions
|
||||
$badgeNames = Collection::make($jsonData)
|
||||
->filter(fn ($value, $key) => str_starts_with((string) $key, 'badge_name_'))
|
||||
->mapWithKeys(fn ($value, $key) => [str_replace('badge_name_', '', $key) => $value]);
|
||||
|
||||
$badgeDescriptions = Collection::make($jsonData)
|
||||
->filter(fn ($value, $key) => str_starts_with((string) $key, self::BADGE_PREFIX))
|
||||
->mapWithKeys(fn ($value, $key) => [str_replace(self::BADGE_PREFIX, '', $key) => $value]);
|
||||
|
||||
// Combine badge names and descriptions
|
||||
$badgeData = $badgeNames->map(fn ($name, $key) => [
|
||||
'badge_key' => $key, // Use only the badge name (e.g., 14X12, 14XR1)
|
||||
'badge_name' => $name,
|
||||
'badge_description' => $badgeDescriptions->get($key, 'No description available'),
|
||||
])->values();
|
||||
|
||||
// Upsert the combined data in chunks
|
||||
$badgeData->chunk(self::CHUNK_SIZE)->each(function ($chunk) {
|
||||
WebsiteBadge::upsert(
|
||||
$chunk->toArray(),
|
||||
['badge_key'],
|
||||
['badge_name', 'badge_description'],
|
||||
);
|
||||
|
||||
$this->info('Processed ' . $chunk->count() . ' badges.');
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+272
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\AlertChannel;
|
||||
use App\Enums\AlertType;
|
||||
use App\Services\AlertService;
|
||||
use App\Services\NitroUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class NitroUpdateCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'nitro:auto
|
||||
{--force : Forceer update ook al is het niet de geplande tijd}
|
||||
{--build-only : Alleen build en deploy uitvoeren}
|
||||
{--full : Volledige reset en reinstall}
|
||||
{--repair : Probeer Nitro te repareren}
|
||||
{--diagnose : Toon diagnose informatie}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Automatische Nitro client en renderer updates';
|
||||
|
||||
private const string CACHE_KEY_LAST_UPDATE = 'nitro_last_update';
|
||||
|
||||
public function handle(AlertService $alertService, NitroUpdateService $nitroService): int
|
||||
{
|
||||
$this->info('Nitro update check...');
|
||||
|
||||
if ($this->option('diagnose')) {
|
||||
return $this->diagnose($nitroService);
|
||||
}
|
||||
|
||||
if ($this->option('repair')) {
|
||||
return $this->repair($alertService, $nitroService);
|
||||
}
|
||||
|
||||
$isForced = $this->option('force');
|
||||
$buildOnly = $this->option('build-only');
|
||||
$full = $this->option('full');
|
||||
|
||||
if ($buildOnly) {
|
||||
return $this->buildOnly($alertService, $nitroService);
|
||||
}
|
||||
|
||||
if (! $isForced && ! $this->isScheduledTime()) {
|
||||
$this->line('Niet de geplande tijd, overslaan.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$repo = setting('nitro_github_url', 'duckietm/Nitro-V3 (default)');
|
||||
$this->info("GitHub: {$repo}");
|
||||
|
||||
if ($full) {
|
||||
return $this->fullReinstall($alertService, $nitroService);
|
||||
}
|
||||
|
||||
$status = $nitroService->getStatus();
|
||||
|
||||
$this->info('Client: ' . ($status['client_installed'] ? '✅' : '❌'));
|
||||
$this->info('Renderer: ' . ($status['renderer_installed'] ? '✅' : '❌'));
|
||||
$this->info('Build: ' . ($status['build_exists'] ? '✅' : '❌'));
|
||||
$this->info('Deployed: ' . ($status['deployed'] ? '✅' : '❌'));
|
||||
|
||||
if (! $status['client_installed'] || ! $status['renderer_installed']) {
|
||||
$this->warn('Nitro niet volledig geïnstalleerd. Voer --full uit voor volledige installatie.');
|
||||
if ($this->confirm('Wil je nu volledig installeren?')) {
|
||||
return $this->fullReinstall($alertService, $nitroService);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line('✅ Nitro client is up-to-date.');
|
||||
|
||||
if ($isForced) {
|
||||
$this->warn('Force update aangevraagd...');
|
||||
|
||||
return $this->fullReinstall($alertService, $nitroService);
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY_LAST_UPDATE, now()->toIso8601String());
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function diagnose(NitroUpdateService $nitroService): int
|
||||
{
|
||||
$this->info('🔍 Nitro diagnose...');
|
||||
$diagnosis = $nitroService->diagnose();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('=== Controle Resultaten ===');
|
||||
|
||||
$checks = $diagnosis['checks'] ?? [];
|
||||
foreach ($checks as $key => $value) {
|
||||
if (is_bool($value)) {
|
||||
$this->line(($value ? '✅' : '❌') . ' ' . $key);
|
||||
} elseif (is_array($value)) {
|
||||
$this->line($key . ': ' . count($value) . ' items');
|
||||
} else {
|
||||
$this->line($key . ': ' . $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($diagnosis['issues'])) {
|
||||
$this->newLine();
|
||||
$this->error('=== Problemen ===');
|
||||
foreach ($diagnosis['issues'] as $issue) {
|
||||
$this->line('❌ ' . $issue);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($diagnosis['recommendations'])) {
|
||||
$this->newLine();
|
||||
$this->info('=== Aanbevelingen ===');
|
||||
foreach ($diagnosis['recommendations'] as $rec) {
|
||||
$this->line('💡 ' . $rec);
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function repair(AlertService $alertService, NitroUpdateService $nitroService): int
|
||||
{
|
||||
$this->warn('🔧 Nitro repair modus gestart...');
|
||||
$this->info('Dit zal de Nitro installatie controleren en repareren.');
|
||||
|
||||
try {
|
||||
$repairResult = $nitroService->repair();
|
||||
|
||||
if ($repairResult['success']) {
|
||||
$this->info('✅ Repair succesvol!');
|
||||
foreach ($repairResult['actions'] ?? [] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_UPDATE,
|
||||
'Nitro client gerepareerd',
|
||||
['actions' => $repairResult['actions'] ?? []],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('❌ Repair mislukt: ' . ($repairResult['error'] ?? 'Onbekende fout'));
|
||||
if (! empty($repairResult['actions'])) {
|
||||
$this->line('Uitgevoerde acties:');
|
||||
foreach ($repairResult['actions'] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
}
|
||||
if (! empty($repairResult['errors'])) {
|
||||
$this->line('Fouten:');
|
||||
foreach ($repairResult['errors'] as $error) {
|
||||
$this->line(' ❌ ' . $error);
|
||||
}
|
||||
}
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_ERROR,
|
||||
'Nitro repair mislukt: ' . ($repairResult['error'] ?? 'Onbekende fout'),
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::FAILURE;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ Repair exception: ' . $e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildOnly(AlertService $alertService, NitroUpdateService $nitroService): int
|
||||
{
|
||||
$this->info('Build en deploy uitvoeren...');
|
||||
|
||||
$buildResult = $nitroService->buildClient();
|
||||
|
||||
if (! $buildResult['success']) {
|
||||
$this->error('Build mislukt: ' . ($buildResult['error'] ?? 'Onbekend'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$nitroService->deployClient();
|
||||
$nitroService->generateConfigs();
|
||||
|
||||
$this->info('Client succesvol gedeployed!');
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_UPDATE,
|
||||
'Nitro client opnieuw gedeployed',
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function fullReinstall(AlertService $alertService, NitroUpdateService $nitroService): int
|
||||
{
|
||||
$this->warn('Volledige reinstall wordt uitgevoerd...');
|
||||
|
||||
try {
|
||||
$result = $nitroService->updateNitro();
|
||||
|
||||
if ($result['success']) {
|
||||
$this->info('Nitro succesvol opnieuw geïnstalleerd!');
|
||||
$alertService->send(
|
||||
AlertType::EMULATOR_UPDATE,
|
||||
'Nitro client succesvol geüpdatet en gedeployed',
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('Reinstall mislukt: ' . ($result['error'] ?? 'Onbekende fout'));
|
||||
$alertService->send(
|
||||
AlertType::CRITICAL_ERROR,
|
||||
'Nitro Update Mislukt: ' . ($result['error'] ?? 'Onbekende fout'),
|
||||
[],
|
||||
AlertChannel::DISCORD,
|
||||
);
|
||||
|
||||
return Command::FAILURE;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Reinstall exception: ' . $e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function isScheduledTime(): bool
|
||||
{
|
||||
$scheduleTime = setting('nitro_auto_update_schedule', '03:00');
|
||||
$scheduleDays = setting('nitro_auto_update_days', '0,6');
|
||||
$enabled = setting('nitro_auto_update_enabled', false);
|
||||
|
||||
if (! $enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$currentTime = $now->format('H:i');
|
||||
$currentDay = (int) $now->dayOfWeek;
|
||||
|
||||
$allowedDays = array_map(intval(...), explode(',', $scheduleDays));
|
||||
|
||||
if (! in_array($currentDay, $allowedDays)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($currentTime !== $scheduleTime) {
|
||||
$minuteDiff = abs(strtotime($currentTime) - strtotime((string) $scheduleTime)) / 60;
|
||||
if ($minuteDiff > 5) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NitroUpdateService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SwitchNitroBranch extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'app:switch-nitro-branch {--branch=main}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Switch Nitro to a specific branch (runs in background)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$branch = $this->option('branch') ?? 'main';
|
||||
|
||||
$this->info("🔄 Switching Nitro to branch: {$branch}");
|
||||
|
||||
try {
|
||||
$nitroService = new NitroUpdateService;
|
||||
$result = $nitroService->updateNitro(true);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
$this->info("✅ Switched to {$branch} successfully!");
|
||||
$this->info($result['message'] ?? '');
|
||||
Log::info('[NitroSwitch] Success', ['branch' => $branch, 'message' => $result['message'] ?? '']);
|
||||
} else {
|
||||
$this->error('❌ Switch failed: ' . ($result['error'] ?? 'Unknown error'));
|
||||
Log::error('[NitroSwitch] Failed', ['branch' => $branch, 'error' => $result['error'] ?? 'Unknown']);
|
||||
}
|
||||
|
||||
Cache::forget('website_settings');
|
||||
SettingsService::clearCache();
|
||||
|
||||
return ($result['success'] ?? false) ? 0 : 1;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ Exception: ' . $e->getMessage());
|
||||
Log::error('[NitroSwitch] Exception', ['branch' => $branch, 'error' => $e->getMessage()]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+5071
File diff suppressed because it is too large
Load Diff
Executable
+251
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\EmulatorUpdateService;
|
||||
use App\Services\NitroUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class SystemHealthCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'system:health
|
||||
{--json : Output als JSON}
|
||||
{--details : Toon meer details}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Controleer systeem gezondheid';
|
||||
|
||||
public function handle(EmulatorUpdateService $emuService, NitroUpdateService $nitroService): int
|
||||
{
|
||||
$json = $this->option('json');
|
||||
$this->option('details');
|
||||
|
||||
$health = $this->performHealthCheck($emuService, $nitroService);
|
||||
|
||||
if ($json) {
|
||||
$this->line(json_encode($health, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $health['status'] === 'healthy' ? 0 : 1;
|
||||
}
|
||||
|
||||
$this->displayHealthCheck($health);
|
||||
|
||||
return $health['status'] === 'healthy' ? 0 : 1;
|
||||
}
|
||||
|
||||
private function performHealthCheck(EmulatorUpdateService $emuService, NitroUpdateService $nitroService): array
|
||||
{
|
||||
$checks = [];
|
||||
$issues = [];
|
||||
$warnings = [];
|
||||
|
||||
$checks['timestamp'] = now()->toIso8601String();
|
||||
|
||||
$checks['php'] = [
|
||||
'version' => PHP_VERSION,
|
||||
'status' => 'ok',
|
||||
];
|
||||
|
||||
$checks['system'] = [
|
||||
'os' => PHP_OS,
|
||||
'user' => posix_getpwuid(posix_geteuid())['name'] ?? 'unknown',
|
||||
];
|
||||
|
||||
$diskFree = @disk_free_space('/');
|
||||
$diskTotal = @disk_total_space('/');
|
||||
$diskPercent = $diskTotal > 0 ? round(($diskFree / $diskTotal) * 100, 1) : 0;
|
||||
$checks['disk'] = [
|
||||
'free' => $this->formatBytes($diskFree),
|
||||
'total' => $this->formatBytes($diskTotal),
|
||||
'percent_free' => $diskPercent,
|
||||
'status' => $diskPercent > 10 ? 'ok' : 'critical',
|
||||
];
|
||||
if ($diskPercent < 10) {
|
||||
$issues[] = 'Disk space critically low: ' . $diskPercent . '% free';
|
||||
} elseif ($diskPercent < 20) {
|
||||
$warnings[] = 'Disk space low: ' . $diskPercent . '% free';
|
||||
}
|
||||
|
||||
try {
|
||||
$emuDiagnosis = $emuService->diagnose();
|
||||
$checks['emulator'] = [
|
||||
'configured' => $emuDiagnosis['checks']['is_configured'] ?? false,
|
||||
'jar_exists' => $emuDiagnosis['checks']['jar_exists'] ?? false,
|
||||
'service_running' => $emuDiagnosis['checks']['service_running'] ?? false,
|
||||
'db_connected' => $emuDiagnosis['checks']['emulator_db_connected'] ?? false,
|
||||
'status' => empty($emuDiagnosis['issues']) ? 'ok' : 'issues',
|
||||
'issues' => $emuDiagnosis['issues'] ?? [],
|
||||
];
|
||||
|
||||
if (! empty($emuDiagnosis['issues'])) {
|
||||
foreach ($emuDiagnosis['issues'] as $issue) {
|
||||
$issues[] = 'Emulator: ' . $issue;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$checks['emulator'] = [
|
||||
'status' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
$issues[] = 'Emulator check failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
$nitroDiagnosis = $nitroService->diagnose();
|
||||
$checks['nitro'] = [
|
||||
'client_installed' => $nitroDiagnosis['checks']['client_installed'] ?? false,
|
||||
'renderer_installed' => $nitroDiagnosis['checks']['renderer_installed'] ?? false,
|
||||
'deployed' => $nitroDiagnosis['checks']['deployed'] ?? false,
|
||||
'status' => empty($nitroDiagnosis['issues']) ? 'ok' : 'issues',
|
||||
'issues' => $nitroDiagnosis['issues'] ?? [],
|
||||
];
|
||||
|
||||
if (! empty($nitroDiagnosis['issues'])) {
|
||||
foreach ($nitroDiagnosis['issues'] as $issue) {
|
||||
$issues[] = 'Nitro: ' . $issue;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$checks['nitro'] = [
|
||||
'status' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
$issues[] = 'Nitro check failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
try {
|
||||
$sqlDiagnosis = $emuService->diagnoseSqlUpdates();
|
||||
$checks['sql_updates'] = [
|
||||
'table_exists' => $sqlDiagnosis['table_exists'] ?? false,
|
||||
'applied' => $sqlDiagnosis['applied_count'] ?? 0,
|
||||
'pending' => $sqlDiagnosis['pending_count'] ?? 0,
|
||||
'status' => ($sqlDiagnosis['pending_count'] ?? 0) > 0 ? 'pending' : 'ok',
|
||||
];
|
||||
|
||||
if (($sqlDiagnosis['pending_count'] ?? 0) > 0) {
|
||||
$warnings[] = $sqlDiagnosis['pending_count'] . ' SQL updates pending';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$checks['sql_updates'] = [
|
||||
'status' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$webserverCheck = Process::timeout(5)->run('which nginx || which apache2 || which httpd');
|
||||
$checks['webserver'] = [
|
||||
'installed' => $webserverCheck->successful(),
|
||||
'status' => $webserverCheck->successful() ? 'ok' : 'unknown',
|
||||
];
|
||||
|
||||
$mysqlCheck = Process::timeout(5)->run('which mysql || which mariadb');
|
||||
$checks['database'] = [
|
||||
'client_installed' => $mysqlCheck->successful(),
|
||||
'status' => $mysqlCheck->successful() ? 'ok' : 'unknown',
|
||||
];
|
||||
|
||||
$nodeCheck = Process::timeout(5)->run('which node && which yarn');
|
||||
$checks['node'] = [
|
||||
'installed' => $nodeCheck->successful(),
|
||||
'status' => $nodeCheck->successful() ? 'ok' : 'missing',
|
||||
];
|
||||
if (! $nodeCheck->successful()) {
|
||||
$warnings[] = 'Node.js/Yarn niet geïnstalleerd';
|
||||
}
|
||||
|
||||
$status = $issues === [] ? 'healthy' : ($warnings === [] ? 'warning' : 'degraded');
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'checks' => $checks,
|
||||
'issues' => $issues,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
private function displayHealthCheck(array $health): void
|
||||
{
|
||||
$status = $health['status'];
|
||||
|
||||
$statusIcon = match ($status) {
|
||||
'healthy' => '✅',
|
||||
'warning' => '⚠️',
|
||||
'degraded' => '❌',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$this->info("{$statusIcon} Systeem Gezondheid: " . strtoupper($status));
|
||||
$this->line('═══════════════════════════════════════════════');
|
||||
|
||||
$checks = $health['checks'] ?? [];
|
||||
|
||||
$this->line('📦 Systeem:');
|
||||
$this->line(' PHP: ' . ($checks['php']['version'] ?? '?'));
|
||||
$this->line(' User: ' . ($checks['system']['user'] ?? '?'));
|
||||
|
||||
$diskStatus = $checks['disk']['status'] ?? '?';
|
||||
$diskIcon = $diskStatus === 'ok' ? '✅' : '❌';
|
||||
$this->line(" {$diskIcon} Disk: " . ($checks['disk']['percent_free'] ?? '?') . '% vrij');
|
||||
|
||||
$emuStatus = $checks['emulator']['status'] ?? '?';
|
||||
$emuIcon = $emuStatus === 'ok' ? '✅' : '⚠️';
|
||||
$this->line(" {$emuIcon} Emulator: " . ucfirst($emuStatus));
|
||||
|
||||
$nitroStatus = $checks['nitro']['status'] ?? '?';
|
||||
$nitroIcon = $nitroStatus === 'ok' ? '✅' : '⚠️';
|
||||
$this->line(" {$nitroIcon} Nitro: " . ucfirst($nitroStatus));
|
||||
|
||||
$sqlStatus = $checks['sql_updates']['status'] ?? '?';
|
||||
$sqlIcon = $sqlStatus === 'ok' ? '✅' : '⚠️';
|
||||
$this->line(" {$sqlIcon} SQL Updates: " . ucfirst($sqlStatus) . ' (' . ($checks['sql_updates']['applied'] ?? 0) . ' toegepast)');
|
||||
|
||||
$nodeStatus = $checks['node']['status'] ?? '?';
|
||||
$nodeIcon = $nodeStatus === 'ok' ? '✅' : '❌';
|
||||
$this->line(" {$nodeIcon} Node.js: " . ucfirst($nodeStatus));
|
||||
|
||||
if (! empty($health['issues'])) {
|
||||
$this->line('');
|
||||
$this->error('❌ Problemen:');
|
||||
foreach ($health['issues'] as $issue) {
|
||||
$this->line(' - ' . $issue);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($health['warnings'])) {
|
||||
$this->line('');
|
||||
$this->warn('⚠️ Waarschuwingen:');
|
||||
foreach ($health['warnings'] as $warning) {
|
||||
$this->line(' - ' . $warning);
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('═══════════════════════════════════════════════');
|
||||
|
||||
if ($status === 'healthy') {
|
||||
$this->info('✅ Alles werkt correct!');
|
||||
} else {
|
||||
$this->warn('Run "php artisan system:repair" om problemen op te lossen');
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBytes(?float $bytes): string
|
||||
{
|
||||
if ($bytes === null) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = 0;
|
||||
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AlertService;
|
||||
use App\Services\EmulatorUpdateService;
|
||||
use App\Services\NitroUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class SystemRepairCommand extends Command
|
||||
{
|
||||
#[\Override]
|
||||
protected $signature = 'system:repair
|
||||
{--emu : Alleen emulator repareren}
|
||||
{--nitro : Alleen Nitro repareren}
|
||||
{--check : Alleen controleren, niet repareren}
|
||||
{--force : Forceer reparatie ook al is alles OK}
|
||||
{--full : Volledige reset en reinstall}
|
||||
{--nuke : Alles verwijderen en opnieuw installeren (gevaarlijk!)}';
|
||||
|
||||
#[\Override]
|
||||
protected $description = 'Automatische systeem reparatie voor emulator en Nitro';
|
||||
|
||||
private const string CACHE_KEY_LAST_REPAIR = 'system_last_repair';
|
||||
|
||||
public function handle(AlertService $alertService, EmulatorUpdateService $emuService, NitroUpdateService $nitroService): int
|
||||
{
|
||||
$this->info('🔧 System Repair Service gestart...');
|
||||
$this->line('═══════════════════════════════════════════════');
|
||||
|
||||
$checkOnly = $this->option('check');
|
||||
$force = $this->option('force');
|
||||
$full = $this->option('full');
|
||||
$emuOnly = $this->option('emu');
|
||||
$nitroOnly = $this->option('nitro');
|
||||
|
||||
$results = [
|
||||
'emulator' => null,
|
||||
'nitro' => null,
|
||||
];
|
||||
|
||||
if (! $nitroOnly) {
|
||||
$results['emulator'] = $this->repairEmulator($emuService, $checkOnly, $force, $full);
|
||||
}
|
||||
|
||||
if (! $emuOnly) {
|
||||
$results['nitro'] = $this->repairNitro($nitroService, $checkOnly, $force, $full);
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY_LAST_REPAIR, now()->toIso8601String());
|
||||
|
||||
$emuOk = $results['emulator'] === null || ($results['emulator']['success'] ?? false);
|
||||
$nitroOk = $results['nitro'] === null || ($results['nitro']['success'] ?? false);
|
||||
|
||||
$this->line('═══════════════════════════════════════════════');
|
||||
if ($emuOk && $nitroOk) {
|
||||
$this->info('✅ Alle systemen OK');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function ensureBaseDirectories(): void
|
||||
{
|
||||
$basePaths = [
|
||||
'/var/www',
|
||||
'/var/www/atomcms',
|
||||
storage_path('app'),
|
||||
];
|
||||
|
||||
foreach ($basePaths as $path) {
|
||||
if (! is_dir($path)) {
|
||||
@mkdir($path, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
Process::timeout(5)->run('chown -R www-data:www-data /var/www/atomcms 2>/dev/null || true');
|
||||
Process::timeout(5)->run('chmod -R 755 /var/www/atomcms 2>/dev/null || true');
|
||||
}
|
||||
|
||||
private function repairEmulator(EmulatorUpdateService $service, bool $checkOnly, bool $force, bool $full): array
|
||||
{
|
||||
$this->info('Emulator controleren...');
|
||||
|
||||
$this->ensureBaseDirectories();
|
||||
|
||||
try {
|
||||
$diagnosis = $service->diagnose();
|
||||
|
||||
if (empty($diagnosis['issues']) && ! $force && ! $full) {
|
||||
$this->line(' ✅ Emulator is OK');
|
||||
|
||||
return ['success' => true, 'status' => 'ok'];
|
||||
}
|
||||
|
||||
if ($checkOnly) {
|
||||
$this->warn(' ⚠️ Emulator problemen gevonden:');
|
||||
foreach ($diagnosis['issues'] ?? [] as $issue) {
|
||||
$this->line(' - ' . $issue);
|
||||
}
|
||||
foreach ($diagnosis['recommendations'] ?? [] as $rec) {
|
||||
$this->line(' 💡 ' . $rec);
|
||||
}
|
||||
|
||||
return ['success' => false, 'status' => 'issues_found', 'issues' => $diagnosis['issues']];
|
||||
}
|
||||
|
||||
$this->warn(' 🔧 Emulator wordt gerepareerd...');
|
||||
|
||||
if ($full) {
|
||||
$this->line(' 📦 Volledige reset...');
|
||||
$repairResult = $service->repairEmulator();
|
||||
} else {
|
||||
$repairResult = $service->repairEmulator();
|
||||
}
|
||||
|
||||
if ($repairResult['success']) {
|
||||
$this->info(' ✅ Emulator gerepareerd!');
|
||||
foreach ($repairResult['actions'] ?? [] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
|
||||
return ['success' => true, 'status' => 'repaired', 'actions' => $repairResult['actions']];
|
||||
}
|
||||
|
||||
$this->error(' ❌ Emulator repair mislukt: ' . ($repairResult['error'] ?? 'Onbekend'));
|
||||
if (! empty($repairResult['actions'])) {
|
||||
$this->line(' Uitgevoerde acties:');
|
||||
foreach ($repairResult['actions'] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => false, 'status' => 'failed', 'error' => $repairResult['error']];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ❌ Emulator exception: ' . $e->getMessage());
|
||||
Log::error('[SystemRepair] Emulator exception', ['error' => $e->getMessage()]);
|
||||
|
||||
return ['success' => false, 'status' => 'exception', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function repairNitro(NitroUpdateService $service, bool $checkOnly, bool $force, bool $full): array
|
||||
{
|
||||
$this->info('Nitro controleren...');
|
||||
|
||||
$this->ensureBaseDirectories();
|
||||
|
||||
try {
|
||||
$diagnosis = $service->diagnose();
|
||||
|
||||
if (empty($diagnosis['issues']) && ! $force && ! $full) {
|
||||
$this->line(' ✅ Nitro is OK');
|
||||
|
||||
return ['success' => true, 'status' => 'ok'];
|
||||
}
|
||||
|
||||
if ($checkOnly) {
|
||||
$this->warn(' ⚠️ Nitro problemen gevonden:');
|
||||
foreach ($diagnosis['issues'] ?? [] as $issue) {
|
||||
$this->line(' - ' . $issue);
|
||||
}
|
||||
foreach ($diagnosis['recommendations'] ?? [] as $rec) {
|
||||
$this->line(' 💡 ' . $rec);
|
||||
}
|
||||
|
||||
return ['success' => false, 'status' => 'issues_found', 'issues' => $diagnosis['issues']];
|
||||
}
|
||||
|
||||
$this->warn(' 🔧 Nitro wordt gerepareerd...');
|
||||
|
||||
if ($full) {
|
||||
$this->line(' 📦 Volledige reset...');
|
||||
$repairResult = $service->updateNitro();
|
||||
} else {
|
||||
$repairResult = $service->repair();
|
||||
}
|
||||
|
||||
if ($repairResult['success']) {
|
||||
$this->info(' ✅ Nitro gerepareerd!');
|
||||
foreach ($repairResult['actions'] ?? [] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
|
||||
return ['success' => true, 'status' => 'repaired', 'actions' => $repairResult['actions']];
|
||||
}
|
||||
|
||||
$this->error(' ❌ Nitro repair mislukt: ' . ($repairResult['error'] ?? 'Onbekend'));
|
||||
if (! empty($repairResult['actions'])) {
|
||||
$this->line(' Uitgevoerde acties:');
|
||||
foreach ($repairResult['actions'] as $action) {
|
||||
$this->line(' - ' . $action);
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => false, 'status' => 'failed', 'error' => $repairResult['error']];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' ❌ Nitro exception: ' . $e->getMessage());
|
||||
Log::error('[SystemRepair] Nitro exception', ['error' => $e->getMessage()]);
|
||||
|
||||
return ['success' => false, 'status' => 'exception', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console;
|
||||
|
||||
use App\Console\Commands\AutoUpdateCommand;
|
||||
use App\Console\Commands\DDoSDetectionCommand;
|
||||
use App\Console\Commands\EmulatorMonitorCommand;
|
||||
use App\Console\Commands\EmulatorUpdateCommand;
|
||||
use App\Console\Commands\FixCodeCommand;
|
||||
use App\Console\Commands\GenerateNitroConfigs;
|
||||
use App\Console\Commands\NitroUpdateCommand;
|
||||
use App\Console\Commands\SystemCheckCommand;
|
||||
use App\Console\Commands\SystemHealthCommand;
|
||||
use App\Console\Commands\SystemRepairCommand;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
#[\Override]
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
$schedule->command('radio:check-dj')->everyMinute()->withoutOverlapping();
|
||||
$schedule->command('maintenance:check-scheduled')->everyMinute()->withoutOverlapping();
|
||||
$schedule->command('monitor:emulator')->everyMinute()->withoutOverlapping();
|
||||
$schedule->command('monitor:ddos')->everyFiveMinutes()->withoutOverlapping();
|
||||
$schedule->command('update:auto')->everyMinute()->withoutOverlapping();
|
||||
$schedule->command('nitro:auto')->everyMinute()->withoutOverlapping();
|
||||
$schedule->command('system:repair')->everyTenMinutes()->withoutOverlapping();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function commands(): void
|
||||
{
|
||||
$this->load(__DIR__ . '/Commands');
|
||||
|
||||
$this->commands[] = SystemCheckCommand::class;
|
||||
$this->commands[] = FixCodeCommand::class;
|
||||
$this->commands[] = EmulatorMonitorCommand::class;
|
||||
$this->commands[] = DDoSDetectionCommand::class;
|
||||
$this->commands[] = EmulatorUpdateCommand::class;
|
||||
$this->commands[] = AutoUpdateCommand::class;
|
||||
$this->commands[] = NitroUpdateCommand::class;
|
||||
$this->commands[] = SystemRepairCommand::class;
|
||||
$this->commands[] = SystemHealthCommand::class;
|
||||
$this->commands[] = GenerateNitroConfigs::class;
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AchievementCategory: string
|
||||
{
|
||||
case Identity = 'identity';
|
||||
case Explore = 'explore';
|
||||
case Music = 'music';
|
||||
case Social = 'social';
|
||||
case Games = 'games';
|
||||
case RoomBuilder = 'room_builder';
|
||||
case Pets = 'pets';
|
||||
case Tools = 'tools';
|
||||
case Events = 'events';
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toInput(): array
|
||||
{
|
||||
$allCurrencies = self::cases();
|
||||
|
||||
return array_combine(
|
||||
array_column($allCurrencies, 'value'),
|
||||
array_column($allCurrencies, 'name'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AlertChannel: string
|
||||
{
|
||||
case EMAIL = 'email';
|
||||
case DISCORD = 'discord';
|
||||
case BOTH = 'both';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EMAIL => 'E-mail',
|
||||
self::DISCORD => 'Discord',
|
||||
self::BOTH => 'Beide',
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AlertSeverity: string
|
||||
{
|
||||
case INFO = 'info';
|
||||
case WARNING = 'warning';
|
||||
case ERROR = 'error';
|
||||
case CRITICAL = 'critical';
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::INFO => 'blue',
|
||||
self::WARNING => 'yellow',
|
||||
self::ERROR => 'orange',
|
||||
self::CRITICAL => 'red',
|
||||
};
|
||||
}
|
||||
|
||||
public function getEmoji(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::INFO => 'ℹ️',
|
||||
self::WARNING => '⚠️',
|
||||
self::ERROR => '❌',
|
||||
self::CRITICAL => '🚨',
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AlertType: string
|
||||
{
|
||||
case EMULATOR_OFFLINE = 'emulator_offline';
|
||||
case EMULATOR_ONLINE = 'emulator_online';
|
||||
case EMULATOR_ERROR = 'emulator_error';
|
||||
case DDOS_DETECTED = 'ddos_detected';
|
||||
case DDOS_BLOCKED = 'ddos_blocked';
|
||||
case HIGH_ERROR_RATE = 'high_error_rate';
|
||||
case CRITICAL_ERROR = 'critical_error';
|
||||
case QUEUE_FAILED = 'queue_failed';
|
||||
case DATABASE_ERROR = 'database_error';
|
||||
case SSL_EXPIRED = 'ssl_expired';
|
||||
case DISK_SPACE_LOW = 'disk_space_low';
|
||||
case MEMORY_WARNING = 'memory_warning';
|
||||
case EMULATOR_UPDATE = 'emulator_update';
|
||||
case SQL_UPDATE = 'sql_update';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EMULATOR_OFFLINE => 'Emulator Offline',
|
||||
self::EMULATOR_ONLINE => 'Emulator Online',
|
||||
self::EMULATOR_ERROR => 'Emulator Error',
|
||||
self::DDOS_DETECTED => 'DDoS Gedetecteerd',
|
||||
self::DDOS_BLOCKED => 'DDoS Geblokkeerd',
|
||||
self::HIGH_ERROR_RATE => 'Hoge Error Rate',
|
||||
self::CRITICAL_ERROR => 'Kritieke Error',
|
||||
self::QUEUE_FAILED => 'Queue Failed',
|
||||
self::DATABASE_ERROR => 'Database Error',
|
||||
self::SSL_EXPIRED => 'SSL Verlopen',
|
||||
self::DISK_SPACE_LOW => 'Weinig Schijfruimte',
|
||||
self::MEMORY_WARNING => 'Geheugen Waarschuwing',
|
||||
self::EMULATOR_UPDATE => 'Emulator Update',
|
||||
self::SQL_UPDATE => 'SQL Update',
|
||||
};
|
||||
}
|
||||
|
||||
public function getSeverity(): AlertSeverity
|
||||
{
|
||||
return match ($this) {
|
||||
self::EMULATOR_OFFLINE, self::DDOS_DETECTED, self::CRITICAL_ERROR => AlertSeverity::CRITICAL,
|
||||
self::EMULATOR_ERROR, self::HIGH_ERROR_RATE, self::QUEUE_FAILED, self::DATABASE_ERROR, self::SSL_EXPIRED => AlertSeverity::ERROR,
|
||||
self::DDOS_BLOCKED, self::DISK_SPACE_LOW, self::MEMORY_WARNING => AlertSeverity::WARNING,
|
||||
self::EMULATOR_UPDATE, self::SQL_UPDATE => AlertSeverity::INFO,
|
||||
self::EMULATOR_ONLINE => AlertSeverity::INFO,
|
||||
};
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EMULATOR_OFFLINE => 'heroicon-o-server',
|
||||
self::EMULATOR_ONLINE => 'heroicon-o-server',
|
||||
self::EMULATOR_ERROR => 'heroicon-o-exclamation-triangle',
|
||||
self::DDOS_DETECTED => 'heroicon-o-shield-exclamation',
|
||||
self::DDOS_BLOCKED => 'heroicon-o-shield-check',
|
||||
self::HIGH_ERROR_RATE => 'heroicon-o-arrow-trending-up',
|
||||
self::CRITICAL_ERROR => 'heroicon-o-x-circle',
|
||||
self::QUEUE_FAILED => 'heroicon-o-queue-list',
|
||||
self::DATABASE_ERROR => 'heroicon-o-database',
|
||||
self::SSL_EXPIRED => 'heroicon-o-lock-closed',
|
||||
self::DISK_SPACE_LOW => 'heroicon-o-hard-drive',
|
||||
self::MEMORY_WARNING => 'heroicon-o-cpu-chip',
|
||||
self::EMULATOR_UPDATE => 'heroicon-o-arrow-down-tray',
|
||||
self::SQL_UPDATE => 'heroicon-o-document-arrow-down',
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CurrencyTypes: int
|
||||
{
|
||||
case Credits = -1;
|
||||
case Duckets = 0;
|
||||
case Diamonds = 5;
|
||||
case Points = 101;
|
||||
|
||||
/**
|
||||
* @return array<int>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
public static function fromCurrencyName(string $currencyName): ?self
|
||||
{
|
||||
$currencyName = strtolower($currencyName);
|
||||
|
||||
return match ($currencyName) {
|
||||
'credits' => self::Credits,
|
||||
'duckets' => self::Duckets,
|
||||
'diamonds' => self::Diamonds,
|
||||
'points' => self::Points,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getImage(): string
|
||||
{
|
||||
return match ($this->value) {
|
||||
CurrencyTypes::Credits->value => asset('assets/images/currencies/credits.gif'),
|
||||
CurrencyTypes::Duckets->value => asset('assets/images/currencies/duckets.png'),
|
||||
CurrencyTypes::Diamonds->value => asset('assets/images/currencies/diamonds.png'),
|
||||
CurrencyTypes::Points->value => asset('assets/images/currencies/points.png'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function toInput(): array
|
||||
{
|
||||
$allCurrencies = self::cases();
|
||||
|
||||
return array_combine(
|
||||
array_column($allCurrencies, 'value'),
|
||||
array_column($allCurrencies, 'name'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationType: string
|
||||
{
|
||||
case ArticlePosted = 'article_posted';
|
||||
case HousekeepingCustomMessage = 'housekeeping_custom_message';
|
||||
}
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Services\AlertService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
#[\Override]
|
||||
protected $levels = [
|
||||
//
|
||||
];
|
||||
|
||||
#[\Override]
|
||||
protected $dontReport = [
|
||||
//
|
||||
];
|
||||
|
||||
#[\Override]
|
||||
protected $dontFlash = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
private const string CACHE_KEY_ERROR_COUNT = 'error_count_';
|
||||
|
||||
private const int ERROR_COUNT_DURATION = 300;
|
||||
|
||||
private const array AUTO_RECOVERABLE_ERRORS = [
|
||||
'ViteManifestNotFoundException',
|
||||
'filemtime',
|
||||
'stat failed for',
|
||||
'No application encryption key has been specified',
|
||||
'file does not exist',
|
||||
'view [',
|
||||
'Target class [view] does not exist',
|
||||
'BindingResolutionException',
|
||||
];
|
||||
|
||||
private const int AUTO_RECOVER_COOLDOWN = 60;
|
||||
|
||||
#[\Override]
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
$this->attemptAutoRecovery($e);
|
||||
$this->handleExceptionAlert($e);
|
||||
});
|
||||
|
||||
$this->renderable(function (Throwable $e) {
|
||||
$recovered = $this->attemptAutoRecovery($e);
|
||||
if ($recovered) {
|
||||
return redirect()->to(request()->url());
|
||||
}
|
||||
|
||||
$this->handleExceptionAlert($e);
|
||||
});
|
||||
}
|
||||
|
||||
private function attemptAutoRecovery(Throwable $e): bool
|
||||
{
|
||||
$message = $e->getMessage() ?: '';
|
||||
$exceptionClass = $e::class;
|
||||
$isRecoverable = array_any(self::AUTO_RECOVERABLE_ERRORS, fn ($pattern) => str_contains($exceptionClass, (string) $pattern) || str_contains($message, (string) $pattern));
|
||||
|
||||
if (! $isRecoverable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cacheKey = 'auto_recovery_cooldown';
|
||||
if (Cache::has($cacheKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Cache::put($cacheKey, true, self::AUTO_RECOVER_COOLDOWN);
|
||||
|
||||
Log::warning('Attempting auto-recovery from error', [
|
||||
'exception' => $exceptionClass,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
try {
|
||||
Artisan::call('view:clear');
|
||||
Artisan::call('cache:clear');
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('route:clear');
|
||||
|
||||
Artisan::call('config:cache');
|
||||
Artisan::call('view:cache');
|
||||
|
||||
if (str_contains($exceptionClass, 'ViteManifestNotFoundException') || str_contains($message, 'Vite manifest')) {
|
||||
$this->rebuildViteManifest();
|
||||
}
|
||||
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
}
|
||||
|
||||
Log::info('Auto-recovery completed successfully');
|
||||
|
||||
return true;
|
||||
} catch (Throwable $recoveryError) {
|
||||
Log::error('Auto-recovery failed', [
|
||||
'original_error' => $message,
|
||||
'recovery_error' => $recoveryError->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function rebuildViteManifest(): void
|
||||
{
|
||||
$manifestPath = public_path('build/manifest.json');
|
||||
|
||||
if (! file_exists($manifestPath)) {
|
||||
Log::warning('Vite manifest missing, attempting rebuild');
|
||||
|
||||
$result = Process::timeout(120)->run('npm run build');
|
||||
|
||||
if ($result->successful()) {
|
||||
Log::info('Vite manifest rebuilt successfully');
|
||||
|
||||
if (file_exists('/var/www/atomcms/public/build')) {
|
||||
Process::run('chown -R www-data:www-data /var/www/atomcms/public/build');
|
||||
Process::run('chmod -R 775 /var/www/atomcms/public/build');
|
||||
}
|
||||
} else {
|
||||
Log::error('Vite manifest rebuild failed', [
|
||||
'output' => $result->output(),
|
||||
'error' => $result->errorOutput(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleExceptionAlert(Throwable $e): void
|
||||
{
|
||||
if (! $this->shouldAlertException($e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$alertService = app(AlertService::class);
|
||||
|
||||
$errorMessage = $e->getMessage() ?: $e::class;
|
||||
|
||||
$alertService->sendCriticalError($errorMessage, $e);
|
||||
|
||||
$this->trackErrorRate();
|
||||
|
||||
Log::channel('emergency')->error('Critical exception reported via alert', [
|
||||
'exception' => $e::class,
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
} catch (\Exception $alertException) {
|
||||
Log::error('Failed to send exception alert: ' . $alertException->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldAlertException(Throwable $e): bool
|
||||
{
|
||||
if (! app()->isBooted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! (bool) setting('alert_errors_enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$criticalExceptions = [
|
||||
QueryException::class,
|
||||
RconConnectionException::class,
|
||||
];
|
||||
|
||||
foreach ($criticalExceptions as $criticalException) {
|
||||
if ($e instanceof $criticalException) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isHighErrorRate()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$minSeverity = setting('alert_min_severity', 'error');
|
||||
|
||||
return $this->isSeverityHighEnough($e, $minSeverity);
|
||||
}
|
||||
|
||||
private function trackErrorRate(): void
|
||||
{
|
||||
if (! app()->isBooted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = self::CACHE_KEY_ERROR_COUNT . now()->format('YmdHi');
|
||||
|
||||
$count = Cache::get($key, 0);
|
||||
Cache::put($key, $count + 1, self::ERROR_COUNT_DURATION);
|
||||
}
|
||||
|
||||
private function isHighErrorRate(): bool
|
||||
{
|
||||
if (! app()->isBooted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$threshold = (int) setting('alert_error_threshold', 10);
|
||||
$key = self::CACHE_KEY_ERROR_COUNT . now()->format('YmdHi');
|
||||
|
||||
$count = Cache::get($key, 0);
|
||||
|
||||
return $count >= $threshold;
|
||||
}
|
||||
|
||||
private function isSeverityHighEnough(Throwable $e, string $minSeverity): bool
|
||||
{
|
||||
$severityLevel = [
|
||||
'info' => 0,
|
||||
'warning' => 1,
|
||||
'error' => 2,
|
||||
'critical' => 3,
|
||||
];
|
||||
|
||||
$exceptionSeverity = $this->determineExceptionSeverity($e);
|
||||
$minLevel = $severityLevel[$minSeverity] ?? 2;
|
||||
|
||||
return $exceptionSeverity >= $minLevel;
|
||||
}
|
||||
|
||||
private function determineExceptionSeverity(Throwable $e): int
|
||||
{
|
||||
if ($e instanceof \Error) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if ($e instanceof ValidationException) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($e instanceof AuthenticationException) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($e instanceof AuthorizationException) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class MigrationFailedException extends Exception
|
||||
{
|
||||
/**
|
||||
* MigrationFailedException constructor.
|
||||
*/
|
||||
public function __construct(string $message = 'Migration failed', int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the exception message with additional context
|
||||
*/
|
||||
public function getDetailedMessage(): string
|
||||
{
|
||||
return 'Migration failed: ' . $this->getMessage() . ' (Code: ' . $this->getCode() . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the exception code with default
|
||||
*/
|
||||
public function getErrorCode(): int
|
||||
{
|
||||
return $this->getCode() ?: 500;
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class RconConnectionException extends Exception {}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Filters;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DateRangeFilter extends Filter
|
||||
{
|
||||
#[\Override]
|
||||
public static function make(?string $name = null): static
|
||||
{
|
||||
return parent::make($name)
|
||||
->schema([
|
||||
DatePicker::make("{$name}_from"),
|
||||
DatePicker::make("{$name}_until"),
|
||||
])
|
||||
->query(fn (Builder $query, array $data): Builder => $query
|
||||
->when(
|
||||
$data["{$name}_from"],
|
||||
fn (Builder $query, ?string $date) => $query->whereDate($name ?? '', '>=', $date),
|
||||
)
|
||||
->when(
|
||||
$data["{$name}_until"],
|
||||
fn (Builder $query, ?string $date) => $query->whereDate($name ?? '', '<=', $date),
|
||||
));
|
||||
}
|
||||
}
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\User;
|
||||
use App\Services\Parsers\ExternalTextsParser;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Action as PageAction;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @property-read Form $form
|
||||
*/
|
||||
class BadgePage extends Page
|
||||
{
|
||||
use InteractsWithForms, TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Hotel';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.badge-page';
|
||||
|
||||
protected static string $translateIdentifier = 'badge-resource';
|
||||
|
||||
public bool $badgeWasPreviouslyCreated = false;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = ['code' => '', 'image' => '', 'nitro' => ['title' => '', 'description' => '']];
|
||||
|
||||
public static string $roleName = 'badge_page';
|
||||
|
||||
#[\Override]
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = auth()->user();
|
||||
|
||||
return $user && $user->can('view::admin::' . static::$roleName);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return (string) __(
|
||||
sprintf('filament::resources.resources.%s.navigation_label', static::$translateIdentifier),
|
||||
);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make((string) __('filament::resources.tabs.Main'))
|
||||
->schema([
|
||||
TextInput::make('code')
|
||||
->label((string) __('filament::resources.inputs.badge_code'))
|
||||
->helperText((string) __('filament::resources.helpers.badge_code_helper'))
|
||||
->afterStateUpdated(function (?string $state, Set $set) {
|
||||
$set('code', strtoupper((string) $state));
|
||||
})
|
||||
->suffixAction(fn (): PageAction => PageAction::make('search')->icon('heroicon-o-magnifying-glass')->action(fn () => $this->searchBadgesByCode()),
|
||||
),
|
||||
|
||||
TextInput::make('image')
|
||||
->label((string) __('filament::resources.inputs.badge_image'))
|
||||
->placeholder('...')
|
||||
->autocomplete()
|
||||
->visible(fn (Get $get) => isset($this->data['image']))
|
||||
->prefixAction(
|
||||
fn (?string $state): PageAction => PageAction::make('visit')
|
||||
->icon('heroicon-s-arrow-top-right-on-square')
|
||||
->tooltip((string) __('filament::resources.common.Open link'))
|
||||
->url($state)
|
||||
->visible(fn () => ! in_array($state, [null, '', '0'], true))
|
||||
->openUrlInNewTab(),
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make(__('Nitro Texts'))
|
||||
->collapsible()
|
||||
->visible(fn () => isset($this->data['nitro']) && ! empty($this->data['nitro']))
|
||||
->schema([
|
||||
TextInput::make('nitro.title')
|
||||
->label(__('filament::resources.inputs.badge_title'))
|
||||
->placeholder('...')
|
||||
->visible(fn () => isset($this->data['nitro']['title'])),
|
||||
|
||||
TextInput::make('nitro.description')
|
||||
->label(__('filament::resources.inputs.badge_description'))
|
||||
->placeholder('...')
|
||||
->visible(fn () => isset($this->data['nitro']['description'])),
|
||||
]),
|
||||
|
||||
Section::make(__('Flash Texts'))
|
||||
->collapsible()
|
||||
->visible(fn () => isset($this->data['flash']) && ! empty($this->data['flash']))
|
||||
->schema([
|
||||
TextInput::make('flash.title')
|
||||
->label(__('filament::resources.inputs.badge_title'))
|
||||
->placeholder('...')
|
||||
->visible(fn () => isset($this->data['flash']['title'])),
|
||||
|
||||
TextInput::make('flash.description')
|
||||
->label(__('filament::resources.inputs.badge_description'))
|
||||
->placeholder('...')
|
||||
->visible(fn () => isset($this->data['flash']['description'])),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
private function searchBadgesByCode(): void
|
||||
{
|
||||
$badgeCode = $this->form->getState()['code'] ?? null;
|
||||
|
||||
if (empty($badgeCode)) {
|
||||
Notification::make()
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->iconColor('danger')
|
||||
->color('danger')
|
||||
->title(__('filament::resources.notifications.badge_code_required'))
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$badgeData = app(ExternalTextsParser::class)->getBadgeData($badgeCode);
|
||||
$this->badgeWasPreviouslyCreated = is_array($badgeData['nitro'] ?? null) || is_array($badgeData['flash'] ?? null);
|
||||
|
||||
if ($this->badgeWasPreviouslyCreated) {
|
||||
Notification::make()
|
||||
->icon('heroicon-o-check-circle')
|
||||
->iconColor('success')
|
||||
->color('success')
|
||||
->title(__('filament::resources.notifications.badge_found'))
|
||||
->send();
|
||||
|
||||
$this->data = [
|
||||
'code' => $badgeCode,
|
||||
...$this->getDefaultDataBehavior(
|
||||
$badgeData['image'] ?? null,
|
||||
$badgeData['nitro']['title'] ?? null,
|
||||
$badgeData['nitro']['description'] ?? null,
|
||||
$badgeData['flash']['title'] ?? null,
|
||||
$badgeData['flash']['description'] ?? null,
|
||||
),
|
||||
];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->color('success')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->iconColor('success')
|
||||
->title(__('filament::resources.notifications.create_badge'))
|
||||
->send();
|
||||
|
||||
$this->data = [
|
||||
'code' => $badgeCode,
|
||||
...$this->getDefaultDataBehavior(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDefaultDataBehavior(
|
||||
?string $badgeImageUrl = null,
|
||||
?string $nitroTitle = null,
|
||||
?string $nitroDesc = null,
|
||||
?string $flashTitle = null,
|
||||
?string $flashDesc = null,
|
||||
): array {
|
||||
return [
|
||||
'image' => $badgeImageUrl ?? '',
|
||||
'nitro' => [
|
||||
'title' => $nitroTitle ?? '',
|
||||
'description' => $nitroDesc ?? '',
|
||||
],
|
||||
'flash' => [
|
||||
'title' => $flashTitle ?? '',
|
||||
'description' => $flashDesc ?? '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$nitroEnabled = config('hotel.client.nitro.enabled');
|
||||
$flashEnabled = config('hotel.client.flash.enabled');
|
||||
|
||||
// image and code fields are required when creating a new badge
|
||||
if (! $this->badgeWasPreviouslyCreated && (empty($this->data['image']) || empty($this->data['code']))) {
|
||||
$notificationTitle = empty($this->data['image']) ?
|
||||
__('filament::resources.notifications.badge_image_required') :
|
||||
__('filament::resources.notifications.badge_code_required');
|
||||
|
||||
Notification::make()
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->iconColor('danger')
|
||||
->color('danger')
|
||||
->title($notificationTitle)
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$externalTextsParser = app(ExternalTextsParser::class);
|
||||
|
||||
if ((empty($this->data['nitro']) && $nitroEnabled) || (empty($this->data['flash']) && $flashEnabled)) {
|
||||
Notification::make()
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->iconColor('danger')
|
||||
->color('danger')
|
||||
->title(__('filament::resources.notifications.badge_texts_required'))
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->uploadBadgeImage($externalTextsParser);
|
||||
|
||||
$nitroData = $this->data['nitro'] ?? [];
|
||||
if (! empty($nitroData) && $nitroEnabled && is_array($nitroData)) {
|
||||
$externalTextsParser->updateNitroBadgeTexts($this->data['code'], ...$nitroData);
|
||||
}
|
||||
|
||||
$flashData = $this->data['flash'] ?? [];
|
||||
if (! empty($flashData) && $flashEnabled && is_array($flashData)) {
|
||||
$externalTextsParser->updateFlashBadgeTexts($this->data['code'], ...$flashData);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('badge')->error('[ORION BADGE RESOURCE] - ERROR: ' . $exception->getMessage());
|
||||
|
||||
Notification::make()
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->iconColor('danger')
|
||||
->color('danger')
|
||||
->title(__('filament::resources.notifications.badge_update_failed'))
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->data['image'] = $externalTextsParser->getBadgeImageUrl($this->data['code']);
|
||||
$this->badgeWasPreviouslyCreated = true;
|
||||
|
||||
Notification::make()
|
||||
->icon('heroicon-o-check-circle')
|
||||
->iconColor('success')
|
||||
->color('success')
|
||||
->title(__('filament::resources.notifications.badge_updated'))
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function uploadBadgeImage(ExternalTextsParser $parser): void
|
||||
{
|
||||
if (empty($this->data['image']) || ! filter_var($this->data['image'], FILTER_VALIDATE_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->data['image'] == $parser->getBadgeImageUrl($this->data['code'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image = Http::get($this->data['image']);
|
||||
|
||||
if (! $image->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$contentType = $image->header('content-type');
|
||||
|
||||
$gdImage = match ($contentType) {
|
||||
'image/png' => imagecreatefrompng($this->data['image']),
|
||||
'image/gif' => imagecreatefromgif($this->data['image']),
|
||||
'image/jpeg' => imagecreatefromjpeg($this->data['image']),
|
||||
default => false
|
||||
};
|
||||
|
||||
if ($gdImage === false) {
|
||||
Notification::make()
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->iconColor('danger')
|
||||
->color('danger')
|
||||
->title(__('filament::resources.notifications.badge_image_upload_failed'))
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadPath = public_path(sprintf('%s%s%s.gif',
|
||||
rtrim((string) config('hotel.client.flash.relative_files_path'), '\//'),
|
||||
'/c_images/album1584/',
|
||||
$this->data['code'],
|
||||
));
|
||||
|
||||
imagegif($gdImage, $uploadPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
PageAction::make('save')
|
||||
->label(__('filament::resources.common.Update'))
|
||||
->action(fn () => $this->create())
|
||||
->color('primary')
|
||||
->visible(fn () => isset($this->data['code']) && $this->badgeWasPreviouslyCreated),
|
||||
|
||||
PageAction::make('create')
|
||||
->label(__('filament::resources.common.Create'))
|
||||
->action(fn () => $this->create())
|
||||
->color('success')
|
||||
->visible(fn () => isset($this->data['code']) && ! $this->badgeWasPreviouslyCreated),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use Filament\Pages\Dashboard as FilamentDashboard;
|
||||
|
||||
class Dashboard extends FilamentDashboard
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Dashboard';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Homepage');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-home';
|
||||
|
||||
public static string $translateIdentifier = 'dashboard';
|
||||
}
|
||||
Executable
+2197
File diff suppressed because it is too large
Load Diff
Executable
+114
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class Login extends \Filament\Auth\Pages\Login
|
||||
{
|
||||
public string $username = '';
|
||||
|
||||
#[\Override]
|
||||
public function authenticate(): ?LoginResponse
|
||||
{
|
||||
try {
|
||||
$this->rateLimit(15);
|
||||
} catch (TooManyRequestsException $exception) {
|
||||
$titleMessage = __('filament-panels::pages/auth/login.notifications.throttled.title', [
|
||||
'seconds' => $exception->secondsUntilAvailable,
|
||||
'minutes' => ceil($exception->secondsUntilAvailable / 60),
|
||||
]);
|
||||
|
||||
$bodyMessage = __('filament-panels::pages/auth/login.notifications.throttled.body', [
|
||||
'seconds' => $exception->secondsUntilAvailable,
|
||||
'minutes' => ceil($exception->secondsUntilAvailable / 60),
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title($titleMessage)
|
||||
->body(is_string($bodyMessage) ? $bodyMessage : null)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->form->getState();
|
||||
|
||||
if (! Filament::auth()->attempt($this->getCredentialsFromFormData($data), $data['remember'] ?? false)) {
|
||||
$this->throwFailureValidationException();
|
||||
}
|
||||
|
||||
$user = Filament::auth()->user();
|
||||
|
||||
$panel = Filament::getCurrentOrDefaultPanel();
|
||||
if (
|
||||
($user instanceof FilamentUser) &&
|
||||
(! $user->canAccessPanel($panel))
|
||||
) {
|
||||
Filament::auth()->logout();
|
||||
|
||||
$this->throwFailureValidationException();
|
||||
}
|
||||
|
||||
session()->regenerate();
|
||||
|
||||
return app(LoginResponse::class);
|
||||
}
|
||||
|
||||
protected function throwFailureValidationException(): never
|
||||
{
|
||||
throw ValidationException::withMessages([
|
||||
'data.username' => __('filament-panels::pages/auth/login.messages.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('username')
|
||||
->label(__('filament::login.fields.username.label'))
|
||||
->required()
|
||||
->autocomplete(),
|
||||
TextInput::make('password')
|
||||
->label(__('filament::login.fields.password.label'))
|
||||
->password()
|
||||
->required(),
|
||||
Checkbox::make('remember')
|
||||
->label(__('filament::login.fields.remember.label')),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function getEmailFormComponent(): Component
|
||||
{
|
||||
return TextInput::make('username')
|
||||
->label(__('filament::login.fields.username.label'))
|
||||
->required()
|
||||
->autocomplete()
|
||||
->autofocus()
|
||||
->extraInputAttributes(['tabindex' => 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
#[\Override]
|
||||
protected function getCredentialsFromFormData(array $data): array
|
||||
{
|
||||
return [
|
||||
'username' => $data['username'],
|
||||
'password' => $data['password'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+2854
File diff suppressed because it is too large
Load Diff
+1944
File diff suppressed because it is too large
Load Diff
+239
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Radio;
|
||||
|
||||
use App\Models\RadioContest;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
final class ContestManagement extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-trophy';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Contests');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $title = 'Competities Beheer';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.radio.contest-management';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$contests = RadioContest::orderBy('created_at', 'desc')->get();
|
||||
|
||||
$this->data['contests'] = $contests->toArray();
|
||||
$this->data['active_contest'] = $contests->firstWhere('is_active', true)?->id;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Nieuwe Competitie Aanmaken')
|
||||
->description('Maak een nieuwe competitie aan')
|
||||
->columns(1)
|
||||
->headerActions([
|
||||
Action::make('create_contest')
|
||||
->label('Competitie Aanmaken')
|
||||
->action('createContest')
|
||||
->color('success'),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('contest.title')
|
||||
->label('Titel')
|
||||
->required()
|
||||
->placeholder('Bijv. Beste Song Suggestie'),
|
||||
Textarea::make('contest.description')
|
||||
->label('Beschrijving')
|
||||
->rows(3)
|
||||
->placeholder('Beschrijf de competitie regels...'),
|
||||
TextInput::make('contest.prize')
|
||||
->label('Prijs')
|
||||
->required()
|
||||
->placeholder('Bijv. VIP voor 1 maand'),
|
||||
TextInput::make('contest.max_winners')
|
||||
->label('Aantal Winnaars')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->placeholder('1'),
|
||||
DateTimePicker::make('contest.starts_at')
|
||||
->label('Start Datum/Tijd')
|
||||
->required()
|
||||
->default(now()),
|
||||
DateTimePicker::make('contest.ends_at')
|
||||
->label('Eind Datum/Tijd')
|
||||
->required()
|
||||
->default(now()->addDays(14)),
|
||||
Toggle::make('contest.require_approval')
|
||||
->label('Goedkeuring Vereist')
|
||||
->default(false),
|
||||
]),
|
||||
Section::make('Actieve Competities')
|
||||
->description('Beheer lopende competities')
|
||||
->columns(1)
|
||||
->schema([
|
||||
Select::make('selected_contest')
|
||||
->label('Selecteer Competitie')
|
||||
->options(RadioContest::where('is_active', true)->pluck('title', 'id'))
|
||||
->placeholder('Kies een competitie...')
|
||||
->live(),
|
||||
]),
|
||||
Section::make('Acties')
|
||||
->description('Competitie beheer')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Action::make('end_contest')
|
||||
->label('Competitie Beëindigen')
|
||||
->action('endContest')
|
||||
->color('danger')
|
||||
->requiresConfirmation(),
|
||||
Action::make('declare_winners')
|
||||
->label('Winnaars Bekend Maken')
|
||||
->action('declareWinners')
|
||||
->color('warning')
|
||||
->requiresConfirmation(),
|
||||
]),
|
||||
Section::make('Afgelopen Competities')
|
||||
->description('Bekijk resultaten van afgelopen competities')
|
||||
->columns(1)
|
||||
->schema([
|
||||
Select::make('ended_contest')
|
||||
->label('Selecteer Afgelopen Competitie')
|
||||
->options(RadioContest::where('is_ended', true)->pluck('title', 'id'))
|
||||
->placeholder('Kies een competitie...'),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function createContest(): void
|
||||
{
|
||||
$data = $this->data['contest'] ?? [];
|
||||
|
||||
$contest = RadioContest::create([
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'prize' => $data['prize'],
|
||||
'max_winners' => $data['max_winners'] ?? 1,
|
||||
'starts_at' => $data['starts_at'] ?? now(),
|
||||
'ends_at' => $data['ends_at'] ?? now()->addDays(14),
|
||||
'is_active' => true,
|
||||
'is_ended' => false,
|
||||
'winners_announced' => false,
|
||||
'require_approval' => $data['require_approval'] ?? false,
|
||||
]);
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Competitie Aangemaakt')
|
||||
->body("Competitie '{$contest->title}' is succesvol aangemaakt!")
|
||||
->send();
|
||||
}
|
||||
|
||||
public function endContest(): void
|
||||
{
|
||||
$contestId = $this->data['selected_contest'] ?? null;
|
||||
|
||||
if (! $contestId) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Geen Competitie Geselecteerd')
|
||||
->body('Selecteer eerst een competitie.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contest = RadioContest::find($contestId);
|
||||
|
||||
$contest->update(['is_ended' => true, 'is_active' => false]);
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Competitie Beëindigd')
|
||||
->body("Competitie '{$contest->title}' is beëindigd.")
|
||||
->send();
|
||||
}
|
||||
|
||||
public function declareWinners(): void
|
||||
{
|
||||
$contestId = $this->data['selected_contest'] ?? null;
|
||||
|
||||
if (! $contestId) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Geen Competitie Geselecteerd')
|
||||
->body('Selecteer eerst een competitie.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contest = RadioContest::find($contestId);
|
||||
$entries = $contest->entries()->with('user')->get();
|
||||
|
||||
if ($entries->isEmpty()) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Geen Inzendingen')
|
||||
->body('Er zijn nog geen inzendingen.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$winners = $entries->random(min($contest->max_winners, $entries->count()));
|
||||
|
||||
foreach ($winners as $winner) {
|
||||
$winner->markAsWinner();
|
||||
}
|
||||
|
||||
$contest->update(['winners_announced' => true]);
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Winnaars Bekend!')
|
||||
->body(count($winners) . ' winnaars zijn geselecteerd!')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Radio;
|
||||
|
||||
use App\Models\RadioGiveaway;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
final class GiveawayManagement extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-gift';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Giveaways');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $title = 'Winacties Beheer';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.radio.giveaway-management';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = [];
|
||||
|
||||
public RadioGiveaway $currentGiveaway;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$giveaways = RadioGiveaway::orderBy('created_at', 'desc')->get();
|
||||
|
||||
$this->data['giveaways'] = $giveaways->toArray();
|
||||
$this->data['active_giveaway'] = $giveaways->firstWhere('is_active', true)?->id;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Nieuwe Winactie Aanmaken')
|
||||
->description('Maak een nieuwe winactie aan voor luisteraars')
|
||||
->columns(1)
|
||||
->headerActions([
|
||||
Action::make('create_giveaway')
|
||||
->label('Winactie Aanmaken')
|
||||
->action('createGiveaway')
|
||||
->color('success'),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('giveaway.title')
|
||||
->label('Titel')
|
||||
->required()
|
||||
->placeholder('Bijv. Win VIP voor 1 maand!'),
|
||||
Textarea::make('giveaway.description')
|
||||
->label('Beschrijving')
|
||||
->rows(3)
|
||||
->placeholder('Beschrijf de winactie en de prijs...'),
|
||||
TextInput::make('giveaway.prize')
|
||||
->label('Prijs')
|
||||
->required()
|
||||
->placeholder('Bijv. VIP Lifetime'),
|
||||
TextInput::make('giveaway.prize_value')
|
||||
->label('Prijs Waarde (EUR)')
|
||||
->numeric()
|
||||
->placeholder('Bijv. 25.00'),
|
||||
DateTimePicker::make('giveaway.starts_at')
|
||||
->label('Start Datum/Tijd')
|
||||
->required()
|
||||
->default(now()),
|
||||
DateTimePicker::make('giveaway.ends_at')
|
||||
->label('Eind Datum/Tijd')
|
||||
->required()
|
||||
->default(now()->addDays(7)),
|
||||
]),
|
||||
Section::make('Actieve Winacties')
|
||||
->description('Beheer lopende winacties')
|
||||
->columns(1)
|
||||
->schema([
|
||||
Select::make('selected_giveaway')
|
||||
->label('Selecteer Winactie')
|
||||
->options(RadioGiveaway::where('is_active', true)->pluck('title', 'id'))
|
||||
->placeholder('Kies een winactie...')
|
||||
->live()
|
||||
->afterStateUpdated(function ($state) {
|
||||
if ($state) {
|
||||
$this->currentGiveaway = RadioGiveaway::find($state);
|
||||
}
|
||||
}),
|
||||
]),
|
||||
Section::make('Acties')
|
||||
->description('Winactie beheer')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Action::make('export_participants')
|
||||
->label('Deelnemers Exporteren')
|
||||
->action('exportParticipants')
|
||||
->color('info'),
|
||||
Action::make('pick_winner')
|
||||
->label('Winnaar Trekken')
|
||||
->action('pickWinner')
|
||||
->color('warning')
|
||||
->requiresConfirmation(),
|
||||
Action::make('end_giveaway')
|
||||
->label('Winactie Beëindigen')
|
||||
->action('endGiveaway')
|
||||
->color('danger')
|
||||
->requiresConfirmation(),
|
||||
]),
|
||||
Section::make('Afgelopen Winacties')
|
||||
->description('Bekijk resultaten van afgelopen winacties')
|
||||
->columns(1)
|
||||
->schema([
|
||||
Select::make('ended_giveaway')
|
||||
->label('Selecteer Afgelopen Winactie')
|
||||
->options(RadioGiveaway::where('is_ended', true)->pluck('title', 'id'))
|
||||
->placeholder('Kies een winactie...'),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function createGiveaway(): void
|
||||
{
|
||||
$data = $this->data['giveaway'] ?? [];
|
||||
|
||||
$giveaway = RadioGiveaway::create([
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'prize' => $data['prize'],
|
||||
'prize_value' => $data['prize_value'] ?? 0,
|
||||
'starts_at' => $data['starts_at'] ?? now(),
|
||||
'ends_at' => $data['ends_at'] ?? now()->addDays(7),
|
||||
'is_active' => true,
|
||||
'is_ended' => false,
|
||||
'winner_announced' => false,
|
||||
]);
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Winactie Aangemaakt')
|
||||
->body("Winactie '{$giveaway->title}' is succesvol aangemaakt!")
|
||||
->send();
|
||||
}
|
||||
|
||||
public function pickWinner(): void
|
||||
{
|
||||
$giveawayId = $this->data['selected_giveaway'] ?? null;
|
||||
|
||||
if (! $giveawayId) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Geen Winactie Geselecteerd')
|
||||
->body('Selecteer eerst een winactie.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$giveaway = RadioGiveaway::find($giveawayId);
|
||||
|
||||
if (! $giveaway) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Winactie Niet Gevonden')
|
||||
->body('De geselecteerde winactie bestaat niet meer.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$participants = $giveaway->participants()->with('user')->get();
|
||||
|
||||
if ($participants->isEmpty()) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Geen Deelnemers')
|
||||
->body('Er zijn nog geen deelnemers voor deze winactie.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$winner = $participants->random();
|
||||
|
||||
$giveaway->announceWinner($winner->user);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Winnaar Gekozen!')
|
||||
->body("Gefeliciteerd {$winner->user->username}! Je hebt {$giveaway->prize} gewonnen!")
|
||||
->send();
|
||||
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
public function endGiveaway(): void
|
||||
{
|
||||
$giveawayId = $this->data['selected_giveaway'] ?? null;
|
||||
|
||||
if (! $giveawayId) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Geen Winactie Geselecteerd')
|
||||
->body('Selecteer eerst een winactie.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$giveaway = RadioGiveaway::find($giveawayId);
|
||||
|
||||
$giveaway->update(['is_ended' => true, 'is_active' => false]);
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Winactie Beëindigd')
|
||||
->body("Winactie '{$giveaway->title}' is beëindigd.")
|
||||
->send();
|
||||
}
|
||||
|
||||
public function exportParticipants(): void
|
||||
{
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Export Gestart')
|
||||
->body('Deelnemerslijst wordt gedownload...')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
Executable
+245
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Radio;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use App\Models\RadioListenerPoint;
|
||||
use App\Models\User;
|
||||
use App\Services\PointsService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Slider;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
final class PointsSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-currency-euro';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Points System');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $title = 'Punten Systeem';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.radio.points-settings';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = [];
|
||||
|
||||
private PointsService $pointsService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pointsService = new PointsService;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->data = [
|
||||
'points_enabled' => $this->getSettingBool('points_enabled'),
|
||||
'points_per_minute' => (int) $this->getSetting('points_per_minute', '1'),
|
||||
'max_points_per_day' => (int) $this->getSetting('max_points_per_day', '100'),
|
||||
'points_for_request' => (int) $this->getSetting('points_for_request', '5'),
|
||||
'points_for_vote' => (int) $this->getSetting('points_for_vote', '2'),
|
||||
'points_for_giveaway_win' => (int) $this->getSetting('points_for_giveaway_win', '50'),
|
||||
'points_for_contest_win' => (int) $this->getSetting('points_for_contest_win', '100'),
|
||||
'points_for_shout' => (int) $this->getSetting('points_for_shout', '1'),
|
||||
];
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Punten Configuratie')
|
||||
->description('Configureer het punten systeem')
|
||||
->columns(2)
|
||||
->headerActions([
|
||||
Action::make('save_points')
|
||||
->label('Opslaan')
|
||||
->action('savePoints')
|
||||
->color('primary'),
|
||||
])
|
||||
->schema([
|
||||
Toggle::make('points_enabled')
|
||||
->label('Punten Systeem Inschakelen')
|
||||
->columnSpanFull(),
|
||||
Section::make('Luisteren')
|
||||
->description('Punten voor luisteren')
|
||||
->columns(1)
|
||||
->schema([
|
||||
Slider::make('points_per_minute')
|
||||
->label('Punten per Minuut')
|
||||
->minValue(0)
|
||||
->maxValue(10)
|
||||
->step(0.5)
|
||||
->default(1),
|
||||
TextInput::make('max_points_per_day')
|
||||
->label('Max. Punten per Dag')
|
||||
->numeric()
|
||||
->default(100),
|
||||
]),
|
||||
Section::make('Acties')
|
||||
->description('Punten voor acties')
|
||||
->columns(1)
|
||||
->schema([
|
||||
TextInput::make('points_for_request')
|
||||
->label('Punten voor Song Request')
|
||||
->numeric()
|
||||
->default(5),
|
||||
TextInput::make('points_for_vote')
|
||||
->label('Punten voor Stemmen')
|
||||
->numeric()
|
||||
->default(2),
|
||||
TextInput::make('points_for_shout')
|
||||
->label('Punten voor Shout')
|
||||
->numeric()
|
||||
->default(1),
|
||||
]),
|
||||
Section::make('Winnaars')
|
||||
->description('Punten voor winst')
|
||||
->columns(1)
|
||||
->schema([
|
||||
TextInput::make('points_for_giveaway_win')
|
||||
->label('Punten voor Winactie Winst')
|
||||
->numeric()
|
||||
->default(50),
|
||||
TextInput::make('points_for_contest_win')
|
||||
->label('Punten voor Competitie Winst')
|
||||
->numeric()
|
||||
->default(100),
|
||||
]),
|
||||
]),
|
||||
Section::make('Acties')
|
||||
->description('Systeem acties')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Action::make('reset_leaderboard')
|
||||
->label('Leaderboard Resetten')
|
||||
->action('resetLeaderboard')
|
||||
->color('warning')
|
||||
->requiresConfirmation(),
|
||||
Action::make('view_stats')
|
||||
->label('Bekijk Statistieken')
|
||||
->action('viewStats')
|
||||
->color('info'),
|
||||
Action::make('export_leaderboard')
|
||||
->label('Exporteren')
|
||||
->action('exportLeaderboard')
|
||||
->color('secondary'),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function savePoints(): void
|
||||
{
|
||||
$keys = [
|
||||
'points_enabled',
|
||||
'points_per_minute',
|
||||
'max_points_per_day',
|
||||
'points_for_request',
|
||||
'points_for_vote',
|
||||
'points_for_shout',
|
||||
'points_for_giveaway_win',
|
||||
'points_for_contest_win',
|
||||
];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$value = $this->data[$key] ?? '';
|
||||
|
||||
if ($key === 'points_enabled') {
|
||||
$dbValue = $value ? '1' : '0';
|
||||
} else {
|
||||
$dbValue = (string) $value;
|
||||
}
|
||||
|
||||
WebsiteSetting::updateOrCreate(['key' => $key], ['value' => $dbValue]);
|
||||
}
|
||||
|
||||
$this->pointsService->clearSettingsCache();
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('Saved'))
|
||||
->body('Punten instellingen zijn bijgewerkt!')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function resetLeaderboard(): void
|
||||
{
|
||||
User::where('radio_points', '>', 0)->update(['radio_points' => 0]);
|
||||
RadioListenerPoint::query()->delete();
|
||||
|
||||
$this->pointsService->clearLeaderboardCache();
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Leaderboard Gereset')
|
||||
->body('Alle punten zijn gewist.')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function viewStats(): void
|
||||
{
|
||||
$stats = $this->pointsService->getStats();
|
||||
|
||||
Notification::make()
|
||||
->title('Punten Statistieken')
|
||||
->body(
|
||||
"Totale punten: {$stats['total_points_awarded']}\n" .
|
||||
"Actieve gebruikers: {$stats['total_active_users']}",
|
||||
)
|
||||
->info()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function exportLeaderboard(): void
|
||||
{
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Export Gestart')
|
||||
->body('Leaderboard wordt gedownload...')
|
||||
->send();
|
||||
}
|
||||
|
||||
private function getSettingBool(string $key): bool
|
||||
{
|
||||
return (bool) WebsiteSetting::where('key', $key)->first()?->value;
|
||||
}
|
||||
|
||||
private function getSetting(string $key, string $default = ''): string
|
||||
{
|
||||
return WebsiteSetting::where('key', $key)->first()?->value ?? $default;
|
||||
}
|
||||
}
|
||||
Executable
+1630
File diff suppressed because it is too large
Load Diff
+310
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Radio;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use App\Services\StreamMonitoringService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class StreamMonitoring extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-signal';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Stream Status');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $title = 'Stream Monitoring';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.radio.stream-monitoring';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = [];
|
||||
|
||||
private StreamMonitoringService $monitoringService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->monitoringService = new StreamMonitoringService;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->data = [
|
||||
'radio_stream_url' => $this->getSetting('radio_stream_url', ''),
|
||||
'radio_monitoring_enabled' => $this->getSettingBool('radio_monitoring_enabled'),
|
||||
'radio_monitoring_interval' => (int) $this->getSetting('radio_monitoring_interval', '5'),
|
||||
'radio_alert_email' => $this->getSetting('radio_alert_email', ''),
|
||||
'radio_alert_enabled' => $this->getSettingBool('radio_alert_enabled'),
|
||||
];
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Monitoring')
|
||||
->tabs([
|
||||
Tab::make('Huidige Status')
|
||||
->icon('heroicon-o-signal')
|
||||
->schema([
|
||||
Section::make('Stream Status')
|
||||
->description('Real-time stream monitoring')
|
||||
->columns(2)
|
||||
->headerActions([
|
||||
Action::make('refresh')
|
||||
->label('Vernieuwen')
|
||||
->action('refreshStatus')
|
||||
->color('primary')
|
||||
->icon('heroicon-o-arrow-path'),
|
||||
])
|
||||
->schema([
|
||||
Section::make('Info')
|
||||
->description('Stream informatie')
|
||||
->columns(1)
|
||||
->schema([
|
||||
TextInput::make('status.online')
|
||||
->label('Status')
|
||||
->disabled(),
|
||||
TextInput::make('status.listeners')
|
||||
->label('Luisteraars')
|
||||
->disabled(),
|
||||
TextInput::make('status.bitrate')
|
||||
->label('Bitrate')
|
||||
->disabled(),
|
||||
TextInput::make('status.format')
|
||||
->label('Formaat')
|
||||
->disabled(),
|
||||
TextInput::make('status.server')
|
||||
->label('Server')
|
||||
->disabled(),
|
||||
]),
|
||||
]),
|
||||
Section::make('Uptime')
|
||||
->description('Uptime statistieken')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('uptime.last_online')
|
||||
->label('Laatst Online')
|
||||
->disabled(),
|
||||
TextInput::make('uptime.uptime_pct')
|
||||
->label('Uptime %')
|
||||
->disabled(),
|
||||
]),
|
||||
]),
|
||||
Tab::make('Instellingen')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->schema([
|
||||
Section::make('Monitoring Configuratie')
|
||||
->description('Configureer monitoring')
|
||||
->columns(2)
|
||||
->headerActions([
|
||||
Action::make('save_monitoring')
|
||||
->label('Opslaan')
|
||||
->action('saveMonitoring')
|
||||
->color('primary'),
|
||||
])
|
||||
->schema([
|
||||
Toggle::make('radio_monitoring_enabled')
|
||||
->label('Monitoring Inschakelen')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('radio_monitoring_interval')
|
||||
->label('Check Interval (minuten)')
|
||||
->numeric()
|
||||
->default(5),
|
||||
]),
|
||||
Section::make('Alert Instellingen')
|
||||
->description('Alert e-mails')
|
||||
->columns(2)
|
||||
->headerActions([
|
||||
Action::make('test_alert')
|
||||
->label('Test Alert')
|
||||
->action('testAlert')
|
||||
->color('info'),
|
||||
])
|
||||
->schema([
|
||||
Toggle::make('radio_alert_enabled')
|
||||
->label('Alerts Inschakelen')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('radio_alert_email')
|
||||
->label('Alert E-mail')
|
||||
->email()
|
||||
->placeholder('admin@jouwdomein.nl'),
|
||||
]),
|
||||
Section::make('Stream URL')
|
||||
->description('Stream configuratie')
|
||||
->columns(1)
|
||||
->schema([
|
||||
TextInput::make('radio_stream_url')
|
||||
->label('Stream URL')
|
||||
->url()
|
||||
->placeholder('https://radio.nl/stream')
|
||||
->columnSpanFull(),
|
||||
Action::make('test_stream')
|
||||
->label('Test Stream')
|
||||
->action('testStream')
|
||||
->color('success'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function refreshStatus(): void
|
||||
{
|
||||
$status = $this->monitoringService->getStatus();
|
||||
|
||||
$this->data['status'] = [
|
||||
'online' => $status['online'] ? 'Online' : 'Offline',
|
||||
'listeners' => $status['listeners'],
|
||||
'bitrate' => $status['bitrate'] ? $status['bitrate'] . ' kbps' : 'N/A',
|
||||
'format' => $status['format'] ?? 'N/A',
|
||||
'server' => $status['server_type'] ?? 'N/A',
|
||||
];
|
||||
|
||||
$uptime = $this->monitoringService->getUptime();
|
||||
|
||||
$this->data['uptime'] = [
|
||||
'last_online' => $uptime['last_online_human'] ?? 'Nooit',
|
||||
'uptime_pct' => ($uptime['uptime_percentage'] ?? 0) . '%',
|
||||
];
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Status Vernieuwd')
|
||||
->body($status['online'] ? 'Stream is Online' : 'Stream is Offline')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function saveMonitoring(): void
|
||||
{
|
||||
$keys = [
|
||||
'radio_monitoring_enabled', 'radio_monitoring_interval',
|
||||
'radio_alert_enabled', 'radio_alert_email',
|
||||
];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$value = $this->data[$key] ?? '';
|
||||
|
||||
if (in_array($key, ['radio_monitoring_enabled', 'radio_alert_enabled'])) {
|
||||
$dbValue = $value ? '1' : '0';
|
||||
} else {
|
||||
$dbValue = (string) $value;
|
||||
}
|
||||
|
||||
WebsiteSetting::updateOrCreate(['key' => $key], ['value' => $dbValue]);
|
||||
}
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('Saved'))
|
||||
->body('Monitoring instellingen zijn bijgewerkt!')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function testStream(): void
|
||||
{
|
||||
$url = $this->data['radio_stream_url'] ?? '';
|
||||
|
||||
if (empty($url)) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('URL Ontbreekt')
|
||||
->body('Voer eerst een stream URL in.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->head($url);
|
||||
|
||||
if ($response->successful()) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Stream Bereikbaar!')
|
||||
->body('De stream URL is correct.')
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Stream Niet Bereikbaar')
|
||||
->body('Status: ' . $response->status())
|
||||
->send();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Verbindingsfout')
|
||||
->body($e->getMessage())
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function testAlert(): void
|
||||
{
|
||||
$email = $this->data['radio_alert_email'] ?? '';
|
||||
|
||||
if (empty($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('Ongeldig E-mail')
|
||||
->body('Voer een geldig e-mailadres in.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Test Alert')
|
||||
->body('Test alert zou verstuurd moeten worden.')
|
||||
->send();
|
||||
}
|
||||
|
||||
private function getSettingBool(string $key): bool
|
||||
{
|
||||
return (bool) WebsiteSetting::where('key', $key)->first()?->value;
|
||||
}
|
||||
|
||||
private function getSetting(string $key, string $default = ''): string
|
||||
{
|
||||
return WebsiteSetting::where('key', $key)->first()?->value ?? $default;
|
||||
}
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Radio;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use App\Services\ContentModerationService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Slider;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class WordFilterSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Word Filter');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $title = 'AI Woord Filter';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.radio.word-filter-settings';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = [];
|
||||
|
||||
/** @var array<mixed> */
|
||||
public array $blockedWords = [];
|
||||
|
||||
private ContentModerationService $moderationService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->moderationService = new ContentModerationService;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->data = [
|
||||
'ai_filter_enabled' => $this->getSettingBool('ai_filter_enabled'),
|
||||
'ai_filter_auto_reject' => $this->getSettingBool('ai_filter_auto_reject'),
|
||||
'ai_filter_sensitivity' => (float) $this->getSetting('ai_filter_sensitivity', '0.7'),
|
||||
'ai_filter_method' => $this->getSetting('ai_filter_method', 'local'),
|
||||
'ai_filter_shouts' => $this->getSettingBool('ai_filter_shouts'),
|
||||
'ai_filter_chat' => $this->getSettingBool('ai_filter_chat'),
|
||||
'ai_filter_applications' => $this->getSettingBool('ai_filter_applications'),
|
||||
'ai_filter_requests' => $this->getSettingBool('ai_filter_requests'),
|
||||
'ai_filter_log_rejected' => $this->getSettingBool('ai_filter_log_rejected'),
|
||||
'openai_api_key' => $this->getSetting('openai_api_key', ''),
|
||||
];
|
||||
|
||||
$this->blockedWords = array_map(fn ($word) => ['word' => $word], $this->moderationService->getBlockedWords());
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Algemene Instellingen')
|
||||
->description('Basis configuratie voor het AI woordfilter')
|
||||
->columns(2)
|
||||
->afterHeader([
|
||||
Action::make('test_filter')
|
||||
->label('Filter Testen')
|
||||
->action('testFilter')
|
||||
->color('info'),
|
||||
Action::make('save_general')
|
||||
->label('Opslaan')
|
||||
->action('saveGeneral')
|
||||
->color('primary'),
|
||||
])
|
||||
->schema([
|
||||
Toggle::make('ai_filter_enabled')
|
||||
->label('Filter Inschakelen')
|
||||
->columnSpanFull()
|
||||
->helperText('Schakel AI-gebaseerde inhoudsfiltratie in voor alle radio gerelateerde content'),
|
||||
Toggle::make('ai_filter_auto_reject')
|
||||
->label('Auto Afwijzen')
|
||||
->helperText('Inhoud automatisch afwijzen zonder handmatige goedkeuring'),
|
||||
Select::make('ai_filter_method')
|
||||
->label('Filter Methode')
|
||||
->options([
|
||||
'local' => 'Lokaal (Woordenlijst)',
|
||||
'openai' => 'OpenAI API (AI-gebaseerd)',
|
||||
'both' => 'Beide Gecombineerd',
|
||||
])
|
||||
->default('local'),
|
||||
Slider::make('ai_filter_sensitivity')
|
||||
->label('Gevoeligheid')
|
||||
->minValue(0.1)
|
||||
->maxValue(1.0)
|
||||
->step(0.05)
|
||||
->default(0.7)
|
||||
->helperText('Hogere waarde = strenger filter (0.1 = lax, 1.0 = strikt)'),
|
||||
]),
|
||||
Section::make('OpenAI API Configuratie')
|
||||
->description('Configureer OpenAI voor AI-gebaseerde detectie')
|
||||
->columns(1)
|
||||
->afterHeader([
|
||||
Action::make('test_openai')
|
||||
->label('Test API')
|
||||
->action('testOpenAI')
|
||||
->color('success'),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('openai_api_key')
|
||||
->label('OpenAI API Key')
|
||||
->password()
|
||||
->placeholder('sk-...')
|
||||
->helperText('Gratis moderatie endpoint beschikbaar via OpenAI. Haal je key op bij platform.openai.com'),
|
||||
]),
|
||||
Section::make('Te Filteren Inhoud')
|
||||
->description('Selecteer welke types content gefilterd moeten worden')
|
||||
->columns(2)
|
||||
->afterHeader([
|
||||
Action::make('save_content')
|
||||
->label('Opslaan')
|
||||
->action('saveContent')
|
||||
->color('primary'),
|
||||
])
|
||||
->schema([
|
||||
Toggle::make('ai_filter_shouts')
|
||||
->label('Shouts/Chat Berichten')
|
||||
->helperText('Filter alle shouts en chat berichten'),
|
||||
Toggle::make('ai_filter_chat')
|
||||
->label('Live Chat')
|
||||
->helperText('Filter live chat berichten in real-time'),
|
||||
Toggle::make('ai_filter_applications')
|
||||
->label('DJ Aanmeldingen')
|
||||
->helperText('Filter DJ sollicitatie formulieren'),
|
||||
Toggle::make('ai_filter_requests')
|
||||
->label('Song Verzoeken')
|
||||
->helperText('Filter song request berichten'),
|
||||
Toggle::make('ai_filter_log_rejected')
|
||||
->label('Log Afwijzingen')
|
||||
->helperText('Bewaar een logboek van afgewezen berichten'),
|
||||
]),
|
||||
Section::make('Geblokkeerde Woorden')
|
||||
->description('Beheer de lokale woordenlijst voor directe filtering')
|
||||
->columns(1)
|
||||
->afterHeader([
|
||||
Action::make('add_word')
|
||||
->label('Woord Toevoegen')
|
||||
->action('addWord')
|
||||
->color('success'),
|
||||
Action::make('clear_all')
|
||||
->label('Alles Wissen')
|
||||
->action('clearAllWords')
|
||||
->color('danger')
|
||||
->requiresConfirmation(),
|
||||
Action::make('save_words')
|
||||
->label('Opslaan')
|
||||
->action('saveWords')
|
||||
->color('primary'),
|
||||
])
|
||||
->schema([
|
||||
Repeater::make('blocked_words')
|
||||
->label('Geblokkeerde Woorden')
|
||||
->schema([
|
||||
TextInput::make('word')
|
||||
->label('Woord')
|
||||
->required()
|
||||
->placeholder('vul een woord in'),
|
||||
])
|
||||
->addActionLabel('Woord toevoegen')
|
||||
->defaultItems(0),
|
||||
]),
|
||||
Section::make('Bulk Acties')
|
||||
->description('Snelle acties voor de woordenlijst')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Action::make('add_common_words')
|
||||
->label('Veelvoorkomende Woorden')
|
||||
->action('addCommonWords')
|
||||
->color('secondary')
|
||||
->requiresConfirmation(),
|
||||
Action::make('export_words')
|
||||
->label('Exporteren')
|
||||
->action('exportWords')
|
||||
->color('secondary'),
|
||||
]),
|
||||
Section::make('Filter Statistieken')
|
||||
->description('Overzicht van filter activiteit')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Action::make('view_stats')
|
||||
->label('Bekijk Statistieken')
|
||||
->action('viewStats')
|
||||
->color('info'),
|
||||
Action::make('reset_stats')
|
||||
->label('Resetten')
|
||||
->action('resetStats')
|
||||
->color('warning')
|
||||
->requiresConfirmation(),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function testFilter(): void
|
||||
{
|
||||
$testPhrases = [
|
||||
'Dit is een normale zin',
|
||||
'Dit bevat slecht_woord',
|
||||
];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($testPhrases as $phrase) {
|
||||
$result = $this->moderationService->moderate($phrase);
|
||||
$results[] = [
|
||||
'phrase' => $phrase,
|
||||
'approved' => $result['approved'],
|
||||
'score' => $result['score'],
|
||||
];
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Filter Test')
|
||||
->body(json_encode($results, JSON_PRETTY_PRINT))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function testOpenAI(): void
|
||||
{
|
||||
$apiKey = $this->data['openai_api_key'] ?? '';
|
||||
|
||||
if (empty($apiKey)) {
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title('API Key Ontbreekt')
|
||||
->body('Voer eerst een OpenAI API key in.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
])->post('https://api.openai.com/v1/moderations', [
|
||||
'input' => 'Test message',
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('OpenAI API Werkt!')
|
||||
->body('API verbinding succesvol.')
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('API Fout')
|
||||
->body('Status: ' . $response->status())
|
||||
->send();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Verbindingsfout')
|
||||
->body($e->getMessage())
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function addWord(): void
|
||||
{
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Woord Toevoegen')
|
||||
->body('Gebruik de repeater om woorden toe te voegen.')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function addCommonWords(): void
|
||||
{
|
||||
$commonWords = [
|
||||
'slecht', 'vervelend', 'stom', 'idiot', 'debiel', 'kanker', 'hoer',
|
||||
'kut', 'godver', 'verdomme', 'fuck', 'shit', 'bitch', 'asshole',
|
||||
];
|
||||
|
||||
foreach ($commonWords as $word) {
|
||||
$this->moderationService->addBlockedWord(trim($word));
|
||||
}
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Woorden Toegevoegd')
|
||||
->body(count($commonWords) . ' woorden toegevoegd.')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function clearAllWords(): void
|
||||
{
|
||||
foreach ($this->moderationService->getBlockedWords() as $word) {
|
||||
$this->moderationService->removeBlockedWord($word);
|
||||
}
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Wissen Succesvol')
|
||||
->body('Alle woorden verwijderd.')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function saveGeneral(): void
|
||||
{
|
||||
$this->saveSettings([
|
||||
'ai_filter_enabled', 'ai_filter_auto_reject', 'ai_filter_sensitivity',
|
||||
'ai_filter_method', 'openai_api_key',
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveContent(): void
|
||||
{
|
||||
$this->saveSettings([
|
||||
'ai_filter_shouts', 'ai_filter_chat', 'ai_filter_applications',
|
||||
'ai_filter_requests', 'ai_filter_log_rejected',
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveWords(): void
|
||||
{
|
||||
foreach ($this->blockedWords as $item) {
|
||||
if (! in_array(trim($item['word'] ?? ''), ['', '0'], true)) {
|
||||
$this->moderationService->addBlockedWord(trim((string) $item['word']));
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('Saved'))
|
||||
->body('Woordenlijst bijgewerkt.')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function exportWords(): void
|
||||
{
|
||||
$words = $this->moderationService->getBlockedWords();
|
||||
|
||||
$filename = 'blocked_words_' . date('Y-m-d') . '.json';
|
||||
|
||||
response()->streamDownload(function () use ($words) {
|
||||
echo json_encode($words, JSON_PRETTY_PRINT);
|
||||
}, $filename)->send();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Export Gestart')
|
||||
->body('Download is gestart.')
|
||||
->send();
|
||||
}
|
||||
|
||||
public function viewStats(): void
|
||||
{
|
||||
$stats = $this->moderationService->getStats();
|
||||
|
||||
Notification::make()
|
||||
->title('Filter Statistieken')
|
||||
->body(json_encode($stats, JSON_PRETTY_PRINT))
|
||||
->info()
|
||||
->send();
|
||||
}
|
||||
|
||||
public function resetStats(): void
|
||||
{
|
||||
WebsiteSetting::where('key', 'like', 'filter_stats_%')->delete();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Gereset')
|
||||
->body('Statistieken zijn gewist.')
|
||||
->send();
|
||||
}
|
||||
|
||||
private function saveSettings(array $keys): void
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$value = $this->data[$key] ?? '';
|
||||
|
||||
$boolKeys = [
|
||||
'ai_filter_enabled', 'ai_filter_auto_reject', 'ai_filter_shouts',
|
||||
'ai_filter_chat', 'ai_filter_applications', 'ai_filter_requests',
|
||||
'ai_filter_log_rejected',
|
||||
];
|
||||
|
||||
$dbValue = in_array($key, $boolKeys) ? ($value ? '1' : '0') : (string) $value;
|
||||
|
||||
WebsiteSetting::updateOrCreate(['key' => $key], ['value' => $dbValue]);
|
||||
}
|
||||
|
||||
$this->fillForm();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('Saved'))
|
||||
->body('Instellingen zijn opgeslagen!')
|
||||
->send();
|
||||
}
|
||||
|
||||
private function getSettingBool(string $key): bool
|
||||
{
|
||||
return (bool) WebsiteSetting::where('key', $key)->first()?->value;
|
||||
}
|
||||
|
||||
private function getSetting(string $key, string $default = ''): string
|
||||
{
|
||||
return WebsiteSetting::where('key', $key)->first()?->value ?? $default;
|
||||
}
|
||||
}
|
||||
Executable
+439
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\VPN;
|
||||
|
||||
use App\Models\Miscellaneous\WebsiteBlockedCountry;
|
||||
use App\Models\Miscellaneous\WebsiteIpBlacklist;
|
||||
use App\Models\Miscellaneous\WebsiteIpWhitelist;
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use App\Services\IpLookupService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
|
||||
final class VPNManagement extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = 'VPN Beheer';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $title = 'VPN & IP Beheer';
|
||||
|
||||
#[\Override]
|
||||
protected string $view = 'filament.pages.vpn.vpn-management';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $data = [];
|
||||
|
||||
public $blockedCountries;
|
||||
|
||||
public $whitelistedIps;
|
||||
|
||||
public $blacklistedIps;
|
||||
|
||||
public $blocklistStats;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
protected function loadData(): void
|
||||
{
|
||||
$this->blockedCountries = WebsiteBlockedCountry::orderBy('country_name')->get();
|
||||
$this->whitelistedIps = WebsiteIpWhitelist::orderBy('created_at', 'desc')->get();
|
||||
$this->blacklistedIps = WebsiteIpBlacklist::orderBy('created_at', 'desc')->get();
|
||||
|
||||
$ipService = new IpLookupService('');
|
||||
$this->blocklistStats = $ipService->getBlocklistStats();
|
||||
}
|
||||
|
||||
protected function fillForm(): void
|
||||
{
|
||||
$this->data = [
|
||||
'vpn_block_enabled' => $this->getSettingBool('vpn_block_enabled'),
|
||||
'country_block_enabled' => $this->getSettingBool('country_block_enabled'),
|
||||
'block_vpn' => $this->getSettingBool('block_vpn'),
|
||||
'block_tor' => $this->getSettingBool('block_tor'),
|
||||
'block_malicious' => $this->getSettingBool('block_malicious'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSetting(string $key, string $default = ''): string
|
||||
{
|
||||
return WebsiteSetting::where('key', $key)->first()?->value ?? $default;
|
||||
}
|
||||
|
||||
protected function getSettingBool(string $key): bool
|
||||
{
|
||||
return WebsiteSetting::where('key', $key)->first()?->value === '1';
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('VPN Beheer')
|
||||
->tabs([
|
||||
Tab::make('Instellingen')
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->schema([
|
||||
Section::make('Gratis Blokkering (Onbeperkt)')
|
||||
->description('Gebruikt gratis community blocklists - geen API keys nodig')
|
||||
->schema([
|
||||
Toggle::make('data.vpn_block_enabled')
|
||||
->label('VPN/TOR/Proxy Blokker Actief')
|
||||
->helperText('Blokkeer VPN, TOR exit nodes, proxies en bekende malicious IPs'),
|
||||
]),
|
||||
Section::make('Wat blokkeren?')
|
||||
->schema([
|
||||
Toggle::make('data.block_vpn')
|
||||
->label('VPN & Proxies')
|
||||
->helperText('FireHol blocklist'),
|
||||
Toggle::make('data.block_tor')
|
||||
->label('TOR Exit Nodes')
|
||||
->helperText('Alle bekende TOR exit nodes'),
|
||||
Toggle::make('data.block_malicious')
|
||||
->label('Malicious IPs')
|
||||
->helperText('Bekende kwaadwillende IPs uit community databases'),
|
||||
]),
|
||||
Section::make('Land Blokkering')
|
||||
->schema([
|
||||
Toggle::make('data.country_block_enabled')
|
||||
->label('Land Blokkering Actief')
|
||||
->helperText('Sta alleen bezoekers uit toegestane landen toe'),
|
||||
]),
|
||||
]),
|
||||
Tab::make('Geblokkeerde Landen')
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->schema([
|
||||
Section::make('Land toevoegen')
|
||||
->schema([
|
||||
Select::make('country_to_block')
|
||||
->label('Selecteer Land')
|
||||
->options($this->getCountryOptions())
|
||||
->searchable()
|
||||
->placeholder('Kies een land'),
|
||||
Action::make('addCountry')
|
||||
->label('Blokkeer Land')
|
||||
->action('addBlockedCountry')
|
||||
->color('danger'),
|
||||
]),
|
||||
]),
|
||||
Tab::make('Whitelist')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->schema([
|
||||
Section::make('Whitelist toevoegen')
|
||||
->schema([
|
||||
TextInput::make('whitelist_ip')
|
||||
->label('IP Adres')
|
||||
->placeholder('192.168.1.1'),
|
||||
TextInput::make('whitelist_asn')
|
||||
->label('ASN')
|
||||
->placeholder('AS15169'),
|
||||
Select::make('whitelist_country')
|
||||
->label('Land')
|
||||
->options($this->getCountryOptions())
|
||||
->searchable(),
|
||||
Action::make('addToWhitelist')
|
||||
->label('Toevoegen')
|
||||
->action('addToWhitelist')
|
||||
->color('success'),
|
||||
]),
|
||||
]),
|
||||
Tab::make('Blacklist')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->schema([
|
||||
Section::make('Blacklist toevoegen')
|
||||
->schema([
|
||||
TextInput::make('blacklist_ip')
|
||||
->label('IP Adres')
|
||||
->placeholder('192.168.1.1'),
|
||||
TextInput::make('blacklist_asn')
|
||||
->label('ASN')
|
||||
->placeholder('AS15169'),
|
||||
Select::make('blacklist_country')
|
||||
->label('Land')
|
||||
->options($this->getCountryOptions())
|
||||
->searchable(),
|
||||
Action::make('addToBlacklist')
|
||||
->label('Toevoegen')
|
||||
->action('addToBlacklist')
|
||||
->color('danger'),
|
||||
]),
|
||||
]),
|
||||
Tab::make('IP Lookup')
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->schema([
|
||||
Section::make('IP Opzoeken')
|
||||
->schema([
|
||||
TextInput::make('lookup_ip')
|
||||
->label('IP Adres')
|
||||
->placeholder('Voer IP in om te controleren'),
|
||||
Action::make('lookupIp')
|
||||
->label('Controleer')
|
||||
->action('lookupIp')
|
||||
->color('info'),
|
||||
]),
|
||||
]),
|
||||
Tab::make('Blocklist Stats')
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->schema([
|
||||
Section::make('Huidige Blocklists')
|
||||
->description('Deze lijsten worden automatisch gedownload en dagelijks ververst')
|
||||
->schema([
|
||||
TextColumn::make('type')->label('Type'),
|
||||
TextColumn::make('count')->label('Aantal IPs'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
protected function getCountryOptions(): array
|
||||
{
|
||||
return [
|
||||
'AD' => 'Andorra', 'AE' => 'United Arab Emirates', 'AF' => 'Afghanistan',
|
||||
'AG' => 'Antigua and Barbuda', 'AI' => 'Anguilla', 'AL' => 'Albania',
|
||||
'AM' => 'Armenia', 'AO' => 'Angola', 'AR' => 'Argentina',
|
||||
'AT' => 'Austria', 'AU' => 'Australia', 'AW' => 'Aruba',
|
||||
'AZ' => 'Azerbaijan', 'BA' => 'Bosnia and Herzegovina', 'BB' => 'Barbados',
|
||||
'BD' => 'Bangladesh', 'BE' => 'Belgium', 'BG' => 'Bulgaria',
|
||||
'BH' => 'Bahrain', 'BI' => 'Burundi', 'BJ' => 'Benin',
|
||||
'BM' => 'Bermuda', 'BN' => 'Brunei', 'BO' => 'Bolivia',
|
||||
'BR' => 'Brazil', 'BS' => 'Bahamas', 'BT' => 'Bhutan',
|
||||
'BW' => 'Botswana', 'BY' => 'Belarus', 'BZ' => 'Belize',
|
||||
'CA' => 'Canada', 'CH' => 'Switzerland', 'CL' => 'Chile',
|
||||
'CN' => 'China', 'CO' => 'Colombia', 'CR' => 'Costa Rica',
|
||||
'CU' => 'Cuba', 'CY' => 'Cyprus', 'CZ' => 'Czechia',
|
||||
'DE' => 'Germany', 'DK' => 'Denmark', 'DO' => 'Dominican Republic',
|
||||
'DZ' => 'Algeria', 'EC' => 'Ecuador', 'EE' => 'Estonia',
|
||||
'EG' => 'Egypt', 'ES' => 'Spain', 'ET' => 'Ethiopia',
|
||||
'FI' => 'Finland', 'FJ' => 'Fiji', 'FR' => 'France',
|
||||
'GB' => 'United Kingdom', 'GD' => 'Grenada', 'GE' => 'Georgia',
|
||||
'GH' => 'Ghana', 'GI' => 'Gibraltar', 'GL' => 'Greenland',
|
||||
'GM' => 'Gambia', 'GN' => 'Guinea', 'GR' => 'Greece',
|
||||
'GT' => 'Guatemala', 'HK' => 'Hong Kong', 'HN' => 'Honduras',
|
||||
'HR' => 'Croatia', 'HU' => 'Hungary', 'ID' => 'Indonesia',
|
||||
'IE' => 'Ireland', 'IL' => 'Israel', 'IN' => 'India',
|
||||
'IQ' => 'Iraq', 'IR' => 'Iran', 'IS' => 'Iceland',
|
||||
'IT' => 'Italy', 'JM' => 'Jamaica', 'JO' => 'Jordan',
|
||||
'JP' => 'Japan', 'KE' => 'Kenya', 'KH' => 'Cambodia',
|
||||
'KR' => 'South Korea', 'KW' => 'Kuwait', 'KZ' => 'Kazakhstan',
|
||||
'LA' => 'Laos', 'LB' => 'Lebanon', 'LK' => 'Sri Lanka',
|
||||
'LT' => 'Lithuania', 'LU' => 'Luxembourg', 'LV' => 'Latvia',
|
||||
'MA' => 'Morocco', 'MC' => 'Monaco', 'MD' => 'Moldova',
|
||||
'ME' => 'Montenegro', 'MG' => 'Madagascar', 'MK' => 'North Macedonia',
|
||||
'MM' => 'Myanmar', 'MN' => 'Mongolia', 'MO' => 'Macao',
|
||||
'MT' => 'Malta', 'MU' => 'Mauritius', 'MV' => 'Maldives',
|
||||
'MX' => 'Mexico', 'MY' => 'Malaysia', 'MZ' => 'Mozambique',
|
||||
'NA' => 'Namibia', 'NG' => 'Nigeria', 'NI' => 'Nicaragua',
|
||||
'NL' => 'Netherlands', 'NO' => 'Norway', 'NP' => 'Nepal',
|
||||
'NZ' => 'New Zealand', 'OM' => 'Oman', 'PA' => 'Panama',
|
||||
'PE' => 'Peru', 'PH' => 'Philippines', 'PK' => 'Pakistan',
|
||||
'PL' => 'Poland', 'PR' => 'Puerto Rico', 'PT' => 'Portugal',
|
||||
'PY' => 'Paraguay', 'QA' => 'Qatar', 'RO' => 'Romania',
|
||||
'RS' => 'Serbia', 'RU' => 'Russia', 'RW' => 'Rwanda',
|
||||
'SA' => 'Saudi Arabia', 'SC' => 'Seychelles', 'SD' => 'Sudan',
|
||||
'SE' => 'Sweden', 'SG' => 'Singapore', 'SI' => 'Slovenia',
|
||||
'SK' => 'Slovakia', 'SN' => 'Senegal', 'SO' => 'Somalia',
|
||||
'SR' => 'Suriname', 'SV' => 'El Salvador', 'SY' => 'Syria',
|
||||
'TH' => 'Thailand', 'TJ' => 'Tajikistan', 'TN' => 'Tunisia',
|
||||
'TR' => 'Turkey', 'TT' => 'Trinidad and Tobago', 'TW' => 'Taiwan',
|
||||
'TZ' => 'Tanzania', 'UA' => 'Ukraine', 'UG' => 'Uganda',
|
||||
'US' => 'United States', 'UY' => 'Uruguay', 'UZ' => 'Uzbekistan',
|
||||
'VE' => 'Venezuela', 'VN' => 'Vietnam', 'ZA' => 'South Africa',
|
||||
'ZM' => 'Zambia', 'ZW' => 'Zimbabwe',
|
||||
];
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$settings = [
|
||||
'vpn_block_enabled' => $this->data['vpn_block_enabled'] ? '1' : '0',
|
||||
'country_block_enabled' => $this->data['country_block_enabled'] ? '1' : '0',
|
||||
'block_vpn' => $this->data['block_vpn'] ?? true ? '1' : '0',
|
||||
'block_tor' => $this->data['block_tor'] ?? true ? '1' : '0',
|
||||
'block_malicious' => $this->data['block_malicious'] ?? true ? '1' : '0',
|
||||
];
|
||||
|
||||
foreach ($settings as $key => $value) {
|
||||
WebsiteSetting::updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
}
|
||||
|
||||
Notification::make()->title(__('Saved'))->success()->send();
|
||||
}
|
||||
|
||||
public function refreshBlocklists(): void
|
||||
{
|
||||
$ipService = new IpLookupService('');
|
||||
$ipService->refreshBlocklists();
|
||||
$this->loadData();
|
||||
|
||||
Notification::make()->title('Blocklists vernieuwd')->success()->send();
|
||||
}
|
||||
|
||||
public function addBlockedCountry(): void
|
||||
{
|
||||
$countryCode = $this->data['country_to_block'] ?? null;
|
||||
if (! $countryCode) {
|
||||
Notification::make()->title('Selecteer een land')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (WebsiteBlockedCountry::where('country_code', $countryCode)->exists()) {
|
||||
Notification::make()->title('Land is al geblokkeerd')->warning()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$countries = $this->getCountryOptions();
|
||||
$countryCode = (string) $countryCode;
|
||||
WebsiteBlockedCountry::create([
|
||||
'country_code' => $countryCode,
|
||||
'country_name' => $countries[$countryCode] ?? $countryCode,
|
||||
]);
|
||||
|
||||
$this->loadData();
|
||||
Notification::make()->title('Land geblokkeerd')->success()->send();
|
||||
}
|
||||
|
||||
public function removeBlockedCountry(int $id): void
|
||||
{
|
||||
WebsiteBlockedCountry::destroy($id);
|
||||
$this->loadData();
|
||||
Notification::make()->title('Verwijderd')->success()->send();
|
||||
}
|
||||
|
||||
public function addToWhitelist(): void
|
||||
{
|
||||
$ip = $this->data['whitelist_ip'] ?? null;
|
||||
$asn = $this->data['whitelist_asn'] ?? null;
|
||||
$countryCode = $this->data['whitelist_country'] ?? null;
|
||||
|
||||
if (! $ip && ! $asn && ! $countryCode) {
|
||||
Notification::make()->title('Voer IP, ASN of land in')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$countries = $this->getCountryOptions();
|
||||
$countryCodeStr = (string) ($countryCode ?? '');
|
||||
$countryName = $countryCode ? ($countries[$countryCodeStr] ?? null) : null;
|
||||
|
||||
WebsiteIpWhitelist::create([
|
||||
'ip_address' => $ip ?? null,
|
||||
'asn' => $asn ?? null,
|
||||
'country_code' => $countryCodeStr,
|
||||
'country_name' => $countryName,
|
||||
'whitelist_asn' => ! empty($asn),
|
||||
'whitelist_country' => ! empty($countryCode),
|
||||
]);
|
||||
|
||||
$this->loadData();
|
||||
Notification::make()->title('Toegevoegd aan whitelist')->success()->send();
|
||||
}
|
||||
|
||||
public function addToBlacklist(): void
|
||||
{
|
||||
$ip = $this->data['blacklist_ip'] ?? null;
|
||||
$asn = $this->data['blacklist_asn'] ?? null;
|
||||
$countryCode = $this->data['blacklist_country'] ?? null;
|
||||
|
||||
if (! $ip && ! $asn && ! $countryCode) {
|
||||
Notification::make()->title('Voer IP, ASN of land in')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$countries = $this->getCountryOptions();
|
||||
$countryCodeStr = (string) ($countryCode ?? '');
|
||||
$countryName = $countryCode ? ($countries[$countryCodeStr] ?? null) : null;
|
||||
|
||||
WebsiteIpBlacklist::create([
|
||||
'ip_address' => $ip ?? null,
|
||||
'asn' => $asn ?? null,
|
||||
'country_code' => $countryCodeStr,
|
||||
'country_name' => $countryName,
|
||||
'blacklist_asn' => ! empty($asn),
|
||||
'blacklist_country' => ! empty($countryCode),
|
||||
]);
|
||||
|
||||
$this->loadData();
|
||||
Notification::make()->title('Toegevoegd aan blacklist')->success()->send();
|
||||
}
|
||||
|
||||
public function removeFromWhitelist(int $id): void
|
||||
{
|
||||
WebsiteIpWhitelist::destroy($id);
|
||||
$this->loadData();
|
||||
Notification::make()->title('Verwijderd')->success()->send();
|
||||
}
|
||||
|
||||
public function removeFromBlacklist(int $id): void
|
||||
{
|
||||
WebsiteIpBlacklist::destroy($id);
|
||||
$this->loadData();
|
||||
Notification::make()->title('Verwijderd')->success()->send();
|
||||
}
|
||||
|
||||
public function lookupIp(): void
|
||||
{
|
||||
$ip = $this->data['lookup_ip'] ?? null;
|
||||
if (! $ip) {
|
||||
Notification::make()->title('Voer een IP in')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ipService = new IpLookupService('');
|
||||
$countryInfo = $ipService->getCountryInfo($ip);
|
||||
$threatInfo = $ipService->checkVpnProxyTor($ip);
|
||||
|
||||
$message = "IP: {$ip}\n\n";
|
||||
|
||||
if (! empty($countryInfo['country_name'])) {
|
||||
$message .= "📍 {$countryInfo['country_name']} ({$countryInfo['country_code']})\n";
|
||||
$message .= " {$countryInfo['city']} - {$countryInfo['isp']}\n\n";
|
||||
}
|
||||
|
||||
$message .= "🚫 Blokkeren:\n";
|
||||
$message .= ' VPN/Proxy: ' . ($threatInfo['is_vpn'] ? 'JA' : 'Nee') . "\n";
|
||||
$message .= ' TOR: ' . ($threatInfo['is_tor'] ? 'JA' : 'Nee') . "\n";
|
||||
$message .= ' Malicious: ' . ($threatInfo['is_malicious'] ? 'JA' : 'Nee') . "\n";
|
||||
|
||||
Notification::make()->title('Resultaat')->body($message)->success()->send();
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('save')->label('Opslaan')->action('save')->color('primary'),
|
||||
Action::make('refreshBlocklists')->label('Vernieuw Blocklists')->action('refreshBlocklists')->color('warning'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @phpstan-type AuthUser = \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable|null
|
||||
*/
|
||||
|
||||
namespace App\Filament\Resources\Atom\Articles;
|
||||
|
||||
use App\Filament\Resources\Atom\Articles\Pages\CreateArticle;
|
||||
use App\Filament\Resources\Atom\Articles\Pages\EditArticle;
|
||||
use App\Filament\Resources\Atom\Articles\Pages\ListArticles;
|
||||
use App\Filament\Resources\Atom\Articles\Pages\ViewArticle;
|
||||
use App\Filament\Resources\Atom\Articles\RelationManagers\TagsRelationManager;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\Articles\WebsiteArticle;
|
||||
use Exception;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ArticleResource extends Resource
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $model = WebsiteArticle::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-newspaper';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'website/articles';
|
||||
|
||||
public static string $translateIdentifier = 'articles';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components(static::getForm());
|
||||
}
|
||||
|
||||
public static function getForm(): array
|
||||
{
|
||||
return [
|
||||
Tabs::make('Main')
|
||||
->tabs([
|
||||
Tab::make(__('filament::resources.tabs.Home'))
|
||||
->icon('heroicon-o-home')
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->label(__('filament::resources.inputs.title'))
|
||||
->required()
|
||||
->autocomplete()
|
||||
->maxLength(255)
|
||||
->columnSpan('full'),
|
||||
|
||||
TextInput::make('short_story')
|
||||
->label(__('filament::resources.inputs.description'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->autocomplete()
|
||||
->columnSpan('full'),
|
||||
|
||||
FileUpload::make('image')
|
||||
->label(__('filament::resources.inputs.image'))
|
||||
->directory('website_news_images')
|
||||
->visibility('public'),
|
||||
|
||||
RichEditor::make('full_story')
|
||||
->label(__('filament::resources.inputs.content'))
|
||||
->required()
|
||||
->columnSpan('full'),
|
||||
|
||||
Hidden::make('user_id')
|
||||
->default(Auth::id() ?? 0),
|
||||
]),
|
||||
|
||||
Tab::make(__('filament::resources.tabs.Configurations'))
|
||||
->icon('heroicon-o-cog')
|
||||
->schema([
|
||||
Toggle::make('is_visible')
|
||||
->label(__('filament::resources.inputs.visible'))
|
||||
->onIcon('heroicon-s-check')
|
||||
->offIcon('heroicon-s-x-mark')
|
||||
->default(true)
|
||||
->live()
|
||||
->afterStateUpdated(function (string $operation, $state, $record) {
|
||||
/** @var WebsiteArticle $record */
|
||||
if ($operation !== 'edit' || ! $record instanceof WebsiteArticle) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($state) {
|
||||
$record->restore();
|
||||
} else {
|
||||
$record->delete();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
report($e);
|
||||
}
|
||||
})
|
||||
->formatStateUsing(function ($record) {
|
||||
if (is_null($record)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_null($record->deleted_at);
|
||||
}),
|
||||
|
||||
Toggle::make('can_comment')
|
||||
->onIcon('heroicon-s-check')
|
||||
->label(__('filament::resources.inputs.allow_comments'))
|
||||
->default(true)
|
||||
->offIcon('heroicon-s-x-mark'),
|
||||
]),
|
||||
])->columnSpanFull(),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->poll('60s')
|
||||
->columns(static::getTable())
|
||||
->filters([
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
DeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getTable(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('id')
|
||||
->label(__('filament::resources.columns.id')),
|
||||
|
||||
ImageColumn::make('image')
|
||||
->circular()
|
||||
->extraAttributes(['style' => 'image-rendering: pixelated'])
|
||||
->size(50)
|
||||
->label(__('filament::resources.columns.image')),
|
||||
|
||||
TextColumn::make('title')
|
||||
->label(__('filament::resources.columns.title'))
|
||||
->searchable()
|
||||
->limit(50),
|
||||
|
||||
TextColumn::make('user.username')
|
||||
->searchable()
|
||||
->label(__('filament::resources.columns.by')),
|
||||
|
||||
ToggleColumn::make('is_visible')
|
||||
->label(__('filament::resources.columns.visible'))
|
||||
->onIcon('heroicon-s-check')
|
||||
->toggleable()
|
||||
->state(fn ($record) => is_null($record->deleted_at))
|
||||
->disabled(),
|
||||
|
||||
ToggleColumn::make('allow_comments')
|
||||
->label(__('filament::resources.columns.allow_comments'))
|
||||
->onIcon('heroicon-s-check')
|
||||
->toggleable()
|
||||
->disabled(),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
TagsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListArticles::route('/'),
|
||||
'create' => CreateArticle::route('/create'),
|
||||
'view' => ViewArticle::route('/{record}'),
|
||||
'edit' => EditArticle::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getGlobalSearchEloquentQuery()->withTrashed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\Articles\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Articles\ArticleResource;
|
||||
use App\Models\Article;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateArticle extends CreateRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = ArticleResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
/** @var null|Article $articleCreated */
|
||||
$articleCreated = $this->getRecord();
|
||||
|
||||
if (! $articleCreated || ! $articleCreated->visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
$articleCreated->createFollowersNotification();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Articles\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Articles\ArticleResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditArticle extends EditRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = ArticleResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Articles\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Articles\ArticleResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListArticles extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = ArticleResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\Articles\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Articles\ArticleResource;
|
||||
use App\Models\Article;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ViewArticle extends ViewRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = ArticleResource::class;
|
||||
|
||||
#[\Override]
|
||||
public function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('Send Notification')
|
||||
->label(__('Send notifications'))
|
||||
->color('gray')
|
||||
->visible(fn (Article $record) => $record->user_id === Auth::id())
|
||||
->requiresConfirmation()
|
||||
->action(function (Article $record) {
|
||||
$record->createFollowersNotification();
|
||||
}),
|
||||
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\Articles\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\Atom\Tags\TagResource;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use Filament\Actions\AttachAction;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DetachAction;
|
||||
use Filament\Actions\DetachBulkAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TagsRelationManager extends RelationManager
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static string $relationship = 'tags';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static string $translateIdentifier = 'tags';
|
||||
|
||||
#[\Override]
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns(TagResource::getTable())
|
||||
->modifyQueryUsing(fn ($query) => $query->latest())
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make()
|
||||
->schema(TagResource::getForm()),
|
||||
|
||||
AttachAction::make()->preloadRecordSelect(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
DetachAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
DetachBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\CameraWebs;
|
||||
|
||||
use App\Filament\Resources\Atom\CameraWebs\Pages\EditCameraWeb;
|
||||
use App\Filament\Resources\Atom\CameraWebs\Pages\ListCameraWeb;
|
||||
use App\Models\Miscellaneous\CameraWeb;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CameraWebResource extends Resource
|
||||
{
|
||||
#[\Override]
|
||||
protected static ?string $model = CameraWeb::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-photo';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'camera-web';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $pluralModelLabel = 'photos';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = 'Web Camera';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Toggle::make('visible')
|
||||
->label(__('Visible'))
|
||||
->default(true),
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('filament::resources.columns.id'))
|
||||
->sortable(),
|
||||
TextColumn::make('user_id')
|
||||
->label(__('filament::resources.columns.user_id')),
|
||||
TextColumn::make('room_id')
|
||||
->label(__('filament::resources.columns.room_id')),
|
||||
TextColumn::make('timestamp')
|
||||
->label(__('filament::resources.columns.created_at'))
|
||||
->dateTime(),
|
||||
ImageColumn::make('url')
|
||||
->label(__('filament::resources.columns.image'))
|
||||
->extraAttributes(['style' => 'image-rendering: pixelated'])
|
||||
->size(125),
|
||||
ToggleColumn::make('visible')
|
||||
->label(__('Visible')),
|
||||
])
|
||||
->recordActions([
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCameraWeb::route('/'),
|
||||
'edit' => EditCameraWeb::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\CameraWebs\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\CameraWebs\CameraWebResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCameraWeb extends EditRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = CameraWebResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\CameraWebs\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\CameraWebs\CameraWebResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCameraWeb extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = CameraWebResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\CmsSettings;
|
||||
|
||||
use App\Filament\Resources\Atom\CmsSettings\Pages\ManageCmsSettings;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\Miscellaneous\WebsiteSetting;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CmsSettingResource extends Resource
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $model = WebsiteSetting::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-cpu-chip';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'website/cms-settings';
|
||||
|
||||
public static string $translateIdentifier = 'cms-settings';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make()
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->label(__('filament::resources.inputs.key'))
|
||||
->maxLength(50)
|
||||
->autocomplete()
|
||||
->unique(ignoreRecord: true)
|
||||
->required(),
|
||||
|
||||
TextInput::make('value')
|
||||
->label(__('filament::resources.inputs.value'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->autocomplete(),
|
||||
|
||||
TextInput::make('comment')
|
||||
->label(__('filament::resources.inputs.comment'))
|
||||
->nullable()
|
||||
->maxLength(255)
|
||||
->autocomplete()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns([
|
||||
'sm' => 2,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('key')
|
||||
->label(__('filament::resources.columns.key'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('value')
|
||||
->label(__('filament::resources.columns.value'))
|
||||
->searchable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('comment')
|
||||
->label(__('filament::resources.columns.comment'))
|
||||
->toggleable()
|
||||
->searchable()
|
||||
->tooltip(function (TextColumn $column): ?string {
|
||||
$state = $column->getState();
|
||||
|
||||
if (! is_string($state) || strlen($state) <= $column->getCharacterLimit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state;
|
||||
})
|
||||
->limit(60),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
// ...
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ManageCmsSettings::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\CmsSettings\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\CmsSettings\CmsSettingResource;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ManageCmsSettings extends ManageRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = CmsSettingResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('reload_cache')
|
||||
->label('Reload Cache')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Reload Settings Cache')
|
||||
->modalDescription('This will clear and reload the website settings cache. The cache will be automatically rebuilt on the next request.')
|
||||
->modalSubmitActionLabel('Reload Cache')
|
||||
->action(function () {
|
||||
Cache::forget('website_settings');
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Cache Cleared')
|
||||
->body('Settings cache has been cleared successfully.')
|
||||
->send();
|
||||
}),
|
||||
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableRecordsPerPageSelectOptions(): array
|
||||
{
|
||||
return [25, 50, 100];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages\CreateHelpQuestionCategory;
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages\EditHelpQuestionCategory;
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages\ListHelpQuestionCategories;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\Help\WebsiteHelpCenterCategory;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class HelpQuestionCategoryResource extends Resource
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $model = WebsiteHelpCenterCategory::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Help Center';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'help/categories';
|
||||
|
||||
public static string $translateIdentifier = 'help-categories';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components(static::getForm());
|
||||
}
|
||||
|
||||
public static function getForm(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('name')
|
||||
->label(__('Name'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText(__('Use :hotel for dynamic hotel name')),
|
||||
|
||||
Textarea::make('content')
|
||||
->label(__('Content'))
|
||||
->rows(8)
|
||||
->helperText(__('Use :hotel for dynamic hotel name. Use <br/> for line breaks.')),
|
||||
|
||||
TextInput::make('image_url')
|
||||
->label(__('Image URL'))
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('button_text')
|
||||
->label(__('Button Text'))
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('button_url')
|
||||
->label(__('Button URL'))
|
||||
->maxLength(255),
|
||||
|
||||
ColorPicker::make('button_color')
|
||||
->label(__('Button Color')),
|
||||
|
||||
ColorPicker::make('button_border_color')
|
||||
->label(__('Button Border Color')),
|
||||
|
||||
Toggle::make('small_box')
|
||||
->label(__('Small Box'))
|
||||
->helperText(__('Show as small box on the right side')),
|
||||
|
||||
TextInput::make('position')
|
||||
->label(__('Position'))
|
||||
->numeric()
|
||||
->default(0),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns(static::getTable());
|
||||
}
|
||||
|
||||
public static function getTable(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('id')
|
||||
->label(__('ID'))
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('name')
|
||||
->label(__('Name'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('button_text')
|
||||
->label(__('Button Text')),
|
||||
|
||||
ToggleColumn::make('small_box')
|
||||
->label(__('Small Box')),
|
||||
|
||||
TextColumn::make('position')
|
||||
->label(__('Position'))
|
||||
->sortable(),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListHelpQuestionCategories::route('/'),
|
||||
'create' => CreateHelpQuestionCategory::route('/create'),
|
||||
'edit' => EditHelpQuestionCategory::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateHelpQuestionCategory extends CreateRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionCategoryResource::class;
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditHelpQuestionCategory extends EditRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionCategoryResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListHelpQuestionCategories extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionCategoryResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableReorderColumn(): ?string
|
||||
{
|
||||
return 'order';
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewHelpQuestionCategory extends ViewRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionCategoryResource::class;
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionCategoryResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use Filament\Actions\AttachAction;
|
||||
use Filament\Actions\DetachAction;
|
||||
use Filament\Actions\DetachBulkAction;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class QuestionsRelationManager extends RelationManager
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static string $relationship = 'questions';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $recordTitleAttribute = 'title';
|
||||
|
||||
public static string $translateIdentifier = 'help-questions';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $inverseRelationship = 'categories';
|
||||
|
||||
#[\Override]
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components(HelpQuestionResource::getForm(true));
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table->columns(HelpQuestionResource::getTable())
|
||||
->modifyQueryUsing(fn ($query) => $query->latest())
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
AttachAction::make(),
|
||||
])
|
||||
->recordActions([
|
||||
DetachAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
DetachBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource\Pages\CreateHelpQuestion;
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource\Pages\EditHelpQuestion;
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource\Pages\ListHelpQuestions;
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource\Pages\ViewHelpQuestion;
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource\RelationManagers\CategoriesRelationManager;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\Help\WebsiteHelpCenterTicket;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class HelpQuestionResource extends Resource
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $model = WebsiteHelpCenterTicket::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Help Center';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'help/questions';
|
||||
|
||||
public static string $translateIdentifier = 'help-questions';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components(static::getForm());
|
||||
}
|
||||
|
||||
public static function getForm(bool $forRelationManager = false): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('title')
|
||||
->label(__('Title'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Textarea::make('content')
|
||||
->label(__('Content'))
|
||||
->required(),
|
||||
|
||||
Select::make('category_id')
|
||||
->label(__('Category'))
|
||||
->relationship('category', 'name')
|
||||
->required()
|
||||
->visible(! $forRelationManager),
|
||||
|
||||
Select::make('user_id')
|
||||
->label(__('User'))
|
||||
->relationship('user', 'username')
|
||||
->required(),
|
||||
|
||||
Toggle::make('open')
|
||||
->label(__('Open'))
|
||||
->default(true),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns(static::getTable());
|
||||
}
|
||||
|
||||
public static function getTable(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('id')
|
||||
->label(__('ID'))
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('title')
|
||||
->label(__('Title'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('user.username')
|
||||
->label(__('User'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('category.name')
|
||||
->label(__('Category')),
|
||||
|
||||
ToggleColumn::make('open')
|
||||
->label(__('Open')),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('Created'))
|
||||
->dateTime(),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
CategoriesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListHelpQuestions::route('/'),
|
||||
'create' => CreateHelpQuestion::route('/create'),
|
||||
'view' => ViewHelpQuestion::route('/{record}'),
|
||||
'edit' => EditHelpQuestion::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateHelpQuestion extends CreateRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionResource::class;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditHelpQuestion extends EditRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListHelpQuestions extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewHelpQuestion extends ViewRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HelpQuestionResource::class;
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\HelpQuestionResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\Atom\HelpQuestionCategoryResource;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use Filament\Actions\AttachAction;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DetachAction;
|
||||
use Filament\Actions\DetachBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CategoriesRelationManager extends RelationManager
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static string $relationship = 'categories';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static string $translateIdentifier = 'help-question-categories';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $inverseRelationship = 'questions';
|
||||
|
||||
#[\Override]
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components(HelpQuestionCategoryResource::getForm());
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table->columns(HelpQuestionCategoryResource::getTable())
|
||||
->modifyQueryUsing(fn ($query) => $query->latest('id'))
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
AttachAction::make(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DetachAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
DetachBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\HousekeepingPermissions;
|
||||
|
||||
use App\Filament\Resources\Atom\HousekeepingPermissions\Pages\ListHousekeepingPermissions;
|
||||
use App\Models\WebsiteHousekeepingPermission;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class HousekeepingPermissionResource extends Resource
|
||||
{
|
||||
#[\Override]
|
||||
protected static ?string $model = WebsiteHousekeepingPermission::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'website/housekeeping-permissions';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $navigationLabel = 'Housekeeping permissions';
|
||||
|
||||
public static string $translateIdentifier = 'housekeeping-permissions';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make()
|
||||
->schema([
|
||||
TextInput::make('permission')
|
||||
->label(__('filament::resources.inputs.permission'))
|
||||
->maxLength(50)
|
||||
->autocomplete()
|
||||
->unique(ignoreRecord: true)
|
||||
->required(),
|
||||
|
||||
TextInput::make('min_rank')
|
||||
->label(__('filament::resources.inputs.min_rank'))
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->autocomplete(),
|
||||
|
||||
TextInput::make('description')
|
||||
->label(__('filament::resources.inputs.description'))
|
||||
->nullable()
|
||||
->maxLength(255)
|
||||
->autocomplete()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns([
|
||||
'sm' => 2,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'asc')
|
||||
->columns([
|
||||
TextColumn::make('permission')
|
||||
->label(__('filament::resources.columns.permission'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('min_rank')
|
||||
->label(__('filament::resources.columns.min_rank'))
|
||||
->searchable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('description')
|
||||
->label(__('filament::resources.columns.description'))
|
||||
->toggleable()
|
||||
->searchable()
|
||||
->tooltip(function (TextColumn $column): ?string {
|
||||
$state = $column->getState();
|
||||
|
||||
if (! is_string($state) || strlen($state) <= $column->getCharacterLimit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state;
|
||||
})
|
||||
->limit(60),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
//
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListHousekeepingPermissions::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\HousekeepingPermissions\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\HousekeepingPermissions\HousekeepingPermissionResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListHousekeepingPermissions extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = HousekeepingPermissionResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom;
|
||||
|
||||
use App\Filament\Resources\Atom\NavigationResource\Pages\CreateNavigation;
|
||||
use App\Filament\Resources\Atom\NavigationResource\Pages\EditNavigation;
|
||||
use App\Filament\Resources\Atom\NavigationResource\Pages\ListNavigations;
|
||||
use App\Filament\Resources\Atom\NavigationResource\RelationManagers\SubNavigationsRelationManager;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\WebsiteNavigation;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class NavigationResource extends Resource
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $model = WebsiteNavigation::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bars-3';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'website/navigations';
|
||||
|
||||
public static string $translateIdentifier = 'navigations';
|
||||
|
||||
#[\Override]
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components(static::getForm());
|
||||
}
|
||||
|
||||
public static function getForm(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('name')
|
||||
->label(__('Name'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('url')
|
||||
->label(__('URL'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('icon')
|
||||
->label(__('Icon'))
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('order')
|
||||
->label(__('Order'))
|
||||
->numeric()
|
||||
->default(0),
|
||||
|
||||
Select::make('parent_id')
|
||||
->label(__('Parent Navigation'))
|
||||
->relationship('parent', 'name')
|
||||
->nullable(),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns(static::getTable());
|
||||
}
|
||||
|
||||
public static function getTable(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('id')
|
||||
->label(__('ID'))
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('name')
|
||||
->label(__('Name'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('url')
|
||||
->label(__('URL')),
|
||||
|
||||
TextColumn::make('order')
|
||||
->label(__('Order'))
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('parent.name')
|
||||
->label(__('Parent')),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
SubNavigationsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListNavigations::route('/'),
|
||||
'create' => CreateNavigation::route('/create'),
|
||||
'edit' => EditNavigation::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\NavigationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\NavigationResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateNavigation extends CreateRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = NavigationResource::class;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\NavigationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\NavigationResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditNavigation extends EditRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = NavigationResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\NavigationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\NavigationResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListNavigations extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = NavigationResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\NavigationResource\RelationManagers;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\DissociateAction;
|
||||
use Filament\Actions\DissociateBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SubNavigationsRelationManager extends RelationManager
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $relationship = 'subNavigations';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $recordTitleAttribute = 'label';
|
||||
|
||||
#[\Override]
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('label')
|
||||
->label(__('filament::resources.inputs.label'))
|
||||
->columnSpanFull()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('filament::resources.inputs.slug')),
|
||||
|
||||
TextInput::make('order')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0)
|
||||
->label(__('filament::resources.columns.order')),
|
||||
|
||||
Toggle::make('visible')
|
||||
->label(__('filament::resources.columns.visible')),
|
||||
|
||||
Toggle::make('new_tab')
|
||||
->label(__('filament::resources.columns.new_tab')),
|
||||
])
|
||||
->columns([
|
||||
'sm' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('label'),
|
||||
|
||||
TextColumn::make('slug')
|
||||
->label(__('filament::resources.columns.slug')),
|
||||
|
||||
ToggleColumn::make('visible')
|
||||
->label(__('filament::resources.columns.visible')),
|
||||
|
||||
ToggleColumn::make('new_tab')
|
||||
->label(__('filament::resources.columns.new_tab')),
|
||||
|
||||
TextColumn::make('order')
|
||||
->label(__('filament::resources.columns.order')),
|
||||
])
|
||||
->reorderable('order')
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
// Tables\Actions\AssociateAction::make(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DissociateAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
DissociateBulkAction::make(),
|
||||
DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Permissions\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Permissions\PermissionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePermission extends CreateRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = PermissionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Permissions\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Permissions\PermissionResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPermission extends EditRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = PermissionResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Permissions\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Permissions\PermissionResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPermissions extends ListRecords
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = PermissionResource::class;
|
||||
|
||||
#[\Override]
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Permissions\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Permissions\PermissionResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewPermission extends ViewRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = PermissionResource::class;
|
||||
|
||||
#[\Override]
|
||||
public function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Atom\Permissions;
|
||||
|
||||
use App\Filament\Resources\Atom\Permissions\Pages\CreatePermission;
|
||||
use App\Filament\Resources\Atom\Permissions\Pages\EditPermission;
|
||||
use App\Filament\Resources\Atom\Permissions\Pages\ListPermissions;
|
||||
use App\Filament\Resources\Atom\Permissions\Pages\ViewPermission;
|
||||
use App\Filament\Tables\Columns\HabboBadgeColumn;
|
||||
use App\Filament\Traits\TranslatableResource;
|
||||
use App\Models\Game\Permission;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\ToggleButtons;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
// ensure Str is imported once
|
||||
|
||||
class PermissionResource extends Resource
|
||||
{
|
||||
use TranslatableResource;
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $model = Permission::class;
|
||||
|
||||
#[\Override]
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
#[\Override]
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Website';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $slug = 'website/permissions';
|
||||
|
||||
public static string $translateIdentifier = 'permissions';
|
||||
|
||||
#[\Override]
|
||||
protected static ?string $recordTitleAttribute = 'rank_name';
|
||||
|
||||
#[\Override]
|
||||
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top;
|
||||
|
||||
#[\Override]
|
||||
public static function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
|
||||
{
|
||||
/**
|
||||
* @param string $name
|
||||
* @param bool $needsSecondOption = false
|
||||
*/
|
||||
$groupedToggleButton = fn (string $name, bool $needsSecondOption = false): ToggleButtons => ToggleButtons::make($name)
|
||||
->label(function () use ($name) {
|
||||
$translationKey = "filament::resources.permissions.{$name}";
|
||||
$translation = __($translationKey);
|
||||
|
||||
if ($translationKey == $translation) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $translation;
|
||||
})
|
||||
->options(function () use ($needsSecondOption) {
|
||||
$options = [
|
||||
'0' => __('filament::resources.options.no'),
|
||||
'1' => __('filament::resources.options.yes'),
|
||||
];
|
||||
|
||||
if ($needsSecondOption) {
|
||||
$options['2'] = __('filament::resources.options.rights');
|
||||
}
|
||||
|
||||
return $options;
|
||||
})
|
||||
->icons(['0' => 'heroicon-o-check', '1' => 'heroicon-o-x-mark', '2' => 'heroicon-o-sparkles'])
|
||||
->colors(['0' => 'danger', '1' => 'success'])
|
||||
->grouped();
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Main')
|
||||
->tabs([
|
||||
Tab::make(__('filament::resources.tabs.General Information'))
|
||||
->schema([
|
||||
TextInput::make('rank_name')
|
||||
->label(__('filament::resources.inputs.name'))
|
||||
->maxLength(25)
|
||||
->required(),
|
||||
|
||||
TextInput::make('badge')
|
||||
->label(__('filament::resources.inputs.badge_code'))
|
||||
->maxLength(12)
|
||||
->required(),
|
||||
|
||||
TextInput::make('level')
|
||||
->label(__('filament::resources.inputs.level'))
|
||||
->required(),
|
||||
|
||||
TextInput::make('room_effect')
|
||||
->label(__('filament::resources.inputs.room_effect'))
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Tab::make(__('filament::resources.tabs.In-game Permissions'))
|
||||
->schema([
|
||||
Section::make(__('filament::resources.sections.permissions.title'))
|
||||
->description(new HtmlString(__('filament::resources.sections.permissions.description')))
|
||||
->schema([
|
||||
Grid::make()
|
||||
->columns([
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema(function () use ($groupedToggleButton) {
|
||||
$columns = Schema::getColumns('permissions');
|
||||
|
||||
$arcturusPermissions = collect($columns)->filter(function (array $column) {
|
||||
$columnName = $column['name'] ?? null;
|
||||
|
||||
if (! $columnName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_starts_with($columnName, 'cmd')
|
||||
|| str_starts_with($columnName, 'acc')
|
||||
|| str_ends_with($columnName, 'cmd');
|
||||
})->values();
|
||||
|
||||
return $arcturusPermissions->map(function (array $column) use ($groupedToggleButton) {
|
||||
$columnName = $column['name'];
|
||||
$needsSecondOption = $column['type_name'] == 'enum' && str_ends_with((string) $column['type'], "'2')");
|
||||
|
||||
return $groupedToggleButton($columnName, $needsSecondOption);
|
||||
})->toArray();
|
||||
}),
|
||||
]),
|
||||
|
||||
]),
|
||||
|
||||
Tab::make(__('filament::resources.tabs.Configurations'))
|
||||
->schema([
|
||||
Grid::make(['default' => 2])
|
||||
->schema([
|
||||
Select::make('log_commands')
|
||||
->label(__('filament::resources.inputs.log_commands'))
|
||||
->columnSpanFull()
|
||||
->options([
|
||||
'0' => __('filament::resources.options.no'),
|
||||
'1' => __('filament::resources.options.yes'),
|
||||
]),
|
||||
|
||||
TextInput::make('prefix')
|
||||
->label(__('filament::resources.inputs.prefix'))
|
||||
->maxLength(5)
|
||||
->required(),
|
||||
|
||||
ColorPicker::make('prefix_color')
|
||||
->label(__('filament::resources.inputs.prefix_color'))
|
||||
->required(),
|
||||
|
||||
Toggle::make('hidden_rank')
|
||||
->label(__('filament::resources.inputs.is_hidden'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make()
|
||||
->schema([
|
||||
Grid::make()
|
||||
->columns([
|
||||
'md' => 2,
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('auto_credits_amount')
|
||||
->columnSpan(1)
|
||||
->label(__('filament::resources.inputs.auto_credits_amount'))
|
||||
->required(),
|
||||
|
||||
TextInput::make('auto_pixels_amount')
|
||||
->label(__('filament::resources.inputs.auto_pixels_amount'))
|
||||
->required(),
|
||||
|
||||
TextInput::make('auto_gotw_amount')
|
||||
->label(__('filament::resources.inputs.auto_gotw_amount'))
|
||||
->required(),
|
||||
|
||||
TextInput::make('auto_points_amount')
|
||||
->label(__('filament::resources.inputs.auto_points_amount'))
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->persistTabInQueryString(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('filament::resources.columns.id')),
|
||||
|
||||
HabboBadgeColumn::make('badge')
|
||||
->alignCenter()
|
||||
->label(__('filament::resources.columns.image')),
|
||||
|
||||
TextColumn::make('rank_name')
|
||||
->label(__('filament::resources.columns.name'))
|
||||
->description(fn (Permission $record) => Str::limit($record->description, 40))
|
||||
->tooltip(function (Permission $record): ?string {
|
||||
$description = $record->description;
|
||||
|
||||
if (strlen($description) <= 40) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $description;
|
||||
})
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('prefix')
|
||||
->label(__('filament::resources.columns.prefix'))
|
||||
->description(fn (Permission $record) => $record->prefix_color)
|
||||
->searchable(),
|
||||
|
||||
ToggleColumn::make('hidden_rank')
|
||||
->label(__('filament::resources.columns.is_hidden')),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPermissions::route('/'),
|
||||
'create' => CreatePermission::route('/create'),
|
||||
'view' => ViewPermission::route('/{record}'),
|
||||
'edit' => EditPermission::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Atom\Tags\Pages;
|
||||
|
||||
use App\Filament\Resources\Atom\Tags\TagResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTag extends CreateRecord
|
||||
{
|
||||
#[\Override]
|
||||
protected static string $resource = TagResource::class;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user