Initial commit

This commit is contained in:
root
2026-05-09 17:28:23 +02:00
commit 9d73f82529
5575 changed files with 281989 additions and 0 deletions
Executable
+113
View File
@@ -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
View File
@@ -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
+87
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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/
+1
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
{
"rules": {
"no-empty-source": null
},
"ignoreFiles": [
"**/node_modules/**",
"**/vendor/**",
"**/dist/**",
"**/build/**",
"**/public/build/**",
"**/.cache/**",
"**/storage/**",
"**/*.min.css",
"**/client.css"
]
}
Executable
+21
View File
@@ -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.
Executable
+325
View File
@@ -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**
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?style=flat&logo=discord&logoColor=white)](https://discord.gg/pP6HyZedAj)
[![Laravel](https://img.shields.io/badge/Laravel-13.x-FF2D20?style=flat&logo=laravel&logoColor=white)](https://laravel.com)
[![PHP](https://img.shields.io/badge/PHP-8.1+-777BB4?style=flat&logo=php&logoColor=white)](https://php.net)
[![Code Quality](https://img.shields.io/badge/Code%20Quality-A+-green?style=flat)](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>
Executable
+273
View File
@@ -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**
@@ -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);
}
}
+185
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+31
View File
@@ -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
View File
@@ -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'];
}
}
+34
View File
@@ -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
View File
@@ -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();
}
}
+41
View File
@@ -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;
}
}
+32
View File
@@ -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'],
]);
}
}
}
}
}
+38
View File
@@ -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,
]);
}
}
+156
View File
@@ -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');
}
}
+150
View File
@@ -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());
}
}
+75
View File
@@ -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}");
}
}
}
+161
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
+74
View File
@@ -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);
});
}
}
+88
View File
@@ -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
View File
@@ -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");
}
}
}
+97
View File
@@ -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.');
});
}
}
+97
View File
@@ -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.');
});
}
}
+272
View File
@@ -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;
}
}
+49
View File
@@ -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;
}
}
}
File diff suppressed because it is too large Load Diff
+251
View File
@@ -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];
}
}
+213
View File
@@ -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()];
}
}
}
+52
View File
@@ -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');
}
}
+39
View File
@@ -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'),
);
}
}
+21
View File
@@ -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',
};
}
}
+33
View File
@@ -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 => '🚨',
};
}
}
+74
View File
@@ -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',
};
}
}
+55
View File
@@ -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'),
);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum NotificationType: string
{
case ArticlePosted = 'article_posted';
case HousekeepingCustomMessage = 'housekeeping_custom_message';
}
+267
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
class RconConnectionException extends Exception {}
+29
View File
@@ -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),
));
}
}
+338
View File
@@ -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),
];
}
}
+28
View File
@@ -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';
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -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'],
];
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+239
View File
@@ -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
View File
@@ -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();
}
}
+245
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+310
View File
@@ -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
View File
@@ -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;
}
}
+439
View File
@@ -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
View File
@@ -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();
}
}
+23
View File
@@ -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(),
];
}
}
+33
View File
@@ -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(),
];
}
}
@@ -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'),
];
}
}
@@ -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;
}
@@ -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(),
];
}
}
@@ -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';
}
}
@@ -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;
}
@@ -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
View File
@@ -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'),
];
}
}
@@ -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;
}
@@ -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(),
];
}
}
@@ -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(),
];
}
}
@@ -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;
}
@@ -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(),
]);
}
}
@@ -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('/'),
];
}
}
@@ -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
View File
@@ -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(),
];
}
}
@@ -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
View File
@@ -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