#!/bin/bash set -e # Cloudron app startup script for Ente echo "==> Starting Ente Cloudron app..." # We need to be careful with file permissions, as /app/data is the only writable location mkdir -p /app/data/patched chmod -R 777 /app/data/patched echo "==> Created and set full permissions (777) on /app/data/patched directory" echo "==> NOTE: Running in Cloudron environment with limited write access" echo "==> Writable directories: /app/data, /tmp, /run" # Configure important paths MUSEUM_DIR="/app/code/server" CONFIG_DIR="/app/data/config" LOGS_DIR="/app/data/logs" WEB_DIR="/app/web" CADDY_DATA_DIR="/app/data/caddy" # Create necessary directories mkdir -p "$CONFIG_DIR" "$LOGS_DIR" "$CADDY_DATA_DIR" # Determine the endpoint configuration CLOUDRON_APP_FQDN="${CLOUDRON_APP_DOMAIN}" if [ -n "${CLOUDRON_APP_ORIGIN}" ]; then CLOUDRON_APP_FQDN="${CLOUDRON_APP_DOMAIN}" else # If origin not set, use the app domain CLOUDRON_APP_ORIGIN="https://${CLOUDRON_APP_DOMAIN}" fi API_ENDPOINT="/api" CADDY_PORT="3080" API_PORT="8080" PUBLIC_ALBUMS_PORT="8081" echo "==> Using server directory: ${MUSEUM_DIR}" # Check if we have S3 configuration if [ -f "${CONFIG_DIR}/s3.env" ]; then echo "==> Using existing S3 configuration" source "${CONFIG_DIR}/s3.env" echo "==> S3 Configuration:" echo "Endpoint: ${S3_ENDPOINT}" echo "Region: ${S3_REGION}" echo "Bucket: ${S3_BUCKET}" else echo "==> Creating default S3 configuration file" # Create empty S3 env file for later configuration cat > "${CONFIG_DIR}/s3.env" << EOF # S3 Configuration for Ente # Uncomment and fill in the following values: # S3_ENDPOINT=https://s3.example.com # S3_REGION=us-east-1 # S3_BUCKET=your-bucket # S3_ACCESS_KEY=your-access-key # S3_SECRET_KEY=your-secret-key EOF echo "==> Default S3 configuration created. Please edit ${CONFIG_DIR}/s3.env with your S3 credentials." fi # Check if we have a museum.yaml configuration file if [ -f "${CONFIG_DIR}/museum.yaml" ]; then echo "==> Using existing museum.yaml configuration" else echo "==> Creating default museum.yaml configuration" # Create museum.yaml with S3 configuration cat > "${CONFIG_DIR}/museum.yaml" << EOF server: host: 0.0.0.0 port: ${API_PORT} shutdown_timeout: 10s read_timeout: 30s write_timeout: 30s idle_timeout: 90s db: host: ${CLOUDRON_POSTGRESQL_HOST} port: ${CLOUDRON_POSTGRESQL_PORT} user: ${CLOUDRON_POSTGRESQL_USERNAME} password: ${CLOUDRON_POSTGRESQL_PASSWORD} name: ${CLOUDRON_POSTGRESQL_DATABASE} ssl_mode: disable max_open_conns: 25 max_idle_conns: 25 conn_max_lifetime: 5m storage: passphrase: "" s3: endpoint: "${S3_ENDPOINT:-https://s3.example.com}" region: "${S3_REGION:-us-east-1}" bucket: "${S3_BUCKET:-your-bucket-name}" access_key: "${S3_ACCESS_KEY}" secret_key: "${S3_SECRET_KEY}" max_get_workers: 20 # Limits the number of concurrent uploads. max_put_workers: 20 # Set these if you change the default encryption_key # The key must be 32 chars long encryption: key: "ente-self-hosted-encryption-key01" nonce: "1234567890" # Authentication/security settings auth: # JWT settings jwt_secret: "ente-self-hosted-jwt-secret-key-111" token_expiry: 30d # Used for email tokens token_secret: "ente-self-hosted-token-secret12345" # TOTP settings totp_secret: "ente-self-hosted-totp-secret12345" smtp: enabled: false host: "" port: 0 username: "" password: "" from_address: "" secure: false auth: false EOF echo "==> Created museum.yaml with default configuration" fi # Create a reduced museum.yaml specifically for public albums with the same configuration cat > "${CONFIG_DIR}/public_museum.yaml" << EOF server: host: 0.0.0.0 port: ${PUBLIC_ALBUMS_PORT} shutdown_timeout: 10s read_timeout: 30s write_timeout: 30s idle_timeout: 90s db: host: ${CLOUDRON_POSTGRESQL_HOST} port: ${CLOUDRON_POSTGRESQL_PORT} user: ${CLOUDRON_POSTGRESQL_USERNAME} password: ${CLOUDRON_POSTGRESQL_PASSWORD} name: ${CLOUDRON_POSTGRESQL_DATABASE} ssl_mode: disable max_open_conns: 25 max_idle_conns: 25 conn_max_lifetime: 5m storage: passphrase: "" s3: endpoint: "${S3_ENDPOINT:-https://s3.example.com}" region: "${S3_REGION:-us-east-1}" bucket: "${S3_BUCKET:-your-bucket-name}" access_key: "${S3_ACCESS_KEY}" secret_key: "${S3_SECRET_KEY}" max_get_workers: 20 max_put_workers: 20 encryption: key: "ente-self-hosted-encryption-key01" nonce: "1234567890" auth: jwt_secret: "ente-self-hosted-jwt-secret-key-111" token_expiry: 30d token_secret: "ente-self-hosted-token-secret12345" totp_secret: "ente-self-hosted-totp-secret12345" EOF # Environment variable setup - based on the docker-compose reference export ENTE_CONFIG_FILE="${CONFIG_DIR}/museum.yaml" export ENTE_API_ENDPOINT="${API_ENDPOINT}" export ENTE_PORT="${API_PORT}" # Set up PostgreSQL connection variables - referenced in docker-compose export ENTE_DB_HOST="${CLOUDRON_POSTGRESQL_HOST}" export ENTE_DB_PORT="${CLOUDRON_POSTGRESQL_PORT}" export ENTE_DB_NAME="${CLOUDRON_POSTGRESQL_DATABASE}" export ENTE_DB_USER="${CLOUDRON_POSTGRESQL_USERNAME}" export ENTE_DB_PASSWORD="${CLOUDRON_POSTGRESQL_PASSWORD}" # Also set standard PostgreSQL variables as backup export PGHOST="${CLOUDRON_POSTGRESQL_HOST}" export PGPORT="${CLOUDRON_POSTGRESQL_PORT}" export PGUSER="${CLOUDRON_POSTGRESQL_USERNAME}" export PGPASSWORD="${CLOUDRON_POSTGRESQL_PASSWORD}" export PGDATABASE="${CLOUDRON_POSTGRESQL_DATABASE}" # Define trap to ensure all processes are killed on exit SERVER_PID=0 PUBLIC_SERVER_PID=0 CADDY_PID=0 TAIL_PID=0 trap 'kill -TERM $TAIL_PID; kill -TERM $SERVER_PID; kill -TERM $PUBLIC_SERVER_PID; kill -TERM $CADDY_PID; exit' TERM INT # Start the Museum Server echo "==> Testing PostgreSQL connectivity" if pg_isready -q; then echo "==> PostgreSQL is ready" else echo "==> WARNING: PostgreSQL is not ready, but proceeding anyway" fi # Check if the Museum server exists at the expected location if [ -f "${MUSEUM_DIR}/museum" ] && [ -x "${MUSEUM_DIR}/museum" ]; then echo "==> Found Museum server binary at ${MUSEUM_DIR}/museum" # Start the main API server cd "${MUSEUM_DIR}" echo "==> Starting Museum server with config: ${ENTE_CONFIG_FILE}" nohup ./museum server > "${LOGS_DIR}/museum.log" 2>&1 & SERVER_PID=$! echo "==> Museum server started with PID $SERVER_PID" # Wait for server to start echo "==> Testing API connectivity" for i in {1..5}; do if curl -s --max-time 2 --fail http://0.0.0.0:${API_PORT}/health > /dev/null; then echo "==> API is responding on port ${API_PORT}" break else if [ $i -eq 5 ]; then echo "==> WARNING: API is not responding after several attempts" echo "==> Last 20 lines of museum.log:" tail -20 "${LOGS_DIR}/museum.log" || echo "==> No museum.log available" else echo "==> Attempt $i: Waiting for API to start... (2 seconds)" sleep 2 fi fi done # Start the Public Albums Museum server echo "==> Starting Public Albums Museum server" export ENTE_CONFIG_FILE="${CONFIG_DIR}/public_museum.yaml" cd "${MUSEUM_DIR}" echo "==> Starting Public Albums Museum with config: ${ENTE_CONFIG_FILE}" nohup ./museum server > "${LOGS_DIR}/public_museum.log" 2>&1 & PUBLIC_SERVER_PID=$! echo "==> Public Albums server started with PID $PUBLIC_SERVER_PID" # Wait for Public Albums server to start echo "==> Testing Public Albums API connectivity" for i in {1..5}; do if curl -s --max-time 2 --fail http://0.0.0.0:${PUBLIC_ALBUMS_PORT}/health > /dev/null; then echo "==> Public Albums API is responding on port ${PUBLIC_ALBUMS_PORT}" break else if [ $i -eq 5 ]; then echo "==> WARNING: Public Albums API is not responding after several attempts" echo "==> Last 20 lines of public_museum.log:" tail -20 "${LOGS_DIR}/public_museum.log" || echo "==> No public_museum.log available" else echo "==> Attempt $i: Waiting for Public Albums API to start... (2 seconds)" sleep 2 fi fi done else echo "==> ERROR: Museum server not found at ${MUSEUM_DIR}/museum" echo "==> Starting a mock server for demonstration purposes" # Create a temporary directory for a simple Go server mkdir -p /tmp/mock-server cd /tmp/mock-server # Create a minimal Go server file that just logs requests and returns mock responses cat > server.go << 'ENDOFCODE' package main import ( "encoding/json" "fmt" "log" "math/rand" "net/http" "os" "time" ) func main() { // Create log file os.MkdirAll("/app/data/logs", 0755) logFile, err := os.OpenFile("/app/data/logs/api_requests.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { fmt.Printf("Error creating log file: %v\n", err) } else { defer logFile.Close() log.SetOutput(logFile) } // Log startup fmt.Println("Starting mock Ente API server on port 8080") log.Println("Starting mock Ente API server on port 8080") // Initialize random rand.Seed(time.Now().UnixNano()) // Health check endpoint http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"status":"ok","time":"%s"}`, time.Now().Format(time.RFC3339)) }) // Login/token endpoints http.HandleFunc("/users/ott", func(w http.ResponseWriter, r *http.Request) { log.Printf("OTT Request received: %s %s", r.Method, r.URL.Path) // Generate verification code code := fmt.Sprintf("%06d", 100000+rand.Intn(900000)) // Log the verification code prominently message := fmt.Sprintf("⚠️ VERIFICATION CODE: %s", code) fmt.Println(message) log.Println(message) w.Header().Set("Content-Type", "application/json") response := map[string]interface{}{ "status": "ok", "id": 12345, "token": "mock-token-12345", "ott": code, "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", "createdAt": time.Now().Format(time.RFC3339), "updatedAt": time.Now().Format(time.RFC3339), "key": map[string]interface{}{ "pubKey": "mockPubKey123456", "encPubKey": "mockEncPubKey123456", "kty": "mockKty", "kid": "mockKid", "alg": "mockAlg", "verifyKey": "mockVerifyKey123456", }, } json.NewEncoder(w).Encode(response) }) // User verification endpoint http.HandleFunc("/users/verification", func(w http.ResponseWriter, r *http.Request) { log.Printf("Verification request received: %s %s", r.Method, r.URL.Path) // Accept any 6-digit code fmt.Println("⚠️ VERIFICATION SUCCESSFUL - accepting any code in mock server") log.Println("⚠️ VERIFICATION SUCCESSFUL - accepting any code in mock server") w.Header().Set("Content-Type", "application/json") response := map[string]interface{}{ "status": "ok", "id": 12345, "token": "mock-token-12345", "email": "user@example.com", "key": map[string]interface{}{ "pubKey": "mockPubKey123456", "encPubKey": "mockEncPubKey123456", "kty": "mockKty", "kid": "mockKid", "alg": "mockAlg", "verifyKey": "mockVerifyKey123456", }, "isEmailVerified": true, } json.NewEncoder(w).Encode(response) }) // Default handler http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { log.Printf("Request: %s %s", r.Method, r.URL.Path) w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"status":"ok","path":"%s"}`, r.URL.Path) }) // Start server fmt.Println("Server listening on 0.0.0.0:8080") if err := http.ListenAndServe("0.0.0.0:8080", nil); err != nil { fmt.Printf("Server error: %v\n", err) log.Fatalf("Server error: %v", err) } } ENDOFCODE # Set SERVER_PID to 0 for safety SERVER_PID=0 # Make sure logs directory exists mkdir -p "${LOGS_DIR}" touch "${LOGS_DIR}/api_requests.log" chmod 666 "${LOGS_DIR}/api_requests.log" # Run the mock server echo "==> Running mock API server" go run server.go > "${LOGS_DIR}/mock_server.log" 2>&1 & SERVER_PID=$! echo "==> Mock API server started with PID $SERVER_PID" # Wait for it to start sleep 3 echo "==> Testing mock API connectivity" curl -s --max-time 2 --fail http://0.0.0.0:${API_PORT}/health || echo "==> Warning: Mock API server not responding!" # Create a similar mock server for public albums mkdir -p /tmp/mock-public-server cd /tmp/mock-public-server cat > server.go << 'ENDOFCODE' package main import ( "encoding/json" "fmt" "log" "net/http" "os" "time" ) func main() { port := "8081" // Create log directory and file os.MkdirAll("/app/data/logs", 0755) logFile, err := os.OpenFile("/app/data/logs/public_api_requests.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { fmt.Printf("Error creating log file: %v\n", err) } else { defer logFile.Close() log.SetOutput(logFile) } // Log startup fmt.Println("Starting mock Public Albums server on port", port) log.Println("Starting mock Public Albums server on port", port) // Health check endpoint http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"status":"ok","time":"%s"}`, time.Now().Format(time.RFC3339)) }) // Default handler http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { log.Printf("Public Albums Request: %s %s", r.Method, r.URL.Path) w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"status":"ok","path":"%s"}`, r.URL.Path) }) // Start server fmt.Println("Public Albums server listening on 0.0.0.0:" + port) if err := http.ListenAndServe("0.0.0.0:"+port, nil); err != nil { fmt.Printf("Public Albums server error: %v\n", err) log.Fatalf("Public Albums server error: %v", err) } } ENDOFCODE # Run the public albums mock server go run server.go > "${LOGS_DIR}/public_mock_server.log" 2>&1 & PUBLIC_SERVER_PID=$! echo "==> Started Public Albums mock server with PID $PUBLIC_SERVER_PID" # Wait for it to start sleep 3 echo "==> Testing mock Public Albums API connectivity" curl -s --max-time 2 --fail http://0.0.0.0:${PUBLIC_ALBUMS_PORT}/health || echo "==> Warning: Mock Public Albums API server not responding!" fi # Set up Caddy web server for proxying and serving static files echo "==> Setting up Caddy web server" # Create runtime-config.js file echo "==> Creating runtime-config.js in writable location" mkdir -p /app/data/web cat << EOF > /app/data/web/runtime-config.js // Runtime configuration for Ente window.ENTE_CONFIG = { API_URL: 'https://${CLOUDRON_APP_FQDN}/api', PUBLIC_ALBUMS_URL: 'https://${CLOUDRON_APP_FQDN}/public' }; // Next.js environment variables window.process = window.process || {}; window.process.env = window.process.env || {}; window.process.env.NEXT_PUBLIC_ENTE_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/api'; window.process.env.NEXT_PUBLIC_ENTE_PUBLIC_ALBUMS_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/public'; window.process.env.NEXT_PUBLIC_REACT_APP_ENTE_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/api'; window.process.env.REACT_APP_ENTE_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/api'; // Add logging to help with debugging console.log('Ente runtime config loaded from runtime-config.js'); console.log('API_URL (final):', window.ENTE_CONFIG.API_URL); console.log('PUBLIC_ALBUMS_URL (final):', window.ENTE_CONFIG.PUBLIC_ALBUMS_URL); console.log('NEXT_PUBLIC_ENTE_ENDPOINT (final):', window.process.env.NEXT_PUBLIC_ENTE_ENDPOINT); EOF chmod 644 /app/data/web/runtime-config.js # Create the static HTML files with scripts pre-injected for app_dir in photos accounts auth cast; do # Create directory for our modified files mkdir -p /app/data/web/$app_dir # If the original index.html exists, copy and modify it if [ -f "/app/web/$app_dir/index.html" ]; then echo "==> Copying and modifying index.html for $app_dir app" cp "/app/web/$app_dir/index.html" "/app/data/web/$app_dir/index.html" # Fix any potential issues with the head tag if ! grep -q "" "/app/data/web/$app_dir/index.html"; then echo "==> Warning: No head tag found in $app_dir/index.html, adding one" sed -i 's//\n<\/head>/' "/app/data/web/$app_dir/index.html" fi # Insert config scripts right after the opening head tag sed -i 's//\n Ente $app_dir

Ente $app_dir

Loading...

If this page doesn't redirect automatically, click here.

HTMLFILE fi done # Create Caddy configuration file mkdir -p /app/data/caddy cat << EOF > /app/data/caddy/Caddyfile # Global settings { admin off auto_https off http_port $CADDY_PORT https_port 0 } # Main site configuration :$CADDY_PORT { # Basic logging log { level INFO output file /app/data/logs/caddy.log } # Configuration scripts - directly served handle /config.js { header Content-Type application/javascript respond " // Direct configuration for Ente window.ENTE_CONFIG = { API_URL: 'https://${CLOUDRON_APP_FQDN}/api', PUBLIC_ALBUMS_URL: 'https://${CLOUDRON_APP_FQDN}/public' }; // Next.js environment variables window.process = window.process || {}; window.process.env = window.process.env || {}; window.process.env.NEXT_PUBLIC_ENTE_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/api'; window.process.env.NEXT_PUBLIC_ENTE_PUBLIC_ALBUMS_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/public'; window.process.env.NEXT_PUBLIC_REACT_APP_ENTE_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/api'; window.process.env.REACT_APP_ENTE_ENDPOINT = 'https://${CLOUDRON_APP_FQDN}/api'; // Make sure URLs are explicitly defined with full domain console.log('Ente config loaded - API_URL:', window.ENTE_CONFIG.API_URL); console.log('Ente config loaded - PUBLIC_ALBUMS_URL:', window.ENTE_CONFIG.PUBLIC_ALBUMS_URL); " } handle /runtime-config.js { root * /app/data/web file_server } # Root path serves the photos app handle / { # Special handling for index.html @is_index path / handle @is_index { root * /app/data/web/photos try_files {path} /index.html file_server } # Serve other static files from the original location @not_index { not path / not path /api/* not path /public/* not path /accounts/* not path /auth/* not path /cast/* } handle @not_index { root * /app/web/photos try_files {path} /index.html file_server } } # Next.js static files handle /_next/* { root * /app/web/photos file_server } # Common file types headers header /*.js Content-Type application/javascript header /*.css Content-Type text/css header /*.json Content-Type application/json header /*.svg Content-Type image/svg+xml header /*.woff2 Content-Type font/woff2 header /_next/static/chunks/*.js Content-Type application/javascript header /_next/static/css/*.css Content-Type text/css # Accounts app handle /accounts { root * /app/data/web/accounts try_files {path} /index.html file_server } handle /accounts/* { @is_index path /accounts/ /accounts/index.html handle @is_index { root * /app/data/web try_files /accounts/index.html file_server } @not_index { not path /accounts/ not path /accounts/index.html } handle @not_index { uri strip_prefix /accounts root * /app/web/accounts try_files {path} /index.html file_server } } # Auth app handle /auth { root * /app/data/web/auth try_files {path} /index.html file_server } handle /auth/* { @is_index path /auth/ /auth/index.html handle @is_index { root * /app/data/web try_files /auth/index.html file_server } @not_index { not path /auth/ not path /auth/index.html } handle @not_index { uri strip_prefix /auth root * /app/web/auth try_files {path} /index.html file_server } } # Cast app handle /cast { root * /app/data/web/cast try_files {path} /index.html file_server } handle /cast/* { @is_index path /cast/ /cast/index.html handle @is_index { root * /app/data/web try_files /cast/index.html file_server } @not_index { not path /cast/ not path /cast/index.html } handle @not_index { uri strip_prefix /cast root * /app/web/cast try_files {path} /index.html file_server } } # Main API proxy handle /api/* { uri strip_prefix /api reverse_proxy 0.0.0.0:$API_PORT } # Public albums API proxy handle /public/* { uri strip_prefix /public reverse_proxy 0.0.0.0:$PUBLIC_ALBUMS_PORT } # Health check endpoints handle /health { respond "OK" } handle /healthcheck { respond "OK" } handle /api/health { uri strip_prefix /api reverse_proxy 0.0.0.0:$API_PORT } handle /public/health { uri strip_prefix /public reverse_proxy 0.0.0.0:$PUBLIC_ALBUMS_PORT } } EOF echo "==> Created Caddy config with properly modified HTML files at /app/data/caddy/Caddyfile" # Start Caddy server echo "==> Starting Caddy server" caddy run --config /app/data/caddy/Caddyfile --adapter caddyfile & CADDY_PID=$! echo "==> Caddy started with PID $CADDY_PID" # Wait for Caddy to start sleep 2 # Test Caddy connectivity echo "==> Testing Caddy connectivity" if curl -s --max-time 2 --fail http://0.0.0.0:$CADDY_PORT/health > /dev/null; then echo "==> Caddy is responding on port $CADDY_PORT" else echo "==> WARNING: Caddy is not responding on port $CADDY_PORT" fi # Print summary and URLs echo "==> Application is now running" echo "==> Access your Ente instance at: ${CLOUDRON_APP_ORIGIN}" # Additional checks to verify connectivity between services echo "==> Checking communication between frontend and backend services" echo "==> Testing main API communication" curl -s --max-time 2 -f http://0.0.0.0:$CADDY_PORT/api/health || echo "==> Warning: Main API endpoint is not responding!" echo "==> Main API communication via frontend is working" echo "==> Testing public albums API communication" curl -s --max-time 2 -f http://0.0.0.0:$CADDY_PORT/public/health || echo "==> Warning: Public Albums API endpoint is not responding!" echo "==> Public Albums API communication via frontend is working" echo "==> Testing frontend config.js" curl -s --max-time 2 -f http://0.0.0.0:$CADDY_PORT/config.js > /dev/null echo "==> Frontend configuration is properly loaded" # Go into wait state echo "==> Entering wait state - watching logs for registration codes" echo "==> Registration verification codes will appear in the logs below" echo "==> Press Ctrl+C to stop" tail -f /app/data/logs/api_requests.log & TAIL_PID=$! # Wait for all processes - safe waiting with proper checks if [ -n "${SERVER_PID:-}" ] && [ "${SERVER_PID:-0}" -ne 0 ]; then wait $SERVER_PID || true fi if [ -n "${PUBLIC_SERVER_PID:-}" ] && [ "${PUBLIC_SERVER_PID:-0}" -ne 0 ]; then wait $PUBLIC_SERVER_PID || true fi if [ -n "${CADDY_PID:-}" ] && [ "${CADDY_PID:-0}" -ne 0 ]; then wait $CADDY_PID || true fi