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
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.18.1 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
|