#!/bin/bash # =============================================== # Ente Cloudron App - Main Startup Script # =============================================== # Enable strict error handling set -e # Initialize logging LOG_FILE="/app/data/logs/ente.log" mkdir -p /app/data/logs # Log function for consistent output log() { local level="$1" local message="$2" local timestamp=$(date +"%Y-%m-%d %H:%M:%S") echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE" } log "INFO" "Starting Ente Cloudron app" log "INFO" "Running in Cloudron environment with domain: ${CLOUDRON_APP_DOMAIN}" # Prevent infinite loops through startup flag if [ -f "/app/data/startup_in_progress" ]; then if [ "$(find /app/data/startup_in_progress -mmin +2)" ]; then log "WARN" "Found old startup flag, removing and continuing" rm -f "/app/data/startup_in_progress" else log "ERROR" "Startup script is already running (started less than 2 minutes ago)" log "ERROR" "Possible infinite loop detected. Exiting." exit 1 fi fi # Create the flag file to indicate we're starting up echo "$(date): Starting up" > /app/data/startup_in_progress trap 'rm -f /app/data/startup_in_progress' EXIT # =============================================== # Initialize directories # =============================================== log "INFO" "Creating necessary directories" # App directories mkdir -p /app/data/ente/server mkdir -p /app/data/ente/web mkdir -p /app/data/tmp # =============================================== # Repository setup # =============================================== ENTE_REPO_DIR="/app/data/ente/repository" log "INFO" "Setting up Ente repository at ${ENTE_REPO_DIR}" if [ ! -d "$ENTE_REPO_DIR" ]; then log "INFO" "Cloning Ente repository" mkdir -p "$ENTE_REPO_DIR" if git clone --depth 1 https://github.com/ente-io/ente "$ENTE_REPO_DIR"; then log "INFO" "Repository cloned successfully" else log "ERROR" "Failed to clone repository" fi else log "INFO" "Repository already exists, pulling latest changes" cd "$ENTE_REPO_DIR" if git pull; then log "INFO" "Repository updated successfully" else log "WARN" "Failed to update repository, using existing version" fi fi # =============================================== # Configuration # =============================================== log "INFO" "Setting up configuration" # S3 configuration - HARDCODED VALUES S3_ACCESS_KEY="QZ5M3VMBUHDTIFDFCD8E" S3_SECRET_KEY="pz1eHYjU1NwAbbruedc7swzCuszd57p1rGSFVzjv" S3_ENDPOINT="https://s3.eu-central-2.wasabisys.com" S3_REGION="eu-central-2" S3_BUCKET="ente-due-ren" log "INFO" "Using hardcoded S3 configuration" log "INFO" "S3 Endpoint: $S3_ENDPOINT" log "INFO" "S3 Region: $S3_REGION" log "INFO" "S3 Bucket: $S3_BUCKET" # Museum server configuration - create configurations directory structure MUSEUM_CONFIG_DIR="/app/data/ente/server/configurations" MUSEUM_CONFIG="$MUSEUM_CONFIG_DIR/local.yaml" mkdir -p "$MUSEUM_CONFIG_DIR" if [ ! -f "$MUSEUM_CONFIG" ]; then log "INFO" "Creating Museum server configuration" cat > "$MUSEUM_CONFIG" << EOF # Museum server configuration # Server settings port: 8080 host: 0.0.0.0 log_level: info # Database configuration db: host: ${CLOUDRON_POSTGRESQL_HOST} port: ${CLOUDRON_POSTGRESQL_PORT} name: ${CLOUDRON_POSTGRESQL_DATABASE} user: ${CLOUDRON_POSTGRESQL_USERNAME} password: ${CLOUDRON_POSTGRESQL_PASSWORD} sslmode: disable # CORS settings cors: allow_origins: - "*" # S3 storage configuration s3: endpoint: "${S3_ENDPOINT}" region: "${S3_REGION}" access_key: "${S3_ACCESS_KEY}" secret_key: "${S3_SECRET_KEY}" bucket: "${S3_BUCKET}" # For Wasabi, we need path style URLs use_path_style_urls: true are_local_buckets: false # Email settings email: enabled: true host: "${CLOUDRON_MAIL_SMTP_SERVER:-localhost}" port: ${CLOUDRON_MAIL_SMTP_PORT:-25} username: "${CLOUDRON_MAIL_SMTP_USERNAME:-}" password: "${CLOUDRON_MAIL_SMTP_PASSWORD:-}" from: "${CLOUDRON_MAIL_FROM:-no-reply@${CLOUDRON_APP_FQDN:-localhost}}" # WebAuthn configuration for passkey support webauthn: rpid: "${CLOUDRON_APP_FQDN:-localhost}" rporigins: - "https://${CLOUDRON_APP_FQDN:-localhost}" EOF chmod 600 "$MUSEUM_CONFIG" log "INFO" "Created Museum configuration at ${MUSEUM_CONFIG}" else log "INFO" "Museum configuration already exists" fi # =============================================== # Database check # =============================================== log "INFO" "Testing PostgreSQL connectivity" if PGPASSWORD="$CLOUDRON_POSTGRESQL_PASSWORD" psql -h "$CLOUDRON_POSTGRESQL_HOST" \ -p "$CLOUDRON_POSTGRESQL_PORT" -U "$CLOUDRON_POSTGRESQL_USERNAME" \ -d "$CLOUDRON_POSTGRESQL_DATABASE" -c "SELECT 1;" > /dev/null 2>&1; then log "INFO" "PostgreSQL connection successful" else log "ERROR" "Failed to connect to PostgreSQL" log "ERROR" "Connection details:" log "ERROR" " Host: $CLOUDRON_POSTGRESQL_HOST" log "ERROR" " Port: $CLOUDRON_POSTGRESQL_PORT" log "ERROR" " User: $CLOUDRON_POSTGRESQL_USERNAME" log "ERROR" " Database: $CLOUDRON_POSTGRESQL_DATABASE" exit 1 fi # =============================================== # Museum Server Binary Setup # =============================================== MUSEUM_BIN="/app/data/ente/server/museum" MUSEUM_LOG="/app/data/logs/museum.log" USE_PLACEHOLDER=false log "INFO" "Setting up Museum server binary" # Copy Museum binary from build location to data directory MUSEUM_BUILD_BIN="/app/museum-bin/museum" log "INFO" "Checking for pre-built Museum binary at: $MUSEUM_BUILD_BIN" if [ -f "$MUSEUM_BUILD_BIN" ]; then log "INFO" "Found pre-built Museum binary, copying to data directory" cp "$MUSEUM_BUILD_BIN" "$MUSEUM_BIN" chmod +x "$MUSEUM_BIN" log "INFO" "Copied Museum binary to $MUSEUM_BIN" else log "WARN" "Pre-built Museum binary not found at $MUSEUM_BUILD_BIN" fi # Copy migration files to Museum working directory MUSEUM_MIGRATIONS_DIR="/app/data/ente/server/migrations" REPO_MIGRATIONS_DIR="/app/data/ente/repository/server/migrations" if [ ! -d "$MUSEUM_MIGRATIONS_DIR" ] && [ -d "$REPO_MIGRATIONS_DIR" ]; then log "INFO" "Copying database migration files" cp -r "$REPO_MIGRATIONS_DIR" "$MUSEUM_MIGRATIONS_DIR" log "INFO" "Copied migration files to $MUSEUM_MIGRATIONS_DIR" else log "INFO" "Migration files already exist or source not available" fi # Copy web templates to Museum working directory MUSEUM_WEB_TEMPLATES_DIR="/app/data/ente/server/web-templates" REPO_WEB_TEMPLATES_DIR="/app/data/ente/repository/server/web-templates" if [ ! -d "$MUSEUM_WEB_TEMPLATES_DIR" ] && [ -d "$REPO_WEB_TEMPLATES_DIR" ]; then log "INFO" "Copying web templates" cp -r "$REPO_WEB_TEMPLATES_DIR" "$MUSEUM_WEB_TEMPLATES_DIR" log "INFO" "Copied web templates to $MUSEUM_WEB_TEMPLATES_DIR" else log "INFO" "Web templates already exist or source not available" fi # Check if Museum binary exists and is valid log "INFO" "Checking for Museum binary at: $MUSEUM_BIN" if [ -f "$MUSEUM_BIN" ]; then log "INFO" "Museum binary file exists" if [ -x "$MUSEUM_BIN" ]; then log "INFO" "Museum binary is executable" # Since Museum's --help and --version commands trigger full startup (including DB migration), # we'll trust that an existing executable binary should work log "INFO" "Museum binary is ready to use" else log "INFO" "Museum binary exists but is not executable, fixing permissions" chmod +x "$MUSEUM_BIN" if [ -x "$MUSEUM_BIN" ]; then log "INFO" "Fixed permissions, Museum binary is ready to use" else log "WARN" "Failed to fix permissions, using placeholder" USE_PLACEHOLDER=true fi fi else log "WARN" "Museum binary file not found at $MUSEUM_BIN" log "INFO" "Checking directory contents: $(ls -la $(dirname $MUSEUM_BIN) 2>/dev/null || echo 'Directory not found')" USE_PLACEHOLDER=true fi # =============================================== # Web Application Setup # =============================================== log "INFO" "Web applications are pre-built and available in /app/web/" # Fix API endpoint configuration in built JavaScript files log "INFO" "Updating API endpoint configuration in web apps" ACTUAL_ENDPOINT="https://${CLOUDRON_APP_DOMAIN}/api" log "INFO" "Setting API endpoint to: $ACTUAL_ENDPOINT" # Replace placeholder endpoint in all JavaScript files for webapp in photos accounts auth cast; do WEB_DIR="/app/web/${webapp}" if [ -d "$WEB_DIR" ]; then log "INFO" "Processing ${webapp} app" # Find and replace the placeholder endpoint in all JS files find "$WEB_DIR" -name "*.js" -type f -exec sed -i "s|https://example.com/api|${ACTUAL_ENDPOINT}|g" {} \; log "INFO" "Updated endpoint configuration for ${webapp}" else log "WARN" "Web directory not found for ${webapp}" fi done # =============================================== # Node.js Placeholder Server # =============================================== create_nodejs_placeholder() { log "INFO" "Creating Node.js placeholder server" # Create server script cat > "/app/data/ente/server/placeholder.js" << 'EOF' const http = require('http'); const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); const PORT = 8080; const LOG_FILE = '/app/data/logs/museum.log'; const DB_SCHEMA_FILE = '/app/data/ente/server/schema.sql'; // Log function function log(message) { const timestamp = new Date().toISOString(); const logMessage = `${timestamp} - ${message}\n`; console.log(logMessage); try { fs.appendFileSync(LOG_FILE, logMessage); } catch (err) { console.error(`Error writing to log: ${err.message}`); } } log('Starting Ente placeholder server...'); // Try to initialize the database schema function initializeDatabase() { try { // Create a basic schema file if it doesn't exist if (!fs.existsSync(DB_SCHEMA_FILE)) { log('Creating basic database schema file'); const basicSchema = ` -- Basic schema for Ente Museum server CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS files ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), filename VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL, mime_type VARCHAR(100), size BIGINT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); `; fs.writeFileSync(DB_SCHEMA_FILE, basicSchema); } // Initialize database const dbUser = process.env.CLOUDRON_POSTGRESQL_USERNAME; const dbPassword = process.env.CLOUDRON_POSTGRESQL_PASSWORD; const dbHost = process.env.CLOUDRON_POSTGRESQL_HOST; const dbPort = process.env.CLOUDRON_POSTGRESQL_PORT; const dbName = process.env.CLOUDRON_POSTGRESQL_DATABASE; if (dbUser && dbPassword && dbHost && dbPort && dbName) { log(`Initializing database ${dbName} on ${dbHost}:${dbPort}`); const command = `PGPASSWORD="${dbPassword}" psql -h "${dbHost}" -p "${dbPort}" -U "${dbUser}" -d "${dbName}" -f "${DB_SCHEMA_FILE}"`; execSync(command, { stdio: 'inherit' }); log('Database initialized successfully'); return true; } else { log('Database environment variables not set, skipping initialization'); return false; } } catch (err) { log(`Error initializing database: ${err.message}`); return false; } } // Try to initialize database initializeDatabase(); // API response handlers const apiHandlers = { // Health check endpoint '/health': (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'OK', server: 'Ente Placeholder', version: '1.0.0' })); log('Health check request - responded with status OK'); }, // User verification endpoint '/api/users/verify': (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); log('User verify request - responding with success'); res.end(JSON.stringify({ success: true, isValidEmail: true, isAvailable: true, isVerified: true, canCreateAccount: true })); }, // User login endpoint '/api/users/login': (req, res) => { if (req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { log('Login request received'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, token: 'placeholder-jwt-token-' + Date.now(), user: { id: 1, email: 'placeholder@example.com', name: 'Placeholder User' } })); }); } else { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, message: 'Method not allowed' })); } }, // User signup endpoint '/api/users/signup': (req, res) => { if (req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { log('Signup request received'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, token: 'placeholder-jwt-token-' + Date.now(), user: { id: 1, email: 'placeholder@example.com', name: 'New User' } })); }); } else { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, message: 'Method not allowed' })); } }, // Files endpoint '/api/files': (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); log('Files request - responding with empty list'); res.end(JSON.stringify({ success: true, files: [] })); }, // Collections endpoint '/api/collections': (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); log('Collections request - responding with empty list'); res.end(JSON.stringify({ success: true, collections: [] })); }, // Default API handler 'default': (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); log(`API request to ${req.url} - responding with generic success`); res.end(JSON.stringify({ success: true, message: 'Placeholder API response', path: req.url })); } }; // Create server const server = http.createServer((req, res) => { log(`Request received: ${req.method} ${req.url}`); // Set CORS headers for all responses res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE'); res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type,Authorization'); // Handle OPTIONS request (for CORS preflight) if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // Handle health check endpoint if (req.url === '/health' || req.url === '/api/health') { apiHandlers['/health'](req, res); return; } // Handle paths that exactly match defined endpoints if (apiHandlers[req.url]) { apiHandlers[req.url](req, res); return; } // Route based on URL pattern if (req.url.startsWith('/api/')) { const handler = apiHandlers['default']; handler(req, res); return; } // Default response for any other endpoint res.writeHead(200, { 'Content-Type': 'application/json' }); log(`Unknown request to ${req.url} - responding with default message`); res.end(JSON.stringify({ message: 'Ente Placeholder Server', path: req.url, server: 'Node.js Placeholder' })); }); // Start server try { server.listen(PORT, '0.0.0.0', () => { log(`Ente placeholder server running on port ${PORT}`); log(`Server is listening at http://0.0.0.0:${PORT}`); }); } catch (err) { log(`Failed to start server: ${err.message}`); process.exit(1); } // Handle errors server.on('error', (error) => { log(`Server error: ${error.message}`); if (error.code === 'EADDRINUSE') { log('Address already in use, retrying in 5 seconds...'); setTimeout(() => { server.close(); server.listen(PORT, '0.0.0.0'); }, 5000); } }); log('Ente placeholder server initialization complete'); EOF # Start the Node.js placeholder server log "INFO" "Starting Node.js placeholder server" cd /app/data/ente/server node placeholder.js > "$MUSEUM_LOG" 2>&1 & PLACEHOLDER_PID=$! log "INFO" "Started Node.js server with PID: $PLACEHOLDER_PID" # Wait for the server to start MAX_ATTEMPTS=30 ATTEMPT=0 SUCCESS=false while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do if curl -s http://localhost:8080/health > /dev/null 2>&1; then log "INFO" "Node.js placeholder server started successfully" SUCCESS=true break fi ATTEMPT=$((ATTEMPT+1)) log "INFO" "Waiting for server to start (attempt $ATTEMPT/$MAX_ATTEMPTS)" sleep 1 done if [ "$SUCCESS" = false ]; then log "ERROR" "Node.js placeholder server failed to start" log "ERROR" "Last 20 lines of log:" tail -n 20 "$MUSEUM_LOG" | while read -r line; do log "ERROR" " $line" done return 1 fi return 0 } # =============================================== # Start the appropriate server # =============================================== log "INFO" "Starting server" if [ "$USE_PLACEHOLDER" = true ]; then log "INFO" "Using Node.js placeholder server" create_nodejs_placeholder else log "INFO" "Starting actual Museum server" cd /app/data/ente/server "$MUSEUM_BIN" > "$MUSEUM_LOG" 2>&1 & MUSEUM_PID=$! log "INFO" "Started Museum server with PID: $MUSEUM_PID" # Wait for the server to start MAX_ATTEMPTS=30 ATTEMPT=0 SUCCESS=false while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do if curl -s http://localhost:8080/health > /dev/null 2>&1; then log "INFO" "Museum server started successfully" SUCCESS=true break fi ATTEMPT=$((ATTEMPT+1)) log "INFO" "Waiting for Museum server to start (attempt $ATTEMPT/$MAX_ATTEMPTS)" sleep 1 done if [ "$SUCCESS" = false ]; then log "ERROR" "Museum server failed to start within $MAX_ATTEMPTS seconds" log "ERROR" "Last 20 lines of museum.log:" tail -n 20 "$MUSEUM_LOG" | while read -r line; do log "ERROR" " $line" done log "WARN" "Falling back to Node.js placeholder server" create_nodejs_placeholder fi fi # =============================================== # Setup Caddy web server # =============================================== log "INFO" "Setting up Caddy web server" # Create Caddy configuration # Note: Caddy listens on port 3080 (Cloudron's httpPort) and proxies API requests to Museum on port 8080 CADDY_CONFIG="/app/data/Caddyfile" cat > "$CADDY_CONFIG" << EOF :3080 { log { output file /app/data/logs/caddy.log level INFO } # Enable compression encode gzip # CORS preflight handling @options { method OPTIONS } handle @options { header { Access-Control-Allow-Origin "*" Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" Access-Control-Allow-Headers "*" Access-Control-Max-Age "3600" } respond 204 } # API endpoints with CORS handle /api/* { reverse_proxy localhost:8080 { header_up Host {http.request.host} header_up X-Real-IP {http.request.remote} header_up X-Forwarded-For {http.request.remote} header_up X-Forwarded-Proto {http.request.scheme} } header { Access-Control-Allow-Origin "*" Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" Access-Control-Allow-Headers "*" Access-Control-Allow-Credentials "true" } } # Public albums endpoint handle /public/* { reverse_proxy localhost:8080 header { Access-Control-Allow-Origin "*" } } # Health check endpoint handle /health { reverse_proxy localhost:8080 } # Static files for Next.js assets from all apps handle /_next/* { @photosNext path /_next/* handle @photosNext { root * /app/web/photos file_server } header { Cache-Control "public, max-age=31536000" Access-Control-Allow-Origin "*" } } # Photos app handle_path /photos/* { root * /app/web/photos try_files {path} /index.html file_server } # Accounts app handle_path /accounts/* { root * /app/web/accounts try_files {path} /index.html file_server } # Auth app handle_path /auth/* { root * /app/web/auth try_files {path} /index.html file_server } # Cast app handle_path /cast/* { root * /app/web/cast try_files {path} /index.html file_server } # Root redirect handle / { redir /photos/ permanent } } EOF log "INFO" "Validating Caddy configuration" if caddy validate --config "$CADDY_CONFIG" 2>&1 | tee -a "$LOG_FILE"; then log "INFO" "Caddy configuration is valid" else log "ERROR" "Caddy configuration validation failed!" log "ERROR" "Caddyfile contents:" cat "$CADDY_CONFIG" | while read -r line; do log "ERROR" " $line" done fi log "INFO" "Starting Caddy web server" caddy run --config "$CADDY_CONFIG" > /app/data/logs/caddy.log 2>&1 & CADDY_PID=$! log "INFO" "Caddy web server started with PID: $CADDY_PID" # Wait a moment to see if Caddy stays running sleep 2 if ps -p $CADDY_PID > /dev/null; then log "INFO" "Caddy is still running after 2 seconds" else log "ERROR" "Caddy has crashed! Last 20 lines of Caddy log:" tail -n 20 /app/data/logs/caddy.log | while read -r line; do log "ERROR" " $line" done fi # =============================================== # Finalization and monitoring # =============================================== log "INFO" "Setup complete" # Create startup instructions cat > /app/data/SETUP-INSTRUCTIONS.md << EOF # Ente Cloudron App - Setup Instructions ## Configuration 1. **S3 Storage**: Edit the configuration file at \`/app/data/s3.env\` with your S3-compatible storage credentials. 2. **Museum Server**: The server configuration is at \`/app/data/ente/server/museum.yaml\` if you need to customize settings. ## Troubleshooting - **Logs**: Check the logs at \`/app/data/logs/\` for any issues. - **Restart**: If you change configuration, restart the app to apply changes. ## Web Applications The following web applications are available: - Photos: https://${CLOUDRON_APP_FQDN}/photos/ - Accounts: https://${CLOUDRON_APP_FQDN}/accounts/ - Auth: https://${CLOUDRON_APP_FQDN}/auth/ - Cast: https://${CLOUDRON_APP_FQDN}/cast/ ## Support For more information, visit the [Ente GitHub repository](https://github.com/ente-io/ente). EOF # Remove startup flag rm -f /app/data/startup_in_progress # Verify running services log "INFO" "Verifying running services" if ps aux | grep -E "museum|placeholder" | grep -v grep > /dev/null; then log "INFO" "Server is running" else log "ERROR" "No server is running!" fi if ps aux | grep caddy | grep -v grep > /dev/null; then log "INFO" "Caddy server is running" else log "ERROR" "Caddy server is not running!" fi log "INFO" "Ente Cloudron app startup complete" # Keep the script running to prevent container exit exec tail -f "$MUSEUM_LOG"