initial deployment v1.0

This commit is contained in:
RaineAllDay
2026-03-18 03:06:27 -06:00
commit eaaadd39e4
69 changed files with 10755 additions and 0 deletions

389
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,389 @@
#!/usr/bin/env bash
# =============================================================================
# ETC PRS — Initial Deployment Script
# Targets: Ubuntu 24.04 LTS on a fresh Digital Ocean droplet
# Usage: sudo bash deploy.sh
# =============================================================================
set -euo pipefail
# ── Colours ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}$*${RESET}"; }
success() { echo -e "${GREEN}$*${RESET}"; }
warn() { echo -e "${YELLOW}$*${RESET}"; }
error() { echo -e "${RED}$*${RESET}"; exit 1; }
header() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
divider() { echo -e "${CYAN}─────────────────────────────────────────────────${RESET}"; }
# ── Root check ────────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (sudo bash deploy.sh)"
fi
# ── Banner ────────────────────────────────────────────────────────────────────
clear
echo -e "${BOLD}${CYAN}"
echo " ███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗"
echo " ██╔════╝╚══██╔══╝██╔════╝ ██╔══██╗██╔══██╗██╔════╝"
echo " █████╗ ██║ ██║ ██████╔╝██████╔╝███████╗"
echo " ██╔══╝ ██║ ██║ ██╔═══╝ ██╔══██╗╚════██║"
echo " ███████╗ ██║ ╚██████╗ ██║ ██║ ██║███████║"
echo " ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝"
echo -e "${RESET}"
echo -e " ${BOLD}Personality Editor — Initial Deployment${RESET}"
echo -e " ${CYAN}Targeting Ubuntu 24.04 · Node 20 · PM2 · Nginx · Certbot${RESET}"
divider
echo ""
# ── Interactive prompts ───────────────────────────────────────────────────────
header "Configuration"
# Domain
while true; do
read -rp "$(echo -e "${BOLD}Domain name${RESET} (e.g. etcprs.app): ")" DOMAIN
DOMAIN="${DOMAIN#www.}" # strip leading www. if provided
DOMAIN="${DOMAIN%/}" # strip trailing slash
[[ -n "$DOMAIN" ]] && break
warn "Domain cannot be empty."
done
# Gitea repo
echo ""
read -rp "$(echo -e "${BOLD}Gitea repo path${RESET} [default: raine/etc-prs-ui]: ")" REPO_PATH
REPO_PATH="${REPO_PATH:-raine/etc-prs-ui}"
REPO_URL="https://git.etcprs.app/${REPO_PATH}.git"
echo -e " ${CYAN}${REPO_URL}${RESET}"
# Gitea credentials (for private repos)
echo ""
echo -e "${YELLOW}If your Gitea repo is private, enter credentials below."
echo -e "Leave blank if the repo is public.${RESET}"
read -rp "$(echo -e "${BOLD}Gitea username${RESET} (blank = public): ")" GITEA_USER
if [[ -n "$GITEA_USER" ]]; then
read -rsp "$(echo -e "${BOLD}Gitea password / token${RESET}: ")" GITEA_PASS
echo ""
REPO_URL="https://${GITEA_USER}:${GITEA_PASS}@git.etcprs.app/${REPO_PATH}.git"
fi
# Admin account
echo ""
echo -e "${BOLD}Create the first admin account:${RESET}"
while true; do
read -rp " Admin username (232 chars): " ADMIN_USER
[[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break
warn "Username must be 232 characters."
done
while true; do
read -rsp " Admin password (min 8 chars): " ADMIN_PASS
echo ""
[[ ${#ADMIN_PASS} -ge 8 ]] && break
warn "Password must be at least 8 characters."
done
read -rsp " Confirm password: " ADMIN_PASS_CONFIRM
echo ""
[[ "$ADMIN_PASS" == "$ADMIN_PASS_CONFIRM" ]] || error "Passwords do not match."
# Rate limits
echo ""
echo -e "${BOLD}Rate limits${RESET} (press Enter to accept defaults):"
read -rp " Publish rate limit per IP/hour [default: 5]: " RATE_PUBLISH
RATE_PUBLISH="${RATE_PUBLISH:-5}"
read -rp " Read rate limit per IP/hour [default: 100]: " RATE_READ
RATE_READ="${RATE_READ:-100}"
# SSL email
echo ""
read -rp "$(echo -e "${BOLD}Email for SSL certificate${RESET} (Let's Encrypt notices): ")" SSL_EMAIL
[[ -n "$SSL_EMAIL" ]] || error "SSL email is required."
# ── Confirmation ──────────────────────────────────────────────────────────────
echo ""
divider
echo -e "${BOLD} Deployment Summary${RESET}"
divider
echo -e " Domain: ${CYAN}${DOMAIN}${RESET} (and www.${DOMAIN})"
echo -e " Repo: ${CYAN}${REPO_URL//:*@/:***@}${RESET}"
echo -e " Admin user: ${CYAN}${ADMIN_USER}${RESET}"
echo -e " Rate limits: publish=${RATE_PUBLISH}/hr read=${RATE_READ}/hr"
echo -e " SSL email: ${CYAN}${SSL_EMAIL}${RESET}"
divider
echo ""
read -rp "$(echo -e "${BOLD}Proceed with deployment? [y/N]: ${RESET}")" CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { echo "Aborted."; exit 0; }
# ── Constants ─────────────────────────────────────────────────────────────────
APP_USER="prs"
APP_DIR="/opt/etc-prs/app"
DATA_DIR="/var/lib/etc-prs"
LOG_DIR="/var/log/etc-prs"
BACKUP_DIR="/var/backups/etc-prs"
BACKUP_SCRIPT="/opt/etc-prs/backup.sh"
NGINX_CONF="/etc/nginx/sites-available/etc-prs"
# ── Step 1: System packages ───────────────────────────────────────────────────
header "Step 1 / 9 — System Packages"
info "Updating package lists…"
apt-get update -qq
info "Upgrading installed packages…"
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
info "Installing build tools, Nginx, Certbot, SQLite…"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential python3 git nginx certbot python3-certbot-nginx sqlite3 ufw curl
info "Installing Node.js 20 LTS via NodeSource…"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs
NODE_VER=$(node --version)
NPM_VER=$(npm --version)
success "Node ${NODE_VER} / npm ${NPM_VER} installed"
info "Installing PM2 globally…"
npm install -g pm2 --quiet
success "PM2 $(pm2 --version) installed"
# ── Step 2: Users & directories ───────────────────────────────────────────────
header "Step 2 / 9 — Users & Directories"
if ! id "$APP_USER" &>/dev/null; then
info "Creating system user '${APP_USER}'…"
useradd --system --shell /bin/false --home /opt/etc-prs "$APP_USER"
success "User '${APP_USER}' created"
else
success "User '${APP_USER}' already exists"
fi
for DIR in /opt/etc-prs "$APP_DIR" "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"; do
mkdir -p "$DIR"
done
chown -R "${APP_USER}:${APP_USER}" /opt/etc-prs "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"
success "Directories created and ownership set"
# ── Step 3: Clone from Gitea ──────────────────────────────────────────────────
header "Step 3 / 9 — Clone Repository"
info "Cloning from ${REPO_URL//:*@/:***@}"
if [[ -d "${APP_DIR}/.git" ]]; then
warn "App directory already contains a git repo — pulling latest instead."
sudo -u "$APP_USER" git -C "$APP_DIR" pull
else
sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR" 2>&1 | \
sed 's/'"${GITEA_PASS:-NOPASS}"'/***REDACTED***/g' || \
error "Git clone failed. Check your repo URL and credentials."
fi
success "Repository cloned to ${APP_DIR}"
# ── Step 4: Environment file ──────────────────────────────────────────────────
header "Step 4 / 9 — Environment Configuration"
ENV_FILE="${APP_DIR}/.env"
cat > "$ENV_FILE" <<EOF
DATABASE_URL=${DATA_DIR}/personalities.db
RATE_LIMIT_PUBLISH=${RATE_PUBLISH}
RATE_LIMIT_READ=${RATE_READ}
PUBLIC_BASE_URL=https://${DOMAIN}
EOF
chown "${APP_USER}:${APP_USER}" "$ENV_FILE"
chmod 600 "$ENV_FILE"
success ".env written to ${ENV_FILE}"
# ── Step 5: Install deps & build ──────────────────────────────────────────────
header "Step 5 / 9 — Install & Build"
info "Installing npm dependencies (this may take a minute)…"
sudo -u "$APP_USER" npm --prefix "$APP_DIR" install --quiet
info "Building SvelteKit app…"
sudo -u "$APP_USER" npm --prefix "$APP_DIR" run build
success "Build complete"
# ── Step 6: PM2 ───────────────────────────────────────────────────────────────
header "Step 6 / 9 — PM2 Process Manager"
ECOSYSTEM="${APP_DIR}/ecosystem.config.cjs"
cat > "$ECOSYSTEM" <<EOF
module.exports = {
apps: [{
name: 'etc-prs',
script: '${APP_DIR}/build/index.js',
cwd: '${APP_DIR}',
user: '${APP_USER}',
env: {
NODE_ENV: 'production',
PORT: '3000',
HOST: '127.0.0.1',
},
error_file: '${LOG_DIR}/error.log',
out_file: '${LOG_DIR}/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
restart_delay: 3000,
max_restarts: 10,
}]
};
EOF
chown "${APP_USER}:${APP_USER}" "$ECOSYSTEM"
info "Starting app with PM2…"
sudo -u "$APP_USER" pm2 start "$ECOSYSTEM"
sudo -u "$APP_USER" pm2 save
info "Configuring PM2 to start on boot…"
# Capture the startup command PM2 emits and run it
PM2_STARTUP=$(sudo -u "$APP_USER" pm2 startup systemd -u "$APP_USER" --hp /opt/etc-prs 2>&1 | grep "sudo env")
if [[ -n "$PM2_STARTUP" ]]; then
eval "$PM2_STARTUP"
fi
success "PM2 configured — app is running on 127.0.0.1:3000"
# ── Step 7: Nginx ─────────────────────────────────────────────────────────────
header "Step 7 / 9 — Nginx"
cat > "$NGINX_CONF" <<EOF
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
# Gzip
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Proxy to SvelteKit
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 30s;
client_max_body_size 2M;
}
}
EOF
# Remove default site, enable ours
rm -f /etc/nginx/sites-enabled/default
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/etc-prs
nginx -t || error "Nginx config test failed — check ${NGINX_CONF}"
systemctl reload nginx
success "Nginx configured for ${DOMAIN}"
# ── Step 8: SSL ───────────────────────────────────────────────────────────────
header "Step 8 / 9 — SSL Certificate (Let's Encrypt)"
info "Requesting certificate for ${DOMAIN} and www.${DOMAIN}"
info "DNS must be pointing at this server's IP for this to succeed."
echo ""
if certbot --nginx \
--non-interactive \
--agree-tos \
--email "$SSL_EMAIL" \
--domains "${DOMAIN},www.${DOMAIN}" \
--redirect; then
success "SSL certificate issued and Nginx updated"
info "Verifying auto-renewal…"
certbot renew --dry-run --quiet && success "Auto-renewal check passed"
else
warn "Certbot failed — HTTP is still working but HTTPS is not configured."
warn "Once your DNS is pointing here, run:"
warn " sudo certbot --nginx -d ${DOMAIN} -d www.${DOMAIN} --email ${SSL_EMAIL} --agree-tos"
fi
# ── Step 9: Backup cron + admin user ─────────────────────────────────────────
header "Step 9 / 9 — Backups, Firewall & Admin Account"
# Backup script
cat > "$BACKUP_SCRIPT" <<'EOF'
#!/usr/bin/env bash
DATE=$(date +%Y-%m-%d_%H%M)
BACKUP_DIR=/var/backups/etc-prs
DB_PATH=/var/lib/etc-prs/personalities.db
mkdir -p "$BACKUP_DIR"
sqlite3 "$DB_PATH" ".backup ${BACKUP_DIR}/personalities-${DATE}.db"
gzip "${BACKUP_DIR}/personalities-${DATE}.db"
# Keep only the last 30 days
find "$BACKUP_DIR" -name "personalities-*.db.gz" -mtime +30 -delete
echo "$(date '+%Y-%m-%d %H:%M:%S') Backup OK: personalities-${DATE}.db.gz"
EOF
chmod +x "$BACKUP_SCRIPT"
# Add cron job (2am daily) — idempotent
CRON_LINE="0 2 * * * ${BACKUP_SCRIPT} >> ${LOG_DIR}/backup.log 2>&1"
( crontab -l 2>/dev/null | grep -v "$BACKUP_SCRIPT"; echo "$CRON_LINE" ) | crontab -
success "Backup cron job scheduled (daily at 2am)"
# Firewall
info "Configuring UFW firewall…"
ufw allow OpenSSH > /dev/null
ufw allow 'Nginx Full' > /dev/null
ufw --force enable > /dev/null
success "Firewall enabled (SSH + HTTP + HTTPS)"
# Admin account
info "Creating admin account '${ADMIN_USER}'…"
cd "$APP_DIR"
sudo -u "$APP_USER" node scripts/create-admin.js "$ADMIN_USER" "$ADMIN_PASS" || \
warn "Admin creation failed — run manually: cd ${APP_DIR} && node scripts/create-admin.js <user> <pass>"
success "Admin account '${ADMIN_USER}' created"
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
divider
echo -e "${BOLD}${GREEN} Deployment complete!${RESET}"
divider
echo ""
echo -e " ${BOLD}Site:${RESET} https://${DOMAIN}"
echo -e " ${BOLD}Admin panel:${RESET} https://${DOMAIN}/admin"
echo -e " ${BOLD}App logs:${RESET} sudo -u ${APP_USER} pm2 logs etc-prs"
echo -e " ${BOLD}DB path:${RESET} ${DATA_DIR}/personalities.db"
echo -e " ${BOLD}Backups:${RESET} ${BACKUP_DIR}"
echo ""
echo -e " ${BOLD}Redeploy after a push:${RESET}"
echo -e " ${CYAN} cd ${APP_DIR} && sudo -u ${APP_USER} git pull && \\"
echo -e " sudo -u ${APP_USER} npm install && \\"
echo -e " sudo -u ${APP_USER} npm run build && \\"
echo -e " sudo -u ${APP_USER} pm2 reload etc-prs${RESET}"
echo ""
divider
echo ""
# ── Verify app is responding ──────────────────────────────────────────────────
info "Checking app is responding on port 3000…"
sleep 2
if curl -sf http://127.0.0.1:3000 > /dev/null; then
success "App is up and responding ✓"
else
warn "App did not respond on port 3000. Check logs:"
warn " sudo -u ${APP_USER} pm2 logs etc-prs --lines 30"
fi
echo ""