diff --git a/api/.env.docker b/api/.env.docker index db728ef6..e3156a35 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -10,6 +10,7 @@ LOG_CHANNEL=errorlog LOG_LEVEL=debug FILESYSTEM_DRIVER=local +LOCAL_FILESYSTEM_VISIBILITY=public BROADCAST_CONNECTION=log @@ -36,5 +37,17 @@ AWS_BUCKET= JWT_TTL=1440 JWT_SECRET= +JWT_SKIP_IP_UA_VALIDATION=true -OPEN_AI_API_KEY= \ No newline at end of file +OPEN_AI_API_KEY= + +H_CAPTCHA_SITE_KEY= +H_CAPTCHA_SECRET_KEY= + +RE_CAPTCHA_SITE_KEY= +RE_CAPTCHA_SECRET_KEY= + +TELEGRAM_BOT_ID= +TELEGRAM_BOT_TOKEN= + +SHOW_OFFICIAL_TEMPLATES=true \ No newline at end of file diff --git a/api/app/Console/Commands/SchedulerStatusCommand.php b/api/app/Console/Commands/SchedulerStatusCommand.php new file mode 100644 index 00000000..49cb331e --- /dev/null +++ b/api/app/Console/Commands/SchedulerStatusCommand.php @@ -0,0 +1,73 @@ +error('This command is only functional in self-hosted mode. Please set SELF_HOSTED=true in your .env file.'); + Log::warning('SchedulerStatusCommand: Attempted to run in non-self-hosted mode.'); + return \Illuminate\Console\Command::FAILURE; + } + + $mode = $this->option('mode'); + + if ($mode === self::MODE_RECORD) { + Cache::put(self::CACHE_KEY_LAST_RUN, Carbon::now()->getTimestamp(), Carbon::now()->addHours(2)); + $this->info('Scheduler last run timestamp recorded.'); + Log::info('SchedulerStatusCommand: Recorded last run timestamp.'); + return \Illuminate\Console\Command::SUCCESS; + } + + // Default to 'check' mode (this covers explicit 'check' or any other value for mode) + + $lastRunTimestamp = Cache::get(self::CACHE_KEY_LAST_RUN); + if (!$lastRunTimestamp) { + $this->error('Scheduler last run timestamp not found.'); + Log::warning('SchedulerStatusCommand: Last run timestamp not found during check.'); + return \Illuminate\Console\Command::FAILURE; + } + + $maxMinutes = (int) $this->option('max-minutes'); + if (Carbon::now()->getTimestamp() - $lastRunTimestamp > $maxMinutes * 60) { + $this->error("Scheduler last ran more than {$maxMinutes} minutes ago. Last run: " . Carbon::createFromTimestamp($lastRunTimestamp)->diffForHumans()); + Log::warning("SchedulerStatusCommand: Health check failed. Last ran more than {$maxMinutes} minutes ago."); + return \Illuminate\Console\Command::FAILURE; + } + + $this->info('Scheduler is healthy. Last ran: ' . Carbon::createFromTimestamp($lastRunTimestamp)->diffForHumans()); + return \Illuminate\Console\Command::SUCCESS; + } +} diff --git a/api/app/Console/Kernel.php b/api/app/Console/Kernel.php index a6ba9add..35993dc1 100644 --- a/api/app/Console/Kernel.php +++ b/api/app/Console/Kernel.php @@ -25,6 +25,9 @@ class Kernel extends ConsoleKernel { $schedule->command('forms:database-cleanup')->hourly(); $schedule->command('forms:integration-events-cleanup')->daily(); + if (config('app.self_hosted')) { + $schedule->command('app:scheduler-status --mode=record')->everyMinute(); + } } /** diff --git a/api/app/Http/Controllers/HealthCheckController.php b/api/app/Http/Controllers/HealthCheckController.php new file mode 100644 index 00000000..da1f1a43 --- /dev/null +++ b/api/app/Http/Controllers/HealthCheckController.php @@ -0,0 +1,53 @@ + false, + 'redis' => false, + ]; + $overallStatus = true; + + try { + DB::connection()->getPdo(); + $checks['database'] = true; + } catch (Throwable $e) { + Log::error('Health check: Database connection failed', ['exception' => $e->getMessage()]); + $overallStatus = false; + } + + try { + Redis::ping(); + $checks['redis'] = true; + } catch (Throwable $e) { + Log::error('Health check: Redis connection failed', ['exception' => $e->getMessage()]); + $overallStatus = false; + } + + if ($overallStatus) { + return response()->json([ + 'status' => 'ok', + 'dependencies' => $checks, + ]); + } + + return response()->json([ + 'status' => 'error', + 'dependencies' => $checks, + ], 503); + } +} diff --git a/api/routes/api.php b/api/routes/api.php index e2efaecb..59e11378 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -30,6 +30,7 @@ use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; +use App\Http\Controllers\HealthCheckController; /* |-------------------------------------------------------------------------- @@ -42,6 +43,10 @@ use Illuminate\Support\Facades\Storage; | */ +if (config('app.self_hosted')) { + Route::get('/healthcheck', [HealthCheckController::class, 'apiCheck']); +} + Route::group(['middleware' => 'auth:api'], function () { Route::post('logout', [LoginController::class, 'logout'])->name('logout'); Route::post('update-credentials', [ProfileController::class, 'updateAdminCredentials'])->name('credentials.update'); diff --git a/client/.env.docker b/client/.env.docker index 45af50e4..03df320d 100644 --- a/client/.env.docker +++ b/client/.env.docker @@ -1,4 +1,7 @@ NUXT_PUBLIC_APP_URL=/ NUXT_PUBLIC_API_BASE=/api NUXT_PRIVATE_API_BASE=http://ingress/api -NUXT_PUBLIC_ENV=dev \ No newline at end of file +NUXT_PUBLIC_ENV=dev +NUXT_PUBLIC_H_CAPTCHA_SITE_KEY= +NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY= +NUXT_PUBLIC_ROOT_REDIRECT_URL= \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index b648ab91..426b9927 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,7 +8,6 @@ "hasInstallScript": true, "dependencies": { "@codemirror/lang-html": "^6.4.9", - "@gtm-support/vue-gtm": "^3.1.0", "@iconify-json/material-symbols": "^1.2.4", "@nuxt/ui": "^2.19.2", "@pinia/nuxt": "^0.11.0", @@ -1448,32 +1447,6 @@ "integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==", "license": "MIT" }, - "node_modules/@gtm-support/core": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-3.0.1.tgz", - "integrity": "sha512-SctcoqvvAbGAgZzOb7DZ4wjbZF3ZS7Las3qIEByv6g7mzPf4E9LpRXcQzjmywYLcUx2jys7PWJAa3s5slvj/0w==", - "license": "MIT" - }, - "node_modules/@gtm-support/vue-gtm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-3.1.0.tgz", - "integrity": "sha512-kGUnCI+Z5lBeCKd7rzgU7UoFU8Q0EkJfh17SgeyAyx8cLdISqeq54BNJKZrME3WXersoigLZVJ1GLs0buYD3lA==", - "license": "MIT", - "dependencies": { - "@gtm-support/core": "^3.0.1" - }, - "optionalDependencies": { - "vue-router": ">= 4.4.1 < 5.0.0" - }, - "peerDependencies": { - "vue": ">= 3.2.26 < 4.0.0" - }, - "peerDependenciesMeta": { - "vue-router": { - "optional": true - } - } - }, "node_modules/@headlessui/tailwindcss": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7b85cbef..08b67dd0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,6 +25,11 @@ services: # Storage settings FILESYSTEM_DISK: local LOCAL_FILESYSTEM_VISIBILITY: public + # PHP Configuration + PHP_MEMORY_LIMIT: "1G" + PHP_MAX_EXECUTION_TIME: "600" + PHP_UPLOAD_MAX_FILESIZE: "64M" + PHP_POST_MAX_SIZE: "64M" # Development settings PHP_IDE_CONFIG: serverName=Docker XDEBUG_MODE: ${XDEBUG_MODE:-off} @@ -86,6 +91,7 @@ services: environment: NGINX_HOST: localhost NGINX_PORT: 80 + NGINX_MAX_BODY_SIZE: 64m ports: - "80:80" depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 22402e96..78f75fda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,54 +15,75 @@ services: DB_USERNAME: ${DB_USERNAME:-forge} DB_PASSWORD: ${DB_PASSWORD:-forge} DB_CONNECTION: ${DB_CONNECTION:-pgsql} - # Storage settings - FILESYSTEM_DISK: local - LOCAL_FILESYSTEM_VISIBILITY: public - env_file: + # PHP Configuration + PHP_MEMORY_LIMIT: "1G" + PHP_MAX_EXECUTION_TIME: "600" + PHP_UPLOAD_MAX_FILESIZE: "64M" + PHP_POST_MAX_SIZE: "64M" + env_file: - ./api/.env depends_on: db: condition: service_healthy redis: - condition: service_started + condition: service_healthy # Depend on redis being healthy too + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s api-worker: <<: *api-environment container_name: opnform-api-worker command: ["php", "artisan", "queue:work"] - volumes: *api-environment-volumes environment: <<: *api-env APP_ENV: production IS_API_WORKER: "true" - env_file: - - ./api/.env + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s api-scheduler: <<: *api-environment container_name: opnform-api-scheduler command: ["php", "artisan", "schedule:work"] - volumes: *api-environment-volumes environment: <<: *api-env APP_ENV: production - IS_API_WORKER: "true" - # Scheduler settings + IS_API_WORKER: "true" # This might not be strictly true for scheduler, but consistent with setup CONTAINER_ROLE: scheduler - PHP_MEMORY_LIMIT: 512M - PHP_MAX_EXECUTION_TIME: 60 - env_file: - - ./api/.env + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1"] + interval: 60s + timeout: 30s + retries: 3 + start_period: 70s # Allow time for first scheduled run and cache write ui: image: jhumanj/opnform-client:latest container_name: opnform-client - env_file: + env_file: - ./client/.env + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s redis: image: redis:7 container_name: opnform-redis + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 30s + timeout: 5s db: image: postgres:16 @@ -73,9 +94,8 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD:-forge} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-forge}"] - interval: 5s + interval: 30s timeout: 5s - retries: 5 volumes: - postgres-data:/var/lib/postgresql/data @@ -86,11 +106,19 @@ services: - ./docker/nginx.conf:/etc/nginx/templates/default.conf.template ports: - 80:80 + environment: + - NGINX_MAX_BODY_SIZE=64m depends_on: api: condition: service_started ui: condition: service_started + healthcheck: + test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s volumes: postgres-data: diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api index 9d607938..a2055666 100644 --- a/docker/Dockerfile.api +++ b/docker/Dockerfile.api @@ -1,54 +1,64 @@ -FROM php:8.3-fpm +# Build stage - using PHP CLI image with extensions +FROM php:8.3-cli AS builder -# Install system dependencies +# Install composer and extensions needed for dependency installation RUN apt-get update && apt-get install -y \ libzip-dev \ libpng-dev \ - postgresql-client \ - libpq-dev \ unzip \ - gcc \ - make \ - autoconf \ - libc-dev \ - pkg-config \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* + git \ + && docker-php-ext-install -j$(nproc) \ + bcmath \ + gd \ + zip \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer -# Install composer -COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer -ENV COMPOSER_ALLOW_SUPERUSER=1 +WORKDIR /app -# Install PHP extensions -ENV CFLAGS="-O1 -D_GNU_SOURCE" \ - CXXFLAGS="-O1 -D_GNU_SOURCE" +# Copy the entire source code for proper installation +COPY api/ . -# Install basic extensions first -RUN set -eux; \ - docker-php-ext-install -j$(nproc) pdo zip && \ - php -m | grep -E 'pdo|zip' +# Install dependencies including dev dependencies +RUN composer install --optimize-autoloader --no-interaction \ + --ignore-platform-req=php \ + --ignore-platform-req=ext-bcmath \ + --ignore-platform-req=ext-gd -# Install GD -RUN set -eux; \ - docker-php-ext-install -j$(nproc) gd && \ - php -m | grep -E 'gd' +# Final stage - smaller runtime image +FROM php:8.3-fpm-alpine -# Install PostgreSQL related extensions -RUN set -eux; \ - docker-php-ext-configure pgsql && \ - docker-php-ext-install -j$(nproc) pgsql pdo_pgsql && \ - php -m | grep -E 'pgsql|pdo_pgsql' +# Install runtime dependencies +RUN apk add --no-cache \ + libzip \ + libpng \ + postgresql-client \ + libpq \ + procps \ + unzip \ + bash \ + icu-libs \ + && rm -rf /var/cache/apk/* -# Install bcmath with optimization flags for stability -RUN set -eux; \ - docker-php-ext-install -j1 bcmath && \ - php -m | grep -E 'bcmath' - -# Install Redis extension -RUN set -eux; \ - pecl install -f --configureoptions 'enable-redis-igbinary="no" enable-redis-lzf="no" enable-redis-zstd="no" enable-redis-msgpack="no" enable-redis-lz4="no"' redis && \ - docker-php-ext-enable redis && \ - php -m | grep -E 'redis' +# Install build dependencies and PHP extensions +RUN apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + libzip-dev \ + libpng-dev \ + postgresql-dev \ + oniguruma-dev \ + icu-dev \ + && docker-php-ext-configure pgsql \ + && docker-php-ext-configure gd \ + && docker-php-ext-install -j$(nproc) \ + pdo \ + zip \ + gd \ + pgsql \ + pdo_pgsql \ + bcmath \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del .build-deps WORKDIR /usr/share/nginx/html/ @@ -62,26 +72,8 @@ RUN mkdir -p storage/framework/sessions \ && chown -R www-data:www-data storage bootstrap/cache \ && chmod -R 775 storage bootstrap/cache -# Copy composer files and helpers.php first -COPY api/composer.json api/composer.lock ./ -COPY api/app/helpers.php ./app/helpers.php - -# Default to production settings unless overridden during build -ARG APP_ENV=production -ARG COMPOSER_FLAGS=--no-dev --optimize-autoloader --no-interaction - -# Install dependencies without running scripts -RUN composer install ${COMPOSER_FLAGS} --no-scripts --ignore-platform-req=php - -# Copy the rest of the application -COPY api/ . - -# Run composer scripts and clear cache -RUN composer dump-autoload -o \ - && php artisan package:discover --ansi \ - && composer clear-cache \ - && chmod -R 775 storage \ - && chown -R www-data:www-data storage +# Copy the entire application from the builder stage +COPY --from=builder /app/ ./ # Setup entrypoint COPY docker/php-fpm-entrypoint /usr/local/bin/opnform-entrypoint diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client index eac0d785..80b26262 100644 --- a/docker/Dockerfile.client +++ b/docker/Dockerfile.client @@ -20,8 +20,15 @@ RUN npm cache clean --force && \ # RUN npm install esbuild@0.21.5 ADD ./client/ /app/ -# Increase Node memory limit to prevent out of memory error during build -RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build + +# Optimize memory usage during build +ENV NODE_OPTIONS="--max-old-space-size=4096" +# Set production mode to reduce memory usage +ENV NODE_ENV=production +# Disable source maps to reduce memory usage +ENV GENERATE_SOURCEMAP=false +# Run the build +RUN npm run build FROM node:20-alpine WORKDIR /app diff --git a/docker/nginx.conf b/docker/nginx.conf index f5dcc7d6..bd81466c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -7,6 +7,7 @@ server { listen 80; server_name opnform; root /usr/share/nginx/html/public; + client_max_body_size ${NGINX_MAX_BODY_SIZE}; access_log /dev/stdout; error_log /dev/stderr error; diff --git a/docker/nginx.dev.conf b/docker/nginx.dev.conf index d6e1eba4..38202a14 100644 --- a/docker/nginx.dev.conf +++ b/docker/nginx.dev.conf @@ -7,6 +7,7 @@ server { listen 80; server_name localhost; root /usr/share/nginx/html/public; + client_max_body_size ${NGINX_MAX_BODY_SIZE}; access_log /dev/stdout; error_log /dev/stderr error; diff --git a/docker/php-fpm-entrypoint b/docker/php-fpm-entrypoint index 60d6377d..aaf48d1e 100644 --- a/docker/php-fpm-entrypoint +++ b/docker/php-fpm-entrypoint @@ -3,42 +3,82 @@ main() { if [ "$IS_API_WORKER" = "true" ]; then # This is the API worker, skip setup and just run the command + apply_php_configuration exec "$@" else - # This is the API service, run full setup + # This is the API service or scheduler, run full setup + apply_php_configuration prep_file_permissions prep_storage wait_for_db apply_db_migrations run_init_project optimize_application + + if [ "$CONTAINER_ROLE" = "scheduler" ]; then + echo "Initializing scheduler status for first run (entrypoint)" + ./artisan app:scheduler-status --mode=record + fi + run_server "$@" fi } +apply_php_configuration() { + echo "Applying PHP configuration from environment variables" + + # Create custom PHP config file + PHP_CUSTOM_CONFIG_FILE="/usr/local/etc/php/conf.d/99-custom.ini" + + # Apply memory limit if provided + if [ -n "$PHP_MEMORY_LIMIT" ]; then + echo "memory_limit = $PHP_MEMORY_LIMIT" >> $PHP_CUSTOM_CONFIG_FILE + fi + + # Apply max execution time if provided + if [ -n "$PHP_MAX_EXECUTION_TIME" ]; then + echo "max_execution_time = $PHP_MAX_EXECUTION_TIME" >> $PHP_CUSTOM_CONFIG_FILE + fi + + # Apply upload max filesize if provided + if [ -n "$PHP_UPLOAD_MAX_FILESIZE" ]; then + echo "upload_max_filesize = $PHP_UPLOAD_MAX_FILESIZE" >> $PHP_CUSTOM_CONFIG_FILE + fi + + # Apply post max size if provided + if [ -n "$PHP_POST_MAX_SIZE" ]; then + echo "post_max_size = $PHP_POST_MAX_SIZE" >> $PHP_CUSTOM_CONFIG_FILE + fi + + # Log applied configuration + echo "Applied PHP configuration:" + cat $PHP_CUSTOM_CONFIG_FILE +} + prep_file_permissions() { chmod a+x ./artisan } prep_storage() { - # Create Laravel-specific directories - mkdir -p /persist/storage/framework/cache/data - mkdir -p /persist/storage/framework/sessions - mkdir -p /persist/storage/framework/views + local app_storage_path="/usr/share/nginx/html/storage" + + # Create Laravel-specific directories directly in the mounted volume + mkdir -p "$app_storage_path/app/public" + mkdir -p "$app_storage_path/framework/cache/data" + mkdir -p "$app_storage_path/framework/sessions" + mkdir -p "$app_storage_path/framework/views" + mkdir -p "$app_storage_path/logs" # Set permissions for the entire storage directory - chown -R www-data:www-data /persist/storage - chmod -R 775 /persist/storage - - # Create symlink to the correct storage location - ln -sf /persist/storage /usr/share/nginx/html/storage + chown -R www-data:www-data "$app_storage_path" + chmod -R 775 "$app_storage_path" touch /var/log/opnform.log chown www-data /var/log/opnform.log - # Ensure proper permissions for the storage directory - chown -R www-data:www-data /usr/share/nginx/html/storage - chmod -R 775 /usr/share/nginx/html/storage + # Run Laravel's storage link command (ensure script is run from app root or adjust path to artisan) + echo "Creating public storage symlink" + ./artisan storage:link } wait_for_db() { diff --git a/docker/php/php-fpm.conf b/docker/php/php-fpm.conf deleted file mode 100644 index b04f84e5..00000000 --- a/docker/php/php-fpm.conf +++ /dev/null @@ -1,15 +0,0 @@ -[www] -user = www-data -group = www-data -listen = 9000 - -pm = dynamic -pm.max_children = 20 -pm.start_servers = 2 -pm.min_spare_servers = 1 -pm.max_spare_servers = 3 -pm.max_requests = 1000 - -clear_env = no -catch_workers_output = yes -decorate_workers_output = no \ No newline at end of file diff --git a/docker/php/php.ini b/docker/php/php.ini deleted file mode 100644 index eae20cda..00000000 --- a/docker/php/php.ini +++ /dev/null @@ -1,27 +0,0 @@ -; PHP Configuration -memory_limit = 512M -max_execution_time = 60 -upload_max_filesize = 64M -post_max_size = 64M -max_input_vars = 3000 - -; Error reporting -error_reporting = E_ALL -display_errors = Off -display_startup_errors = Off -log_errors = On -error_log = /dev/stderr - -; Date -date.timezone = UTC - -; Session -session.save_handler = redis -session.save_path = "tcp://redis:6379" - -; OpCache -opcache.enable=1 -opcache.memory_consumption=256 -opcache.max_accelerated_files=20000 -opcache.validate_timestamps=1 -opcache.revalidate_freq=0 \ No newline at end of file