require "open3" module Api module V1 module Admin class DatabaseController < BaseController MAX_UPLOAD_SIZE = 250.megabytes def backup Dir.mktmpdir("praktikum-backup") do |directory| sql_path = File.join(directory, "praktikum-#{Time.current.strftime('%Y%m%d-%H%M%S')}.sql") run_database_command!(pg_dump_command(sql_path)) zip_path = "#{sql_path}.zip" stdout, stderr, status = Open3.capture3("zip", "-j", zip_path, sql_path) raise "ZIP-Erstellung fehlgeschlagen: #{stderr.presence || stdout}" unless status.success? send_data File.binread(zip_path), filename: File.basename(zip_path), type: "application/zip" end rescue StandardError => error Rails.logger.error("Backup failed: #{error.class}: #{error.message}") render json: { error: "Datenbanksicherung konnte nicht erstellt werden" }, status: :internal_server_error end def restore upload = params[:file] return render json: { error: "Keine Datei ausgewählt" }, status: :bad_request unless upload return render json: { error: "Datei ist größer als 250 MB" }, status: :payload_too_large if upload.size.to_i > MAX_UPLOAD_SIZE Dir.mktmpdir("praktikum-restore") do |directory| uploaded_path = File.join(directory, File.basename(upload.original_filename.to_s)) File.binwrite(uploaded_path, upload.read) sql_path = extract_sql(uploaded_path, directory) restore_path = build_atomic_restore_file(sql_path, directory) Rails.logger.info("Atomic database restore v2026.08.16.8 using #{File.basename(restore_path)}") run_database_command!(psql_command(restore_path)) # The restored schema invalidates prepared statements and the schema # cache of already open Rails connections. Reconnect cleanly before # the next request uses the restored database. ActiveRecord::Base.connection_handler.clear_all_connections! run_pending_migrations! ActiveRecord::Base.connection_handler.clear_all_connections! end render json: { message: "Datenbank wurde erfolgreich wiederhergestellt" } rescue StandardError => error Rails.logger.error("Restore failed: #{error.class}: #{error.message}") render json: { error: "Wiederherstellung fehlgeschlagen: #{error.message}" }, status: :unprocessable_content end private def extract_sql(uploaded_path, directory) return uploaded_path if File.extname(uploaded_path).downcase == ".sql" raise "Erlaubt sind nur .zip- oder .sql-Dateien" unless File.extname(uploaded_path).downcase == ".zip" list, stderr, status = Open3.capture3("unzip", "-Z1", uploaded_path) raise "ZIP-Datei ist ungültig: #{stderr}" unless status.success? entries = list.lines.map(&:strip).reject(&:blank?) sql_entries = entries.select { |entry| File.extname(entry).downcase == ".sql" && !entry.include?("..") && !entry.start_with?("/") } raise "ZIP muss genau eine SQL-Datei enthalten" unless sql_entries.one? sql_path = File.join(directory, "restore.sql") File.binwrite(sql_path, Open3.capture3("unzip", "-p", uploaded_path, sql_entries.first).then { |out, err, result| raise("SQL konnte nicht entpackt werden: #{err}") unless result.success?; out }) sql_path end def database_config = ActiveRecord::Base.connection_db_config.configuration_hash # Use one physical SQL input so resetting the existing schema always # happens before the first CREATE TABLE from legacy plain-text dumps. # psql --single-transaction rolls the reset back as well when any # statement from the uploaded dump fails. def build_atomic_restore_file(sql_path, directory) restore_path = File.join(directory, "atomic_restore.sql") File.open(restore_path, "wb") do |restore| restore.write("DROP SCHEMA IF EXISTS public CASCADE;\n") restore.write("CREATE SCHEMA public;\n") File.open(sql_path, "rb") { |source| IO.copy_stream(source, restore) } end restore_path end def pg_dump_command(path) config = database_config ["pg_dump", "-U", config[:username].to_s, "-h", (config[:host] || "localhost").to_s, "-p", (config[:port] || 5432).to_s, "-d", config[:database].to_s, "--clean", "--if-exists", "--no-owner", "--no-privileges", "-f", path] end def psql_command(path) config = database_config [ "psql", "-v", "ON_ERROR_STOP=1", "--single-transaction", "-U", config[:username].to_s, "-h", (config[:host] || "localhost").to_s, "-p", (config[:port] || 5432).to_s, "-d", config[:database].to_s, "-f", path ] end def run_database_command!(command) _stdout, stderr, status = Open3.capture3({ "PGPASSWORD" => database_config[:password].to_s }, *command) raise stderr.presence || "Datenbankbefehl fehlgeschlagen" unless status.success? end def run_pending_migrations! stdout, stderr, status = Open3.capture3( "bundle", "exec", "rails", "db:migrate", chdir: Rails.root.to_s ) Rails.logger.info("Post-restore migrations completed: #{stdout.lines.last.to_s.strip}") if status.success? raise "Migrationen nach dem Restore fehlgeschlagen: #{stderr.presence || stdout}" unless status.success? end end end end end