Files
picpeak/scripts/picpeak-setup.sh
T
Claude a59a4232ff Fix worker service and Docker storage permission issues (Issues #66, #67)
Issue #66: Remove redundant picpeak-workers.service creation from setup script.
Workers (fileWatcher, expirationChecker, emailProcessor) are now started
automatically by server.js, so a separate systemd service is not needed.
The legacy service cleanup code is retained for migration purposes.

Issue #67: Ensure storage directories exist at container startup in
wait-for-db.sh. When host directories are bind-mounted in Docker, the
container's built-in directories are overridden. This fix creates the
required directory structure (events/active, events/archived, thumbnails)
before the application starts, preventing EACCES permission errors.
2025-11-28 17:54:36 +01:00

1430 lines
44 KiB
Bash
Executable File

#!/usr/bin/env bash
################################################################################
# PicPeak Unified Setup Script
# Version: 2.1.0
# Description: Universal installer for PicPeak with Docker and Native options
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
################################################################################
set -euo pipefail
IFS=$'\n\t'
# Script configuration
readonly SCRIPT_VERSION="2.1.0"
readonly APP_NAME="PicPeak"
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
readonly NODE_VERSION="20"
readonly MIN_RAM_DOCKER=2048
readonly MIN_RAM_NATIVE=1024
readonly MIN_DISK_GB=2
readonly DEFAULT_PORT=3001
# Installation paths
readonly NATIVE_APP_DIR="/opt/picpeak"
readonly NATIVE_APP_USER="picpeak"
readonly DOCKER_APP_DIR="$HOME/picpeak"
# Color codes for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly PURPLE='\033[0;35m'
readonly CYAN='\033[0;36m'
readonly NC='\033[0m' # No Color
# Logging
readonly LOG_FILE="/tmp/picpeak-setup-$(date +%Y%m%d-%H%M%S).log"
exec 1> >(tee -a "$LOG_FILE")
exec 2>&1
# Global variables
INSTALL_METHOD="" # docker or native
OS_TYPE=""
OS_VERSION=""
PACKAGE_MANAGER=""
ADMIN_EMAIL="admin@example.com"
DOMAIN_NAME=""
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
ENABLE_SSL=false
CUSTOM_PORT=""
UNATTENDED=false
UPDATE_MODE=false
UNINSTALL_MODE=false
FORCE_ADMIN_PASSWORD_RESET=false
################################################################################
# Helper Functions
################################################################################
# Run a command as the application user, even if sudo is not available
run_as_user() {
local cmd="$*"
local current_dir_escaped
current_dir_escaped=$(printf '%q' "$(pwd)")
if [[ "$(id -u)" -ne 0 ]]; then
# Already non-root; preserve working directory
bash -lc "cd $current_dir_escaped && $cmd"
return $?
fi
if command_exists sudo; then
sudo -H -u "$NATIVE_APP_USER" bash -lc "cd $current_dir_escaped && $cmd"
elif command_exists runuser; then
runuser -u "$NATIVE_APP_USER" -- bash -lc "cd $current_dir_escaped && $cmd"
else
su -s /bin/bash - "$NATIVE_APP_USER" -c "cd $current_dir_escaped && $cmd"
fi
}
# Prompt for admin email interactively (unless provided or unattended)
prompt_admin_email() {
if [[ -n "$ADMIN_EMAIL" ]]; then
return
fi
if [[ "$UNATTENDED" == "true" ]]; then
ADMIN_EMAIL="admin@example.com"
return
fi
echo
echo "Please enter the admin email address (used for the initial admin account):"
read -p "Admin email [admin@example.com]: " ADMIN_EMAIL
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
}
print_banner() {
echo -e "${PURPLE}"
echo "╔════════════════════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ____ _ ____ _ ____ _ ║"
echo "║ | _ \\(_) ___| _ \\ ___ __ _| | __ / ___| ___| |_ _ _ _ __ ║"
echo "║ | |_) | |/ __| |_) / _ \\/ _\` | |/ / \\___ \\ / _ \\ __| | | | '_ \\ ║"
echo "║ | __/| | (__| __/ __/ (_| | < ___) | __/ |_| |_| | |_) | ║"
echo "║ |_| |_|\\___|_| \\___|\\__,_|_|\\_\\ |____/ \\___|\\__|\\__,_| .__/ ║"
echo "║ |_| ║"
echo "║ ║"
echo "║ 🚀 Unified Setup Script v${SCRIPT_VERSION}"
echo "║ 📸 Secure Photo Sharing for Weddings & Events ║"
echo "║ ║"
echo "╚════════════════════════════════════════════════════════════════════════╝"
echo -e "${NC}\n"
}
print_header() {
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
log_info() { echo -e "${CYAN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_success() { echo -e "${GREEN}${NC} $1"; }
log_step() { echo -e "${PURPLE}🔄${NC} $1"; }
die() {
log_error "$1"
echo -e "\n${RED}Installation failed. Check the log file: $LOG_FILE${NC}"
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
ensure_storage_layout() {
local base_dir="$1"
local storage_root="$base_dir/storage"
local storage_events_dir="$storage_root/events"
mkdir -p "$storage_events_dir/active" \
"$storage_events_dir/archived" \
"$storage_root/thumbnails" \
"$storage_root/tmp"
local legacy_dir="$base_dir/events"
if [[ -d "$legacy_dir" ]]; then
log_step "Migrating legacy events directory to storage/events..."
mkdir -p "$storage_events_dir"
local existing=""
if [[ -d "$storage_events_dir" ]]; then
existing=$(ls -A "$storage_events_dir" 2>/dev/null || true)
fi
if [[ ! -d "$storage_events_dir" || -z "$existing" ]]; then
rm -rf "$storage_events_dir"
mv "$legacy_dir" "$storage_events_dir"
else
cp -a "$legacy_dir/." "$storage_events_dir/"
rm -rf "$legacy_dir"
fi
fi
mkdir -p "$storage_events_dir/active" "$storage_events_dir/archived"
}
generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
}
generate_jwt_secret() {
openssl rand -base64 64 | tr -d "\n"
}
get_available_ram_mb() {
# Prefer /proc/meminfo (always available on Linux), fallback to free(1)
if [[ -r /proc/meminfo ]]; then
awk '/^MemTotal:/ { printf "%d\n", $2/1024 }' /proc/meminfo
return
fi
if command_exists free; then
free -m | awk '/^Mem:/ {print $2}'
return
fi
echo "0"
}
get_available_disk_gb() {
df -BG / | awk 'NR==2 {print $4}' | sed 's/G//'
}
confirm() {
local prompt="$1"
local default="${2:-n}"
local REPLY
if [[ "$UNATTENDED" == "true" ]]; then
[[ "$default" == "y" ]] && return 0 || return 1
fi
if [[ "$default" == "y" ]]; then
prompt="$prompt [Y/n]: "
else
prompt="$prompt [y/N]: "
fi
read -p "$prompt" REPLY
REPLY=${REPLY:-$default}
[[ "$REPLY" =~ ^[Yy]$ ]]
}
detect_os() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
OS_TYPE="$ID"
OS_VERSION="$VERSION_ID"
else
die "Cannot detect operating system"
fi
case "$OS_TYPE" in
ubuntu|debian|raspbian)
PACKAGE_MANAGER="apt"
;;
fedora|rhel|centos|rocky|almalinux)
PACKAGE_MANAGER="dnf"
command_exists dnf || PACKAGE_MANAGER="yum"
;;
*)
die "Unsupported operating system: $OS_TYPE"
;;
esac
log_info "Detected OS: $OS_TYPE $OS_VERSION"
}
################################################################################
# Installation Method Selection
################################################################################
select_install_method() {
if [[ -n "$INSTALL_METHOD" ]]; then
return
fi
if [[ "$UNATTENDED" == "true" ]]; then
INSTALL_METHOD="docker"
return
fi
print_header "Select Installation Method"
echo "Please choose your preferred installation method:"
echo
echo -e "${GREEN}1) Docker Installation (Recommended)${NC}"
echo " ✅ Easier to install and update"
echo " ✅ Isolated environment"
echo " ✅ Includes PostgreSQL and Redis"
echo " ⚠️ Requires ~4GB RAM"
echo
echo -e "${YELLOW}2) Native Installation${NC}"
echo " ✅ Lower resource usage (~1GB RAM)"
echo " ✅ Better for Raspberry Pi"
echo " ✅ Direct system control"
echo " ⚠️ More complex setup"
echo
local choice
while true; do
read -p "Enter your choice (1 or 2): " choice
case $choice in
1) INSTALL_METHOD="docker"; break;;
2) INSTALL_METHOD="native"; break;;
*) log_error "Invalid choice. Please enter 1 or 2.";;
esac
done
log_success "Selected: ${INSTALL_METHOD^} installation"
}
################################################################################
# System Requirements Check
################################################################################
check_system_requirements() {
print_header "Checking System Requirements"
local required_ram
if [[ "$INSTALL_METHOD" == "docker" ]]; then
required_ram=$MIN_RAM_DOCKER
else
required_ram=$MIN_RAM_NATIVE
fi
# Check RAM
local available_ram=$(get_available_ram_mb)
if [[ $available_ram -lt $required_ram ]]; then
log_warn "System has ${available_ram}MB RAM, recommended: ${required_ram}MB"
if ! confirm "Continue with limited RAM?"; then
die "Insufficient RAM"
fi
else
log_success "RAM check passed: ${available_ram}MB available"
fi
# Check disk space
local available_disk=$(get_available_disk_gb)
if [[ $available_disk -lt $MIN_DISK_GB ]]; then
log_warn "System has ${available_disk}GB free space, recommended: ${MIN_DISK_GB}GB"
if ! confirm "Continue with limited disk space?"; then
die "Insufficient disk space"
fi
else
log_success "Disk space check passed: ${available_disk}GB available"
fi
# Check architecture
local arch=$(uname -m)
log_info "System architecture: $arch"
# Check required commands
if ! command_exists openssl; then
log_warn "OpenSSL not found, installing..."
install_package openssl
fi
if ! command_exists curl; then
log_warn "curl not found, installing..."
install_package curl
fi
if ! command_exists git; then
log_warn "git not found, installing..."
install_package git
fi
}
################################################################################
# Docker Installation
################################################################################
install_docker() {
if command_exists docker; then
log_success "Docker is already installed"
return
fi
print_header "Installing Docker"
log_step "Installing Docker and Docker Compose..."
case "$PACKAGE_MANAGER" in
apt)
curl -fsSL https://get.docker.com | sh
;;
dnf|yum)
$PACKAGE_MANAGER config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
$PACKAGE_MANAGER install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
;;
esac
# Start Docker service
systemctl start docker
systemctl enable docker
# Add current user to docker group
if [[ -n "${SUDO_USER:-}" ]]; then
usermod -aG docker "$SUDO_USER"
fi
log_success "Docker installed successfully"
}
setup_docker_installation() {
print_header "Docker Installation Setup"
# Install Docker if needed
install_docker
# Create application directory
local app_dir="$DOCKER_APP_DIR"
if [[ -n "${SUDO_USER:-}" ]]; then
app_dir="/home/$SUDO_USER/picpeak"
fi
log_step "Preparing application directory at $app_dir"
local app_parent_dir
app_parent_dir=$(dirname "$app_dir")
mkdir -p "$app_parent_dir"
if [[ -d "$app_dir/.git" ]]; then
log_step "Existing PicPeak repository detected; pulling latest changes"
cd "$app_dir"
git pull
elif [[ -d "$app_dir" ]]; then
if [[ -z "$(ls -A "$app_dir" 2>/dev/null)" ]]; then
log_warn "Existing directory $app_dir is empty but not a git repository; recreating it..."
rm -rf "$app_dir"
git clone "$REPO_URL" "$app_dir"
else
log_warn "Directory $app_dir already exists and is not a git repository."
if [[ "$UNATTENDED" == "true" ]]; then
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
log_warn "Unattended mode: backing up directory to $backup_dir and cloning a fresh copy."
mv "$app_dir" "$backup_dir"
git clone "$REPO_URL" "$app_dir"
else
if confirm "Replace existing directory $app_dir with a fresh clone? This will move the current contents to a backup folder." "y"; then
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
mv "$app_dir" "$backup_dir"
log_step "Existing directory moved to $backup_dir"
git clone "$REPO_URL" "$app_dir"
else
die "Installation aborted because $app_dir already exists and is not a PicPeak git repository."
fi
fi
fi
else
if [[ -d "$app_dir" && -n "$(find "$app_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
die "Target directory $app_dir already exists and is not empty. Remove it or specify --install-dir before retrying."
fi
log_step "Cloning PicPeak..."
rm -rf "$app_dir"
git clone "$REPO_URL" "$app_dir"
fi
# Ensure storage layout exists after cloning
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
# Determine host user for container mapping (PUID/PGID)
local host_uid host_gid
if [[ -n "${SUDO_USER:-}" ]]; then
host_uid=$(id -u "$SUDO_USER" 2>/dev/null || echo 1000)
host_gid=$(id -g "$SUDO_USER" 2>/dev/null || echo 1000)
else
host_uid=$(id -u 2>/dev/null || echo 1000)
host_gid=$(id -g 2>/dev/null || echo 1000)
fi
# Generate secrets
local jwt_secret=$(generate_jwt_secret)
local db_password=$(generate_password)
local redis_password=$(generate_password)
# Create .env file
log_step "Creating configuration..."
cat > "$app_dir/.env" <<EOF
# PicPeak Docker Configuration
# Generated: $(date)
# Application
NODE_ENV=production
PORT=3001
JWT_SECRET=$jwt_secret
# Admin
ADMIN_EMAIL=$ADMIN_EMAIL
# Runtime user mapping for Docker bind mounts
PUID=$host_uid
PGID=$host_gid
# Database
DB_HOST=postgres
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=$db_password
DB_NAME=picpeak
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=$redis_password
# Storage
PHOTOS_DIR=/app/storage/events
# Email
SMTP_ENABLED=${SMTP_HOST:+true}
SMTP_HOST=$SMTP_HOST
SMTP_PORT=$SMTP_PORT
SMTP_USER=$SMTP_USER
SMTP_PASS=$SMTP_PASS
SMTP_FROM=${SMTP_USER:-noreply@localhost}
# URLs
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
# Features
ENABLE_FILE_WATCHER=true
ENABLE_EXPIRATION_CHECKER=true
ENABLE_EMAIL_SERVICE=true
DEFAULT_EXPIRY_DAYS=30
EOF
# Ensure bind mounts are writable by mapped user
chown -R "$host_uid":"$host_gid" "$app_dir"/storage "$app_dir"/logs "$app_dir"/backup "$app_dir"/data "$app_dir"/events 2>/dev/null || true
# Create docker-compose.yml if it doesn't exist
if [[ ! -f "$app_dir/docker-compose.yml" ]]; then
log_step "Creating Docker Compose configuration..."
create_docker_compose_file "$app_dir"
fi
# Set up SSL if requested
if [[ "$ENABLE_SSL" == "true" ]] && [[ -n "$DOMAIN_NAME" ]]; then
setup_ssl_docker "$app_dir"
fi
# Start services
log_step "Starting services..."
cd "$app_dir"
docker compose up -d
# Wait for services to be ready
log_step "Waiting for services to initialize..."
sleep 10
# Run database migrations
log_step "Running database migrations..."
docker compose exec -T backend npm run migrate
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
else
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
fi
fi
log_success "Docker installation completed!"
}
create_docker_compose_file() {
local app_dir="$1"
cat > "$app_dir/docker-compose.yml" <<'EOF'
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: picpeak-postgres
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- picpeak-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: picpeak-redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
networks:
- picpeak-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
container_name: picpeak-backend
env_file: .env
volumes:
- ./storage:/app/storage
- ./logs:/app/logs
ports:
- "${PORT:-3001}:3001"
networks:
- picpeak-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
workers:
build: ./backend
container_name: picpeak-workers
command: npm run workers
env_file: .env
volumes:
- ./storage:/app/storage
- ./logs:/app/logs
networks:
- picpeak-network
depends_on:
- backend
restart: unless-stopped
volumes:
postgres-data:
redis-data:
networks:
picpeak-network:
driver: bridge
EOF
}
################################################################################
# Native Installation
################################################################################
install_nodejs() {
if command_exists node && [[ $(node -v | cut -d'v' -f2 | cut -d'.' -f1) -ge $NODE_VERSION ]]; then
log_success "Node.js $(node -v) is already installed"
return
fi
log_step "Installing Node.js $NODE_VERSION..."
case "$PACKAGE_MANAGER" in
apt)
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
apt-get install -y nodejs
;;
dnf|yum)
curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash -
$PACKAGE_MANAGER install -y nodejs
;;
esac
log_success "Node.js installed: $(node -v)"
}
setup_native_installation() {
print_header "Native Installation Setup"
# Install Node.js
install_nodejs
# Install SQLite
log_step "Installing SQLite..."
install_package sqlite3
# Install build tools
log_step "Installing build tools..."
case "$PACKAGE_MANAGER" in
apt)
apt-get install -y build-essential python3
;;
dnf)
if ! $PACKAGE_MANAGER install -y @development-tools; then
log_warn "dnf @development-tools group install failed, retrying with legacy groupinstall syntax..."
$PACKAGE_MANAGER groupinstall -y "Development Tools"
fi
$PACKAGE_MANAGER install -y python3
;;
yum)
$PACKAGE_MANAGER groupinstall -y "Development Tools"
$PACKAGE_MANAGER install -y python3
;;
esac
# Create system user
if ! id "$NATIVE_APP_USER" &>/dev/null; then
log_step "Creating system user..."
useradd -r -s /bin/bash -m -d /home/$NATIVE_APP_USER $NATIVE_APP_USER
fi
# Create application directory
log_step "Creating application directory..."
mkdir -p "$NATIVE_APP_DIR"/{app,logs,config}
ensure_storage_layout "$NATIVE_APP_DIR"
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
# Clone repository
log_step "Downloading PicPeak..."
if [[ -d "$NATIVE_APP_DIR/app/.git" ]]; then
cd "$NATIVE_APP_DIR/app"
# Ensure correct remote and update even if history was rewritten
run_as_user "git config --global --add safe.directory $NATIVE_APP_DIR/app" || true
run_as_user "git remote set-url origin $REPO_URL" || true
run_as_user "git fetch --all --prune" || true
# Prefer checking out remote main and hard resetting to avoid merge prompts
if ! run_as_user "git checkout -B main origin/main"; then
run_as_user "git checkout main" || true
run_as_user "git reset --hard origin/main"
fi
else
run_as_user "git clone $REPO_URL $NATIVE_APP_DIR/app" || {
run_as_user "git config --global --add safe.directory $NATIVE_APP_DIR/app"
run_as_user "git clone $REPO_URL $NATIVE_APP_DIR/app"
}
fi
# Install dependencies
log_step "Installing dependencies..."
# The repository root contains both backend/ and frontend/
# Install backend production dependencies
cd "$NATIVE_APP_DIR/app/backend"
npm install --production
# Ensure SQLite data directory exists for native installs
mkdir -p "$NATIVE_APP_DIR/app/backend/data"
# Build frontend for native serving
log_step "Building frontend..."
if [[ -d "$NATIVE_APP_DIR/app/frontend" ]]; then
cd "$NATIVE_APP_DIR/app/frontend"
# Try ci (faster/clean) then fallback to install
run_as_user "npm ci --include=dev" || run_as_user "npm install"
run_as_user "npm run build"
else
log_warn "Frontend directory not found; admin UI will not be served by backend"
fi
# Generate secrets
local jwt_secret=$(generate_jwt_secret)
# Create .env file
log_step "Creating configuration..."
cat > "$NATIVE_APP_DIR/app/backend/.env" <<EOF
# PicPeak Native Configuration
# Generated: $(date)
# Application
NODE_ENV=production
PORT=${CUSTOM_PORT:-$DEFAULT_PORT}
JWT_SECRET=$jwt_secret
# Admin
ADMIN_USERNAME=admin
ADMIN_EMAIL=$ADMIN_EMAIL
# Database (native uses SQLite by default)
DATABASE_CLIENT=sqlite3
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
# Storage root (thumbnails/uploads live under this path)
STORAGE_PATH=$NATIVE_APP_DIR/storage
# Email
SMTP_ENABLED=${SMTP_HOST:+true}
SMTP_HOST=$SMTP_HOST
SMTP_PORT=$SMTP_PORT
SMTP_USER=$SMTP_USER
SMTP_PASS=$SMTP_PASS
SMTP_FROM=${SMTP_USER:-noreply@localhost}
# URLs
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
# Features
ENABLE_FILE_WATCHER=true
ENABLE_EXPIRATION_CHECKER=true
ENABLE_EMAIL_SERVICE=true
DEFAULT_EXPIRY_DAYS=30
# Logging
LOG_DIR=$NATIVE_APP_DIR/logs
LOG_LEVEL=info
# Frontend serving (native installs)
SERVE_FRONTEND=true
FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist
EOF
# Set permissions
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
chmod 600 "$NATIVE_APP_DIR/app/backend/.env"
# Run database migrations
log_step "Initializing database..."
cd "$NATIVE_APP_DIR/app/backend"
run_as_user "npm run migrate"
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
fi
fi
# Create systemd services
create_systemd_services
# Set up web server if requested
if [[ "$ENABLE_SSL" == "true" ]] && [[ -n "$DOMAIN_NAME" ]]; then
setup_caddy
fi
# Start services
log_step "Starting services..."
systemctl daemon-reload
systemctl enable picpeak-backend
# Stop/remove legacy workers service if present
if systemctl list-unit-files | grep -q '^picpeak-workers.service'; then
systemctl disable picpeak-workers || true
systemctl stop picpeak-workers || true
rm -f /etc/systemd/system/picpeak-workers.service
systemctl daemon-reload
fi
systemctl start picpeak-backend
log_success "Native installation completed!"
}
create_systemd_services() {
log_step "Creating systemd services..."
# Backend service (includes workers - fileWatcher, expirationChecker are started by server.js)
cat > /etc/systemd/system/picpeak-backend.service <<EOF
[Unit]
Description=PicPeak Backend Service
After=network.target
[Service]
Type=simple
User=$NATIVE_APP_USER
WorkingDirectory=$NATIVE_APP_DIR/app/backend
Environment="NODE_ENV=production"
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=10
StandardOutput=append:$NATIVE_APP_DIR/logs/backend.log
StandardError=append:$NATIVE_APP_DIR/logs/backend-error.log
[Install]
WantedBy=multi-user.target
EOF
# Note: Workers (fileWatcher, expirationChecker, emailProcessor) are now started
# automatically by server.js, so a separate workers service is no longer needed.
# Legacy picpeak-workers.service will be cleaned up during installation.
}
setup_caddy() {
log_step "Setting up Caddy web server..."
# Install Caddy
case "$PACKAGE_MANAGER" in
apt)
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update
apt-get install -y caddy
;;
dnf|yum)
$PACKAGE_MANAGER install -y 'dnf-command(copr)'
$PACKAGE_MANAGER copr enable @caddy/caddy -y
$PACKAGE_MANAGER install -y caddy
;;
esac
# Configure Caddy
cat > /etc/caddy/Caddyfile <<EOF
$DOMAIN_NAME {
reverse_proxy localhost:${CUSTOM_PORT:-$DEFAULT_PORT}
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection "1; mode=block"
Referrer-Policy no-referrer-when-downgrade
}
encode gzip
file_server {
hide .git
}
@api path /api/*
handle @api {
reverse_proxy localhost:${CUSTOM_PORT:-$DEFAULT_PORT}
}
@admin path /admin/*
handle @admin {
reverse_proxy localhost:${CUSTOM_PORT:-$DEFAULT_PORT}
}
}
EOF
# Start Caddy
systemctl enable caddy
systemctl restart caddy
}
################################################################################
# Common Functions
################################################################################
install_package() {
local package="$1"
log_step "Installing $package..."
case "$PACKAGE_MANAGER" in
apt)
apt-get update
apt-get install -y "$package"
;;
dnf|yum)
$PACKAGE_MANAGER install -y "$package"
;;
esac
}
configure_email() {
if [[ "$UNATTENDED" == "true" ]]; then
return
fi
print_header "Email Configuration (Optional)"
echo "Configure email notifications for gallery expiration warnings?"
echo "You can also configure this later in the admin panel."
echo
if ! confirm "Set up email now?" "n"; then
log_info "Skipping email configuration"
return
fi
echo -e "\nSelect email provider:"
echo "1) Gmail"
echo "2) SendGrid"
echo "3) Custom SMTP"
echo "4) Skip"
local choice
read -p "Enter choice (1-4): " choice
case $choice in
1)
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
read -p "Gmail address: " SMTP_USER
read -s -p "App password: " SMTP_PASS
echo
;;
2)
SMTP_HOST="smtp.sendgrid.net"
SMTP_PORT="587"
SMTP_USER="apikey"
read -s -p "SendGrid API key: " SMTP_PASS
echo
;;
3)
read -p "SMTP host: " SMTP_HOST
read -p "SMTP port [587]: " SMTP_PORT
SMTP_PORT=${SMTP_PORT:-587}
read -p "SMTP username: " SMTP_USER
read -s -p "SMTP password: " SMTP_PASS
echo
;;
*)
log_info "Email configuration skipped"
;;
esac
}
print_success_message() {
local app_dir port manual_reset_hint
if [[ "$INSTALL_METHOD" == "docker" ]]; then
app_dir="$DOCKER_APP_DIR"
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
manual_reset_hint="cd $(printf %q "$app_dir") && docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
else
app_dir="$NATIVE_APP_DIR"
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
manual_reset_hint="cd $(printf %q "${NATIVE_APP_DIR}/app/backend") && sudo -H -u $(printf %q "$NATIVE_APP_USER") node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
fi
print_header "🎉 Installation Complete!"
echo -e "${GREEN}PicPeak has been successfully installed!${NC}\n"
echo "📍 Access Information:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ -n "$DOMAIN_NAME" ]] && [[ "$ENABLE_SSL" == "true" ]]; then
echo -e "Admin Panel: ${CYAN}https://$DOMAIN_NAME/admin${NC}"
echo -e "Gallery URL: ${CYAN}https://$DOMAIN_NAME${NC}"
else
echo -e "Admin Panel: ${CYAN}http://$(hostname -I | awk '{print $1}'):$port/admin${NC}"
echo -e "Gallery URL: ${CYAN}http://$(hostname -I | awk '{print $1}'):$port${NC}"
fi
echo
echo "🔐 Admin Credentials:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Read from ADMIN_CREDENTIALS.txt when available
local cred_file email_line pass_line admin_email_val admin_pass_val
if [[ "$INSTALL_METHOD" == "docker" ]]; then
cred_file="$app_dir/data/ADMIN_CREDENTIALS.txt"
else
cred_file="$NATIVE_APP_DIR/app/backend/data/ADMIN_CREDENTIALS.txt"
fi
if [[ -f "$cred_file" ]]; then
email_line=$(grep -m1 '^Email:' "$cred_file" || true)
pass_line=$(grep -m1 '^Password:' "$cred_file" || true)
admin_email_val=${email_line#Email: }
admin_pass_val=${pass_line#Password: }
if [[ -n "$admin_email_val" ]]; then
echo -e "Email: ${CYAN}$admin_email_val${NC}"
else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
fi
if [[ -n "$admin_pass_val" ]]; then
echo -e "Password: ${CYAN}$admin_pass_val${NC}"
else
echo -e "Password: ${YELLOW}(see $cred_file)${NC}"
fi
else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run '${manual_reset_hint}')${NC}"
fi
echo
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
echo
echo "📁 Installation Details:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Installation directory: $app_dir"
echo "Installation method: ${INSTALL_METHOD^}"
echo "Log file: $LOG_FILE"
if [[ "$INSTALL_METHOD" == "docker" ]]; then
echo
echo "🐳 Docker Commands:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "View logs: cd $app_dir && docker compose logs -f"
echo "Stop: cd $app_dir && docker compose down"
echo "Start: cd $app_dir && docker compose up -d"
echo "Update: cd $app_dir && git pull && docker compose up -d --build"
else
echo
echo "🔧 Service Commands:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "View logs: sudo journalctl -u picpeak-backend -f"
echo "Stop: sudo systemctl stop picpeak-backend"
echo "Start: sudo systemctl start picpeak-backend"
echo "Status: sudo systemctl status picpeak-backend"
fi
echo
echo "📚 Documentation:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Setup Guide: https://github.com/the-luap/picpeak/blob/main/SIMPLE_SETUP.md"
echo "Full Docs: https://github.com/the-luap/picpeak"
echo
echo -e "${GREEN}✨ Setup complete! Visit the admin panel to start creating galleries.${NC}"
}
################################################################################
# Update Functions
################################################################################
update_installation() {
print_header "Updating PicPeak"
# Prefer explicit native install detection first
native_detected=false
docker_detected=false
# Native detection: app/backend exists OR systemd unit present
if [[ -d "$NATIVE_APP_DIR/app/backend" ]]; then
native_detected=true
elif command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q '^picpeak-backend.service'; then
native_detected=true
fi
# Docker detection: docker app dir or user home picpeak dir exists
if [[ -d "$DOCKER_APP_DIR" ]] || [[ -n "${SUDO_USER:-}" && -d "/home/${SUDO_USER}/picpeak" ]]; then
docker_detected=true
fi
if [[ "$native_detected" == true ]]; then
INSTALL_METHOD="native"
update_native_installation
elif [[ "$docker_detected" == true ]]; then
INSTALL_METHOD="docker"
update_docker_installation
else
die "No existing PicPeak installation found (native dir $NATIVE_APP_DIR/app/backend or docker dir $DOCKER_APP_DIR not present)"
fi
}
update_docker_installation() {
local app_dir="$DOCKER_APP_DIR"
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
log_step "Updating Docker installation..."
cd "$app_dir"
# Backup current configuration
cp .env .env.backup-$(date +%Y%m%d-%H%M%S)
# Pull latest code
git pull
# Rebuild and restart containers
docker compose down
docker compose build --no-cache
docker compose up -d
# Run migrations
docker compose exec -T backend npm run migrate
log_success "Docker installation updated successfully!"
}
update_native_installation() {
log_step "Updating native installation..."
# Stop services
systemctl stop picpeak-backend || true
if systemctl list-unit-files | grep -q '^picpeak-workers.service'; then
systemctl stop picpeak-workers || true
fi
# Backup current configuration
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
cp "$NATIVE_APP_DIR/app/backend/.env" "$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)"
fi
# Pull latest code
cd "$NATIVE_APP_DIR/app"
run_as_user "git config --global --add safe.directory $NATIVE_APP_DIR/app" || true
run_as_user "git remote set-url origin $REPO_URL" || true
run_as_user "git fetch --all --prune"
if ! run_as_user "git checkout -B main origin/main"; then
run_as_user "git checkout main" || true
run_as_user "git reset --hard origin/main"
fi
# Update backend dependencies
cd "$NATIVE_APP_DIR/app/backend"
run_as_user "npm install --production"
# Run migrations
run_as_user "npm run migrate"
# Rebuild frontend (ensure admin UI for native installs)
if [[ -d "$NATIVE_APP_DIR/app/frontend" ]]; then
log_step "Rebuilding frontend..."
cd "$NATIVE_APP_DIR/app/frontend"
run_as_user "npm ci --include=dev" || run_as_user "npm install"
run_as_user "npm run build"
fi
# Ensure env has frontend serving flags
if ! grep -q '^SERVE_FRONTEND=' "$NATIVE_APP_DIR/app/backend/.env"; then
echo "SERVE_FRONTEND=true" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
if ! grep -q '^FRONTEND_DIR=' "$NATIVE_APP_DIR/app/backend/.env"; then
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
ensure_storage_layout "$NATIVE_APP_DIR"
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR/storage"
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
if grep -q '^STORAGE_PATH=' "$NATIVE_APP_DIR/app/backend/.env"; then
sed -i "s|^STORAGE_PATH=.*|STORAGE_PATH=$NATIVE_APP_DIR/storage|" "$NATIVE_APP_DIR/app/backend/.env"
else
echo "STORAGE_PATH=$NATIVE_APP_DIR/storage" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
fi
# Restart services
systemctl restart picpeak-backend
log_success "Native installation updated successfully!"
}
################################################################################
# Uninstall Functions
################################################################################
uninstall_picpeak() {
print_header "Uninstall PicPeak"
log_warn "This will remove PicPeak from your system."
if ! confirm "Are you sure you want to uninstall PicPeak?" "n"; then
log_info "Uninstall cancelled"
exit 0
fi
# Detect existing installation
if [[ -d "$DOCKER_APP_DIR" ]] || [[ -d "/home/${SUDO_USER:-}/picpeak" ]]; then
uninstall_docker
elif [[ -d "$NATIVE_APP_DIR" ]]; then
uninstall_native
else
die "No PicPeak installation found"
fi
log_success "PicPeak has been uninstalled"
}
uninstall_docker() {
local app_dir="$DOCKER_APP_DIR"
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
log_step "Removing Docker installation..."
cd "$app_dir"
docker compose down -v
if confirm "Remove all data and photos?" "n"; then
rm -rf "$app_dir"
else
log_info "Keeping data in $app_dir"
fi
}
uninstall_native() {
log_step "Removing native installation..."
# Stop and disable services
systemctl stop picpeak-backend picpeak-workers
systemctl disable picpeak-backend picpeak-workers
rm -f /etc/systemd/system/picpeak-*.service
systemctl daemon-reload
# Remove Caddy configuration if exists
[[ -f /etc/caddy/Caddyfile ]] && rm -f /etc/caddy/Caddyfile
if confirm "Remove all data and photos?" "n"; then
rm -rf "$NATIVE_APP_DIR"
userdel -r $NATIVE_APP_USER 2>/dev/null || true
else
log_info "Keeping data in $NATIVE_APP_DIR"
fi
}
################################################################################
# Main Script Flow
################################################################################
parse_arguments() {
while [[ $# -gt 0 ]]; do
case "$1" in
--docker)
INSTALL_METHOD="docker"
shift
;;
--native)
INSTALL_METHOD="native"
shift
;;
--unattended)
UNATTENDED=true
shift
;;
--domain)
DOMAIN_NAME="$2"
shift 2
;;
--email)
ADMIN_EMAIL="$2"
shift 2
;;
--smtp-host)
SMTP_HOST="$2"
shift 2
;;
--smtp-port)
SMTP_PORT="$2"
shift 2
;;
--smtp-user)
SMTP_USER="$2"
shift 2
;;
--smtp-pass)
SMTP_PASS="$2"
shift 2
;;
--force-admin-password-reset)
FORCE_ADMIN_PASSWORD_RESET=true
shift
;;
--enable-ssl)
ENABLE_SSL=true
shift
;;
--port)
CUSTOM_PORT="$2"
shift 2
;;
--update)
UPDATE_MODE=true
shift
;;
--uninstall)
UNINSTALL_MODE=true
shift
;;
--help)
show_help
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
}
show_help() {
cat <<EOF
PicPeak Unified Setup Script v$SCRIPT_VERSION
Usage: $0 [OPTIONS]
Installation Methods:
--docker Use Docker installation (default if unattended)
--native Use native installation (systemd)
Options:
--unattended Run without prompts
--domain DOMAIN Set domain name for HTTPS
--email EMAIL Admin email address
--smtp-host HOST SMTP server hostname
--smtp-port PORT SMTP server port
--smtp-user USER SMTP username
--smtp-pass PASS SMTP password
--force-admin-password-reset Regenerate admin credentials after setup
--enable-ssl Enable HTTPS with Let's Encrypt
--port PORT Custom port (native only)
--update Update existing installation
--uninstall Remove PicPeak installation
--help Show this help message
Examples:
# Interactive installation
sudo $0
# Docker installation with domain
sudo $0 --docker --domain photos.example.com --enable-ssl
# Native installation for Raspberry Pi
sudo $0 --native --port 8080
# Fully automated Docker setup
sudo $0 --docker --unattended --domain photos.example.com \\
--email admin@example.com \\
--smtp-host smtp.gmail.com --smtp-port 587 \\
--smtp-user user@gmail.com --smtp-pass app-password \\
--enable-ssl
EOF
}
main() {
# Check if running as root
if [[ $EUID -ne 0 ]]; then
die "This script must be run as root (use sudo)"
fi
# Parse command line arguments
parse_arguments "$@"
# Handle special modes
if [[ "$UPDATE_MODE" == "true" ]]; then
update_installation
exit 0
fi
if [[ "$UNINSTALL_MODE" == "true" ]]; then
uninstall_picpeak
exit 0
fi
# Start installation
print_banner
# Detect OS
detect_os
# Select installation method
select_install_method
# Check system requirements
check_system_requirements
# Prompt for admin email (design choice: always ask unless provided)
prompt_admin_email
# Configure email (optional)
configure_email
# Perform installation
if [[ "$INSTALL_METHOD" == "docker" ]]; then
setup_docker_installation
else
setup_native_installation
fi
# Show success message
print_success_message
}
# Run main function
main "$@"