diff --git a/.editorconfig b/.editorconfig index c0a8a708f9d..c0b2a31418c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,6 +21,10 @@ trim_trailing_whitespace = false indent_style = tab indent_size = 4 +[*.sh] +indent_style = space +indent_size = 2 + [*.yml] indent_style = space indent_size = 2 diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index df68069a428..0d58c2b33aa 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -184,7 +184,7 @@ jobs: vuln-type: 'os,library' severity: 'CRITICAL,HIGH' - docker-build: + docker_build: name: 4️⃣ Build Docker Image runs-on: ubuntu-latest if: > @@ -254,6 +254,116 @@ jobs: build-args: | NODE_ENV=production + docker_legacy_check: + name: 3️⃣ Legacy Dockerfile Lint + runs-on: ubuntu-latest + needs: + - phpstan + - check_js + + steps: + - name: Harden Runner + uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3.12.0 + + - name: Docker Lint + uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 + with: + dockerfile: ./Dockerfile-legacy + failure-threshold: warning + + - name: Build Docker image locally + run: docker build -f Dockerfile-legacy -t lychee:local-legacy . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: lychee:local-legacy + format: 'table' + exit-code: 1 + ignore-unfixed: true + vuln-type: 'os,library' + severity: 'CRITICAL,HIGH' + + docker_legacy_build: + name: 4️⃣ Build Legacy Docker Image + runs-on: ubuntu-latest + if: > + (github.ref == 'refs/heads/master' && github.event_name == 'push') || + (startsWith(github.ref, 'refs/tags/') && github.event_name == 'push') + needs: + - docker_legacy_check + permissions: + contents: read + packages: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3.12.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - + name: Login to DockerHub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: | + ${{ github.repository }} + ghcr.io/${{ github.repository }} + flavor: | + latest=${{ startsWith(github.ref, 'refs/tags/') }} + suffix=-legacy,onlatest=true + tags: | + # define default branch + type=edge,branch=master + # branch event + type=ref,event=branch + # tag event + type=ref,event=tag + + - name: Build and push Docker image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + push: true + file: Dockerfile-legacy + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + NODE_ENV=production + createArtifact: name: 3️⃣ Build Artifact if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') diff --git a/Dockerfile b/Dockerfile index 4e02c66367a..32b85947ac1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,6 +84,7 @@ RUN apk add --no-cache \ netcat-openbsd \ unzip \ curl \ + bash \ && install-php-extensions \ pdo_mysql \ pdo_pgsql \ diff --git a/Dockerfile-legacy b/Dockerfile-legacy new file mode 100644 index 00000000000..7c780c4b22a --- /dev/null +++ b/Dockerfile-legacy @@ -0,0 +1,171 @@ +# Lychee - Laravel Backend Dockerfile +# Multi-stage build with Laravel Octane + FrankenPHP +ARG NODE_ENV=production + +# ============================================================================ +# Stage 1: Composer Dependencies +# ============================================================================ +FROM composer:2.8@sha256:5248900ab8b5f7f880c2d62180e40960cd87f60149ec9a1abfd62ac72a02577c AS composer + +WORKDIR /app + +# Copy composer files first for layer caching +COPY composer.json composer.lock ./ + +# Install dependencies (no dev packages for production) +# Remove markdown and test directories to slim down the image +RUN composer install \ + --no-dev \ + --no-interaction \ + --no-progress \ + --no-scripts \ + --prefer-dist \ + --optimize-autoloader \ + --ignore-platform-reqs \ + && find vendor \ + \( -iname "*.md" -o -iname "test" -o -iname "tests" \) \ + -exec rm -rf {} + + +# ============================================================================ +# Stage 2: Node.js Build for Frontend Assets +# ============================================================================ +FROM node:20-alpine@sha256:658d0f63e501824d6c23e06d4bb95c71e7d704537c9d9272f488ac03a370d448 AS node + +# Build argument to control dev vs production build +ARG NODE_ENV +ENV NODE_ENV=$NODE_ENV + +WORKDIR /app + +# Copy package files for layer caching +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci --no-audit + +# Copy frontend source +COPY resources/ ./resources/ +COPY public/ ./public/ +COPY lang/ ./lang/ +COPY vite.config.ts vite.embed.config.ts tsconfig.json ./ + +# Build frontend assets +# When NODE_ENV=development, Vite sets import.meta.env.DEV=true +RUN npm run build + + +FROM debian:bookworm-slim@sha256:d5d3f9c23164ea16f31852f95bd5959aad1c5e854332fe00f7b3a20fcc9f635c AS base + +LABEL maintainer="lycheeorg" +LABEL org.opencontainers.image.title="Lychee" +LABEL org.opencontainers.image.description="Self-hosted photo management system done right." +LABEL org.opencontainers.image.authors="LycheeOrg" +LABEL org.opencontainers.image.vendor="LycheeOrg" +LABEL org.opencontainers.image.source="https://github.com/LycheeOrg/Lychee" +LABEL org.opencontainers.image.url="https://lycheeorg.github.io" +LABEL org.opencontainers.image.documentation="https://lycheeorg.dev/docs" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.base.name="debian:bookworm-slim" + +# Environment variables +ENV PUID='1000' +ENV PGID='1000' +ENV PHP_TZ=UTC + +# https://stackoverflow.com/questions/53377176/change-imagemagick-policy-on-a-dockerfile (for the sed on policy.xml) +# Install base dependencies, add user and group, clone the repo and install php libraries +# hadolint ignore=DL3008 +RUN \ + set -ev && \ + apt-get update && \ + apt-get upgrade -qy && \ + apt-get install -qy --no-install-recommends\ + ca-certificates \ + curl \ + apt-transport-https && \ + curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb && \ + dpkg -i /tmp/debsuryorg-archive-keyring.deb && \ + sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ bookworm main" > /etc/apt/sources.list.d/php.list' && \ + apt-get update && \ + apt-get install -qy --no-install-recommends \ + adduser \ + nginx-light \ + php8.5-mysql \ + php8.5-pgsql \ + php8.5-sqlite3 \ + php8.5-imagick \ + php8.5-mbstring \ + php8.5-gd \ + php8.5-xml \ + php8.5-zip \ + php8.5-fpm \ + php8.5-redis \ + php8.5-bcmath \ + php8.5-intl \ + netcat-openbsd \ + libimage-exiftool-perl \ + ffmpeg \ + jpegoptim \ + optipng \ + pngquant \ + gifsicle \ + webp \ + cron \ + ghostscript && \ + sed -i 's///g' /etc/ImageMagick-6/policy.xml && \ + usermod -o -u "$PUID" "www-data" && \ + groupmod -o -g "$PGID" "www-data" && \ + echo "* * * * * www-data cd /app && php artisan schedule:run >> /dev/null 2>&1" >> /etc/crontab && \ + apt-get clean -qy && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy application code +COPY --chown=www-data:www-data . . + +# Copy vendor from composer stage +COPY --from=composer --chown=www-data:www-data /app/vendor ./vendor + +# Copy built frontend assets from node stage +COPY --from=node --chown=www-data:www-data /app/public/build ./public/build + +# Ensure storage and bootstrap/cache are writable with minimal permissions +RUN mkdir -p storage/framework/cache \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache \ + public/dist \ + && chown -R www-data:www-data storage bootstrap/cache public/dist \ + && chmod -R 750 storage bootstrap/cache \ + && chmod -R 755 public/dist \ + && touch /app/docker_target \ + && touch /app/public/dist/user.css \ + && touch /app/public/dist/custom.js \ + && chown www-data:www-data /app/public/dist/user.css /app/public/dist/custom.js \ + && chmod 644 /app/public/dist/user.css /app/public/dist/custom.js + +# Copy entrypoint and validation scripts +COPY docker/scripts/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY docker/scripts/validate-env.sh /usr/local/bin/validate-env.sh +COPY docker/scripts/create-admin-user.sh /usr/local/bin/create-admin-user.sh +COPY docker/scripts/permissions-check.sh /usr/local/bin/permissions-check.sh +COPY docker/scripts/dump-env.sh /usr/local/bin/dump-env.sh +COPY docker/nginx.conf /etc/nginx/nginx.conf +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/validate-env.sh /usr/local/bin/permissions-check.sh /usr/local/bin/create-admin-user.sh /usr/local/bin/dump-env.sh + +# Expose port 8000 (Octane) +EXPOSE 8000 + +# Set entrypoint +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + + +# RUN chmod +x /entrypoint.sh && \ +# chmod +x /inject.sh && \ +# if [ ! -e /run/php ] ; then mkdir /run/php ; fi + +HEALTHCHECK CMD curl --fail http://localhost:8000/ || exit 1 + +CMD [ "nginx" ] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 4423d7ce1e2..4197b981329 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,6 +3,7 @@ services: # Laravel Backend API lychee_api: image: lychee-frankenphp:latest + # image: lychee-legacy:latest # build: # context: ./app # dockerfile: Dockerfile @@ -131,6 +132,7 @@ services: # to disable it for your Log Viewer. # Should redis crash, you will no longer be able to access your logs. LOG_VIEWER_CACHE_DRIVER: "file" + SKIP_PERMISSIONS_CHECKS: "yes" # Queue QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" @@ -270,7 +272,6 @@ services: - ./lychee/storage/app:/app/storage/app - ./lychee/logs:/app/storage/logs - ./lychee/tmp:/app/storage/tmp - - .env:/app/.env:ro depends_on: lychee_db: @@ -393,6 +394,8 @@ services: depends_on: lychee_db: condition: service_healthy + lychee_api: + condition: service_healthy # Worker health check # Verifies queue:work process is running diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000000..901a45a2e30 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,102 @@ +user www-data; +worker_processes auto; +daemon off; + +error_log /var/log/nginx/error.log; +error_log stderr; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + # Maps to exclude successful Docker health checks from stdout + map $remote_addr $loggable_ip { + 127.0.0.1 ""; + default 1; + } + map $status $loggable_status { + 200 ""; + default 1; + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + access_log /dev/stdout main if=$loggable_status$loggable_ip; + + sendfile on; + keepalive_timeout 65; + + # By default, if the processing of images takes more than 60s, + # a 504 Gateway timeout occurs, so we increase the timeout here + # to allow procesing of large images or when multiple images are + # being processed at the same time. We set max_execution_time + # below to the same value. + fastcgi_read_timeout 3600; + + # We also set the send timeout since this can otherwise also cause + # issues with slow connections + fastcgi_send_timeout 3600; + + gzip on; + + server { + root /app/public; + listen 8000; + listen [::]:8000; + server_name localhost; + client_max_body_size 100M; + + index index.php; + + location = /favicon.ico { + access_log off; + log_not_found off; + } + location = /robots.txt { + access_log off; + log_not_found off; + } + + # removes trailing slashes (prevents SEO duplicate content issues) + if (!-d $request_filename) + { + rewrite ^/(.+)/$ /$1 permanent; + } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + error_page 404 /index.php; + + # Serve /index.php through PHP + location ~ ^/index\.php(/|$) { + # Mitigate https://httpoxy.org/ vulnerabilities + fastcgi_param HTTP_PROXY ""; + + fastcgi_pass unix:/var/run/php/php8.5-fpm.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param PHP_VALUE "post_max_size=100M + max_execution_time=3600 + upload_max_filesize=100M + memory_limit=256M"; + fastcgi_param PATH /usr/local/bin:/usr/bin:/bin; + fastcgi_hide_header X-Powered-By; + include fastcgi_params; + } + + # Deny access to other .php files, rather than exposing their contents + location ~ [^/]\.php(/|$) { + return 403; + } + } + + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/docker/scripts/create-admin-user.sh b/docker/scripts/create-admin-user.sh index b7c73a9687d..c12b6b8fb78 100644 --- a/docker/scripts/create-admin-user.sh +++ b/docker/scripts/create-admin-user.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # shellcheck disable=SC3040 set -euo pipefail @@ -32,4 +32,4 @@ if [ -n "${ADMIN_USER:-}" ]; then else echo "⚠️ WARNING: ADMIN_USER set but no password provided (need ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)" fi -fi \ No newline at end of file +fi diff --git a/docker/scripts/dump-env.sh b/docker/scripts/dump-env.sh new file mode 100755 index 00000000000..191538c6bc6 --- /dev/null +++ b/docker/scripts/dump-env.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# shellcheck disable=SC3040 +set -euo pipefail + +echo "📝 Dumping environment variables to .env file..." + +ENV_FILE="/app/.env" +CONFIG_DIR="/app/config" + +# List of system/infrastructure variable prefixes to EXCLUDE +# These are Docker, system, or shell variables that should NOT go into Laravel's .env +EXCLUDED_PREFIXES=" +PATH +HOME +HOSTNAME +PWD +OLDPWD +SHLVL +TERM +USER +LOGNAME +SHELL +LANG +LC_ +DISPLAY +DOCKER_ +KUBERNETES_ +K8S_ +COMPOSE_ +_ +" + +# Extract all env() calls from Laravel config files to build a list of known variables +KNOWN_VARS="" +if [ -d "$CONFIG_DIR" ]; then + echo " Scanning $CONFIG_DIR for env() calls..." + # Find all env() calls in config files and extract the variable names + # Matches: env('VAR_NAME'), env("VAR_NAME"), env('VAR_NAME', default), etc. + KNOWN_VARS=$(find "$CONFIG_DIR" -name "*.php" -type f -exec grep -oE "env\(['\"]([A-Z0-9_]+)['\"]" {} \; | sed "s/env(['\"]//g" | sed "s/['\"]//g" | sort -u) + var_count=$(echo "$KNOWN_VARS" | wc -w) + echo " Found $var_count unique environment variables in config files" +else + echo "⚠️ WARNING: Config directory not found at $CONFIG_DIR" +fi + +# Create or truncate the .env file +>"$ENV_FILE" + +# Add a header comment +cat >>"$ENV_FILE" <<'EOF' +# Laravel Environment Configuration +# Auto-generated from environment variables +# DO NOT EDIT THIS FILE MANUALLY - changes will be overwritten +# +EOF + +# Function to check if a variable is known from config files +# Returns 0 (true) if the variable is found in KNOWN_VARS or matches a known prefix +is_known_var() { + var_name="$1" + + # If we have no known vars, accept everything (fallback mode) + [ -z "$KNOWN_VARS" ] && return 0 + + # Check for exact match + for known in $KNOWN_VARS; do + if [ "$var_name" = "$known" ]; then + return 0 + fi + + # Check for prefix match (e.g., APP_NAME is in config, accept APP_DEBUG too) + case "$known" in + *_*) + prefix="${known%%_*}_" + case "$var_name" in + $prefix*) + return 0 + ;; + esac + ;; + esac + done + + return 1 +} + +# Function to check if a variable should be excluded +# Returns 0 (true) if the variable is a system variable that should be excluded +should_exclude_var() { + var_name="$1" + + # Check against excluded prefixes + for excluded in $EXCLUDED_PREFIXES; do + case "$var_name" in + $excluded*) + return 0 + ;; + esac + done + + return 1 +} + +# Export all relevant environment variables +# We use `env` to get all current environment variables +env | while IFS='=' read -r name value; do + # Skip empty names (shouldn't happen, but be safe) + [ -z "$name" ] && continue + + # Check if this variable should be excluded or is not known + if ! should_exclude_var "$name" && is_known_var "$name"; then + # Handle values that may contain special characters or newlines + # We need to properly escape the value for the .env file + + # If value contains newline, quotes, or special chars, wrap in quotes + case "$value" in + *$'\n'* | *\"* | *\'* | *\$* | *\`* | *\\* | *" "* | *"="*) + # Escape backslashes and double quotes + escaped_value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + echo "$name=\"$escaped_value\"" >>"$ENV_FILE" + ;; + *) + # Simple value, no quotes needed + echo "$name=$value" >>"$ENV_FILE" + ;; + esac + fi +done + +# Ensure the file is readable +chmod 644 "$ENV_FILE" + +echo "✅ Environment variables written to $ENV_FILE" + +# Count how many variables were written +var_count=$(grep -v '^#' "$ENV_FILE" | grep -v '^$' | wc -l) +echo " Exported $var_count environment variables" diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index 743e0d01c44..3dafd5367ae 100644 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # shellcheck disable=SC3040 set -euo pipefail @@ -7,29 +7,40 @@ echo "🚀 Starting Lychee entrypoint..." # Run environment validation /usr/local/bin/validate-env.sh +# This is commended for now as FrankenPHP uses native env vars +# And we are double checking that php also has access to them +# If php is complaining, then we can re-enable this. + +# Dump environment variables to .env file for Laravel (only if not using FrankenPHP) +# if [ ! -f "/app/frankenphp_target" ]; then +# /usr/local/bin/dump-env.sh +# else +# echo "ℹ️ Skipping .env dump (FrankenPHP uses native environment variables)" +# fi + # Wait for database to be ready if [ "${DB_CONNECTION:-}" = "mysql" ] || [ "${DB_CONNECTION:-}" = "pgsql" ]; then - echo "⏳ Waiting for database to be ready..." - - max_attempts=30 - attempt=0 - - while [ "$attempt" -lt "$max_attempts" ]; do - if nc -z "${DB_HOST}" "${DB_PORT}" 2>/dev/null; then - echo "✅ Database port is open!" - sleep 2 # Give it a moment to fully initialize - break - fi - - attempt=$((attempt + 1)) - echo " Attempt $attempt/$max_attempts... (waiting 2s)" - sleep 2 - done - - if [ "$attempt" -eq "$max_attempts" ]; then - echo "❌ ERROR: Database connection timeout" - exit 1 + echo "⏳ Waiting for database to be ready..." + + max_attempts=30 + attempt=0 + + while [ "$attempt" -lt "$max_attempts" ]; do + if nc -z "${DB_HOST}" "${DB_PORT}" 2>/dev/null; then + echo "✅ Database port is open!" + sleep 2 # Give it a moment to fully initialize + break fi + + attempt=$((attempt + 1)) + echo " Attempt $attempt/$max_attempts... (waiting 2s)" + sleep 2 + done + + if [ "$attempt" -eq "$max_attempts" ]; then + echo "❌ ERROR: Database connection timeout" + exit 1 + fi fi echo "Validating and setting PUID/PGID" @@ -38,22 +49,26 @@ PGID=${PGID:-33} # Validate PUID/PGID are within safe ranges (no root, within system limits) if [ "$PUID" -lt 33 ] || [ "$PUID" -gt 65534 ]; then - echo "❌ ERROR: PUID must be between 33 and 65534 (got: $PUID)" - exit 1 + echo "❌ ERROR: PUID must be between 33 and 65534 (got: $PUID)" + exit 1 fi if [ "$PGID" -lt 33 ] || [ "$PGID" -gt 65534 ]; then - echo "❌ ERROR: PGID must be between 33 and 65534 (got: $PGID)" - exit 1 + echo "❌ ERROR: PGID must be between 33 and 65534 (got: $PGID)" + exit 1 fi -# Only modify user/group if shadow package is available -if command -v usermod >/dev/null 2>&1; then +if pgrep -u www-data >/dev/null; then + echo "www-data has running processes; skipping usermod" +else + if command -v usermod >/dev/null 2>&1; then + # Only modify user/group if shadow package is available if [ "$(id -u www-data)" -ne "$PUID" ]; then - usermod -o -u "$PUID" www-data + usermod -o -u "$PUID" www-data fi if [ "$(id -g www-data)" -ne "$PGID" ]; then - groupmod -o -g "$PGID" www-data + groupmod -o -g "$PGID" www-data fi + fi fi echo " User UID: $(id -u www-data)" echo " User GID: $(id -g www-data)" @@ -66,125 +81,131 @@ rm -rf bootstrap/cache/*.php # Check for /conf/.env file - this indicates misconfiguration if [ -f "/conf/.env" ]; then - echo "❌ ERROR: /conf/.env file detected" - echo " Containers should not have mounted .env files at /conf/.env" - echo " Please check your docker-compose.yml configuration" - echo " See https://lycheeorg.github.io/docs/upgrade.html" - exit 1 + echo "❌ ERROR: /conf/.env file detected" + echo " Containers should not have mounted .env files at /conf/.env" + echo " Please check your docker-compose.yml configuration" + echo " See https://lycheeorg.github.io/docs/upgrade.html" + exit 1 fi # Detect LYCHEE_MODE and execute appropriate command LYCHEE_MODE=${LYCHEE_MODE:-web} case "$LYCHEE_MODE" in - web) - echo "🌐 Starting Lychee in web mode..." - - # Run database migrations (only in web mode to avoid race conditions) - echo "🔄 Running database migrations..." - php artisan migrate --force - - # Clear and cache configuration - echo "🧹 Optimizing application..." - php artisan config:clear - php artisan config:cache - php artisan route:cache - php artisan view:cache - - echo "✅ Application ready!" - - # Execute the main command (from Dockerfile CMD: octane:start) - exec "$@" - ;; - worker) - echo "⚙️ Starting Lychee in worker mode..." - - # Check for pending migrations (wait for web container to complete them) - max_migration_attempts=720 # 1h max (720*5s) - migration_attempt=0 - - while [ "$migration_attempt" -lt "$max_migration_attempts" ]; do - # Check if there are pending migrations - # php artisan migrate:status returns exit code 0 if all migrations are run - # We check for "Pending" in the output to detect pending migrations - if php artisan migrate:status 2>/dev/null | grep -q "Pending"; then - migration_attempt=$((migration_attempt + 1)) - echo "⏳ Pending migrations detected (attempt $migration_attempt/$max_migration_attempts)" - echo " Waiting 5 seconds for web container to complete migrations..." - sleep 5 - else - echo "✅ All migrations are up to date" - break - fi - done - - if [ "$migration_attempt" -eq "$max_migration_attempts" ]; then - echo "⚠️ WARNING: Migrations still pending after ${max_migration_attempts} attempts (1 hour)" - echo " Starting worker anyway - this may cause issues if migrations are required" - fi - - echo "🔄 Auto-restart enabled: worker will restart if it exits" - - # Get queue configuration from environment - QUEUE_NAMES=${QUEUE_NAMES:-default} - WORKER_MAX_TIME=${WORKER_MAX_TIME:-3600} - QUEUE_CONNECTION=${QUEUE_CONNECTION:-sync} - - echo "📋 Queue names: $QUEUE_NAMES" - echo "⏱️ Max time: ${WORKER_MAX_TIME}s" - echo "📡 Queue connection: $QUEUE_CONNECTION" - - # Warn if using sync driver (not recommended for worker mode) - if [ "$QUEUE_CONNECTION" = "sync" ]; then - echo "⚠️ WARNING: QUEUE_CONNECTION=sync is not recommended for worker mode." - echo " Jobs will run synchronously, defeating the purpose of a queue worker." - echo " Consider using 'redis' or 'database' for persistent asynchronous queues." - fi - - # Track if we should keep running - KEEP_RUNNING=true - - # Handle graceful shutdown - trap 'echo "🛑 Received shutdown signal, stopping..."; KEEP_RUNNING=false' TERM INT - - # Auto-restart loop: if queue:work exits, restart it - # This handles memory leak mitigation (max-time) and crash recovery - while $KEEP_RUNNING; do - echo "🚀 Starting queue worker ($(date '+%Y-%m-%d %H:%M:%S'))" - - # Default exit code to 0 - EXIT_CODE=0 - - # Run queue worker with standard options - # --tries=3: retry failed jobs up to 3 times - # --timeout=3600: kill job if it runs longer than 1 hour - # --sleep=3: sleep 3 seconds when queue is empty - # --max-time=$WORKER_MAX_TIME: restart worker after N seconds (memory leak mitigation) - php artisan queue:work \ - --queue="$QUEUE_NAMES" \ - --tries=3 \ - --timeout=3600 \ - --sleep=3 \ - --max-time="$WORKER_MAX_TIME" || EXIT_CODE=$? - - if [ $EXIT_CODE -eq 0 ]; then - echo "✅ Queue worker exited cleanly (exit code 0)" - else - echo "⚠️ Queue worker exited with code $EXIT_CODE" - fi - - # Exit if we received shutdown signal - if ! $KEEP_RUNNING; then - echo "👋 Shutting down worker..." - exit $EXIT_CODE - fi - - echo "⏳ Waiting 5 seconds before restart..." - sleep 5 - done - ;; - *) - echo "❌ ERROR: Invalid LYCHEE_MODE: $LYCHEE_MODE. Must be 'web' or 'worker'." - exit 1 - ;; +web) + echo "🌐 Starting Lychee in web mode..." + + # Run database migrations (only in web mode to avoid race conditions) + echo "🔄 Running database migrations..." + php artisan migrate --force + + # Clear and cache configuration + echo "🧹 Optimizing application..." + php artisan config:clear + php artisan config:cache + php artisan route:cache + php artisan view:cache + + echo "✅ Application ready!" + + if [ ! -f "/app/frankenphp_target" ]; then + # Start PHP-FPM and Nginx for traditional Docker setup + echo "🚀 Starting PHP-FPM..." + php-fpm8.5 + fi + + # Execute the main command (from Dockerfile CMD: octane:start) + exec "$@" + ;; +worker) + echo "⚙️ Starting Lychee in worker mode..." + + # Check for pending migrations (wait for web container to complete them) + max_migration_attempts=720 # 1h max (720*5s) + migration_attempt=0 + + while [ "$migration_attempt" -lt "$max_migration_attempts" ]; do + # Check if there are pending migrations + # php artisan migrate:status returns exit code 0 if all migrations are run + # We check for "Pending" in the output to detect pending migrations + if php artisan migrate:status 2>/dev/null | grep -q "Pending"; then + migration_attempt=$((migration_attempt + 1)) + echo "⏳ Pending migrations detected (attempt $migration_attempt/$max_migration_attempts)" + echo " Waiting 5 seconds for web container to complete migrations..." + sleep 5 + else + echo "✅ All migrations are up to date" + break + fi + done + + if [ "$migration_attempt" -eq "$max_migration_attempts" ]; then + echo "⚠️ WARNING: Migrations still pending after ${max_migration_attempts} attempts (1 hour)" + echo " Starting worker anyway - this may cause issues if migrations are required" + fi + + echo "🔄 Auto-restart enabled: worker will restart if it exits" + + # Get queue configuration from environment + QUEUE_NAMES=${QUEUE_NAMES:-default} + WORKER_MAX_TIME=${WORKER_MAX_TIME:-3600} + QUEUE_CONNECTION=${QUEUE_CONNECTION:-sync} + + echo "📋 Queue names: $QUEUE_NAMES" + echo "⏱️ Max time: ${WORKER_MAX_TIME}s" + echo "📡 Queue connection: $QUEUE_CONNECTION" + + # Warn if using sync driver (not recommended for worker mode) + if [ "$QUEUE_CONNECTION" = "sync" ]; then + echo "⚠️ WARNING: QUEUE_CONNECTION=sync is not recommended for worker mode." + echo " Jobs will run synchronously, defeating the purpose of a queue worker." + echo " Consider using 'redis' or 'database' for persistent asynchronous queues." + fi + + # Track if we should keep running + KEEP_RUNNING=true + + # Handle graceful shutdown + trap 'echo "🛑 Received shutdown signal, stopping..."; KEEP_RUNNING=false' TERM INT + + # Auto-restart loop: if queue:work exits, restart it + # This handles memory leak mitigation (max-time) and crash recovery + while $KEEP_RUNNING; do + echo "🚀 Starting queue worker ($(date '+%Y-%m-%d %H:%M:%S'))" + + # Default exit code to 0 + EXIT_CODE=0 + + # Run queue worker with standard options + # --tries=3: retry failed jobs up to 3 times + # --timeout=3600: kill job if it runs longer than 1 hour + # --sleep=3: sleep 3 seconds when queue is empty + # --max-time=$WORKER_MAX_TIME: restart worker after N seconds (memory leak mitigation) + php artisan queue:work \ + --queue="$QUEUE_NAMES" \ + --tries=3 \ + --timeout=3600 \ + --sleep=3 \ + --max-time="$WORKER_MAX_TIME" || EXIT_CODE=$? + + if [ $EXIT_CODE -eq 0 ]; then + echo "✅ Queue worker exited cleanly (exit code 0)" + else + echo "⚠️ Queue worker exited with code $EXIT_CODE" + fi + + # Exit if we received shutdown signal + if ! $KEEP_RUNNING; then + echo "👋 Shutting down worker..." + exit $EXIT_CODE + fi + + echo "⏳ Waiting 5 seconds before restart..." + sleep 5 + done + ;; +*) + echo "❌ ERROR: Invalid LYCHEE_MODE: $LYCHEE_MODE. Must be 'web' or 'worker'." + exit 1 + ;; esac diff --git a/docker/scripts/permissions-check.sh b/docker/scripts/permissions-check.sh index 9c7471cc820..c7ec08b0646 100644 --- a/docker/scripts/permissions-check.sh +++ b/docker/scripts/permissions-check.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # shellcheck disable=SC3040 set -euo pipefail @@ -7,8 +7,8 @@ echo "🔍 Validating permissions..." # Safely check SKIP_PERMISSIONS_CHECKS skip_check="${SKIP_PERMISSIONS_CHECKS:-no}" if [ "$skip_check" = "yes" ] || [ "$skip_check" = "YES" ]; then - echo "⚠️ WARNING: Skipping permissions check" - exit 0 + echo "⚠️ WARNING: Skipping permissions check" + exit 0 fi echo "⏰ Set Permissions (this may take a while)..." @@ -35,4 +35,4 @@ find /app/storage -type f \( ! -perm 640 \) -exec chmod 640 {} + 2>/dev/null || find /app/public/uploads -type f \( ! -perm 644 \) -exec chmod 644 {} + 2>/dev/null || true find /app/public/dist -type f \( ! -perm 644 \) -exec chmod 644 {} + 2>/dev/null || true -echo "✅ Permissions set securely" \ No newline at end of file +echo "✅ Permissions set securely" diff --git a/docker/scripts/validate-env.sh b/docker/scripts/validate-env.sh index 75f6dfe9d21..ee2a0792f27 100644 --- a/docker/scripts/validate-env.sh +++ b/docker/scripts/validate-env.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # shellcheck disable=SC3040 set -euo pipefail @@ -10,29 +10,29 @@ echo "🔍 Validating environment variables..." # Check if APP_KEY exists, with fallback mechanisms if [ -z "${APP_KEY:-}" ]; then - # Check if APP_KEY_FILE is set and load from file - if [ -n "${APP_KEY_FILE:-}" ] && [ -f "$APP_KEY_FILE" ]; then - APP_KEY=$(cat "$APP_KEY_FILE") - export APP_KEY - # Fallback to /app/.env if it exists - elif [ -f "/app/.env" ]; then - APP_KEY=$(grep "^APP_KEY=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'") - export APP_KEY - fi + # Check if APP_KEY_FILE is set and load from file + if [ -n "${APP_KEY_FILE:-}" ] && [ -f "$APP_KEY_FILE" ]; then + APP_KEY=$(cat "$APP_KEY_FILE") + export APP_KEY + # Fallback to /app/.env if it exists + elif [ -f "/app/.env" ]; then + APP_KEY=$(grep "^APP_KEY=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'") + export APP_KEY + fi fi # Error out if APP_KEY is still empty if [ -z "${APP_KEY:-}" ]; then - echo "❌ ERROR: APP_KEY is not set" - echo " Set it via APP_KEY environment variable, APP_KEY_FILE, or /app/.env" - exit 1 + echo "❌ ERROR: APP_KEY is not set" + echo " Set it via APP_KEY environment variable, APP_KEY_FILE, or /app/.env" + exit 1 fi # Validate APP_KEY format (should be base64:... for Laravel) if ! echo "${APP_KEY}" | grep -qE '^base64:.{32,}'; then - echo "❌ ERROR: APP_KEY must be in format 'base64:...' with sufficient length" - echo " Generate one with: php artisan key:generate --show" - exit 1 + echo "❌ ERROR: APP_KEY must be in format 'base64:...' with sufficient length" + echo " Generate one with: php artisan key:generate --show" + exit 1 fi ########################### @@ -40,55 +40,55 @@ fi ########################### if [ -z "${DB_CONNECTION:-}" ]; then - echo "❌ ERROR: DB_CONNECTION is not set" - exit 1 + echo "❌ ERROR: DB_CONNECTION is not set" + exit 1 fi # Validate DB_CONNECTION value case "${DB_CONNECTION}" in - mysql|pgsql|sqlite) - echo "✅ Valid database connection type: ${DB_CONNECTION}" - ;; - *) - echo "❌ ERROR: DB_CONNECTION must be mysql, pgsql, or sqlite (got: ${DB_CONNECTION})" - exit 1 - ;; +mysql | pgsql | sqlite) + echo "✅ Valid database connection type: ${DB_CONNECTION}" + ;; +*) + echo "❌ ERROR: DB_CONNECTION must be mysql, pgsql, or sqlite (got: ${DB_CONNECTION})" + exit 1 + ;; esac # Validate database credentials are set for mysql/pgsql if [ "${DB_CONNECTION}" = "mysql" ] || [ "${DB_CONNECTION}" = "pgsql" ]; then - if [ -z "${DB_HOST:-}" ]; then - echo "❌ ERROR: DB_HOST is required for ${DB_CONNECTION}" - exit 1 - fi - if [ -z "${DB_DATABASE:-}" ]; then - echo "❌ ERROR: DB_DATABASE is required for ${DB_CONNECTION}" - exit 1 - fi - if [ -z "${DB_USERNAME:-}" ]; then - echo "❌ ERROR: DB_USERNAME is required for ${DB_CONNECTION}" - exit 1 - fi - - # Check if DB_PASSWORD exists, with fallback mechanisms - if [ -z "${DB_PASSWORD:-}" ]; then - # Check if DB_PASSWORD_FILE is set and load from file - if [ -n "${DB_PASSWORD_FILE:-}" ] && [ -f "$DB_PASSWORD_FILE" ]; then - DB_PASSWORD=$(cat "$DB_PASSWORD_FILE") - export DB_PASSWORD - # Fallback to /app/.env if it exists - elif [ -f "/app/.env" ]; then - DB_PASSWORD=$(grep "^DB_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'") - export DB_PASSWORD - fi + if [ -z "${DB_HOST:-}" ]; then + echo "❌ ERROR: DB_HOST is required for ${DB_CONNECTION}" + exit 1 + fi + if [ -z "${DB_DATABASE:-}" ]; then + echo "❌ ERROR: DB_DATABASE is required for ${DB_CONNECTION}" + exit 1 + fi + if [ -z "${DB_USERNAME:-}" ]; then + echo "❌ ERROR: DB_USERNAME is required for ${DB_CONNECTION}" + exit 1 + fi + + # Check if DB_PASSWORD exists, with fallback mechanisms + if [ -z "${DB_PASSWORD:-}" ]; then + # Check if DB_PASSWORD_FILE is set and load from file + if [ -n "${DB_PASSWORD_FILE:-}" ] && [ -f "$DB_PASSWORD_FILE" ]; then + DB_PASSWORD=$(cat "$DB_PASSWORD_FILE") + export DB_PASSWORD + # Fallback to /app/.env if it exists + elif [ -f "/app/.env" ]; then + DB_PASSWORD=$(grep "^DB_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'") + export DB_PASSWORD fi + fi - # Error out if DB_PASSWORD is still empty - if [ -z "${DB_PASSWORD:-}" ]; then - echo "❌ ERROR: DB_PASSWORD is not set" - echo " Set it via DB_PASSWORD environment variable, DB_PASSWORD_FILE, or /app/.env" - exit 1 - fi + # Error out if DB_PASSWORD is still empty + if [ -z "${DB_PASSWORD:-}" ]; then + echo "❌ ERROR: DB_PASSWORD is not set" + echo " Set it via DB_PASSWORD environment variable, DB_PASSWORD_FILE, or /app/.env" + exit 1 + fi fi ########################### @@ -97,42 +97,41 @@ fi # Check if REDIS_PASSWORD exists, with fallback mechanisms (only if Redis is being used) if [ -n "${REDIS_HOST:-}" ] || [ -n "${REDIS_PASSWORD:-}" ] || [ -n "${REDIS_PASSWORD_FILE:-}" ]; then - if [ -z "${REDIS_PASSWORD:-}" ]; then - # Check if REDIS_PASSWORD_FILE is set and load from file - if [ -n "${REDIS_PASSWORD_FILE:-}" ] && [ -f "$REDIS_PASSWORD_FILE" ]; then - REDIS_PASSWORD=$(cat "$REDIS_PASSWORD_FILE") - export REDIS_PASSWORD - # Fallback to /app/.env if it exists - elif [ -f "/app/.env" ]; then - REDIS_PASSWORD=$(grep "^REDIS_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'") - export REDIS_PASSWORD - fi + if [ -z "${REDIS_PASSWORD:-}" ]; then + # Check if REDIS_PASSWORD_FILE is set and load from file + if [ -n "${REDIS_PASSWORD_FILE:-}" ] && [ -f "$REDIS_PASSWORD_FILE" ]; then + REDIS_PASSWORD=$(cat "$REDIS_PASSWORD_FILE") + export REDIS_PASSWORD + # Fallback to /app/.env if it exists + elif [ -f "/app/.env" ]; then + REDIS_PASSWORD=$(grep "^REDIS_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'") + export REDIS_PASSWORD fi + fi fi ########################### # ADDITIONAL ENV # ########################### - # Validate APP_ENV if [ -n "${APP_ENV:-}" ]; then - case "${APP_ENV}" in - production|staging|development|local|testing) - echo "✅ Valid environment: ${APP_ENV}" - ;; - *) - echo "⚠️ WARNING: Unusual APP_ENV value: ${APP_ENV}" - ;; - esac + case "${APP_ENV}" in + production | staging | development | local | testing) + echo "✅ Valid environment: ${APP_ENV}" + ;; + *) + echo "⚠️ WARNING: Unusual APP_ENV value: ${APP_ENV}" + ;; + esac fi # Security checks if [ "${APP_ENV:-production}" = "production" ]; then - if [ "${APP_DEBUG:-false}" = "true" ]; then - echo "⚠️ WARNING: APP_DEBUG is enabled in production - this exposes sensitive information!" - fi + if [ "${APP_DEBUG:-false}" = "true" ]; then + echo "⚠️ WARNING: APP_DEBUG is enabled in production - this exposes sensitive information!" + fi fi echo "✅ Core variables validated" -echo "🎉 Environment validation complete!" \ No newline at end of file +echo "🎉 Environment validation complete!"