From 576c71c9601a071e94f2d65dab670e3dfa3f0da1 Mon Sep 17 00:00:00 2001 From: Saufi Date: Mon, 18 May 2026 15:36:47 +0800 Subject: [PATCH] feat: Docker Compose setup untuk development & production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/php/Dockerfile: PHP 8.4-FPM + GD + imagick (PECL) + semua extension Laravel - docker/php/php.ini: upload 20MB, memory 512MB, opcache, Asia/Kuala_Lumpur - docker/php/php-dev.ini: validate_timestamps=1, display_errors=On (dev) - docker/nginx/default.conf: gzip, security headers, static asset caching - docker/entrypoint.sh: tunggu MySQL → migrate → seed AdminSeeder → cache (prod) - docker-compose.yml: dev stack — port 8003, DB host 33060, queue worker - docker-compose.prod.yml: production overrides — storage volume, no DB port exposed - .env.docker: template env untuk Docker (DB_HOST=db) - .dockerignore: exclude node_modules, vendor, .env, logs fix: testGenerate try/catch kembalikan JSON error (bukan HTML 500) fix: loadPreview() semak r.ok, tunjuk error alert, loading spinner Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 48 +++++++ .env.docker | 65 +++++++++ .../Admin/CertificateTemplateController.php | 8 +- docker-compose.prod.yml | 65 +++++++++ docker-compose.yml | 123 ++++++++++++++++++ docker/entrypoint.sh | 82 ++++++++++++ docker/nginx/default.conf | 78 +++++++++++ docker/php/Dockerfile | 70 ++++++++++ docker/php/php-dev.ini | 13 ++ docker/php/php.ini | 44 +++++++ .../admin/programs/template/show.blade.php | 22 +++- 11 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.docker create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/nginx/default.conf create mode 100644 docker/php/Dockerfile create mode 100644 docker/php/php-dev.ini create mode 100644 docker/php/php.ini diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b572ec2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,48 @@ +# ────────────────────────────────────────────────────────────────────────────── +# .dockerignore — fail yang TIDAK disertakan dalam Docker build context +# ────────────────────────────────────────────────────────────────────────────── + +# Git +.git +.gitignore +.gitattributes + +# Dependencies (akan dipasang semula dalam container) +node_modules +vendor + +# Environment secrets +.env +.env.* +!.env.docker +!.env.example + +# Build output (akan dihasilkan semula) +public/hot +public/build + +# Dev tools +.idea +.vscode +*.code-workspace +.editorconfig +.phpunit.cache +phpunit.xml + +# Docker files (tidak perlu dalam app container) +docker-compose*.yml +docker/ + +# Logs & cache +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +bootstrap/cache/* + +# OS +.DS_Store +Thumbs.db + +# Tests +tests/ diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..5536215 --- /dev/null +++ b/.env.docker @@ -0,0 +1,65 @@ +# ────────────────────────────────────────────────────────────────────────────── +# eCert MBIP — Contoh .env untuk Docker +# Salin ke .env dan ubah nilai yang perlu: +# cp .env.docker .env +# ────────────────────────────────────────────────────────────────────────────── + +APP_NAME="eCert MBIP" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +# PENTING: Tukar ke domain sebenar untuk production +# Contoh: APP_URL=https://ecert.mbip.gov.my +APP_URL=http://localhost:8003 + +APP_LOCALE=ms +APP_FALLBACK_LOCALE=ms +APP_FAKER_LOCALE=ms_MY + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +# Production: guna level "warning" atau "error" +LOG_LEVEL=debug + +# ── Database ────────────────────────────────────────────────────────────────── +DB_CONNECTION=mysql +DB_HOST=db # nama service dalam docker-compose.yml +DB_PORT=3306 +DB_DATABASE=ecert_mbip +DB_USERNAME=ecert +# Tukar password ini! +DB_PASSWORD=ecert_secret_2025 + +# ── Session ─────────────────────────────────────────────────────────────────── +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +# ── Cache & Queue (guna database — tiada Redis diperlukan) ──────────────────── +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database +CACHE_STORE=database + +# ── Mail ────────────────────────────────────────────────────────────────────── +# Dev: guna "log" untuk lihat email dalam storage/logs/laravel.log +# Production: tukar ke SMTP +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=smtp.mbip.gov.my +MAIL_PORT=587 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS="ecert@mbip.gov.my" +MAIL_FROM_NAME="eCert MBIP" + +# ── Vite ────────────────────────────────────────────────────────────────────── +VITE_APP_NAME="${APP_NAME}" diff --git a/app/Http/Controllers/Admin/CertificateTemplateController.php b/app/Http/Controllers/Admin/CertificateTemplateController.php index 269e6f8..d730f47 100644 --- a/app/Http/Controllers/Admin/CertificateTemplateController.php +++ b/app/Http/Controllers/Admin/CertificateTemplateController.php @@ -94,7 +94,7 @@ class CertificateTemplateController extends Controller ]); } - public function testGenerate(Request $request, Program $program, CertificateService $service): Response + public function testGenerate(Request $request, Program $program, CertificateService $service): \Illuminate\Http\Response|\Illuminate\Http\JsonResponse { $template = $program->certificateTemplate; abort_if(! $template, 404); @@ -102,7 +102,11 @@ class CertificateTemplateController extends Controller $sampleName = $request->input('sample_name', 'NAMA PESERTA CONTOH'); $sampleNo = $request->input('sample_no', 'ECT/2025/0001'); - $imageData = $service->generatePreview($template, $sampleName, $sampleNo); + try { + $imageData = $service->generatePreview($template, $sampleName, $sampleNo); + } catch (\Throwable $e) { + return response()->json(['error' => $e->getMessage()], 500); + } return response($imageData, 200, [ 'Content-Type' => 'image/jpeg', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..4aee433 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,65 @@ +############################################################################### +# eCert MBIP — Docker Compose Production Overrides (Ubuntu Server) +# +# Penggunaan: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +# +# Perbezaan dari dev: +# • APP_ENV=production, APP_DEBUG=false +# • DB port TIDAK didedahkan ke host +# • Storage sijil/template disimpan dalam named volume (kekal semasa deploy) +# • Opcache validate_timestamps=0 (prestasi) +# • php-dev.ini tidak dimuat +############################################################################### +name: ecert + +services: + + # ── PHP-FPM Application (production) ────────────────────────────────────── + app: + container_name: ecert_app + restart: always + volumes: + # Kod dari server (git pull) + - .:/var/www + # php.ini sahaja (tanpa php-dev.ini) + - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro + # Storage kekal semasa redeploy + - storage_data:/var/www/storage + environment: + APP_ENV: production + APP_DEBUG: "false" + + # ── Nginx (production) ───────────────────────────────────────────────────── + nginx: + container_name: ecert_nginx + restart: always + volumes: + - .:/var/www:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + # Mount storage supaya nginx boleh serve fail statik jika perlu + - storage_data:/var/www/storage:ro + + # ── MySQL (production) ───────────────────────────────────────────────────── + db: + container_name: ecert_db + restart: always + ports: [] # Jangan dedahkan DB port ke luar dalam production + volumes: + - dbdata:/var/lib/mysql + + # ── Queue Worker (production) ────────────────────────────────────────────── + queue: + container_name: ecert_queue + restart: always + volumes: + - .:/var/www + - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro + - storage_data:/var/www/storage + environment: + APP_ENV: production + +############################################################################### +volumes: + storage_data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0dab64f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,123 @@ +############################################################################### +# eCert MBIP — Docker Compose (Development — Windows 11 / Linux) +# +# Penggunaan: +# docker compose up -d --build +# +# Aplikasi: http://localhost:8003 +# DB (host): localhost:33060 (untuk TablePlus / HeidiSQL) +############################################################################### +name: ecert + +services: + + # ── PHP-FPM Application ──────────────────────────────────────────────────── + app: + build: + context: . + dockerfile: docker/php/Dockerfile + container_name: ecert_app + restart: unless-stopped + working_dir: /var/www + volumes: + - .:/var/www + - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro + - ./docker/php/php-dev.ini:/usr/local/etc/php/conf.d/99-ecert-dev.ini:ro + env_file: + - .env + environment: + APP_ENV: local + APP_DEBUG: "true" + depends_on: + db: + condition: service_healthy + networks: + - ecert + + # ── Nginx Web Server ─────────────────────────────────────────────────────── + nginx: + image: nginx:1.27-alpine + container_name: ecert_nginx + restart: unless-stopped + ports: + - "8003:80" + volumes: + - .:/var/www:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + networks: + - ecert + + # ── MySQL 8.0 ────────────────────────────────────────────────────────────── + db: + image: mysql:8.0 + container_name: ecert_db + restart: unless-stopped + environment: + MYSQL_DATABASE: ${DB_DATABASE:-ecert_mbip} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret} + MYSQL_USER: ${DB_USERNAME:-ecert} + MYSQL_PASSWORD: ${DB_PASSWORD:-secret} + volumes: + - dbdata:/var/lib/mysql + ports: + - "33060:3306" # port host 33060 → elak konflik dengan MySQL tempatan (3306) + healthcheck: + test: + - CMD + - mysqladmin + - ping + - -h + - localhost + - -u + - root + - --password=${DB_PASSWORD:-secret} + interval: 5s + timeout: 5s + retries: 15 + start_period: 20s + networks: + - ecert + + # ── Queue Worker ─────────────────────────────────────────────────────────── + queue: + build: + context: . + dockerfile: docker/php/Dockerfile + container_name: ecert_queue + restart: unless-stopped + working_dir: /var/www + volumes: + - .:/var/www + - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro + env_file: + - .env + environment: + APP_ENV: local + # Override entrypoint: langkau migrate/seed (app container dah buat) + entrypoint: [] + command: + - php + - artisan + - queue:work + - --sleep=3 + - --tries=3 + - --max-time=3600 + - --timeout=90 + depends_on: + db: + condition: service_healthy + app: + condition: service_started + networks: + - ecert + +############################################################################### +networks: + ecert: + driver: bridge + +volumes: + dbdata: + driver: local diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..d3fe901 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# ────────────────────────────────────────────────────────────────────────────── +# eCert MBIP — Container Entrypoint +# Jalankan sebelum php-fpm bermula: +# 1. Tunggu MySQL +# 2. Install Composer deps (dev sahaja) +# 3. Generate APP_KEY jika tiada +# 4. Migrate + seed AdminSeeder +# 5. Storage link +# 6. Cache (prod sahaja) +# ────────────────────────────────────────────────────────────────────────────── +set -e + +echo "" +echo "╔══════════════════════════════════════╗" +echo "║ eCert MBIP — Container Start ║" +echo "╚══════════════════════════════════════╝" +echo "" + +# ── 1. Tunggu MySQL bersedia ────────────────────────────────────────────────── +DB_HOST="${DB_HOST:-db}" +DB_PORT="${DB_PORT:-3306}" +DB_DATABASE="${DB_DATABASE:-ecert_mbip}" +DB_USERNAME="${DB_USERNAME:-root}" +DB_PASSWORD="${DB_PASSWORD:-secret}" + +echo "⏳ Menunggu MySQL di ${DB_HOST}:${DB_PORT}..." + +until mysqladmin ping \ + -h "${DB_HOST}" \ + -P "${DB_PORT}" \ + -u "${DB_USERNAME}" \ + --password="${DB_PASSWORD}" \ + --silent 2>/dev/null; do + printf "." + sleep 2 +done +echo "" +echo "✓ MySQL bersedia." + +# ── 2. Pasang Composer dependencies (development sahaja) ───────────────────── +if [ "${APP_ENV}" != "production" ] && [ ! -d /var/www/vendor ]; then + echo "📦 Memasang Composer dependencies (dev)..." + composer install \ + --no-interaction \ + --no-progress \ + --prefer-dist +fi + +# ── 3. Generate APP_KEY jika kosong ─────────────────────────────────────────── +if [ -z "${APP_KEY}" ] || [ "${APP_KEY}" = "" ]; then + echo "🔑 Menjana APP_KEY..." + php artisan key:generate --force +fi + +# ── 4. Database migration ───────────────────────────────────────────────────── +echo "🗄 Menjalankan migration..." +php artisan migrate --force + +# Seed admin account (idempotent — guna firstOrCreate) +echo "👤 Seeding AdminSeeder..." +php artisan db:seed --class=AdminSeeder --force + +# ── 5. Storage symbolic link ────────────────────────────────────────────────── +php artisan storage:link 2>/dev/null || true + +# ── 6. Cache (production sahaja) ────────────────────────────────────────────── +if [ "${APP_ENV}" = "production" ]; then + echo "⚡ Caching config, routes, views..." + php artisan config:cache + php artisan route:cache + php artisan view:cache + php artisan event:cache + # Opcache: matikan validate_timestamps untuk prestasi + # (sudah dikonfigur dalam php.ini prod) +fi + +echo "" +echo "✅ Aplikasi bersedia." +echo "" + +exec "$@" diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..8c94ca6 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,78 @@ +# ────────────────────────────────────────────────────────────────────────────── +# eCert MBIP — Nginx Server Block +# Document root: /var/www/public | PHP-FPM upstream: app:9000 +# ────────────────────────────────────────────────────────────────────────────── + +# Gzip compression +gzip on; +gzip_comp_level 5; +gzip_min_length 256; +gzip_proxied any; +gzip_vary on; +gzip_types + text/plain text/css text/javascript application/javascript + application/json application/xml image/svg+xml font/woff2; + +server { + listen 80; + server_name _; + + root /var/www/public; + index index.php; + + # Max upload (kena sama dengan php.ini: post_max_size) + client_max_body_size 25M; + + charset utf-8; + + # ── Security headers ────────────────────────────────────────────────────── + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ── Laravel routes ──────────────────────────────────────────────────────── + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # ── PHP-FPM ─────────────────────────────────────────────────────────────── + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + + fastcgi_pass app:9000; + fastcgi_index index.php; + include fastcgi_params; + + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + + fastcgi_read_timeout 120s; + fastcgi_connect_timeout 10s; + fastcgi_buffer_size 16k; + fastcgi_buffers 8 16k; + } + + # ── Static assets — cache 1 tahun ───────────────────────────────────────── + location ~* \.(jpg|jpeg|png|gif|ico|svg|css|js|woff2?|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + # ── Halang akses fail tersembunyi ───────────────────────────────────────── + location ~ /\. { + deny all; + } + + # ── Halang akses terus ke fail sensitif ─────────────────────────────────── + location ~* \.(env|log|htaccess|htpasswd|ini|sh|sql|bak)$ { + deny all; + } + + # ── Logging ─────────────────────────────────────────────────────────────── + access_log /var/log/nginx/ecert-access.log; + error_log /var/log/nginx/ecert-error.log warn; +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..1223bff --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,70 @@ +############################################################################### +# eCert MBIP — PHP-FPM Runtime Image +# PHP 8.4 + GD + imagick + semua extension Laravel +############################################################################### +FROM php:8.4-fpm + +LABEL org.opencontainers.image.title="eCert MBIP" \ + org.opencontainers.image.description="Sistem Pengurusan Sijil Digital MBIP" + +# ── System libraries ────────────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + zip \ + unzip \ + # zip extension + libzip-dev \ + # GD: PNG, JPEG, WebP, FreeType + libpng-dev \ + libjpeg62-turbo-dev \ + libfreetype6-dev \ + libwebp-dev \ + # mbstring + libonig-dev \ + # xml / intl + libxml2-dev \ + libicu-dev \ + # imagick + libmagickwand-dev \ + # mysql client (wait-for-db in entrypoint) + default-mysql-client \ + && rm -rf /var/lib/apt/lists/* + +# ── GD (untuk Intervention Image — GD driver) ───────────────────────────────── +RUN docker-php-ext-configure gd \ + --with-freetype \ + --with-jpeg \ + --with-webp \ + && docker-php-ext-install -j$(nproc) gd + +# ── Core PHP extensions ─────────────────────────────────────────────────────── +RUN docker-php-ext-install -j$(nproc) \ + pdo_mysql \ + mbstring \ + exif \ + pcntl \ + bcmath \ + zip \ + intl \ + opcache + +# ── imagick (PECL) — tersedia sebagai driver alternatif ─────────────────────── +RUN pecl install imagick \ + && docker-php-ext-enable imagick \ + && rm -rf /tmp/pear + +# ── Composer ────────────────────────────────────────────────────────────────── +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# ── Working directory ───────────────────────────────────────────────────────── +WORKDIR /var/www + +# ── Entrypoint ──────────────────────────────────────────────────────────────── +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 9000 + +ENTRYPOINT ["entrypoint.sh"] +CMD ["php-fpm"] diff --git a/docker/php/php-dev.ini b/docker/php/php-dev.ini new file mode 100644 index 0000000..b34bbd7 --- /dev/null +++ b/docker/php/php-dev.ini @@ -0,0 +1,13 @@ +; ────────────────────────────────────────────────────────────────────────────── +; Development overrides — dilengkap di atas php.ini +; Hanya dimuat dalam docker-compose.yml (dev), tidak dalam prod +; ────────────────────────────────────────────────────────────────────────────── + +; Reload fail PHP tanpa restart container +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 + +; Tunjuk ralat dalam browser semasa dev +display_errors = On +display_startup_errors = On +error_reporting = E_ALL diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..a08cd17 --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,44 @@ +; ────────────────────────────────────────────────────────────────────────────── +; eCert MBIP — PHP Runtime Configuration +; Letakkan dalam /usr/local/etc/php/conf.d/99-ecert.ini +; ────────────────────────────────────────────────────────────────────────────── + +; Timezone Malaysia +date.timezone = Asia/Kuala_Lumpur + +; ── Upload (template sijil hingga 10MB + buffer) ────────────────────────────── +upload_max_filesize = 20M +post_max_size = 25M +max_file_uploads = 10 + +; ── Execution (image generation + queue workers) ────────────────────────────── +max_execution_time = 120 +max_input_time = 120 + +; ── Memory (Intervention Image untuk sijil resolusi tinggi) ─────────────────── +memory_limit = 512M + +; ── Session ─────────────────────────────────────────────────────────────────── +session.cookie_httponly = 1 +session.cookie_samesite = Lax +session.gc_maxlifetime = 7200 + +; ── OPcache ─────────────────────────────────────────────────────────────────── +opcache.enable = 1 +opcache.memory_consumption = 192 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 10000 +; validate_timestamps: 1 untuk dev (auto-reload), 0 untuk prod (prestasi) +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 2 +opcache.fast_shutdown = 1 + +; ── Error (override per environment via APP_ENV) ────────────────────────────── +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /var/log/php_errors.log + +; ── imagick ─────────────────────────────────────────────────────────────────── +[imagick] +imagick.skip_version_check = 1 diff --git a/resources/views/admin/programs/template/show.blade.php b/resources/views/admin/programs/template/show.blade.php index 9e5f624..4004b90 100644 --- a/resources/views/admin/programs/template/show.blade.php +++ b/resources/views/admin/programs/template/show.blade.php @@ -54,7 +54,7 @@ value="NAMA PESERTA CONTOH" placeholder="Nama untuk pratonton">
-
@@ -247,18 +247,34 @@ function toggleCertNo(cb) { } function loadPreview() { - const name = document.getElementById('sampleName').value || 'NAMA PESERTA CONTOH'; + const name = document.getElementById('sampleName').value.trim() || 'NAMA PESERTA CONTOH'; const img = document.getElementById('templatePreview'); + const btn = document.getElementById('previewBtn'); const url = "{{ route('admin.programs.template.test', $program) }}"; + btn.disabled = true; + btn.innerHTML = ' Memuatkan...'; + const form = new FormData(); form.append('_token', '{{ csrf_token() }}'); form.append('sample_name', name); fetch(url, { method: 'POST', body: form }) - .then(r => r.blob()) + .then(r => { + if (!r.ok) return r.json().then(j => { throw new Error(j.error || 'Ralat pelayan (' + r.status + ')'); }); + return r.blob(); + }) .then(blob => { + const prevSrc = img.src; img.src = URL.createObjectURL(blob); + if (prevSrc.startsWith('blob:')) URL.revokeObjectURL(prevSrc); + }) + .catch(err => { + alert('Gagal jana pratonton: ' + err.message); + }) + .finally(() => { + btn.disabled = false; + btn.innerHTML = ' Pratonton'; }); }