#!/bin/bash set -euo pipefail # ───────────────────────────────────────────── # One-Click HTTPS Website Deployment # Theme: Young Developer Personal Homepage (English) # Cert: Let's Encrypt (domain) / Self-signed (IP) # OS: Ubuntu / Debian / CentOS / AlmaLinux / Rocky / Fedora / Alpine # ───────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' log() { echo -e "${GREEN}[✓]${NC} $*"; } info() { echo -e "${BLUE}[i]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; } err() { echo -e "${RED}[✗]${NC} $*"; exit 1; } echo -e "${CYAN}" echo "╔══════════════════════════════════════════╗" echo "║ One-Click HTTPS Web Deploy ║" echo "║ Theme: Young Dev Personal Homepage ║" echo "║ Supports: Ubuntu/Debian/CentOS/Alpine ║" echo "╚══════════════════════════════════════════╝" echo -e "${NC}" [[ $EUID -ne 0 ]] && err "Please run as root or with sudo" # ── OS Detection ────────────────────────────── detect_os() { if [[ -f /etc/os-release ]]; then # shellcheck source=/dev/null source /etc/os-release OS_ID="${ID,,}" else err "Cannot detect OS — /etc/os-release not found." fi case "$OS_ID" in ubuntu|debian) PKG_UPDATE="apt-get update -qq" PKG_INSTALL="apt-get install -y -qq -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold" NGINX_PKG="nginx" CERTBOT_PKG="certbot" CERTBOT_NGINX_PKG="python3-certbot-nginx" OPENSSL_PKG="openssl" CURL_PKG="curl" NGINX_USER="www-data" NEED_EPEL=false USE_SYSTEMD=true export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a # suppress needrestart interactive prompt ;; centos|rhel|almalinux|rocky|ol) PKG_UPDATE="yum makecache -q" PKG_INSTALL="yum install -y -q" NGINX_PKG="nginx" CERTBOT_PKG="certbot" CERTBOT_NGINX_PKG="python3-certbot-nginx" OPENSSL_PKG="openssl" CURL_PKG="curl" NGINX_USER="nginx" NEED_EPEL=true USE_SYSTEMD=true ;; fedora) PKG_UPDATE="dnf makecache -q" PKG_INSTALL="dnf install -y -q" NGINX_PKG="nginx" CERTBOT_PKG="certbot" CERTBOT_NGINX_PKG="python3-certbot-nginx" OPENSSL_PKG="openssl" CURL_PKG="curl" NGINX_USER="nginx" NEED_EPEL=false USE_SYSTEMD=true ;; alpine) PKG_UPDATE="apk update -q" PKG_INSTALL="apk add -q" NGINX_PKG="nginx" CERTBOT_PKG="certbot" CERTBOT_NGINX_PKG="certbot-nginx" OPENSSL_PKG="openssl" CURL_PKG="curl" NGINX_USER="nginx" NEED_EPEL=false USE_SYSTEMD=false ;; *) err "Unsupported OS: ${PRETTY_NAME:-$OS_ID}. Supported: Ubuntu / Debian / CentOS / AlmaLinux / Rocky / Fedora / Alpine" ;; esac info "OS detected: ${PRETTY_NAME:-$OS_ID}" } detect_os # ── Service helpers (systemd vs OpenRC) ─────── nginx_enable_start() { if [[ "$USE_SYSTEMD" == true ]]; then systemctl enable nginx --quiet systemctl restart nginx else rc-update add nginx default 2>/dev/null || true rc-service nginx restart fi } nginx_reload() { "$NGINX_BIN" -s reload } # ── Let's Encrypt IP Certificate (shortlived, 6-day, browser-trusted) ────── # Returns 0 and updates SSL_CERT/SSL_KEY on success; returns 1 on any failure. # Requires: nginx running on port 80, port 80 reachable from internet. ACME_CERT_DIR="/etc/ssl/private/acmecert" try_acme_ip_cert() { local _ip="$1" _webroot="$2" _email="${3:-noreply@acme.sh}" local _acme=~/.acme.sh/acme.sh # Install acme.sh if missing if [[ ! -x "$_acme" ]]; then log "Installing acme.sh..." curl -fsSL https://get.acme.sh | sh -s "email=${_email}" >/dev/null 2>&1 || { warn "acme.sh install failed — falling back to self-signed" return 1 } fi [[ -x "$_acme" ]] || { warn "acme.sh not executable"; return 1; } log "Requesting Let's Encrypt IP certificate for ${_ip}..." log "(shortlived profile — 6-day cert, auto-renews, no browser warning)" # Use ECC key (ec-256) — faster and equally secure "$_acme" --issue \ --server letsencrypt \ --cert-profile shortlived \ -d "$_ip" \ -w "$_webroot" \ --keylength ec-256 \ --force \ --log /dev/null 2>&1 | tail -5 || true # Verify cert was issued local _cert_src=~/.acme.sh/${_ip}_ecc if [[ ! -f "${_cert_src}/fullchain.cer" ]]; then warn "IP certificate request failed (port 80 blocked? rate-limited? IP not reachable)" warn "Falling back to self-signed — browser will show security warning" return 1 fi # Determine nginx reload command (must be hardcoded path for cron context) local _reload_cmd if [[ "${BT_MODE:-false}" == true && "${BT_WEB:-}" == "nginx" ]]; then _reload_cmd="/www/server/nginx/sbin/nginx -s reload 2>/dev/null || true" else _reload_cmd="nginx -s reload 2>/dev/null || true" fi # Install cert to a stable path nginx can read mkdir -p "$ACME_CERT_DIR" "$_acme" --install-cert -d "$_ip" --ecc \ --key-file "$ACME_CERT_DIR/privkey.pem" \ --fullchain-file "$ACME_CERT_DIR/fullchain.pem" \ --reloadcmd "$_reload_cmd" >/dev/null 2>&1 || { warn "acme.sh install-cert failed" return 1 } chmod 600 "$ACME_CERT_DIR/privkey.pem" SSL_CERT="$ACME_CERT_DIR/fullchain.pem" SSL_KEY="$ACME_CERT_DIR/privkey.pem" log "IP certificate installed — auto-renewal configured via acme.sh cron" return 0 } # ── BaoTa conflict check ─────────────────────── # Must run before user questions so we can warn early BT_EXISTING_SITES=() check_baota_sites() { if [[ -f /www/server/panel/BT-Panel ]] || \ [[ -x /www/server/nginx/sbin/nginx ]] || \ [[ -x /www/server/apache/bin/httpd ]]; then # Scan both nginx and apache vhost directories while IFS= read -r conf; do fname=$(basename "$conf") [[ "$fname" == "0.default.conf" || "$fname" == "default.conf" ]] && continue BT_EXISTING_SITES+=("$conf") done < <(find /www/server/panel/vhost/nginx \ /www/server/panel/vhost/apache \ -maxdepth 1 -name "*.conf" 2>/dev/null) fi } # ── BaoTa early scan (read-only, safe before confirm) ─ check_baota_sites # ── IP Geolocation ───────────────────────────── COUNTRY_CODE="XX" COUNTRY_NAME="Unknown" LANG_CODE="en" LANG_DISPLAY="English" detect_locale() { command -v curl &>/dev/null || return local _code _json _code=$(curl -s --max-time 6 "https://ipapi.co/country_code/" 2>/dev/null \ | tr -cd 'A-Za-z' | tr '[:lower:]' '[:upper:]' | cut -c1-2) if [[ ${#_code} -eq 2 ]]; then COUNTRY_CODE="$_code" COUNTRY_NAME=$(curl -s --max-time 3 "https://ipapi.co/country_name/" 2>/dev/null \ | tr -d '\n\r' | head -c 64) else _json=$(curl -s --max-time 6 "http://ip-api.com/json/?fields=status,countryCode,country" 2>/dev/null) _code=$(echo "$_json" | grep -o '"countryCode":"[^"]*"' | cut -d'"' -f4 \ | tr -cd 'A-Za-z' | tr '[:lower:]' '[:upper:]' | cut -c1-2) [[ ${#_code} -eq 2 ]] && COUNTRY_CODE="$_code" COUNTRY_NAME=$(echo "$_json" | grep -o '"country":"[^"]*"' | cut -d'"' -f4) fi [[ -z "$COUNTRY_NAME" ]] && COUNTRY_NAME="$COUNTRY_CODE" case "$COUNTRY_CODE" in CN) LANG_CODE="zh-CN"; LANG_DISPLAY="中文(简体)" ;; TW|HK|MO) LANG_CODE="zh-TW"; LANG_DISPLAY="中文(繁體)" ;; JP) LANG_CODE="ja"; LANG_DISPLAY="日本語" ;; KR) LANG_CODE="ko"; LANG_DISPLAY="한국어" ;; RU|UA|BY|KZ) LANG_CODE="ru"; LANG_DISPLAY="Русский" ;; DE|AT|CH) LANG_CODE="de"; LANG_DISPLAY="Deutsch" ;; FR|BE) LANG_CODE="fr"; LANG_DISPLAY="Français" ;; ES|MX|AR|CL|CO|PE|VE|EC|BO|PY|UY|CR|PA|DO|HN|SV|GT|NI|CU) LANG_CODE="es"; LANG_DISPLAY="Español" ;; BR|PT) LANG_CODE="pt"; LANG_DISPLAY="Português" ;; IT) LANG_CODE="it"; LANG_DISPLAY="Italiano" ;; VN) LANG_CODE="vi"; LANG_DISPLAY="Tiếng Việt" ;; ID|MY) LANG_CODE="id"; LANG_DISPLAY="Bahasa Indonesia" ;; SA|AE|EG|MA|DZ|TN|IQ|SY|JO|KW|QA|BH|OM|YE|LB|SD|LY) LANG_CODE="ar"; LANG_DISPLAY="العربية" ;; *) LANG_CODE="en"; LANG_DISPLAY="English" ;; esac info "Server location: ${COUNTRY_NAME} (${COUNTRY_CODE}) — site language: ${LANG_DISPLAY}" } detect_locale # Prompt defaults — change by detected language case "$LANG_CODE" in zh-CN) L_DEFAULT_NAME="小明"; L_DEFAULT_BIO="我喜欢把想法变成真实存在于网络上的东西。" ;; zh-TW) L_DEFAULT_NAME="小明"; L_DEFAULT_BIO="我喜歡把想法變成真實存在於網路上的東西。" ;; ja) L_DEFAULT_NAME="タロウ"; L_DEFAULT_BIO="アイデアをインターネット上の現実に変えるのが好きです。" ;; ko) L_DEFAULT_NAME="민준"; L_DEFAULT_BIO="아이디어를 인터넷 위의 현실로 만드는 걸 좋아합니다." ;; ru) L_DEFAULT_NAME="Алекс"; L_DEFAULT_BIO="Люблю воплощать идеи в реальные вещи в интернете." ;; de) L_DEFAULT_NAME="Alex"; L_DEFAULT_BIO="Ich verwandle Ideen in echte Dinge im Internet." ;; fr) L_DEFAULT_NAME="Alex"; L_DEFAULT_BIO="J'adore transformer des idées en choses concrètes sur internet." ;; es) L_DEFAULT_NAME="Alex"; L_DEFAULT_BIO="Me encanta convertir ideas en cosas reales en internet." ;; pt) L_DEFAULT_NAME="Alex"; L_DEFAULT_BIO="Adoro transformar ideias em coisas reais na internet." ;; it) L_DEFAULT_NAME="Alex"; L_DEFAULT_BIO="Adoro trasformare le idee in cose reali su internet." ;; id) L_DEFAULT_NAME="Budi"; L_DEFAULT_BIO="Saya suka mengubah ide menjadi sesuatu yang nyata di internet." ;; vi) L_DEFAULT_NAME="Minh"; L_DEFAULT_BIO="Tôi thích biến ý tưởng thành những thứ thực sự trên internet." ;; ar) L_DEFAULT_NAME="أحمد"; L_DEFAULT_BIO="أحب تحويل الأفكار إلى أشياء حقيقية على الإنترنت." ;; *) L_DEFAULT_NAME="Alex Chen"; L_DEFAULT_BIO="I build things that live on the internet." ;; esac # ── build_locale_content: sets all L_ HTML strings ────────── # Called AFTER DEV_NAME / DEV_AGE / DEV_BIO are known build_locale_content() { case "$LANG_CODE" in zh-CN) L_HTML_LANG="zh-CN" L_HTML_DIR="" L_BODY_FONT_EXTRA=",'PingFang SC','Microsoft YaHei','Source Han Sans SC'" L_TITLE_SUFFIX="— 开发者主页" L_NAV_ABOUT="关于"; L_NAV_SKILLS="技能"; L_NAV_PROJECTS="项目"; L_NAV_CONTACT="联系" L_HERO_GREETING="嘿 👋 我是" L_HERO_AGE_LINE="[ ${DEV_AGE}岁 · 开发者 & 创造者 ]" L_HERO_BTN1="▶ 查看项目"; L_HERO_BTN2="✉ 联系我" L_ABOUT_KW="// 关于我"; L_ABOUT_H2="了解一下 about_me" L_ABOUT_P1="你好!我是 ${DEV_NAME}${DEV_AGE}岁,自学编程,喜欢把脑海里的想法变成互联网上真实存在的东西。" L_ABOUT_P2="写代码的起点是想做自己的游戏。然后就停不下来了——总有新东西可以学,每次修完 bug 都有种小小的成就感。" L_ABOUT_P3="不敲代码的时候,我在研究计算机原理、刷技术视频,或者想怎么把分数再打高一点。" L_FACTS_KW="// 一点趣事" L_FACT1="☕ 靠零食和好奇心驱动"; L_FACT2="🌙 最好的代码写于深夜" L_FACT3="🐛 Bug 只是被误解的功能"; L_FACT4="🌍 梦想做出百万人在用的东西" L_TERM_COMMENT="// 我的个人简历" L_JSON_STATUS="持续学习中"; L_JSON_GOAL="做出让人喜欢的东西" L_SKILLS_KW="// 技能"; L_SKILLS_H2="我掌握的 know()" L_PROJ_KW="// 项目"; L_PROJ_H2="我做过的 built()" L_PROJ_DEMO="演示"; L_PROJ_CODE="代码" L_P1_NAME="贪吃蛇游戏"; L_P1_DESC="用 Python 和 Pygame 从零实现的经典贪吃蛇。难度随分数递增,有最高分记录和自制像素图形。" L_P2_NAME="实时天气看板"; L_P2_DESC="调用 OpenWeather API 展示实时天气和预报,带动态图标。纯原生 JavaScript 实现。" L_P3_NAME="Discord 机器人"; L_P3_DESC="多功能 Bot:频道管理、播放音乐、趣味问答,支持自定义指令。" L_P4_NAME="Markdown 笔记本"; L_P4_DESC="简洁笔记应用,支持实时预览、本地存储和深色/亮色切换。不依赖框架,纯 HTML/CSS/JS。" L_P5_NAME="记账 CLI 工具"; L_P5_DESC="命令行记账软件,追踪收支、生成月报、导出 CSV,数据存于 SQLite。" L_P6_NAME="本站"; L_P6_DESC="部署在 VPS 上的个人主页,Nginx + HTTPS。纯 HTML & CSS,无框架,快速轻量,完全属于自己。" L_CONTACT_KW="// 联系"; L_CONTACT_H2="打个招呼 hello()" L_CONTACT_P="有想聊的项目?想一起做点什么?还是单纯想交流技术?我的邮件随时开着——通常当天回复。" L_FOOTER_LEFT="设计 & 构建:${DEV_NAME},${DEV_AGE}岁" L_FOOTER_RIGHT="© $(date +%Y) · 用 ☕ & 🐛 做的" ;; zh-TW) L_HTML_LANG="zh-TW"; L_HTML_DIR="" L_BODY_FONT_EXTRA=",'PingFang TC','Microsoft JhengHei','Source Han Sans TC'" L_TITLE_SUFFIX="— 開發者主頁" L_NAV_ABOUT="關於"; L_NAV_SKILLS="技能"; L_NAV_PROJECTS="專案"; L_NAV_CONTACT="聯絡" L_HERO_GREETING="嗨 👋 我是" L_HERO_AGE_LINE="[ ${DEV_AGE}歲 · 開發者 & 創造者 ]" L_HERO_BTN1="▶ 查看專案"; L_HERO_BTN2="✉ 聯絡我" L_ABOUT_KW="// 關於我"; L_ABOUT_H2="認識一下 about_me" L_ABOUT_P1="嗨!我是 ${DEV_NAME}${DEV_AGE}歲,自學程式設計,喜歡把腦海中的想法變成網路上真實存在的東西。" L_ABOUT_P2="寫程式的起點是想做自己的遊戲。從此停不下來了——永遠有新東西可以學,每次修完 bug 都有種小小的成就感。" L_ABOUT_P3="不寫程式的時候,我在研究電腦原理、刷技術影片,或是想辦法突破自己的高分紀錄。" L_FACTS_KW="// 有趣的事" L_FACT1="☕ 靠零食和好奇心驅動"; L_FACT2="🌙 最好的程式碼寫於深夜" L_FACT3="🐛 Bug 只是被誤解的功能"; L_FACT4="🌍 夢想做出百萬人在用的東西" L_TERM_COMMENT="// 我的個人簡歷" L_JSON_STATUS="持續學習中"; L_JSON_GOAL="做出讓人喜歡的東西" L_SKILLS_KW="// 技能"; L_SKILLS_H2="我掌握的 know()" L_PROJ_KW="// 專案"; L_PROJ_H2="我做過的 built()" L_PROJ_DEMO="展示"; L_PROJ_CODE="程式碼" L_P1_NAME="貪吃蛇遊戲"; L_P1_DESC="用 Python 和 Pygame 從零實現的貪吃蛇。難度隨分數提升,有最高分記錄和像素圖形。" L_P2_NAME="即時天氣看板"; L_P2_DESC="串接 OpenWeather API,展示即時天氣與預報,帶動態圖示。純原生 JavaScript。" L_P3_NAME="Discord 機器人"; L_P3_DESC="多功能 Bot:頻道管理、播放音樂、趣味問答,支援自訂指令。" L_P4_NAME="Markdown 筆記本"; L_P4_DESC="簡潔筆記應用,即時預覽、本地儲存、深色/亮色切換。不依賴框架,純 HTML/CSS/JS。" L_P5_NAME="記帳 CLI 工具"; L_P5_DESC="命令列記帳軟體,追蹤收支、月報統計、匯出 CSV,資料存於 SQLite。" L_P6_NAME="本站"; L_P6_DESC="部署在 VPS 上的個人主頁,Nginx + HTTPS。純 HTML & CSS,無框架,快速輕量。" L_CONTACT_KW="// 聯絡"; L_CONTACT_H2="打聲招呼 hello()" L_CONTACT_P="有想聊的專案?想一起做點什麼?或是單純想交流技術?我的信箱隨時開著——通常當天回覆。" L_FOOTER_LEFT="設計 & 構建:${DEV_NAME},${DEV_AGE}歲" L_FOOTER_RIGHT="© $(date +%Y) · 用 ☕ & 🐛 做的" ;; ja) L_HTML_LANG="ja"; L_HTML_DIR="" L_BODY_FONT_EXTRA=",'Hiragino Kaku Gothic ProN','Noto Sans CJK JP','Yu Gothic'" L_TITLE_SUFFIX="— 開発者ポートフォリオ" L_NAV_ABOUT="プロフィール"; L_NAV_SKILLS="スキル"; L_NAV_PROJECTS="プロジェクト"; L_NAV_CONTACT="連絡" L_HERO_GREETING="こんにちは 👋 私は" L_HERO_AGE_LINE="[ ${DEV_AGE}歳 · デベロッパー & クリエイター ]" L_HERO_BTN1="▶ プロジェクトを見る"; L_HERO_BTN2="✉ 連絡する" L_ABOUT_KW="// プロフィール"; L_ABOUT_H2="about_me について" L_ABOUT_P1="こんにちは!${DEV_NAME}です。${DEV_AGE}歳の独学プログラマーで、頭の中のアイデアをインターネット上の実物に変えることが大好きです。" L_ABOUT_P2="コードを書き始めたきっかけは自分でゲームを作りたかったから。今は止まれません——常に新しいことを学べて、バグが直るたびに達成感があります。" L_ABOUT_P3="コードを書いていないときは、コンピュータの仕組みを調べたり、技術系の動画を見たり、ハイスコアを更新しようとしたりしています。" L_FACTS_KW="// 豆知識" L_FACT1="☕ お菓子と好奇心で動いている"; L_FACT2="🌙 最高のコードは深夜に生まれる" L_FACT3="🐛 バグはただ誤解された機能"; L_FACT4="🌍 数百万人が使うものを作る夢" L_TERM_COMMENT="// 個人プロフィール" L_JSON_STATUS="常に学習中"; L_JSON_GOAL="人に使われるものを作る" L_SKILLS_KW="// スキル"; L_SKILLS_H2="できること know()" L_PROJ_KW="// プロジェクト"; L_PROJ_H2="作ったもの built()" L_PROJ_DEMO="デモ"; L_PROJ_CODE="コード" L_P1_NAME="スネークゲーム"; L_P1_DESC="Python と Pygame でゼロから作ったスネークゲーム。スコアに応じて速度が上がり、ハイスコア保存とドット絵グラフィック付き。" L_P2_NAME="天気ダッシュボード"; L_P2_DESC="OpenWeather API で取得したリアルタイム天気とアニメーションアイコンを表示。バニラ JavaScript のみで実装。" L_P3_NAME="Discord Bot"; L_P3_DESC="モデレーション・音楽再生・クイズ・カスタムコマンドに対応した多機能 Bot。" L_P4_NAME="Markdown ノートアプリ"; L_P4_DESC="リアルタイムプレビュー・ローカル保存・ダーク/ライト切替。フレームワーク不使用、純粋な HTML/CSS/JS。" L_P5_NAME="家計簿 CLI ツール"; L_P5_DESC="収支管理・月次レポート・CSV エクスポートができるコマンドラインツール。SQLite でデータ保存。" L_P6_NAME="このサイト"; L_P6_DESC="VPS に Nginx と HTTPS でデプロイした個人サイト。フレームワークなし、純粋な HTML & CSS で高速軽量。" L_CONTACT_KW="// 連絡"; L_CONTACT_H2="hello() と言ってね" L_CONTACT_P="面白いプロジェクトの話、コラボのお誘い、技術の雑談など何でも歓迎です。メールはいつでも開いていて、大体その日のうちに返信します。" L_FOOTER_LEFT="デザイン & 構築:${DEV_NAME}(${DEV_AGE}歳)" L_FOOTER_RIGHT="© $(date +%Y) · ☕ & 🐛 で作った" ;; ko) L_HTML_LANG="ko"; L_HTML_DIR="" L_BODY_FONT_EXTRA=",'Apple SD Gothic Neo','Noto Sans KR','Malgun Gothic'" L_TITLE_SUFFIX="— 개발자 포트폴리오" L_NAV_ABOUT="소개"; L_NAV_SKILLS="스킬"; L_NAV_PROJECTS="프로젝트"; L_NAV_CONTACT="연락" L_HERO_GREETING="안녕하세요 👋 저는" L_HERO_AGE_LINE="[ ${DEV_AGE}세 · 개발자 & 메이커 ]" L_HERO_BTN1="▶ 프로젝트 보기"; L_HERO_BTN2="✉ 연락하기" L_ABOUT_KW="// 소개"; L_ABOUT_H2="저에 대해 about_me" L_ABOUT_P1="안녕하세요! 저는 ${DEV_NAME}이에요. ${DEV_AGE}살의 독학 개발자로, 머릿속 아이디어를 인터넷 위의 현실로 만드는 걸 좋아합니다." L_ABOUT_P2="코딩을 시작한 건 내 게임을 만들고 싶어서였어요. 지금은 멈출 수가 없어요—항상 새로운 걸 배울 수 있고, 버그를 고칠 때마다 성취감이 생깁니다." L_ABOUT_P3="코딩을 안 할 때는 컴퓨터 작동 원리를 공부하거나, 기술 영상을 보거나, 하이스코어를 깨려고 노력하고 있어요." L_FACTS_KW="// 재미있는 사실" L_FACT1="☕ 간식과 호기심으로 작동 중"; L_FACT2="🌙 최고의 코드는 자정 이후에 탄생" L_FACT3="🐛 버그는 오해받은 기능"; L_FACT4="🌍 수백만 명이 쓰는 걸 만들고 싶다" L_TERM_COMMENT="// 개인 프로필" L_JSON_STATUS="항상 배우는 중"; L_JSON_GOAL="사람들이 좋아하는 걸 만들기" L_SKILLS_KW="// 스킬"; L_SKILLS_H2="내가 아는 것들 know()" L_PROJ_KW="// 프로젝트"; L_PROJ_H2="내가 만든 것들 built()" L_PROJ_DEMO="데모"; L_PROJ_CODE="코드" L_P1_NAME="스네이크 게임"; L_P1_DESC="Python과 Pygame으로 처음부터 만든 스네이크 게임. 점수에 따라 속도 증가, 최고점수 저장, 픽셀 아트 그래픽." L_P2_NAME="날씨 대시보드"; L_P2_DESC="OpenWeather API로 실시간 날씨를 가져와 애니메이션 아이콘과 예보를 보여주는 앱. 바닐라 JS로 제작." L_P3_NAME="Discord 봇"; L_P3_DESC="관리·음악 재생·퀴즈·커스텀 명령어를 지원하는 다기능 봇." L_P4_NAME="Markdown 노트 앱"; L_P4_DESC="실시간 미리보기, 로컬 저장, 다크/라이트 모드 지원. 프레임워크 없이 순수 HTML/CSS/JS로 제작." L_P5_NAME="가계부 CLI 도구"; L_P5_DESC="수입/지출 추적, 월별 요약, CSV 내보내기가 가능한 커맨드라인 도구. SQLite 저장." L_P6_NAME="이 사이트"; L_P6_DESC="VPS에 Nginx와 HTTPS로 배포한 개인 포트폴리오. 프레임워크 없이 순수 HTML & CSS, 빠르고 가볍다." L_CONTACT_KW="// 연락"; L_CONTACT_H2="hello() 해줘요" L_CONTACT_P="멋진 프로젝트 아이디어가 있나요? 같이 뭔가 만들고 싶나요? 아니면 그냥 기술 얘기가 하고 싶나요? 메일은 항상 열려 있어요—보통 당일에 답장해요." L_FOOTER_LEFT="디자인 & 제작:${DEV_NAME}(${DEV_AGE}세)" L_FOOTER_RIGHT="© $(date +%Y) · ☕ & 🐛 으로 만들었어요" ;; ru) L_HTML_LANG="ru"; L_HTML_DIR="" L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Страница разработчика" L_NAV_ABOUT="Обо мне"; L_NAV_SKILLS="Навыки"; L_NAV_PROJECTS="Проекты"; L_NAV_CONTACT="Контакт" L_HERO_GREETING="Привет 👋 Я —" L_HERO_AGE_LINE="[ ${DEV_AGE}-летний разработчик & создатель ]" L_HERO_BTN1="▶ Мои проекты"; L_HERO_BTN2="✉ Написать мне" L_ABOUT_KW="// обо мне"; L_ABOUT_H2="Немного about_me" L_ABOUT_P1="Привет! Я ${DEV_NAME}, ${DEV_AGE}-летний самоучка, который любит превращать идеи в реальные вещи в интернете." L_ABOUT_P2="Начал программировать, потому что хотел сделать свою игру. Теперь остановиться невозможно — всегда есть что-то новое, а каждый исправленный баг ощущается как маленькая победа." L_ABOUT_P3="Когда я не за кодом, читаю о том, как работают компьютеры, смотрю технические видео или пытаюсь побить свой рекорд." L_FACTS_KW="// интересные факты" L_FACT1="☕ Работает на снеках и любопытстве"; L_FACT2="🌙 Лучший код пишется после полуночи" L_FACT3="🐛 Баги — это просто непонятые фичи"; L_FACT4="🌍 Мечта — создать то, чем пользуются миллионы" L_TERM_COMMENT="// мой персональный профиль" L_JSON_STATUS="всегда учусь"; L_JSON_GOAL="сделать что-то крутое" L_SKILLS_KW="// навыки"; L_SKILLS_H2="Что я умею()" L_PROJ_KW="// проекты"; L_PROJ_H2="Что я построил()" L_PROJ_DEMO="демо"; L_PROJ_CODE="код" L_P1_NAME="Игра «Змейка»"; L_P1_DESC="Классическая змейка на Python и Pygame с нуля: скорость растёт с очками, есть рекорд и пиксельная графика." L_P2_NAME="Погодная панель"; L_P2_DESC="Приложение на чистом JS с реалтайм-погодой через OpenWeather API и анимированными иконками прогноза." L_P3_NAME="Discord-бот"; L_P3_DESC="Многофункциональный бот: модерация, музыка, викторина и пользовательские команды." L_P4_NAME="Заметки на Markdown"; L_P4_DESC="Минималистичное приложение с живым превью, локальным хранилищем и тёмной/светлой темой. Без фреймворков." L_P5_NAME="Трекер бюджета (CLI)"; L_P5_DESC="Командная строка для учёта доходов и расходов, экспорта в CSV и отчётов по месяцам. База — SQLite." L_P6_NAME="Этот сайт"; L_P6_DESC="Личный портфолио на VPS с Nginx и HTTPS. Чистый HTML & CSS без фреймворков — быстро и по-своему." L_CONTACT_KW="// контакт"; L_CONTACT_H2="Скажи hello()" L_CONTACT_P="Есть идея для проекта? Хочешь поработать вместе? Или просто поговорить о коде? Мой ящик всегда открыт — отвечаю в тот же день." L_FOOTER_LEFT="Придумал и сделал ${DEV_NAME} в ${DEV_AGE} лет" L_FOOTER_RIGHT="© $(date +%Y) · Сделано с ☕ & 🐛" ;; de) L_HTML_LANG="de"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Entwickler-Portfolio" L_NAV_ABOUT="Über mich"; L_NAV_SKILLS="Skills"; L_NAV_PROJECTS="Projekte"; L_NAV_CONTACT="Kontakt" L_HERO_GREETING="Hallo 👋 Ich bin" L_HERO_AGE_LINE="[ ${DEV_AGE}-jähriger Entwickler & Maker ]" L_HERO_BTN1="▶ Meine Projekte"; L_HERO_BTN2="✉ Kontakt aufnehmen" L_ABOUT_KW="// über mich"; L_ABOUT_H2="Ein bisschen about_me" L_ABOUT_P1="Hey! Ich bin ${DEV_NAME}, ein ${DEV_AGE}-jähriger Autodidakt, der Ideen in echte Dinge im Internet verwandelt." L_ABOUT_P2="Ich hab mit dem Coden angefangen, weil ich eigene Spiele bauen wollte. Jetzt kann ich nicht mehr aufhören — es gibt immer was Neues zu lernen." L_ABOUT_P3="Wenn ich nicht code, lese ich über Computer, schaue Tech-YouTube oder versuche meinen Highscore zu brechen." L_FACTS_KW="// Spaß-Fakten" L_FACT1="☕ Läuft auf Snacks und Neugier"; L_FACT2="🌙 Bester Code entsteht nach Mitternacht" L_FACT3="🐛 Bugs sind nur missverstandene Features"; L_FACT4="🌍 Traum: etwas bauen, das Millionen nutzen" L_TERM_COMMENT="// mein persönliches Profil" L_JSON_STATUS="immer am Lernen"; L_JSON_GOAL="etwas Cooles veröffentlichen" L_SKILLS_KW="// Skills"; L_SKILLS_H2="Was ich kann()" L_PROJ_KW="// Projekte"; L_PROJ_H2="Was ich gebaut habe()" L_PROJ_DEMO="Demo"; L_PROJ_CODE="Code" L_P1_NAME="Snake-Spiel"; L_P1_DESC="Klassisches Snake von Grund auf in Python mit Pygame. Steigende Schwierigkeit, Highscore-Speicherung und Pixel-Art." L_P2_NAME="Wetter-Dashboard"; L_P2_DESC="Echtzeit-Wetter-App mit OpenWeather API und animierten Icons. Mit reinem JavaScript gebaut." L_P3_NAME="Discord-Bot"; L_P3_DESC="Mehrzweck-Bot mit Moderation, Musik, Quiz und benutzerdefinierten Befehlen." L_P4_NAME="Markdown-Notiz-App"; L_P4_DESC="Minimalistische Notiz-App mit Live-Vorschau, LocalStorage und Dark/Light-Modus. Kein Framework." L_P5_NAME="Budget-Tracker (CLI)"; L_P5_DESC="Kommandozeilen-Tool für Einnahmen & Ausgaben, CSV-Export und SQLite-Datenspeicherung." L_P6_NAME="Diese Website"; L_P6_DESC="Persönliches Portfolio auf einem VPS mit Nginx und HTTPS. Reines HTML & CSS — kein Framework, schnell und leicht." L_CONTACT_KW="// Kontakt"; L_CONTACT_H2="Sag Hallo()" L_CONTACT_P="Hast du eine coole Projektidee? Möchtest du zusammenarbeiten oder einfach über Code reden? Mein Posteingang ist immer offen — ich antworte meistens noch am selben Tag." L_FOOTER_LEFT="Designed & gebaut von ${DEV_NAME} — ${DEV_AGE} Jahre" L_FOOTER_RIGHT="© $(date +%Y) · Gemacht mit ☕ & 🐛" ;; fr) L_HTML_LANG="fr"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Portfolio développeur" L_NAV_ABOUT="À propos"; L_NAV_SKILLS="Compétences"; L_NAV_PROJECTS="Projets"; L_NAV_CONTACT="Contact" L_HERO_GREETING="Salut 👋 Je suis" L_HERO_AGE_LINE="[ développeur de ${DEV_AGE} ans & créateur ]" L_HERO_BTN1="▶ Mes projets"; L_HERO_BTN2="✉ Me contacter" L_ABOUT_KW="// à propos"; L_ABOUT_H2="Un peu about_me" L_ABOUT_P1="Salut ! Je suis ${DEV_NAME}, un développeur autodidacte de ${DEV_AGE} ans qui adore transformer des idées en choses concrètes sur internet." L_ABOUT_P2="J'ai commencé à coder pour créer mes propres jeux. Maintenant je ne peux plus m'arrêter — il y a toujours quelque chose de nouveau à apprendre." L_ABOUT_P3="Quand je ne code pas, je lis sur le fonctionnement des ordinateurs, je regarde des vidéos tech ou j'essaie de battre mon high score." L_FACTS_KW="// anecdotes" L_FACT1="☕ Fonctionne aux snacks et à la curiosité"; L_FACT2="🌙 Le meilleur code s'écrit après minuit" L_FACT3="🐛 Les bugs sont juste des features incomprises"; L_FACT4="🌍 Rêve de construire quelque chose utilisé par des millions" L_TERM_COMMENT="// mon profil personnel" L_JSON_STATUS="toujours en apprentissage"; L_JSON_GOAL="livrer quelque chose de cool" L_SKILLS_KW="// compétences"; L_SKILLS_H2="Ce que je sais faire()" L_PROJ_KW="// projets"; L_PROJ_H2="Ce que j'ai construit()" L_PROJ_DEMO="démo"; L_PROJ_CODE="code" L_P1_NAME="Jeu Snake"; L_P1_DESC="Le classique Snake en Python avec Pygame, de zéro. Vitesse croissante, meilleur score sauvegardé et pixel art." L_P2_NAME="Dashboard Météo"; L_P2_DESC="App météo temps réel avec l'API OpenWeather et des icônes animées. En JavaScript pur." L_P3_NAME="Bot Discord"; L_P3_DESC="Bot multifonctions : modération, musique, quiz et commandes personnalisées." L_P4_NAME="App Notes Markdown"; L_P4_DESC="App de notes minimaliste avec aperçu live, stockage local et mode sombre/clair. Sans framework." L_P5_NAME="Budget Tracker (CLI)"; L_P5_DESC="Outil en ligne de commande pour suivre revenus et dépenses, exporter en CSV, stocker dans SQLite." L_P6_NAME="Ce site"; L_P6_DESC="Portfolio personnel déployé sur VPS avec Nginx et HTTPS. HTML & CSS pur, sans framework — rapide et léger." L_CONTACT_KW="// contact"; L_CONTACT_H2="Dis bonjour()" L_CONTACT_P="Tu as une idée de projet sympa ? Tu veux collaborer ou juste parler de code ? Ma boîte mail est toujours ouverte — je réponds généralement dans la journée." L_FOOTER_LEFT="Conçu & construit par ${DEV_NAME} — ${DEV_AGE} ans" L_FOOTER_RIGHT="© $(date +%Y) · Fait avec ☕ & 🐛" ;; es) L_HTML_LANG="es"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Portfolio de desarrollador" L_NAV_ABOUT="Sobre mí"; L_NAV_SKILLS="Habilidades"; L_NAV_PROJECTS="Proyectos"; L_NAV_CONTACT="Contacto" L_HERO_GREETING="Hola 👋 Soy" L_HERO_AGE_LINE="[ desarrollador de ${DEV_AGE} años & creador ]" L_HERO_BTN1="▶ Ver proyectos"; L_HERO_BTN2="✉ Contactarme" L_ABOUT_KW="// sobre mí"; L_ABOUT_H2="Un poco about_me" L_ABOUT_P1="¡Hola! Soy ${DEV_NAME}, un desarrollador autodidacta de ${DEV_AGE} años que ama convertir ideas en cosas reales en internet." L_ABOUT_P2="Empecé a programar porque quería hacer mis propios juegos. Ahora no puedo parar — siempre hay algo nuevo que aprender y cada bug corregido se siente como una victoria." L_ABOUT_P3="Cuando no estoy programando, leo sobre cómo funcionan los ordenadores, veo vídeos de tecnología o intento superar mi récord." L_FACTS_KW="// curiosidades" L_FACT1="☕ Funciona con snacks y curiosidad"; L_FACT2="🌙 El mejor código se escribe pasada la medianoche" L_FACT3="🐛 Los bugs son features incomprendidas"; L_FACT4="🌍 Sueño con construir algo que usen millones" L_TERM_COMMENT="// mi perfil personal" L_JSON_STATUS="siempre aprendiendo"; L_JSON_GOAL="lanzar algo guay" L_SKILLS_KW="// habilidades"; L_SKILLS_H2="Lo que sé hacer()" L_PROJ_KW="// proyectos"; L_PROJ_H2="Lo que he construido()" L_PROJ_DEMO="demo"; L_PROJ_CODE="código" L_P1_NAME="Juego Snake"; L_P1_DESC="El clásico Snake en Python con Pygame desde cero. Velocidad creciente, guardado de récord y pixel art." L_P2_NAME="Panel del Tiempo"; L_P2_DESC="App del tiempo en tiempo real con la API de OpenWeather e iconos animados. En JavaScript puro." L_P3_NAME="Bot de Discord"; L_P3_DESC="Bot multipropósito con moderación, música, trivial y comandos personalizados." L_P4_NAME="App de Notas Markdown"; L_P4_DESC="App de notas minimalista con vista previa en vivo, almacenamiento local y modo oscuro/claro. Sin frameworks." L_P5_NAME="Rastreador de Presupuesto (CLI)"; L_P5_DESC="Herramienta CLI para rastrear ingresos y gastos, exportar a CSV y almacenar en SQLite." L_P6_NAME="Esta web"; L_P6_DESC="Portfolio personal en VPS con Nginx y HTTPS. HTML & CSS puro sin frameworks — rápido, ligero y mío." L_CONTACT_KW="// contacto"; L_CONTACT_H2="Di hola()" L_CONTACT_P="¿Tienes una idea de proyecto chula? ¿Quieres colaborar o simplemente hablar de código? Mi correo siempre está abierto — suelo responder el mismo día." L_FOOTER_LEFT="Diseñado & construido por ${DEV_NAME} — ${DEV_AGE} años" L_FOOTER_RIGHT="© $(date +%Y) · Hecho con ☕ & 🐛" ;; pt) L_HTML_LANG="pt"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Portfolio do desenvolvedor" L_NAV_ABOUT="Sobre mim"; L_NAV_SKILLS="Habilidades"; L_NAV_PROJECTS="Projetos"; L_NAV_CONTACT="Contato" L_HERO_GREETING="Olá 👋 Eu sou" L_HERO_AGE_LINE="[ desenvolvedor de ${DEV_AGE} anos & criador ]" L_HERO_BTN1="▶ Ver projetos"; L_HERO_BTN2="✉ Entrar em contato" L_ABOUT_KW="// sobre mim"; L_ABOUT_H2="Um pouco about_me" L_ABOUT_P1="Olá! Eu sou ${DEV_NAME}, um desenvolvedor autodidata de ${DEV_AGE} anos que adora transformar ideias em coisas reais na internet." L_ABOUT_P2="Comecei a programar porque queria fazer meus próprios jogos. Agora não consigo parar — sempre há algo novo para aprender e cada bug corrigido parece uma vitória." L_ABOUT_P3="Quando não estou programando, leio sobre como os computadores funcionam, assisto vídeos de tecnologia ou tento bater meu recorde." L_FACTS_KW="// curiosidades" L_FACT1="☕ Roda com snacks e curiosidade"; L_FACT2="🌙 O melhor código é escrito depois da meia-noite" L_FACT3="🐛 Bugs são só features incompreendidas"; L_FACT4="🌍 Sonho em construir algo usado por milhões" L_TERM_COMMENT="// meu perfil pessoal" L_JSON_STATUS="sempre aprendendo"; L_JSON_GOAL="lançar algo incrível" L_SKILLS_KW="// habilidades"; L_SKILLS_H2="O que eu sei fazer()" L_PROJ_KW="// projetos"; L_PROJ_H2="O que eu construí()" L_PROJ_DEMO="demo"; L_PROJ_CODE="código" L_P1_NAME="Jogo da Cobrinha"; L_P1_DESC="A clássica cobrinha em Python com Pygame do zero. Velocidade crescente, salva recorde e tem pixel art." L_P2_NAME="Painel do Tempo"; L_P2_DESC="App de clima em tempo real com API do OpenWeather e ícones animados. Feito em JavaScript puro." L_P3_NAME="Bot do Discord"; L_P3_DESC="Bot multiuso com moderação, música, quiz e comandos personalizados." L_P4_NAME="App de Notas Markdown"; L_P4_DESC="App minimalista com prévia ao vivo, armazenamento local e modo escuro/claro. Sem frameworks." L_P5_NAME="Rastreador de Orçamento (CLI)"; L_P5_DESC="Ferramenta CLI para rastrear receitas e despesas, exportar para CSV e armazenar em SQLite." L_P6_NAME="Este site"; L_P6_DESC="Portfolio pessoal em VPS com Nginx e HTTPS. HTML & CSS puro sem frameworks — rápido, leve e meu." L_CONTACT_KW="// contato"; L_CONTACT_H2="Diz olá()" L_CONTACT_P="Tem uma ideia de projeto legal? Quer colaborar ou só bater um papo sobre código? Meu e-mail está sempre aberto — costumo responder no mesmo dia." L_FOOTER_LEFT="Projetado & construído por ${DEV_NAME} — ${DEV_AGE} anos" L_FOOTER_RIGHT="© $(date +%Y) · Feito com ☕ & 🐛" ;; it) L_HTML_LANG="it"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Portfolio sviluppatore" L_NAV_ABOUT="Chi sono"; L_NAV_SKILLS="Competenze"; L_NAV_PROJECTS="Progetti"; L_NAV_CONTACT="Contatti" L_HERO_GREETING="Ciao 👋 Sono" L_HERO_AGE_LINE="[ sviluppatore di ${DEV_AGE} anni & creatore ]" L_HERO_BTN1="▶ I miei progetti"; L_HERO_BTN2="✉ Contattami" L_ABOUT_KW="// chi sono"; L_ABOUT_H2="Un po' di about_me" L_ABOUT_P1="Ciao! Sono ${DEV_NAME}, uno sviluppatore autodidatta di ${DEV_AGE} anni che ama trasformare idee in cose reali su internet." L_ABOUT_P2="Ho cominciato a programmare perché volevo creare i miei videogiochi. Ora non riesco a smettere — c'è sempre qualcosa di nuovo da imparare." L_ABOUT_P3="Quando non sto programmando, leggo di computer, guardo video tech o cerco di battere il mio record." L_FACTS_KW="// curiosità" L_FACT1="☕ Funziona a snack e curiosità"; L_FACT2="🌙 Il codice migliore nasce dopo mezzanotte" L_FACT3="🐛 I bug sono solo feature fraintese"; L_FACT4="🌍 Sogno di creare qualcosa usato da milioni" L_TERM_COMMENT="// il mio profilo personale" L_JSON_STATUS="sempre in apprendimento"; L_JSON_GOAL="pubblicare qualcosa di figo" L_SKILLS_KW="// competenze"; L_SKILLS_H2="Cosa so fare()" L_PROJ_KW="// progetti"; L_PROJ_H2="Cosa ho costruito()" L_PROJ_DEMO="demo"; L_PROJ_CODE="codice" L_P1_NAME="Gioco Snake"; L_P1_DESC="Il classico Snake in Python con Pygame da zero. Velocità crescente, salvataggio del record e pixel art." L_P2_NAME="Dashboard Meteo"; L_P2_DESC="App meteo in tempo reale con API OpenWeather e icone animate. In JavaScript puro." L_P3_NAME="Bot Discord"; L_P3_DESC="Bot multifunzione con moderazione, musica, quiz e comandi personalizzati." L_P4_NAME="App Note Markdown"; L_P4_DESC="App note minimalista con anteprima live, storage locale e modalità scura/chiara. Senza framework." L_P5_NAME="Budget Tracker (CLI)"; L_P5_DESC="Strumento CLI per tracciare entrate e uscite, esportare in CSV e salvare su SQLite." L_P6_NAME="Questo sito"; L_P6_DESC="Portfolio personale su VPS con Nginx e HTTPS. HTML & CSS puro senza framework — veloce e leggero." L_CONTACT_KW="// contatti"; L_CONTACT_H2="Di' ciao()" L_CONTACT_P="Hai un'idea di progetto interessante? Vuoi collaborare o semplicemente parlare di codice? La mia mail è sempre aperta — rispondo di solito in giornata." L_FOOTER_LEFT="Progettato & costruito da ${DEV_NAME} — ${DEV_AGE} anni" L_FOOTER_RIGHT="© $(date +%Y) · Fatto con ☕ & 🐛" ;; id) L_HTML_LANG="id"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Portofolio Pengembang" L_NAV_ABOUT="Tentang"; L_NAV_SKILLS="Keahlian"; L_NAV_PROJECTS="Proyek"; L_NAV_CONTACT="Kontak" L_HERO_GREETING="Halo 👋 Saya" L_HERO_AGE_LINE="[ pengembang ${DEV_AGE} tahun & kreator ]" L_HERO_BTN1="▶ Lihat Proyek"; L_HERO_BTN2="✉ Hubungi Saya" L_ABOUT_KW="// tentang saya"; L_ABOUT_H2="Sedikit tentang about_me" L_ABOUT_P1="Halo! Saya ${DEV_NAME}, seorang pengembang otodidak berusia ${DEV_AGE} tahun yang suka mengubah ide menjadi sesuatu yang nyata di internet." L_ABOUT_P2="Saya mulai coding karena ingin membuat game sendiri. Sekarang tidak bisa berhenti — selalu ada hal baru untuk dipelajari dan setiap bug yang diperbaiki terasa seperti kemenangan kecil." L_ABOUT_P3="Ketika tidak coding, saya membaca tentang cara kerja komputer, menonton video teknologi, atau berusaha memecahkan rekor saya sendiri." L_FACTS_KW="// fakta seru" L_FACT1="☕ Berjalan dengan camilan dan rasa ingin tahu"; L_FACT2="🌙 Kode terbaik ditulis setelah tengah malam" L_FACT3="🐛 Bug hanyalah fitur yang disalahpahami"; L_FACT4="🌍 Impian membangun sesuatu yang digunakan jutaan orang" L_TERM_COMMENT="// profil pribadi saya" L_JSON_STATUS="selalu belajar"; L_JSON_GOAL="kirimkan sesuatu yang keren" L_SKILLS_KW="// keahlian"; L_SKILLS_H2="Yang saya bisa()" L_PROJ_KW="// proyek"; L_PROJ_H2="Yang saya bangun()" L_PROJ_DEMO="demo"; L_PROJ_CODE="kode" L_P1_NAME="Game Ular"; L_P1_DESC="Game ular klasik dari nol pakai Python dan Pygame. Kecepatan naik seiring skor, ada simpan rekor dan pixel art." L_P2_NAME="Dasbor Cuaca"; L_P2_DESC="Aplikasi cuaca real-time dengan API OpenWeather dan ikon animasi. Dibuat dengan JavaScript murni." L_P3_NAME="Bot Discord"; L_P3_DESC="Bot serbaguna dengan moderasi, putar musik, kuis, dan perintah kustom." L_P4_NAME="Aplikasi Catatan Markdown"; L_P4_DESC="Aplikasi catatan minimalis dengan pratinjau langsung, penyimpanan lokal, dan mode gelap/terang. Tanpa framework." L_P5_NAME="Pelacak Anggaran (CLI)"; L_P5_DESC="Alat CLI untuk melacak pemasukan dan pengeluaran, ekspor CSV, dan penyimpanan SQLite." L_P6_NAME="Situs ini"; L_P6_DESC="Portofolio pribadi di VPS dengan Nginx dan HTTPS. HTML & CSS murni tanpa framework — cepat dan ringan." L_CONTACT_KW="// kontak"; L_CONTACT_H2="Katakan halo()" L_CONTACT_P="Punya ide proyek keren? Ingin berkolaborasi? Atau sekadar ngobrol soal kode? Kotak masuk saya selalu terbuka — biasanya saya balas di hari yang sama." L_FOOTER_LEFT="Dirancang & dibangun oleh ${DEV_NAME} — umur ${DEV_AGE}" L_FOOTER_RIGHT="© $(date +%Y) · Dibuat dengan ☕ & 🐛" ;; vi) L_HTML_LANG="vi"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Trang cá nhân lập trình viên" L_NAV_ABOUT="Giới thiệu"; L_NAV_SKILLS="Kỹ năng"; L_NAV_PROJECTS="Dự án"; L_NAV_CONTACT="Liên hệ" L_HERO_GREETING="Xin chào 👋 Tôi là" L_HERO_AGE_LINE="[ lập trình viên ${DEV_AGE} tuổi & người sáng tạo ]" L_HERO_BTN1="▶ Xem dự án"; L_HERO_BTN2="✉ Liên hệ tôi" L_ABOUT_KW="// giới thiệu"; L_ABOUT_H2="Đôi điều về about_me" L_ABOUT_P1="Xin chào! Tôi là ${DEV_NAME}, lập trình viên tự học ${DEV_AGE} tuổi, thích biến ý tưởng thành thứ gì đó thực sự trên internet." L_ABOUT_P2="Tôi bắt đầu học code vì muốn tự làm game. Bây giờ không dừng được nữa — luôn có thứ mới để học và mỗi bug được sửa là một chiến thắng nhỏ." L_ABOUT_P3="Khi không code, tôi đọc về cách máy tính hoạt động, xem video công nghệ hoặc cố gắng phá kỷ lục của bản thân." L_FACTS_KW="// sự thật thú vị" L_FACT1="☕ Chạy bằng đồ ăn vặt và sự tò mò"; L_FACT2="🌙 Code hay nhất được viết sau nửa đêm" L_FACT3="🐛 Bug chỉ là tính năng bị hiểu nhầm"; L_FACT4="🌍 Ước mơ xây dựng thứ gì đó triệu người dùng" L_TERM_COMMENT="// hồ sơ cá nhân" L_JSON_STATUS="luôn học hỏi"; L_JSON_GOAL="ra mắt thứ gì đó thú vị" L_SKILLS_KW="// kỹ năng"; L_SKILLS_H2="Những gì tôi biết()" L_PROJ_KW="// dự án"; L_PROJ_H2="Những gì tôi đã xây dựng()" L_PROJ_DEMO="demo"; L_PROJ_CODE="code" L_P1_NAME="Game Rắn"; L_P1_DESC="Game rắn cổ điển từ đầu bằng Python và Pygame. Tốc độ tăng theo điểm, lưu kỷ lục và đồ họa pixel art." L_P2_NAME="Bảng Thời Tiết"; L_P2_DESC="Ứng dụng thời tiết thực tế với API OpenWeather và biểu tượng hoạt hình. Viết bằng JavaScript thuần." L_P3_NAME="Bot Discord"; L_P3_DESC="Bot đa năng: kiểm duyệt, phát nhạc, trắc nghiệm và lệnh tùy chỉnh." L_P4_NAME="Ứng dụng Ghi chú Markdown"; L_P4_DESC="Ứng dụng ghi chú tối giản với xem trước trực tiếp, lưu trữ cục bộ và chế độ tối/sáng. Không framework." L_P5_NAME="Quản lý Chi tiêu (CLI)"; L_P5_DESC="Công cụ dòng lệnh theo dõi thu chi, xuất CSV và lưu trữ SQLite." L_P6_NAME="Trang này"; L_P6_DESC="Portfolio cá nhân trên VPS với Nginx và HTTPS. HTML & CSS thuần, không framework — nhanh và nhẹ." L_CONTACT_KW="// liên hệ"; L_CONTACT_H2="Nói xin chào()" L_CONTACT_P="Có ý tưởng dự án thú vị? Muốn hợp tác? Hay chỉ muốn nói chuyện về code? Hộp thư của tôi luôn mở — thường phản hồi trong ngày." L_FOOTER_LEFT="Thiết kế & xây dựng bởi ${DEV_NAME} — ${DEV_AGE} tuổi" L_FOOTER_RIGHT="© $(date +%Y) · Làm bằng ☕ & 🐛" ;; ar) L_HTML_LANG="ar"; L_HTML_DIR=' dir="rtl"' L_BODY_FONT_EXTRA=",'Cairo','Noto Kufi Arabic','Tahoma'" L_TITLE_SUFFIX="— صفحة المطوّر الشخصية" L_NAV_ABOUT="نبذة عني"; L_NAV_SKILLS="المهارات"; L_NAV_PROJECTS="المشاريع"; L_NAV_CONTACT="التواصل" L_HERO_GREETING="مرحباً 👋 أنا" L_HERO_AGE_LINE="[ مطوّر عمره ${DEV_AGE} سنوات & صانع ]" L_HERO_BTN1="▶ استعرض مشاريعي"; L_HERO_BTN2="✉ تواصل معي" L_ABOUT_KW="// نبذة عني"; L_ABOUT_H2="قليل من about_me" L_ABOUT_P1="مرحباً! أنا ${DEV_NAME}، مطوّر يتعلم ذاتياً في عمر ${DEV_AGE} سنوات، أحب تحويل الأفكار إلى أشياء حقيقية على الإنترنت." L_ABOUT_P2="بدأت البرمجة لأنني أردت صنع ألعابي الخاصة. الآن لا أستطيع التوقف — هناك دائماً شيء جديد لتعلّمه، وكل خطأ يُحلّ يبدو إنجازاً صغيراً." L_ABOUT_P3="حين لا أبرمج، أقرأ عن آلية عمل الحواسيب، أشاهد مقاطع التقنية، أو أحاول تجاوز رقمي القياسي." L_FACTS_KW="// حقائق طريفة" L_FACT1="☕ يعمل بالوجبات الخفيفة والفضول"; L_FACT2="🌙 أفضل الأكواد تُكتب بعد منتصف الليل" L_FACT3="🐛 الأخطاء البرمجية مجرد ميزات مسيئة الفهم"; L_FACT4="🌍 حلمي بناء شيء يستخدمه الملايين" L_TERM_COMMENT="// ملفي الشخصي" L_JSON_STATUS="أتعلم دائماً"; L_JSON_GOAL="إطلاق شيء رائع" L_SKILLS_KW="// المهارات"; L_SKILLS_H2="ما أُتقنه ()" L_PROJ_KW="// المشاريع"; L_PROJ_H2="ما بنيته ()" L_PROJ_DEMO="عرض"; L_PROJ_CODE="كود" L_P1_NAME="لعبة الأفعى"; L_P1_DESC="لعبة الأفعى الكلاسيكية مبنية من الصفر بـ Python وPygame. السرعة ترتفع مع النقاط وتحفظ الرقم القياسي." L_P2_NAME="لوحة الطقس"; L_P2_DESC="تطبيق طقس فوري يعرض البيانات من OpenWeather API مع أيقونات متحركة. مبني بـ JavaScript الخالص." L_P3_NAME="بوت Discord"; L_P3_DESC="بوت متعدد الوظائف: إدارة القناة، تشغيل الموسيقى، مسابقات، وأوامر مخصصة." L_P4_NAME="تطبيق ملاحظات Markdown"; L_P4_DESC="تطبيق ملاحظات بسيط مع معاينة فورية وتخزين محلي ووضعَي الإضاءة والظلام. بدون أطر عمل." L_P5_NAME="متتبع الميزانية (CLI)"; L_P5_DESC="أداة سطر أوامر لتتبع الدخل والمصروفات وتصدير CSV وتخزين البيانات في SQLite." L_P6_NAME="هذا الموقع"; L_P6_DESC="محفظة شخصية على خادم VPS مع Nginx وHTTPS. HTML & CSS نقي بلا أطر عمل — سريع وخفيف." L_CONTACT_KW="// التواصل"; L_CONTACT_H2="قل مرحباً()" L_CONTACT_P="لديك فكرة مشروع مثيرة؟ تريد التعاون أو مجرد الحديث عن البرمجة؟ صندوق بريدي مفتوح دائماً — عادةً أرد في نفس اليوم." L_FOOTER_LEFT="صُمِّم وبُني بواسطة ${DEV_NAME} — عمر ${DEV_AGE}" L_FOOTER_RIGHT="© $(date +%Y) · صُنع بـ ☕ & 🐛" ;; *) # English (default) L_HTML_LANG="en"; L_HTML_DIR=""; L_BODY_FONT_EXTRA="" L_TITLE_SUFFIX="— Young Developer" L_NAV_ABOUT="about"; L_NAV_SKILLS="skills"; L_NAV_PROJECTS="projects"; L_NAV_CONTACT="contact" L_HERO_GREETING="Hi there 👋 I'm" L_HERO_AGE_LINE="[ ${DEV_AGE}-year-old developer & builder ]" L_HERO_BTN1="▶ View My Projects"; L_HERO_BTN2="✉ Get in Touch" L_ABOUT_KW="// about me"; L_ABOUT_H2="A little bit about_me" L_ABOUT_P1="Hey! I'm ${DEV_NAME}, a ${DEV_AGE}-year-old self-taught developer who loves turning ideas into real things on the internet." L_ABOUT_P2="I started coding because I wanted to build my own games. Now I can't stop — there's always something new to learn, and every bug fixed feels like a victory." L_ABOUT_P3="When I'm not coding, I'm probably reading about how computers work, watching tech YouTube, or trying to beat my high score." L_FACTS_KW="// fun facts" L_FACT1="☕ Runs on snacks and curiosity"; L_FACT2="🌙 Best code written after midnight" L_FACT3="🐛 Bugs are just features in disguise"; L_FACT4="🌍 Dreams of building something millions use" L_TERM_COMMENT="// my personal readme" L_JSON_STATUS="always learning"; L_JSON_GOAL="ship something cool" L_SKILLS_KW="// skills"; L_SKILLS_H2="What I know()" L_PROJ_KW="// projects"; L_PROJ_H2="Things I've built()" L_PROJ_DEMO="demo"; L_PROJ_CODE="code" L_P1_NAME="Snake Game"; L_P1_DESC="A classic Snake game built from scratch in Python using Pygame. Features increasing speed, high-score saving, and custom pixel art." L_P2_NAME="Weather Dashboard"; L_P2_DESC="Real-time weather app that fetches data from the OpenWeather API and displays forecasts with animated icons. Built with vanilla JS." L_P3_NAME="Discord Bot"; L_P3_DESC="A multi-purpose Discord bot that handles moderation, plays music, runs trivia quizzes, and responds to custom commands." L_P4_NAME="Markdown Notes App"; L_P4_DESC="A clean, minimal notes app with live Markdown preview, local storage, and dark/light mode. No frameworks — just HTML, CSS, JS." L_P5_NAME="Budget Tracker CLI"; L_P5_DESC="Command-line tool for tracking income and expenses. Exports reports to CSV, shows monthly summaries, and stores data in SQLite." L_P6_NAME="This Website"; L_P6_DESC="My personal portfolio — deployed on a VPS with Nginx and HTTPS. Built with pure HTML & CSS, no frameworks. Fast, lightweight, mine." L_CONTACT_KW="// contact"; L_CONTACT_H2="Say hello()" L_CONTACT_P="Got a cool project idea? Want to collaborate? Or just want to talk code? My inbox is always open — I try to reply within a day." L_FOOTER_LEFT="Designed & built by ${DEV_NAME} — age ${DEV_AGE}" L_FOOTER_RIGHT="© $(date +%Y) · Made with ☕ & 🐛" ;; esac } # ── Interactive Config ───────────────────────── echo -e "${YELLOW}>>> Please answer a few setup questions${NC}\n" read -rp "Domain name (leave blank for IP-only access): " DOMAIN DOMAIN=$(echo "$DOMAIN" | tr -d '[:space:]') LE_EMAIL="" if [[ -n "$DOMAIN" ]]; then read -rp "Email for Let's Encrypt notifications: " LE_EMAIL [[ -z "$LE_EMAIL" ]] && err "An email is required for Let's Encrypt" CERT_MODE="letsencrypt" else read -rp "Email for SSL notifications (optional, press Enter to skip): " LE_EMAIL CERT_MODE="selfsigned" info "No domain — will attempt Let's Encrypt IP certificate (no browser warning)" info "Requires port 80 publicly accessible. Falls back to self-signed if unavailable." fi read -rp "Your name [default: ${L_DEFAULT_NAME}]: " DEV_NAME DEV_NAME="${DEV_NAME:-${L_DEFAULT_NAME}}" while true; do read -rp "Your age [default: 14]: " DEV_AGE DEV_AGE="${DEV_AGE:-14}" [[ "$DEV_AGE" =~ ^[0-9]+$ ]] && break warn "Age must be a number. Please try again." done read -rp "One-line bio [default: ${L_DEFAULT_BIO}]: " DEV_BIO DEV_BIO="${DEV_BIO:-${L_DEFAULT_BIO}}" read -rp "GitHub username (optional, leave blank to skip): " GITHUB_USER GITHUB_USER="${GITHUB_USER:-}" read -rp "Contact email [default: hello@example.com]: " CONTACT_EMAIL CONTACT_EMAIL="${CONTACT_EMAIL:-hello@example.com}" read -rp "Website root directory [default: /var/www/html]: " WEB_ROOT WEB_ROOT="${WEB_ROOT:-/var/www/html}" read -rp "Enable firewall rules? (y/N): " ENABLE_UFW ENABLE_UFW="${ENABLE_UFW:-n}" echo "" info "Configuration summary:" echo " Certificate : $CERT_MODE" [[ -n "$DOMAIN" ]] && echo " Domain : $DOMAIN" echo " Developer : $DEV_NAME (age $DEV_AGE)" [[ -n "$GITHUB_USER" ]] && echo " GitHub : $GITHUB_USER" echo " Contact : $CONTACT_EMAIL" echo " Web root : $WEB_ROOT" echo "" # ── BaoTa Panel Detection ───────────────────── if [[ -f /www/server/panel/BT-Panel ]] || \ [[ -x /www/server/nginx/sbin/nginx ]] || \ [[ -x /www/server/apache/bin/httpd ]]; then BT_MODE=true # Detect which web server BaoTa is running if [[ -x /www/server/nginx/sbin/nginx ]] && \ /www/server/nginx/sbin/nginx -v &>/dev/null 2>&1; then BT_WEB="nginx" WEB_BIN="/www/server/nginx/sbin/nginx" NGINX_BIN="$WEB_BIN" NGINX_CONF_DIR="/www/server/panel/vhost/nginx" info "BaoTa Panel detected — web server: nginx" elif [[ -x /www/server/apache/bin/httpd ]]; then BT_WEB="apache" WEB_BIN="/www/server/apache/bin/apachectl" NGINX_BIN="$WEB_BIN" # keep NGINX_BIN defined to avoid nounset errors NGINX_CONF_DIR="/www/server/panel/vhost/apache" info "BaoTa Panel detected — web server: apache" else err "BaoTa detected but neither nginx nor apache binary found" fi # BaoTa runs its web server as 'www', not the OS default (www-data / nginx) NGINX_USER="www" # Warn about existing BaoTa sites if [[ ${#BT_EXISTING_SITES[@]} -gt 0 ]]; then echo "" warn "BaoTa Panel has existing site config(s):" for s in "${BT_EXISTING_SITES[@]}"; do echo " $s"; done warn "This script will add a NEW config (website.conf)." warn "Existing sites will NOT be touched — but check for port 80/443 conflicts." echo "" read -rp "Understood — continue anyway? (y/N): " BT_CONFIRM [[ "${BT_CONFIRM:-n}" =~ ^[Yy]$ ]] || { info "Cancelled."; exit 0; } fi else BT_MODE=false BT_WEB="" WEB_BIN="nginx" NGINX_BIN="nginx" NGINX_CONF_DIR="/etc/nginx/sites-available" info "No BaoTa Panel — standard nginx setup" fi read -rp "Start installation? (Y/n): " CONFIRM [[ "${CONFIRM:-y}" =~ ^[Nn]$ ]] && { info "Cancelled."; exit 0; } # ── Install Dependencies ─────────────────────── # EPEL must be installed before certbot on RHEL-based systems if [[ "$NEED_EPEL" == true ]]; then log "Enabling EPEL repository..." yum install -y -q epel-release 2>/dev/null || true fi log "Updating package lists..." $PKG_UPDATE log "Installing dependencies..." $PKG_INSTALL $CURL_PKG $OPENSSL_PKG if [[ "$BT_MODE" == false ]]; then log "Installing Nginx..." $PKG_INSTALL $NGINX_PKG fi if [[ "$CERT_MODE" == "letsencrypt" ]]; then log "Installing Certbot..." $PKG_INSTALL $CERTBOT_PKG # certbot-nginx plugin only needed in standard mode [[ "$BT_MODE" == false ]] && $PKG_INSTALL $CERTBOT_NGINX_PKG fi # ── Pre-compute name parts ──────────────────── # Splits "First Last" → two spans; single-word names get an empty last span DEV_FIRST="${DEV_NAME%% *}" DEV_LAST="${DEV_NAME#* }" [[ "$DEV_FIRST" == "$DEV_LAST" ]] && DEV_LAST="" # ── Build locale content (now that DEV_NAME/DEV_AGE/DEV_BIO are known) ─ build_locale_content # ── Safe-init cert vars (avoids nounset errors) ─ SSL_CERT="" SSL_KEY="" # ── Build GitHub HTML block (includes wrapper div; empty string when no username) ─ GH_SVG='' if [[ -n "$GITHUB_USER" ]]; then GITHUB_HTML="
${GH_SVG} GitHub
" else GITHUB_HTML="" fi # ── Website Content ──────────────────────────── log "Creating web root: $WEB_ROOT" mkdir -p "$WEB_ROOT" log "Generating personal homepage..." cat > "$WEB_ROOT/index.html" < ${DEV_NAME} ${L_TITLE_SUFFIX}
~ whoami

${L_HERO_GREETING}

${DEV_FIRST}${DEV_LAST:+ ${DEV_LAST}}

${L_HERO_AGE_LINE}

${DEV_BIO}

${GITHUB_HTML}
${L_ABOUT_KW}

${L_ABOUT_H2}

${L_ABOUT_P1}

${L_ABOUT_P2}

${L_ABOUT_P3}

${L_FACTS_KW}

${L_FACT1}
${L_FACT2}
${L_FACT3}
${L_FACT4}
about.json
${L_TERM_COMMENT}
{
  "name": "${DEV_NAME}",
  "age": ${DEV_AGE},
  "location": "Planet Earth 🌍",
  "status": "${L_JSON_STATUS}",
  "open_to_collab": true,
  "languages": [
    "Python", "JavaScript",
    "HTML/CSS", "Bash"
  ],
  "current_goal": "${L_JSON_GOAL}"
}
${L_SKILLS_KW}

${L_SKILLS_H2}

🐍 Python
82%
🌐 HTML & CSS
90%
JavaScript
70%
🐧 Linux / Bash
65%
🗄️ SQL & Databases
55%
🐙 Git & GitHub
75%
${L_PROJ_KW}

${L_PROJ_H2}

${L_P1_NAME}

${L_P1_DESC}

Python Pygame

${L_P2_NAME}

${L_P2_DESC}

JavaScript REST API CSS

${L_P3_NAME}

${L_P3_DESC}

Python discord.py

${L_P4_NAME}

${L_P4_DESC}

JavaScript HTML CSS

${L_P5_NAME}

${L_P5_DESC}

Python SQLite CLI

${L_P6_NAME}

${L_P6_DESC}

HTML CSS Nginx Linux
${L_CONTACT_KW}

${L_CONTACT_H2}

${L_CONTACT_P}

${CONTACT_EMAIL} ${GITHUB_HTML}
HTMLEOF chown -R "${NGINX_USER}:${NGINX_USER}" "$WEB_ROOT" 2>/dev/null || true chmod -R 755 "$WEB_ROOT" # ── Get Public IP ───────────────────────────── # Prefer IPv4 (-4 flag) — better Let's Encrypt support; fall back to IPv6 then hostname PUBLIC_IP=$(curl -s -4 --max-time 5 ip.sb 2>/dev/null \ || curl -s -4 --max-time 5 ifconfig.me 2>/dev/null \ || curl -s --max-time 5 ip.sb 2>/dev/null \ || hostname -I | awk '{print $1}') # ── SSL Certificate ─────────────────────────── CERT_DIR="/etc/ssl/private/selfcert" SSL_CERT="" SSL_KEY="" if [[ "$CERT_MODE" == "selfsigned" ]]; then log "Generating self-signed SSL certificate (10-year validity)..." mkdir -p "$CERT_DIR" CN="${DOMAIN:-${PUBLIC_IP}}" openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout "$CERT_DIR/privkey.pem" \ -out "$CERT_DIR/fullchain.pem" \ -subj "/C=US/ST=CA/L=SF/O=DevSite/CN=${CN}" 2>/dev/null chmod 600 "$CERT_DIR/privkey.pem" SSL_CERT="$CERT_DIR/fullchain.pem" SSL_KEY="$CERT_DIR/privkey.pem" fi # ── DH Parameters ───────────────────────────── DH_PARAM="/etc/ssl/certs/dhparam.pem" if [[ ! -f "$DH_PARAM" ]]; then log "Generating DH parameters (~30 seconds)..." openssl dhparam -out "$DH_PARAM" 2048 2>/dev/null fi # ── Write Nginx Config ──────────────────────── log "Configuring Nginx..." # Shared SSL block _ssl_block() { local cert="$1" key="$2" cat </dev/null || err "BaoTa Apache config test failed" "$WEB_BIN" graceful 2>/dev/null || \ /etc/init.d/httpd graceful 2>/dev/null || \ /etc/init.d/apache2 graceful 2>/dev/null || \ err "Could not reload BaoTa Apache" log "BaoTa Apache reloaded" fi } # ── BaoTa Mode ──────────────────────────────── if [[ "$BT_MODE" == true ]]; then SERVER_NAME="${DOMAIN:-${PUBLIC_IP}}" mkdir -p "${NGINX_CONF_DIR}" if [[ "$CERT_MODE" == "letsencrypt" ]]; then # ── BaoTa nginx (LE) ── if [[ "$BT_WEB" == "nginx" ]]; then cat > "${NGINX_CONF_DIR}/website.conf" < "${NGINX_CONF_DIR}/website.conf" < "${NGINX_CONF_DIR}/website.conf" < ServerName ${SERVER_NAME} DocumentRoot ${WEB_ROOT} Options -Indexes +FollowSymLinks AllowOverride All Require all granted Alias "/.well-known/acme-challenge/" "${WEB_ROOT}/.well-known/acme-challenge/" APACHE bt_reload log "Requesting Let's Encrypt certificate for: $DOMAIN" certbot certonly --webroot --webroot-path "$WEB_ROOT" \ --non-interactive --agree-tos --email "$LE_EMAIL" \ -d "$DOMAIN" || err "Certificate request failed — check DNS and port 80" LE_CERT="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" LE_KEY="/etc/letsencrypt/live/${DOMAIN}/privkey.pem" cat > "${NGINX_CONF_DIR}/website.conf" < ServerName ${SERVER_NAME} Redirect permanent "/" "https://${SERVER_NAME}/" Alias "/.well-known/acme-challenge/" "${WEB_ROOT}/.well-known/acme-challenge/" ServerName ${SERVER_NAME} DocumentRoot ${WEB_ROOT} SSLEngine on SSLCertificateFile ${LE_CERT} SSLCertificateKeyFile ${LE_KEY} SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains" Header always set X-Frame-Options DENY Header always set X-Content-Type-Options nosniff Options -Indexes +FollowSymLinks AllowOverride All Require all granted APACHE fi if [[ "$BT_WEB" == "nginx" ]]; then CRON_RELOAD="${WEB_BIN} -s reload" else CRON_RELOAD="${WEB_BIN} graceful" fi CRON_CMD="0 3 * * * certbot renew --quiet && ${CRON_RELOAD}" (crontab -l 2>/dev/null | grep -v "certbot renew"; echo "$CRON_CMD") | crontab - log "Let's Encrypt certificate issued — cron renewal configured" else # ── Self-signed: BaoTa nginx ── if [[ "$BT_WEB" == "nginx" ]]; then cat > "${NGINX_CONF_DIR}/website.conf" < "${NGINX_CONF_DIR}/website.conf" < ServerName ${SERVER_NAME} DocumentRoot ${WEB_ROOT} Redirect permanent "/" "https://${SERVER_NAME}/" Options -Indexes +FollowSymLinks AllowOverride All Require all granted ServerName ${SERVER_NAME} DocumentRoot ${WEB_ROOT} SSLEngine on SSLCertificateFile ${SSL_CERT} SSLCertificateKeyFile ${SSL_KEY} SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 Header always set X-Frame-Options DENY Header always set X-Content-Type-Options nosniff Options -Indexes +FollowSymLinks AllowOverride All Require all granted APACHE fi fi bt_reload # ── Standard Mode ───────────────────────────── else rm -f /etc/nginx/sites-enabled/default if [[ "$CERT_MODE" == "letsencrypt" ]]; then cat > "${NGINX_CONF_DIR}/website" </dev/null || \ { (crontab -l 2>/dev/null | grep -v "certbot renew"; echo "0 3 * * * certbot renew --quiet && $NGINX_BIN -s reload") | crontab -; } else (crontab -l 2>/dev/null | grep -v "certbot renew"; echo "0 3 * * * certbot renew --quiet && $NGINX_BIN -s reload") | crontab - fi log "Let's Encrypt certificate issued — auto-renewal configured" else _write_selfsigned_nginx_conf() { local _cert="$1" _key="$2" cat > "${NGINX_CONF_DIR}/website" </dev/null || true info " UFW: pre-allowed $_key" done } _ufw_preallow tcp < <(ss -tlnp 2>/dev/null) _ufw_preallow udp < <(ss -ulnp 2>/dev/null) unset -f _ufw_preallow # Ensure 80 and 443 are allowed (web server just installed) ufw allow 80/tcp comment 'HTTP' &>/dev/null || true ufw allow 443/tcp comment 'HTTPS' &>/dev/null || true ufw allow 443/udp comment 'HTTPS/QUIC' &>/dev/null || true ufw --force enable log "UFW enabled — all pre-existing ports preserved" ;; centos|rhel|almalinux|rocky|ol|fedora) log "Configuring firewalld..." if command -v firewall-cmd &>/dev/null; then firewall-cmd --permanent --add-service=http 2>/dev/null firewall-cmd --permanent --add-service=https 2>/dev/null firewall-cmd --permanent --add-service=ssh 2>/dev/null firewall-cmd --reload log "firewalld: http / https / ssh allowed" else warn "firewall-cmd not found — skipping firewall config" fi ;; alpine) warn "UFW not available on Alpine — configure iptables manually if needed" ;; esac fi fi # ── acme.sh standalone → webroot migration ──── # IMPORTANT: Only patch the config file — do NOT call --renew --force or --issue. # Forcing a new cert changes the certificate fingerprint, which breaks HY2/TUIC # clients that have pinSHA256 set. The patch takes effect on the next scheduled # cron renewal, preserving the existing cert and all sb client configs. if [[ -x "$HOME/.acme.sh/acme.sh" ]]; then ACME_MIGRATED=0 SB_RUNNING=false systemctl is-active sing-box --quiet 2>/dev/null && SB_RUNNING=true rc-service sing-box status &>/dev/null 2>/dev/null && SB_RUNNING=true while IFS= read -r conf; do [[ -f "$conf" ]] || continue wb=$(grep "^Le_Webroot=" "$conf" 2>/dev/null | cut -d"'" -f2) [[ "$wb" == "no" ]] || continue # already webroot — skip dom=$(grep "^Le_Domain=" "$conf" 2>/dev/null | cut -d"'" -f2) warn "acme.sh [$dom] standalone mode detected — patching to webroot (no cert regeneration)" # 1. Switch renewal method to webroot sed -i "s|Le_Webroot='no'|Le_Webroot='${WEB_ROOT}'|" "$conf" # 2. If sing-box is running, ensure it reloads the new cert after each renewal. # Only set if currently empty — don't override a custom reload command. if [[ "$SB_RUNNING" == true ]]; then if grep -q "Le_ReloadCmd=''" "$conf"; then sed -i "s|Le_ReloadCmd=''|Le_ReloadCmd='systemctl restart sing-box 2>/dev/null \|\| rc-service sing-box restart 2>/dev/null'|" "$conf" log "acme.sh [$dom] reload cmd set → restart sing-box after renewal" fi fi # 3. Preserve shortlived profile for IP-based certs (Let's Encrypt requirement) if ! grep -q "^Le_Certificate_Profile=" "$conf"; then # Only add if domain looks like an IP address if [[ "$dom" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Le_Certificate_Profile='shortlived'" >> "$conf" fi fi # 4. Check current cert validity and warn if expiring soon (but don't auto-renew) cert_file=$(grep "^Le_RealFullChainPath=" "$conf" 2>/dev/null | cut -d"'" -f2) [[ -z "$cert_file" ]] && cert_file=$(find "$HOME/.acme.sh" -path "*${dom}*" \ -name "fullchain.cer" 2>/dev/null | head -1) if [[ -n "$cert_file" && -f "$cert_file" ]]; then expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2) expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry" +%s 2>/dev/null) now_epoch=$(date +%s) days_left=$(( (expiry_epoch - now_epoch) / 86400 )) if [[ $days_left -le 1 ]]; then warn "acme.sh [$dom] cert expires in ${days_left}d — run: ~/.acme.sh/acme.sh --renew -d ${dom} --ecc" else log "acme.sh [$dom] cert valid for ${days_left} more days — renewal method patched for next cycle" fi fi ACME_MIGRATED=$((ACME_MIGRATED + 1)) done < <(find "$HOME/.acme.sh" -maxdepth 2 -name "*.conf" 2>/dev/null | grep -v "account\.conf") if [[ $ACME_MIGRATED -gt 0 ]]; then info "acme.sh: $ACME_MIGRATED domain(s) patched to webroot — existing certs and sb client configs unchanged" fi fi # ── sing-box UFW port guard ──────────────────── # Scan sing-box inbound ports and ensure UFW allows them all. # Prevents newly-installed UFW from silently blocking proxy protocols. if [[ "$BT_MODE" == false ]] && command -v ufw &>/dev/null && \ ufw status 2>/dev/null | grep -q "Status: active"; then SB_CONF="" for _f in /etc/s-box/sb.json /etc/sing-box/config.json \ /usr/local/etc/sing-box/config.json; do [[ -f "$_f" ]] && SB_CONF="$_f" && break done if [[ -n "$SB_CONF" ]] && command -v python3 &>/dev/null; then log "sing-box detected — verifying UFW rules for all inbound ports" python3 - "$SB_CONF" "$WEB_ROOT" <<'PYEOF' import json, subprocess, sys try: with open(sys.argv[1]) as f: cfg = json.load(f) except Exception as e: print(f" [!] Could not parse sing-box config: {e}") sys.exit(0) # These protocols use UDP (or both TCP+UDP) udp_types = {'hysteria2', 'tuic', 'naive', 'shadowtls'} status = subprocess.run(['ufw', 'status'], capture_output=True, text=True).stdout for ib in cfg.get('inbounds', []): port = ib.get('listen_port') if not port: continue ptype = ib.get('type', 'unknown') protos = ['tcp', 'udp'] if ptype in udp_types else ['tcp'] for p in protos: rule = f'{port}/{p}' # Check if this port/proto is already in ufw status output if rule in status or f'{port} ' in status: print(f' [✓] {rule:15s} already allowed (sing-box {ptype})') else: subprocess.run( ['ufw', 'allow', rule, 'comment', f'sing-box {ptype}'], capture_output=True ) print(f' [+] {rule:15s} rule added (sing-box {ptype})') PYEOF fi fi # ── Done ─────────────────────────────────────── echo "" echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ 🎉 Deployment Complete! ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}" echo "" if [[ "$CERT_MODE" == "letsencrypt" ]]; then echo -e " URL : ${CYAN}https://${DOMAIN}${NC}" else echo -e " URL (HTTP) : ${CYAN}http://${PUBLIC_IP}${NC}" echo -e " URL (HTTPS) : ${CYAN}https://${PUBLIC_IP}${NC}" warn "Self-signed cert: click Advanced → Proceed in browser, or bind a domain for a trusted cert" fi echo "" echo -e " Edit homepage : ${WEB_ROOT}/index.html" if [[ "$BT_MODE" == true ]]; then echo -e " Vhost config : ${NGINX_CONF_DIR}/website.conf" if [[ "$BT_WEB" == "nginx" ]]; then echo -e " Web server : BaoTa Nginx" echo -e " Reload cmd : ${WEB_BIN} -s reload" else echo -e " Web server : BaoTa Apache" echo -e " Reload cmd : ${WEB_BIN} graceful" fi else echo -e " Nginx config : ${NGINX_CONF_DIR}/website" echo -e " Nginx reload : systemctl reload nginx" fi [[ "$CERT_MODE" == "selfsigned" ]] && echo -e " Certificate : ${CERT_DIR}/" echo "" echo -e " Handy commands:" if [[ "$BT_MODE" == true ]]; then if [[ "$BT_WEB" == "nginx" ]]; then echo -e " ${WEB_BIN} -t # test config" echo -e " ${WEB_BIN} -s reload # reload" else echo -e " ${WEB_BIN} -t # test config" echo -e " ${WEB_BIN} graceful # graceful reload" fi else echo -e " systemctl restart nginx # restart Nginx" fi echo -e " tail -f /var/log/nginx/access.log # live traffic log" [[ "$CERT_MODE" == "letsencrypt" ]] && \ echo -e " certbot renew --dry-run # test cert renewal" echo ""