diff --git a/static/updatePanel.sh b/static/updatePanel.sh index 198ff4f..7ec8788 100644 --- a/static/updatePanel.sh +++ b/static/updatePanel.sh @@ -1,12 +1,212 @@ #!/bin/bash +# Pelican Panel – Incremental Git-Based Update Script +# Applies only the files that changed between the current version and the latest release. +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────────────── +# 0a. Dependency check – git must be available +# ───────────────────────────────────────────────────────────────────────────── +if ! command -v git &>/dev/null; then + echo "git is not installed or not in PATH. Please install git and re-run this script." >&2 + exit 1 +fi + +# ───────────────────────────────────────────────────────────────────────────── +# 0b. Root check +# ───────────────────────────────────────────────────────────────────────────── if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root or with sudo." >&2 - exit 1 + echo "This script must be run as root or with sudo." >&2 + exit 1 fi -read -p "Enter the directory for the panel location [/var/www/pelican]: " install_dir -install_dir=${install_dir:-/var/www/pelican} +PANEL_REPO="https://github.com/pelican/panel.git" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +# ───────────────────────────────────────────────────────────────────────────── +# 0c. Log file – tee all output so we can upload it later +# ───────────────────────────────────────────────────────────────────────────── +LOG_FILE="/tmp/pelican_update_${TIMESTAMP}.log" +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "Update log: $LOG_FILE" + +panel_in_maintenance=false +tmp_repo="" +release_tarball="" +assets_tmp_dir="" + +# Upload log to logs.pelican.dev and print the URL. +upload_log() { + if ! command -v curl &>/dev/null; then + echo "curl not found – cannot upload log." >&2 + return 1 + fi + local response + response=$(curl -s -w "\n%{http_code}" \ + -F "c=<$LOG_FILE" \ + -F "e=14d" \ + "https://logs.pelican.dev") + local http_code + http_code=$(echo "$response" | tail -n1) + local body + body=$(echo "$response" | sed '$d') + if [ "$http_code" = "200" ]; then + # Parse the url field from JSON response + local paste_url + paste_url=$(echo "$body" | sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' || true) + if [ -n "$paste_url" ]; then + echo "" + echo " ✓ Log uploaded." + echo " URL: $paste_url" + echo "" + else + echo "Uploaded but could not parse URL from response: $body" + fi + else + echo "Upload failed (HTTP $http_code): $body" >&2 + return 1 + fi +} + +# Offer to upload the log – called on success and on error traps. +offer_log_upload() { + echo "" + echo "Log saved to: $LOG_FILE" + echo "Note: the log contains command output, file paths, and version information from this run." + read -rp "Upload log to logs.pelican.dev to share? (y/n) [n]: " upload_confirm "$tmp_dest" 2>/dev/null; then + chmod 644 "$tmp_dest" + mv -f "$tmp_dest" "$dest" + else + rm -f "$tmp_dest" + return 1 + fi + ;; + 120000) + tmp_dir="$(mktemp -d "$(dirname "$dest")/.tmpdir_XXXXXX")" + link_target="" + if link_target="$(git show "${tag}:${file}" 2>/dev/null)"; then + ln -s "$link_target" "$tmp_dir/entry" + mv -Tf "$tmp_dir/entry" "$dest" + rmdir "$tmp_dir" + else + rm -rf "$tmp_dir" + return 1 + fi + ;; + *) + echo " [WARN ] Unsupported git mode $git_mode for $file" + return 1 + ;; + esac +} + +build_assets_with_yarn() { + local yarn_status=0 + + echo " Attempting to build assets locally with yarn (yarn install && yarn build)..." + if ( + cd "$install_dir" || exit 10 + yarn install 2>&1 || exit 11 + yarn build 2>&1 || exit 12 + ); then + $VERBOSE && echo " [OK ] Assets built successfully via yarn build." + return 0 + else + yarn_status=$? + fi + + case "$yarn_status" in + 11) + echo " [ERROR] yarn install failed. Frontend assets will be outdated and/or broken :/" + ;; + 12) + echo " [ERROR] yarn build failed. Frontend assets will be outdated and/or broken :/" + ;; + *) + echo " [ERROR] Could not run the local asset build fallback. Frontend assets will be outdated and/or broken :/" + ;; + esac + echo " Try to run manually: cd $install_dir && yarn install && yarn build" + + return 1 +} + +stamp_panel_version() { + local version_tag="${1:-$latest_version}" + local tag_version="${version_tag#v}" + + if [ -f "${install_dir}/config/app.php" ]; then + sed -i "s/'version'[[:space:]]*=>[[:space:]]*'[^']*'/'version' => '${tag_version}'/" \ + "${install_dir}/config/app.php" + echo " [MOD ] config/app.php (version -> ${tag_version})" + fi +} + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Installation directory +# ───────────────────────────────────────────────────────────────────────────── +read -rp "Enter the directory for the panel location [/var/www/pelican]: " install_dir /dev/null || echo "www-data") +read -rp "Enter the owner of the files [$owner]: " owner_input /dev/null || echo "www-data") +read -rp "Enter the group of the files [$group]: " group_input \s*'\K[^']+" "$config_app" | tr -d '[:space:]' || true) +fi -if [ -z "$db_connection" ]; then - db_connection='sqlite' - echo "DB_CONNECTION not found in $env_file, using $db_connection as default." -else - echo "DB_CONNECTION is set to: $db_connection" +if [ -z "$current_version" ]; then + read -rp "Could not detect current version from config/app.php. Enter it manually (e.g. v1.0.0-beta34): " current_version $v" + prev="$v" +done + +# ───────────────────────────────────────────────────────────────────────────── +# 6. DB check +# ───────────────────────────────────────────────────────────────────────────── +db_connection=$(grep "^DB_CONNECTION=" "$env_file" | cut -d '=' -f2 | tr -d "\"'" || echo "sqlite") +db_connection="${db_connection:-sqlite}" +echo "" +echo "DB_CONNECTION: $db_connection" - if [ -z "$db_database" ]; then - uses_default=true - db_database='database.sqlite' - echo "DB_DATABASE not found in $env_file, using $db_database as default." +db_database="" +if [ "$db_connection" = "sqlite" ]; then + db_database=$(grep "^DB_DATABASE=" "$env_file" | cut -d '=' -f2 | tr -d "\"'" || true) + db_database="${db_database:-database/database.sqlite}" + # Resolve relative paths against the install directory + if [[ "$db_database" != /* ]]; then + db_database="$install_dir/$db_database" fi - - if [[ "$db_database" != *.sqlite ]]; then - db_database="$db_database.sqlite" + # If the resolved path doesn't exist but the file lives in the database/ subdirectory, try that + if [ ! -f "$db_database" ] && [ -f "$install_dir/database/$(basename "$db_database")" ]; then + db_database="$install_dir/database/$(basename "$db_database")" fi + echo "SQLite database: $db_database" +fi - if [ -z "$uses_default" ]; then - echo "DB_DATABASE is set to: $db_database" - fi +# ───────────────────────────────────────────────────────────────────────────── +# 7. Backup +# ───────────────────────────────────────────────────────────────────────────── +read -rp "Do you want to create a backup before updating? (y/n) [y]: " backup_confirm /dev/null; then + echo "ERROR: sqlite3 is required to back up the SQLite database but is not installed. Install sqlite3 and re-run." >&2 exit 1 fi + if [ ! -f "$db_database" ]; then + echo "ERROR: SQLite database not found at '$db_database'. Aborting." + exit 1 + fi + db_backup_file="$backup_dir/$(basename "$db_database").backup" + sqlite3 "$db_database" ".backup '$db_backup_file'" + echo " ✓ Backed up SQLite database" else - read -p "NOTE: THIS WILL NOT BACKUP MySQL/MariaDB DATABASES!!! You should pause now and make your own backup!! You've been warned! Continue? (y/n) [y]: " database_confirm - database_confirm=${database_confirm:-y} - if [ "$database_confirm" != "y" ]; then - echo "Update Canceled." + echo "" + echo "WARNING: MySQL/MariaDB databases are NOT backed up by this script." + read -rp "Pause now and make your own DB backup, then continue? (y/n) [y]: " db_warn &2 exit 1 fi -echo "Enabling maintenance mode" -(cd "$install_dir" && php artisan down) -if [ $? -ne 0 ]; then - echo "Failed to enable maintenance mode, aborting" - exit 1 -fi +# ───────────────────────────────────────────────────────────────────────────── +# 10. Apply each version hop +# ───────────────────────────────────────────────────────────────────────────── +needs_composer=false +needs_migrations=false +any_changes=false + +prev_tag="$current_version" + +for next_tag in "${upgrade_path[@]}"; do + echo "" + echo "-----------------------------------------" + echo " Applying changes: $prev_tag -> $next_tag" + echo "-----------------------------------------" + + # Capture diff output to a temp file so we can: + # 1. Read exit code reliably (process-substitution swallows it). + # 2. Read the file cleanly without subshell/pipefail edge-cases. + diff_file=$(mktemp) + diff_err_file=$(mktemp) + diff_exit=0 + git -c core.quotepath=false diff --name-status "${prev_tag}" "${next_tag}" > "$diff_file" 2>"$diff_err_file" || diff_exit=$? + + added=0; modified=0; deleted=0; renamed=0; skipped=0; diff_lines=0 + + if [ "$diff_exit" -ne 0 ]; then + echo " [ERROR] git diff failed (exit $diff_exit) for ${prev_tag}..${next_tag}:" + cat "$diff_err_file" + rm -f "$diff_file" "$diff_err_file" + exit 1 + fi + rm -f "$diff_err_file" + + while IFS=$'\t' read -r status old_path new_path; do + ((diff_lines++)) || true + + # For non-rename/copy entries new_path is empty; the target file is old_path + file="${new_path:-$old_path}" + + case "$status" in + A|M|C*) + # Added, Modified, Copied -> extract from next_tag and write + if is_protected "$file"; then + echo " [SKIP ] $file (protected)" + ((skipped++)) || true + continue + fi + dest="$install_dir/$file" + if restore_git_entry "$next_tag" "$file" "$dest"; then + if [ "$status" = "A" ]; then + $VERBOSE && echo " [ADD ] $file" + ((added++)) || true + else + $VERBOSE && echo " [MOD ] $file" + ((modified++)) || true + fi + [[ "$file" == composer.json || "$file" == composer.lock ]] && needs_composer=true + [[ "$file" == database/migrations/* || "$file" == database/Seeders/* ]] && needs_migrations=true + else + echo " [WARN ] Could not extract $file from $next_tag" + ((skipped++)) || true + fi + ;; + + T) + # Type-changed -> restore the new entry or abort rather than leave a stale path in place + if is_protected "$file"; then + echo " [SKIP ] $file (protected)" + ((skipped++)) || true + continue + fi + dest="$install_dir/$file" + if restore_git_entry "$next_tag" "$file" "$dest"; then + $VERBOSE && echo " [MOD ] $file (type changed)" + ((modified++)) || true + [[ "$file" == composer.json || "$file" == composer.lock ]] && needs_composer=true + [[ "$file" == database/migrations/* || "$file" == database/Seeders/* ]] && needs_migrations=true + else + echo " [ERROR] Could not apply type-changed path $file from $next_tag" + rm -f "$diff_file" + exit 1 + fi + ;; + + R*) + # Renamed -> write new path first, then remove old path + if is_protected "$file"; then + echo " [SKIP ] $file (protected)" + ((skipped++)) || true + continue + fi + dest="$install_dir/$file" + if restore_git_entry "$next_tag" "$file" "$dest"; then + if ! is_protected "$old_path" && { [ -e "$install_dir/$old_path" ] || [ -L "$install_dir/$old_path" ]; }; then + rm -rf "$install_dir/$old_path" + $VERBOSE && echo " [DEL ] $old_path (renamed)" + ((deleted++)) || true + fi + $VERBOSE && echo " [ADD ] $file (renamed from $old_path)" + ((renamed++)) || true + [[ "$file" == composer.json || "$file" == composer.lock ]] && needs_composer=true + [[ "$file" == database/migrations/* || "$file" == database/Seeders/* ]] && needs_migrations=true + else + echo " [WARN ] Could not extract $file from $next_tag" + ((skipped++)) || true + fi + ;; + + D) + # Deleted + if is_protected "$file"; then + echo " [SKIP ] $file (protected)" + ((skipped++)) || true + continue + fi + if [ -e "$install_dir/$file" ] || [ -L "$install_dir/$file" ]; then + rm -rf "$install_dir/$file" + $VERBOSE && echo " [DEL ] $file" + ((deleted++)) || true + fi + ;; + + *) + $VERBOSE && echo " [SKIP ] $file (unhandled status: $status)" + ((skipped++)) || true + ;; + esac + done < "$diff_file" + rm -f "$diff_file" + + if [ "$diff_lines" -eq 0 ]; then + echo " No file changes detected between $prev_tag and $next_tag." + else + any_changes=true + echo "" + echo " Summary for $prev_tag -> $next_tag:" + echo " Added: $added" + echo " Modified: $modified" + echo " Deleted: $deleted" + echo " Renamed: $renamed" + echo " Skipped: $skipped" + fi -# The maintenance flag (storage/framework/down) must survive the delete: it stops the -# live panel from writing logs/sessions into storage mid-delete, which made rm fail -# with "Directory not empty" and abort the update. Everything else in storage goes too. -find "$install_dir" -mindepth 1 -maxdepth 1 ! -name 'backup' ! -name 'plugins' ! -name 'storage' ! -name 'panel.tar.gz' -exec rm -rf {} + && -find "$install_dir/storage" -mindepth 1 -maxdepth 1 ! -name 'framework' -exec rm -rf {} + && -find "$install_dir/storage/framework" -mindepth 1 -maxdepth 1 ! -name 'down' ! -name 'maintenance.php' -exec rm -rf {} + -if [ $? -ne 0 ]; then - echo "Failed to delete old files, aborting" - exit 1 -fi -echo "Deleted all files and folders in \"$install_dir\" except the backup folder." + prev_tag="$next_tag" +done -echo "Extracting tarball." -tar -xzf panel.tar.gz -C "$install_dir" -if [ $? -ne 0 ]; then - echo "Failed to extract tarball, aborting" - exit 1 -fi -rm panel.tar.gz -if [ $? -ne 0 ]; then - echo "Failed to delete leftover tarball, continuing." -fi +unset GIT_DIR -echo "Restoring .env" -cp -a "$backup_dir/.env.backup" "$install_dir/.env" -if [ $? -ne 0 ]; then - echo "Failed to restore the .env file, aborting" - exit 1 -fi +# ───────────────────────────────────────────────────────────────────────────── +# 11. Fetch the latest release (public/build + config/app.php version) +# ───────────────────────────────────────────────────────────────────────────── +# This is done for the final release +echo "" +echo "Fetching release tarball for $latest_version to update public/build..." +release_tarball=$(mktemp --suffix=.tar.gz) +tarball_url="https://github.com/pelican/panel/releases/download/${latest_version}/panel.tar.gz" +if curl -fsSL "$tarball_url" -o "$release_tarball"; then + assets_tmp_dir=$(mktemp -d) + + # Extract public/build (tarball root may be bare or wrapped in a subdirectory) + tarball_listing=$(tar -tzf "$release_tarball" 2>/dev/null || true) + extraction_ok=false + if echo "$tarball_listing" | grep -q '^public/build/'; then + if tar -xzf "$release_tarball" -C "$assets_tmp_dir" --strip-components=0 \ + --wildcards 'public/build/*' 2>/dev/null; then + extraction_ok=true + fi + elif echo "$tarball_listing" | grep -q '/public/build/'; then + # Wrapped in a top-level directory — strip one component + if tar -xzf "$release_tarball" -C "$assets_tmp_dir" --strip-components=1 \ + --wildcards '*/public/build/*' 2>/dev/null; then + extraction_ok=true + fi + else + echo " [WARN ] public/build not found in release tarball for $latest_version" + fi -if [ -d "$backup_dir/storage/app/public" ]; then - echo "Restoring avatars & fonts" - cp -a "$backup_dir/storage/app/public" "$install_dir/storage/app/" - if [ $? -ne 0 ]; then - echo "Failed to restore avatars & fonts files, aborting" - exit 1 + if $extraction_ok && [ -d "$assets_tmp_dir/public/build" ] \ + && find "$assets_tmp_dir/public/build" -mindepth 1 -print -quit | grep -q .; then + rm -rf "${install_dir}/public/build" + mkdir -p "${install_dir}/public" + mv "$assets_tmp_dir/public/build" "${install_dir}/public/build" + $VERBOSE && echo " [OK ] public/build updated from release tarball." + else + echo " [WARN ] Failed to extract a valid public/build from the release tarball." + build_assets_with_yarn || true fi +else + echo " [WARN ] Could not download release from $tarball_url — public/build not updated." + build_assets_with_yarn || true fi -if [ -f "$backup_dir/$db_database.backup" ]; then - echo "Restoring sqlite database" - cp -a "$backup_dir/$db_database.backup" "$install_dir/database/$db_database" - if [ $? -ne 0 ]; then - echo "Failed to restore the database, aborting" - exit 1 - fi +stamp_panel_version "$latest_version" + + +# ───────────────────────────────────────────────────────────────────────────── +# 12. Post-update steps +# ───────────────────────────────────────────────────────────────────────────── +if ! $any_changes; then + echo "" + echo "No file changes were applied. Panel may already be at $latest_version." + echo "" + echo "Bringing panel back online..." + (cd "$install_dir" && php artisan up) || echo "WARNING: php artisan up failed — run manually: cd $install_dir && php artisan up" + panel_in_maintenance=false + offer_log_upload + exit 0 fi cd "$install_dir" -echo "Installing Composer" -COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader --no-interaction -if [ $? -ne 0 ]; then - echo "Failed to run composer, aborting" - exit 1 +if $needs_composer || [ ! -d "$install_dir/vendor" ]; then + echo "" + echo "Running Composer..." + COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader --no-interaction fi -echo "Optimizing" +echo "" +echo "Clearing & optimizing cache..." php artisan optimize:clear php artisan filament:optimize -echo "Creating storage symlinks" +echo "" +echo "Ensuring storage symlinks..." php artisan storage:link -echo "Updating database" -php artisan migrate --seed --force - -echo "Setting Permissions" -chmod_command="chmod -R 755 \"$install_dir\"/storage/* \"$install_dir\"/bootstrap/cache" -eval $chmod_command -if [ $? -ne 0 ]; then - echo "Failed to run chmod, Please run the following commands manually:" - echo "sudo $chmod_command" -fi -chown_command="chown -R $owner:$group \"$install_dir\"" -eval $chown_command -if [ $? -ne 0 ]; then - echo "Failed to run chown, Please run the following commands manually:" - echo "sudo $chown_command" +if $needs_migrations; then + echo "" + echo "Running database migrations..." + php artisan migrate --seed --force +else + # Always run migrations to be safe; migrations are idempotent + echo "" + echo "Running database migrations (idempotent)..." + php artisan migrate --force fi +echo "" +echo "Restarting queue workers..." php artisan queue:restart -php artisan up -echo "Panel Updated!" -echo "If you previously had any themes installed you need to build the panel assets again by manually running \"yarn install\" and \"yarn build\" inside \"$install_dir\"." +# ───────────────────────────────────────────────────────────────────────────── +# 13. Permissions +# ───────────────────────────────────────────────────────────────────────────── echo "" -echo "To make sure permissions are correct, please run the following commands manually:" -echo "sudo $chmod_command" -echo "sudo $chown_command" +echo "Setting permissions..." + +chmod_cmd="chmod -R 755 \"$install_dir\"/storage/* \"$install_dir\"/bootstrap/cache" +chown_cmd="chown -R $owner:$group \"$install_dir\"" + +chmod -R 755 "$install_dir"/storage/* "$install_dir"/bootstrap/cache \ + || echo "WARNING: chmod failed – run manually: sudo $chmod_cmd" +chown -R "$owner:$group" "$install_dir" \ + || echo "WARNING: chown failed – run manually: sudo $chown_cmd" + +# ───────────────────────────────────────────────────────────────────────────── +# 14. Bring the panel back online +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "Bringing panel back online..." +(cd "$install_dir" && php artisan up) || echo "WARNING: php artisan up failed — run manually: cd $install_dir && php artisan up" +panel_in_maintenance=false + +# ───────────────────────────────────────────────────────────────────────────── +# 15. Done +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "==================================================" +echo " Panel updated: $current_version -> $latest_version" +echo "==================================================" +echo "" +echo "Backup saved to: $backup_dir" +echo "" +echo "If you had custom themes installed, rebuild assets manually:" +echo " cd $install_dir && yarn install && yarn build" +echo "" +echo "To verify permissions:" +echo " sudo $chmod_cmd" +echo " sudo $chown_cmd" + +offer_log_upload + exit 0