diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2712969 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.idea +.vscode +.agents +.ai +.claude +.gemini +.junie +node_modules +vendor +npm-debug.log +yarn-error.log +.env +.env.* +*.md +!README.md +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +tests/ +phpunit.xml +phpunit.result.cache +docker-compose.yml diff --git a/.env.example b/.env.example index 3a78744..b766e4a 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,7 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" +VITE_REVERB_PATH="" ACTIVITY_LOGGER_ENABLED=true ACTIVITY_LOGGER_TABLE_NAME=activity_log diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..ee9f058 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,64 @@ +APP_NAME=iMail +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://your-domain.com + +LOG_CHANNEL=stderr +LOG_LEVEL=info + +# Database (MariaDB via Dokploy/External) +DB_CONNECTION=mariadb +DB_HOST=mariadb-host +DB_PORT=3306 +DB_DATABASE=imail +DB_USERNAME=root +DB_PASSWORD= + +# MongoDB (via Dokploy/External) +MONGODB_URI=mongodb://mongodb-host:27017 + +# Redis (via Dokploy/External) +REDIS_CLIENT=phpredis # Required for Laravel Pulse ingest +REDIS_HOST=redis-host +REDIS_PASSWORD=null +REDIS_PORT=6379 + +CACHE_STORE=redis +QUEUE_CONNECTION=redis +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=reverb + +FILESYSTEM_DISK=s3 + +# S3 Compatible Storage (RustFS via Dokploy/External) +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET=imail +AWS_USE_PATH_STYLE_ENDPOINT=true +AWS_ENDPOINT=http://rustfs-host:9000 + +# Reverb Configuration +REVERB_APP_ID= +REVERB_APP_KEY= +REVERB_APP_SECRET= +REVERB_HOST="your-domain.com" +REVERB_PORT=443 +REVERB_SCHEME=https + +# Pulse Configuration +PULSE_INGEST_DRIVER=redis +PULSE_REDIS_CONNECTION=default + +VITE_APP_NAME="${APP_NAME}" +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" +VITE_REVERB_PATH="/_ws" diff --git a/.junie/guidelines.md b/.junie/guidelines.md index e8aef0e..4440f77 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -46,8 +46,10 @@ This application is a Laravel application and its main Laravel ecosystems packag - filament/filament (FILAMENT) - v4 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 - laravel/pint (PINT) - v1 - laravel/prompts (PROMPTS) - v0 +- laravel/pulse (PULSE) - v1 - laravel/reverb (REVERB) - v1 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 diff --git a/CLAUDE.md b/CLAUDE.md index e8aef0e..4440f77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,10 @@ This application is a Laravel application and its main Laravel ecosystems packag - filament/filament (FILAMENT) - v4 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 - laravel/pint (PINT) - v1 - laravel/prompts (PROMPTS) - v0 +- laravel/pulse (PULSE) - v1 - laravel/reverb (REVERB) - v1 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..397bf29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# 1. Node Builder Stage +FROM node:22-alpine AS node-builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY ./ ./ +RUN npm run build + +# 2. Composer Builder Stage +FROM php:8.4-cli-alpine AS composer-builder +RUN apk add --no-cache unzip +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +WORKDIR /app +COPY composer.json composer.lock ./ +# Note: ignoring platform requirements since mongo/redis aren't natively in this alpine CLI image +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs +COPY ./ ./ +RUN composer dump-autoload --optimize --no-dev + +# 3. Production Stage +FROM php:8.4-fpm-alpine +WORKDIR /var/www/html + +# Install system dependencies +RUN apk add --no-cache \ + nginx \ + supervisor \ + libpng-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + libzip-dev \ + icu-dev \ + git \ + $PHPIZE_DEPS \ + linux-headers \ + openssl-dev + +# Install PHP extensions +RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) \ + pdo_mysql \ + gd \ + zip \ + intl \ + bcmath \ + pcntl \ + opcache \ + sockets + +# Install PECL extensions (MongoDB and Redis) +RUN pecl install mongodb redis \ + && docker-php-ext-enable mongodb redis \ + && rm -rf /tmp/pear + +# Copy source code and vendor dependencies +COPY . . +COPY --from=composer-builder /app/vendor ./vendor +COPY --from=node-builder /app/public/build ./public/build + +# Copy configuration files +COPY docker/nginx.conf /etc/nginx/http.d/default.conf +COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf +COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini +COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh + +# Make entrypoint executable +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Cleanup +RUN apk del $PHPIZE_DEPS \ + && rm -rf /var/cache/apk/* + +EXPOSE 80 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/GEMINI.md b/GEMINI.md index e8aef0e..4440f77 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -46,8 +46,10 @@ This application is a Laravel application and its main Laravel ecosystems packag - filament/filament (FILAMENT) - v4 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 - laravel/pint (PINT) - v1 - laravel/prompts (PROMPTS) - v0 +- laravel/pulse (PULSE) - v1 - laravel/reverb (REVERB) - v1 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..59599dc --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,36 @@ +email, [ + // + ]); + }); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 8f4c427..44ede7b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + $middleware->trustProxies(at: '*'); $middleware->alias([ 'verify.webhook.secret' => VerifyWebhookSecret::class, ]); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 4a80642..9e63687 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -5,4 +5,5 @@ return [ App\Providers\DynamicMailConfigServiceProvider::class, App\Providers\Filament\DashPanelProvider::class, App\Providers\FortifyServiceProvider::class, + App\Providers\HorizonServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 1e7059b..bf56374 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,8 @@ "jacobtims/filament-logger": "^1.0", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/horizon": "^5.45", + "laravel/pulse": "^1.6", "laravel/reverb": "^1.8", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.1.1", diff --git a/composer.lock b/composer.lock index 49280ba..20419e0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ac4723b249fb557c0154b1b59679a168", + "content-hash": "cd1592da57958973057c2f7e7a9cfb1d", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1245,6 +1245,61 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "doctrine/sql-formatter", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" + }, + "bin": [ + "bin/sql-formatter" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\SqlFormatter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "support": { + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" + }, + "time": "2026-02-08T16:21:46+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -3072,6 +3127,86 @@ }, "time": "2025-11-25T14:46:28+00:00" }, + { + "name": "laravel/horizon", + "version": "v5.45.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/horizon.git", + "reference": "637e065ae0a704288595b896ad1c7c3c9741869b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/horizon/zipball/637e065ae0a704288595b896ad1c7c3c9741869b", + "reference": "637e065ae0a704288595b896ad1c7c3c9741869b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcntl": "*", + "ext-posix": "*", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", + "nesbot/carbon": "^2.17|^3.0", + "php": "^8.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", + "symfony/polyfill-php83": "^1.28", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^1.10|^2.0", + "predis/predis": "^1.1|^2.0|^3.0" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Horizon": "Laravel\\Horizon\\Horizon" + }, + "providers": [ + "Laravel\\Horizon\\HorizonServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Horizon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Dashboard and code-driven configuration for Laravel queues.", + "keywords": [ + "laravel", + "queue" + ], + "support": { + "issues": "https://github.com/laravel/horizon/issues", + "source": "https://github.com/laravel/horizon/tree/v5.45.1" + }, + "time": "2026-03-06T15:31:27+00:00" + }, { "name": "laravel/pint", "version": "v1.27.1", @@ -3198,6 +3333,94 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/pulse", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pulse.git", + "reference": "7cde76c1abe23492edeee7dadec01906cf70427d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pulse/zipball/7cde76c1abe23492edeee7dadec01906cf70427d", + "reference": "7cde76c1abe23492edeee7dadec01906cf70427d", + "shasum": "" + }, + "require": { + "doctrine/sql-formatter": "^1.4.1", + "guzzlehttp/promises": "^1.0|^2.0", + "illuminate/auth": "^10.48.4|^11.0.8|^12.0", + "illuminate/cache": "^10.48.4|^11.0.8|^12.0", + "illuminate/config": "^10.48.4|^11.0.8|^12.0", + "illuminate/console": "^10.48.4|^11.0.8|^12.0", + "illuminate/contracts": "^10.48.4|^11.0.8|^12.0", + "illuminate/database": "^10.48.4|^11.0.8|^12.0", + "illuminate/events": "^10.48.4|^11.0.8|^12.0", + "illuminate/http": "^10.48.4|^11.0.8|^12.0", + "illuminate/queue": "^10.48.4|^11.0.8|^12.0", + "illuminate/redis": "^10.48.4|^11.0.8|^12.0", + "illuminate/routing": "^10.48.4|^11.0.8|^12.0", + "illuminate/support": "^10.48.4|^11.0.8|^12.0", + "illuminate/view": "^10.48.4|^11.0.8|^12.0", + "laravel/sentinel": "^1.0", + "livewire/livewire": "^3.6.4|^4.0", + "nesbot/carbon": "^2.67|^3.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0" + }, + "conflict": { + "nunomaduro/collision": "<7.7.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.7", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^8.36|^9.15|^10.8", + "pestphp/pest": "^2.0|^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^2.2|^3.0|^4.0", + "phpstan/phpstan": "^1.12.21", + "predis/predis": "^1.0|^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Pulse": "Laravel\\Pulse\\Facades\\Pulse" + }, + "providers": [ + "Laravel\\Pulse\\PulseServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pulse\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.", + "homepage": "https://github.com/laravel/pulse", + "keywords": [ + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/pulse/issues", + "source": "https://github.com/laravel/pulse" + }, + "time": "2026-02-12T18:51:24+00:00" + }, { "name": "laravel/reverb", "version": "v1.8.0", @@ -3277,6 +3500,65 @@ }, "time": "2026-02-21T14:37:48+00:00" }, + { + "name": "laravel/sentinel", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sentinel.git", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/pint": "^1.27", + "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^2.1.33" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sentinel\\SentinelServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sentinel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "mior@laravel.com" + } + ], + "support": { + "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + }, + "time": "2026-02-12T13:32:54+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 0000000..32f8f0d --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,254 @@ + env('HORIZON_NAME'), + + /* + |-------------------------------------------------------------------------- + | Horizon Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Horizon will be accessible from. If this + | setting is null, Horizon will reside under the same domain as the + | application. Otherwise, this value will serve as the subdomain. + | + */ + + 'domain' => env('HORIZON_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('HORIZON_PATH', 'horizon'), + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env( + 'HORIZON_PREFIX', + Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' + ), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:default' => 60, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Silenced Jobs + |-------------------------------------------------------------------------- + | + | Silencing a job will instruct Horizon to not place the job in the list + | of completed jobs within the Horizon dashboard. This setting may be + | used to fully remove any noisy jobs from the completed jobs list. + | + */ + + 'silenced' => [ + // App\Jobs\ExampleJob::class, + ], + + 'silenced_tags' => [ + // 'notifications', + ], + + /* + |-------------------------------------------------------------------------- + | Metrics + |-------------------------------------------------------------------------- + | + | Here you can configure how many snapshots should be kept to display in + | the metrics graph. This will get used in combination with Horizon's + | `horizon:snapshot` schedule to define how long to retain metrics. + | + */ + + 'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon master + | supervisor may consume before it is terminated and restarted. For + | configuring these limits on your workers, see the next section. + | + */ + + 'memory_limit' => 64, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 128, + 'tries' => 1, + 'timeout' => 60, + 'nice' => 0, + ], + ], + + 'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], + ], + + 'local' => [ + 'supervisor-1' => [ + 'maxProcesses' => 3, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | File Watcher Configuration + |-------------------------------------------------------------------------- + | + | The following list of directories and files will be watched when using + | the `horizon:listen` command. Whenever any directories or files are + | changed, Horizon will automatically restart to apply all changes. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + 'composer.json', + '.env', + ], +]; diff --git a/config/pulse.php b/config/pulse.php new file mode 100644 index 0000000..662703f --- /dev/null +++ b/config/pulse.php @@ -0,0 +1,244 @@ + env('PULSE_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Pulse Path + |-------------------------------------------------------------------------- + | + | This is the path which the Pulse dashboard will be accessible from. Feel + | free to change this path to anything you'd like. Note that this won't + | affect the path of the internal API that is never exposed to users. + | + */ + + 'path' => env('PULSE_PATH', 'pulse'), + + /* + |-------------------------------------------------------------------------- + | Pulse Master Switch + |-------------------------------------------------------------------------- + | + | This configuration option may be used to completely disable all Pulse + | data recorders regardless of their individual configurations. This + | provides a single option to quickly disable all Pulse recording. + | + */ + + 'enabled' => env('PULSE_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Pulse Storage Driver + |-------------------------------------------------------------------------- + | + | This configuration option determines which storage driver will be used + | while storing entries from Pulse's recorders. In addition, you also + | may provide any options to configure the selected storage driver. + | + */ + + 'storage' => [ + 'driver' => env('PULSE_STORAGE_DRIVER', 'database'), + + 'trim' => [ + 'keep' => env('PULSE_STORAGE_KEEP', '7 days'), + ], + + 'database' => [ + 'connection' => env('PULSE_DB_CONNECTION'), + 'chunk' => 1000, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Pulse Ingest Driver + |-------------------------------------------------------------------------- + | + | This configuration options determines the ingest driver that will be used + | to capture entries from Pulse's recorders. Ingest drivers are great to + | free up your request workers quickly by offloading the data storage. + | + */ + + 'ingest' => [ + 'driver' => env('PULSE_INGEST_DRIVER', 'storage'), + + 'buffer' => env('PULSE_INGEST_BUFFER', 5_000), + + 'trim' => [ + 'lottery' => [1, 1_000], + 'keep' => env('PULSE_INGEST_KEEP', '7 days'), + ], + + 'redis' => [ + 'connection' => env('PULSE_REDIS_CONNECTION'), + 'chunk' => 1000, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Pulse Cache Driver + |-------------------------------------------------------------------------- + | + | This configuration option determines the cache driver that will be used + | for various tasks, including caching dashboard results, establishing + | locks for events that should only occur on one server and signals. + | + */ + + 'cache' => env('PULSE_CACHE_DRIVER'), + + /* + |-------------------------------------------------------------------------- + | Pulse Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will be assigned to every Pulse route, giving you the + | chance to add your own middleware to this list or change any of the + | existing middleware. Of course, reasonable defaults are provided. + | + */ + + 'middleware' => [ + 'web', + Authorize::class, + ], + + /* + |-------------------------------------------------------------------------- + | Pulse Recorders + |-------------------------------------------------------------------------- + | + | The following array lists the "recorders" that will be registered with + | Pulse, along with their configuration. Recorders gather application + | event data from requests and tasks to pass to your ingest driver. + | + */ + + 'recorders' => [ + Laravel\Reverb\Pulse\Recorders\ReverbConnections::class => [ + 'sample_rate' => 1, + ], + + Laravel\Reverb\Pulse\Recorders\ReverbMessages::class => [ + 'sample_rate' => 1, + ], + + Recorders\CacheInteractions::class => [ + 'enabled' => env('PULSE_CACHE_INTERACTIONS_ENABLED', true), + 'sample_rate' => env('PULSE_CACHE_INTERACTIONS_SAMPLE_RATE', 1), + 'ignore' => [ + ...Pulse::defaultVendorCacheKeys(), + ], + 'groups' => [ + '/^job-exceptions:.*/' => 'job-exceptions:*', + // '/:\d+/' => ':*', + ], + ], + + Recorders\Exceptions::class => [ + 'enabled' => env('PULSE_EXCEPTIONS_ENABLED', true), + 'sample_rate' => env('PULSE_EXCEPTIONS_SAMPLE_RATE', 1), + 'location' => env('PULSE_EXCEPTIONS_LOCATION', true), + 'ignore' => [ + // '/^Package\\\\Exceptions\\\\/', + ], + ], + + Recorders\Queues::class => [ + 'enabled' => env('PULSE_QUEUES_ENABLED', true), + 'sample_rate' => env('PULSE_QUEUES_SAMPLE_RATE', 1), + 'ignore' => [ + // '/^Package\\\\Jobs\\\\/', + ], + ], + + Recorders\Servers::class => [ + 'server_name' => env('PULSE_SERVER_NAME', gethostname()), + 'directories' => explode(':', env('PULSE_SERVER_DIRECTORIES', '/')), + ], + + Recorders\SlowJobs::class => [ + 'enabled' => env('PULSE_SLOW_JOBS_ENABLED', true), + 'sample_rate' => env('PULSE_SLOW_JOBS_SAMPLE_RATE', 1), + 'threshold' => env('PULSE_SLOW_JOBS_THRESHOLD', 1000), + 'ignore' => [ + // '/^Package\\\\Jobs\\\\/', + ], + ], + + Recorders\SlowOutgoingRequests::class => [ + 'enabled' => env('PULSE_SLOW_OUTGOING_REQUESTS_ENABLED', true), + 'sample_rate' => env('PULSE_SLOW_OUTGOING_REQUESTS_SAMPLE_RATE', 1), + 'threshold' => env('PULSE_SLOW_OUTGOING_REQUESTS_THRESHOLD', 1000), + 'ignore' => [ + // '#^http://127\.0\.0\.1:13714#', // Inertia SSR... + ], + 'groups' => [ + // '#^https://api\.github\.com/repos/.*$#' => 'api.github.com/repos/*', + // '#^https?://([^/]*).*$#' => '\1', + // '#/\d+#' => '/*', + ], + ], + + Recorders\SlowQueries::class => [ + 'enabled' => env('PULSE_SLOW_QUERIES_ENABLED', true), + 'sample_rate' => env('PULSE_SLOW_QUERIES_SAMPLE_RATE', 1), + 'threshold' => env('PULSE_SLOW_QUERIES_THRESHOLD', 1000), + 'location' => env('PULSE_SLOW_QUERIES_LOCATION', true), + 'max_query_length' => env('PULSE_SLOW_QUERIES_MAX_QUERY_LENGTH'), + 'ignore' => [ + '/(["`])pulse_[\w]+?\1/', // Pulse tables... + '/(["`])telescope_[\w]+?\1/', // Telescope tables... + ], + ], + + Recorders\SlowRequests::class => [ + 'enabled' => env('PULSE_SLOW_REQUESTS_ENABLED', true), + 'sample_rate' => env('PULSE_SLOW_REQUESTS_SAMPLE_RATE', 1), + 'threshold' => env('PULSE_SLOW_REQUESTS_THRESHOLD', 1000), + 'ignore' => [ + '#^/'.env('PULSE_PATH', 'pulse').'$#', // Pulse dashboard... + '#^/telescope#', // Telescope dashboard... + ], + ], + + Recorders\UserJobs::class => [ + 'enabled' => env('PULSE_USER_JOBS_ENABLED', true), + 'sample_rate' => env('PULSE_USER_JOBS_SAMPLE_RATE', 1), + 'ignore' => [ + // '/^Package\\\\Jobs\\\\/', + ], + ], + + Recorders\UserRequests::class => [ + 'enabled' => env('PULSE_USER_REQUESTS_ENABLED', true), + 'sample_rate' => env('PULSE_USER_REQUESTS_SAMPLE_RATE', 1), + 'ignore' => [ + '#^/'.env('PULSE_PATH', 'pulse').'$#', // Pulse dashboard... + '#^/telescope#', // Telescope dashboard... + ], + ], + ], +]; diff --git a/database/migrations/2026_03_09_171042_create_pulse_tables.php b/database/migrations/2026_03_09_171042_create_pulse_tables.php new file mode 100644 index 0000000..5d194e2 --- /dev/null +++ b/database/migrations/2026_03_09_171042_create_pulse_tables.php @@ -0,0 +1,84 @@ +shouldRun()) { + return; + } + + Schema::create('pulse_values', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('timestamp'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->mediumText('value'); + + $table->index('timestamp'); // For trimming... + $table->index('type'); // For fast lookups and purging... + $table->unique(['type', 'key_hash']); // For data integrity and upserts... + }); + + Schema::create('pulse_entries', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('timestamp'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->bigInteger('value')->nullable(); + + $table->index('timestamp'); // For trimming... + $table->index('type'); // For purging... + $table->index('key_hash'); // For mapping... + $table->index(['timestamp', 'type', 'key_hash', 'value']); // For aggregate queries... + }); + + Schema::create('pulse_aggregates', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('bucket'); + $table->unsignedMediumInteger('period'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->string('aggregate'); + $table->decimal('value', 20, 2); + $table->unsignedInteger('count')->nullable(); + + $table->unique(['bucket', 'period', 'type', 'aggregate', 'key_hash']); // Force "on duplicate update"... + $table->index(['period', 'bucket']); // For trimming... + $table->index('type'); // For purging... + $table->index(['period', 'type', 'aggregate', 'bucket']); // For aggregate queries... + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pulse_values'); + Schema::dropIfExists('pulse_entries'); + Schema::dropIfExists('pulse_aggregates'); + } +}; diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..eb2f6fd --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -e + +echo "Starting iMail container initialization..." + +# Ensure storage directories exist +mkdir -p /var/www/html/storage/framework/cache/data +mkdir -p /var/www/html/storage/framework/sessions +mkdir -p /var/www/html/storage/framework/views +mkdir -p /var/www/html/storage/logs +mkdir -p /var/www/html/storage/app/public +mkdir -p /var/www/html/bootstrap/cache + +# Fix permissions +chown -R www-data:www-data /var/www/html/storage +chown -R www-data:www-data /var/www/html/bootstrap/cache + +# Cache configuration, routes, views, events +echo "Caching configuration and routes..." +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan event:cache + +# Create storage symlink +php artisan storage:link + +# Run migrations automatically +echo "Running migrations..." +php artisan migrate --force + +echo "Initialization complete. Starting Supervisord..." +# Execute supervisord in the foreground +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..a4c4ed2 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,71 @@ +server { + listen 80; + server_name _; + root /var/www/html/public; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.php; + + charset utf-8; + + client_max_body_size 64M; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + # Laravel routes + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Pulse Dashboard + location = /pulse { + try_files $uri $uri/ /index.php?$query_string; + } + + # Horizon Dashboard + location = /horizon { + try_files $uri $uri/ /index.php?$query_string; + } + + # Reverb WebSocket Proxy + # The trailing slash on proxy_pass strips the /_ws prefix: + # /_ws/app/{key} → /app/{key} + location /_ws/ { + proxy_pass http://127.0.0.1:8080/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Scheme $scheme; + proxy_set_header SERVER_PORT $server_port; + proxy_set_header REMOTE_ADDR $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_read_timeout 60m; + proxy_connect_timeout 60m; + } + + # Pass PHP scripts to FastCGI server + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, no-transform"; + access_log off; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/docker/php-fpm.conf b/docker/php-fpm.conf new file mode 100644 index 0000000..e0f51aa --- /dev/null +++ b/docker/php-fpm.conf @@ -0,0 +1,21 @@ +[global] +error_log = /dev/stderr + +[www] +user = www-data +group = www-data + +listen = 127.0.0.1:9000 + +pm = dynamic +pm.max_children = 50 +pm.start_servers = 5 +pm.min_spare_servers = 5 +pm.max_spare_servers = 35 +pm.max_requests = 500 + +clear_env = no +catch_workers_output = yes +decorate_workers_output = no + +access.log = /dev/null diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 0000000..7156a6e --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,21 @@ +[PHP] +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /dev/stderr +memory_limit = 256M +upload_max_filesize = 64M +post_max_size = 64M +max_execution_time = 60 +max_input_time = 60 +variables_order = "EGPCS" + +[opcache] +opcache.enable=1 +opcache.memory_consumption=256 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=20000 +opcache.validate_timestamps=0 +opcache.save_comments=1 +opcache.fast_shutdown=1 diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 0000000..a238ab0 --- /dev/null +++ b/docker/supervisord.conf @@ -0,0 +1,83 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 + +[program:php-fpm] +command=php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf +autostart=true +autorestart=true +priority=5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:horizon] +command=php /var/www/html/artisan horizon +user=www-data +autostart=true +autorestart=true +priority=15 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopwaitsecs=3600 + +[program:scheduler] +command=/bin/sh -c "while sleep 60; do php /var/www/html/artisan schedule:run; done" +user=www-data +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:reverb] +command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080 +user=www-data +autostart=true +autorestart=true +priority=25 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:pulse-check] +command=php /var/www/html/artisan pulse:check +user=www-data +autostart=true +autorestart=true +priority=30 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopwaitsecs=3600 + +[program:pulse-work] +command=php /var/www/html/artisan pulse:work +user=www-data +autostart=true +autorestart=true +priority=35 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopwaitsecs=3600 diff --git a/install.log b/install.log new file mode 100644 index 0000000..e7eb25f Binary files /dev/null and b/install.log differ diff --git a/resources/js/app.js b/resources/js/app.js index 3abade8..c739e91 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -10,6 +10,7 @@ if (document.querySelector('[data-requires-reverb]')) { wsHost: import.meta.env.VITE_REVERB_HOST, wsPort: import.meta.env.VITE_REVERB_PORT ?? 80, wssPort: import.meta.env.VITE_REVERB_PORT ?? 443, + wsPath: import.meta.env.VITE_REVERB_PATH ?? '', forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], }); diff --git a/resources/views/vendor/pulse/dashboard.blade.php b/resources/views/vendor/pulse/dashboard.blade.php new file mode 100644 index 0000000..6a95bb1 --- /dev/null +++ b/resources/views/vendor/pulse/dashboard.blade.php @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/routes/console.php b/routes/console.php index 4e57cdb..8485937 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,4 @@ Artisan::command('inspire', function (): void { })->purpose('Display an inspiring quote'); Schedule::command('mailboxes:cleanup')->everyMinute(); +Schedule::command('horizon:snapshot')->everyFiveMinutes();