You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

114 lines
5.6 KiB

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