add database backup and restore admin page
Some checks failed
deploy / deploy (push) Has been cancelled

- SQLCipher-encrypted backup creation via VACUUM INTO
- Backup history with auto-pruning (keeps last 5)
- Pre-restore automatic backup for safety
- Restore from history or uploaded file
- Stats display with table breakdown
- Download hook for client-side file download
- SECRET_KEY_DB config for encryption at rest

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-13 13:33:29 +00:00
parent b0f8eea2bc
commit 09f55dfe67
10 changed files with 2183 additions and 1 deletions

814
lib/berrypod/backup.ex Normal file
View File

@@ -0,0 +1,814 @@
defmodule Berrypod.Backup do
@moduledoc """
Database backup and restore functionality.
Provides database statistics, backup creation via VACUUM INTO,
backup history management, and restore operations for SQLCipher-encrypted databases.
Backups are stored in the configured backup directory (default: priv/backups/).
Before any restore, an automatic backup of the current database is created.
Old backups are automatically pruned to keep the most recent N backups.
"""
alias Berrypod.Repo
require Logger
# Tables to show row counts for in the stats display
@key_tables ~w(products product_variants orders images pages newsletter_subscribers)
# Critical tables that must exist for a valid Berrypod database
@required_tables ~w(users settings products orders pages images schema_migrations)
# Maximum number of backups to keep (configurable via :berrypod, :backup, :max_backups)
@default_max_backups 5
@doc """
Returns the directory where backups are stored.
Defaults to priv/backups/ but can be configured via :berrypod, :backup, :directory.
"""
def backup_dir do
config = Application.get_env(:berrypod, :backup, [])
dir =
Keyword.get_lazy(config, :directory, fn ->
Path.join(:code.priv_dir(:berrypod), "backups")
end)
# Ensure directory exists
File.mkdir_p!(dir)
dir
end
@doc """
Returns the maximum number of backups to keep.
"""
def max_backups do
config = Application.get_env(:berrypod, :backup, [])
Keyword.get(config, :max_backups, @default_max_backups)
end
@doc """
Lists all available backups, sorted by date (newest first).
Returns a list of maps with:
- filename: the backup filename
- path: full path to the backup file
- size: file size in bytes
- created_at: DateTime when the backup was created (parsed from filename)
- type: :manual or :pre_restore
"""
def list_backups do
dir = backup_dir()
dir
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".db"))
|> Enum.map(fn filename ->
path = Path.join(dir, filename)
stat = File.stat!(path)
%{
filename: filename,
path: path,
size: stat.size,
created_at: parse_backup_timestamp(filename),
type: parse_backup_type(filename)
}
end)
|> Enum.sort_by(& &1.created_at, {:desc, DateTime})
end
defp parse_backup_timestamp(filename) do
# Expected format: berrypod-backup-YYYYMMDD-HHMMSS.db or pre-restore-YYYYMMDD-HHMMSS.db
case Regex.run(~r/(\d{8})-(\d{6})\.db$/, filename) do
[_, date, time] ->
<<y::binary-4, m::binary-2, d::binary-2>> = date
<<hh::binary-2, mm::binary-2, ss::binary-2>> = time
case NaiveDateTime.new(
String.to_integer(y),
String.to_integer(m),
String.to_integer(d),
String.to_integer(hh),
String.to_integer(mm),
String.to_integer(ss)
) do
{:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC")
_ -> DateTime.utc_now()
end
_ ->
DateTime.utc_now()
end
end
defp parse_backup_type(filename) do
if String.starts_with?(filename, "pre-restore-") do
:pre_restore
else
:manual
end
end
@doc """
Returns comprehensive database statistics.
Includes:
- Total database size
- Encryption status (SQLCipher version or nil)
- Per-table row counts and sizes
- Key entity counts
"""
def get_stats do
%{
total_size: get_total_size(),
encryption_status: get_encryption_status(),
tables: get_table_stats(),
key_counts: get_key_counts(),
schema_version: get_current_schema_version()
}
end
@doc """
Returns the total database file size in bytes.
"""
def get_total_size do
case Repo.query(
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
) do
{:ok, %{rows: [[size]]}} -> size
_ -> 0
end
end
@doc """
Returns the SQLCipher version if encryption is enabled, nil otherwise.
"""
def get_encryption_status do
case Repo.query("PRAGMA cipher_version") do
{:ok, %{rows: [[version]]}} when is_binary(version) and version != "" -> version
_ -> nil
end
end
@doc """
Returns a list of tables with their row counts and sizes.
"""
def get_table_stats do
# Get all user tables (exclude sqlite internals and FTS shadow tables)
case Repo.query("""
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name NOT LIKE '%_content'
AND name NOT LIKE '%_data'
AND name NOT LIKE '%_idx'
AND name NOT LIKE '%_docsize'
AND name NOT LIKE '%_config'
ORDER BY name
""") do
{:ok, %{rows: tables}} ->
process_table_stats(tables)
{:error, _} ->
[]
end
end
defp process_table_stats(tables) do
table_names = Enum.map(tables, fn [name] -> name end)
# Get sizes via dbstat if available
sizes = get_table_sizes()
# Get row counts
Enum.map(table_names, fn name ->
count = get_row_count(name)
size = Map.get(sizes, name, 0)
%{
name: name,
rows: count,
size: size
}
end)
|> Enum.sort_by(& &1.size, :desc)
end
defp get_table_sizes do
# Try dbstat first (most accurate, but requires ENABLE_DBSTAT_VTAB compile flag)
case Repo.query("""
SELECT name, SUM(pgsize) as size
FROM dbstat
GROUP BY name
""") do
{:ok, %{rows: rows}} ->
Map.new(rows, fn [name, size] -> {name, size || 0} end)
_ ->
# Fallback: estimate sizes by summing column data lengths
# This gives a reasonable approximation for display purposes
estimate_table_sizes()
end
end
defp estimate_table_sizes do
# Get all user tables
case Repo.query("""
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name NOT LIKE '%_content'
AND name NOT LIKE '%_data'
AND name NOT LIKE '%_idx'
AND name NOT LIKE '%_docsize'
AND name NOT LIKE '%_config'
""") do
{:ok, %{rows: tables}} ->
tables
|> Enum.map(fn [name] -> {name, estimate_table_size(name)} end)
|> Enum.into(%{})
_ ->
%{}
end
end
defp estimate_table_size(table_name) do
# Get column names for this table
case Repo.query("PRAGMA table_info(\"#{table_name}\")") do
{:ok, %{rows: columns}} when columns != [] ->
column_names = Enum.map(columns, fn [_cid, name | _] -> name end)
# Build a query that sums the length of all columns
# Using COALESCE and length() for text, or 8 bytes for numeric types
length_exprs =
column_names
|> Enum.map(fn col ->
"COALESCE(LENGTH(CAST(\"#{col}\" AS BLOB)), 0)"
end)
|> Enum.join(" + ")
query = "SELECT SUM(#{length_exprs}) FROM \"#{table_name}\""
case Repo.query(query) do
{:ok, %{rows: [[size]]}} when is_integer(size) -> size
{:ok, %{rows: [[size]]}} when is_float(size) -> round(size)
_ -> 0
end
_ ->
0
end
end
defp get_row_count(table_name) do
# Safe since table_name comes from sqlite_master
case Repo.query("SELECT COUNT(*) FROM \"#{table_name}\"") do
{:ok, %{rows: [[count]]}} -> count
_ -> 0
end
end
@doc """
Returns counts for key entities (products, orders, etc).
"""
def get_key_counts do
@key_tables
|> Enum.map(fn table ->
{table, get_row_count(table)}
end)
|> Enum.into(%{})
end
@doc """
Creates a backup of the database using VACUUM INTO.
Returns `{:ok, backup_path}` on success.
The backup is encrypted with the same key as the source database.
"""
def create_backup(opts \\ []) do
prefix = Keyword.get(opts, :prefix, "berrypod-backup")
save_to_history = Keyword.get(opts, :save_to_history, true)
timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S")
filename = "#{prefix}-#{timestamp}.db"
backup_path =
if save_to_history do
Path.join(backup_dir(), filename)
else
Path.join(System.tmp_dir!(), filename)
end
case Repo.query("VACUUM INTO ?", [backup_path]) do
{:ok, _} ->
if save_to_history, do: prune_old_backups()
{:ok, backup_path}
{:error, error} ->
{:error, error}
end
end
@doc """
Creates a pre-restore backup of the current database.
This is automatically called before any restore operation.
"""
def create_pre_restore_backup do
create_backup(prefix: "pre-restore", save_to_history: true)
end
@doc """
Deletes old backups, keeping only the most recent N backups.
"""
def prune_old_backups do
max = max_backups()
backups = list_backups()
if length(backups) > max do
backups
|> Enum.drop(max)
|> Enum.each(fn backup ->
Logger.info("[Backup] Pruning old backup: #{backup.filename}")
File.rm(backup.path)
end)
end
:ok
end
@doc """
Deletes a specific backup by filename.
"""
def delete_backup(filename) do
path = Path.join(backup_dir(), filename)
if File.exists?(path) and String.ends_with?(filename, ".db") do
File.rm(path)
else
{:error, :not_found}
end
end
@doc """
Validates that a backup file can be opened and is a valid Berrypod database.
Performs comprehensive checks:
1. File can be opened with current encryption key
2. Contains required tables (users, settings, products, etc.)
3. Has schema_migrations table with valid versions
4. Integrity check passes
Returns:
- `{:ok, validation_result}` with detailed stats and validation info
- `{:error, reason}` with specific error details
"""
def validate_backup(path) do
config = Application.get_env(:berrypod, Berrypod.Repo)
key = Keyword.get(config, :key)
case Exqlite.Sqlite3.open(path, mode: :readonly) do
{:ok, conn} ->
result = validate_backup_connection(conn, key)
Exqlite.Sqlite3.close(conn)
result
{:error, reason} ->
{:error, {:open_failed, reason}}
end
end
defp validate_backup_connection(conn, key) do
# Set the encryption key if we have one
if key do
case Exqlite.Sqlite3.execute(conn, "PRAGMA key = #{key}") do
:ok -> :ok
{:error, reason} -> throw({:error, {:key_failed, reason}})
end
end
# Try to read from the database (will fail if wrong key)
case Exqlite.Sqlite3.execute(conn, "SELECT COUNT(*) FROM sqlite_master") do
:ok -> :ok
{:error, "file is not a database"} -> throw({:error, :invalid_key})
{:error, reason} -> throw({:error, {:read_failed, reason}})
end
# Run integrity check
case run_integrity_check(conn) do
:ok -> :ok
{:error, reason} -> throw({:error, {:integrity_failed, reason}})
end
# Check required tables exist
case check_required_tables(conn) do
:ok -> :ok
{:error, missing} -> throw({:error, {:missing_tables, missing}})
end
# Check schema migrations
case check_schema_migrations(conn) do
{:ok, migration_info} -> migration_info
{:error, reason} -> throw({:error, {:migrations_failed, reason}})
end
# Get comprehensive stats
stats = get_backup_stats(conn)
{:ok, stats}
catch
{:error, reason} -> {:error, reason}
end
defp run_integrity_check(conn) do
{:ok, stmt} = Exqlite.Sqlite3.prepare(conn, "PRAGMA integrity_check(1)")
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, ["ok"]} ->
Exqlite.Sqlite3.release(conn, stmt)
:ok
{:row, [error]} ->
Exqlite.Sqlite3.release(conn, stmt)
{:error, error}
{:error, reason} ->
Exqlite.Sqlite3.release(conn, stmt)
{:error, reason}
end
end
defp check_required_tables(conn) do
{:ok, stmt} =
Exqlite.Sqlite3.prepare(conn, "SELECT name FROM sqlite_master WHERE type='table'")
tables = collect_rows(conn, stmt, [])
table_names = Enum.map(tables, fn [name] -> name end)
missing = @required_tables -- table_names
if Enum.empty?(missing) do
:ok
else
{:error, missing}
end
end
defp check_schema_migrations(conn) do
{:ok, stmt} =
Exqlite.Sqlite3.prepare(
conn,
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
)
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, [latest_version]} ->
Exqlite.Sqlite3.release(conn, stmt)
{:ok, %{latest_migration: latest_version}}
:done ->
Exqlite.Sqlite3.release(conn, stmt)
{:error, "no migrations found"}
{:error, reason} ->
Exqlite.Sqlite3.release(conn, stmt)
{:error, reason}
end
end
defp collect_rows(conn, stmt, acc) do
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, row} -> collect_rows(conn, stmt, [row | acc])
:done -> Exqlite.Sqlite3.release(conn, stmt) && Enum.reverse(acc)
end
end
defp get_backup_stats(conn) do
# Get table count (excluding sqlite internals and FTS shadow tables, same as get_table_stats)
{:ok, stmt} =
Exqlite.Sqlite3.prepare(conn, """
SELECT COUNT(*) FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name NOT LIKE '%_content'
AND name NOT LIKE '%_data'
AND name NOT LIKE '%_idx'
AND name NOT LIKE '%_docsize'
AND name NOT LIKE '%_config'
""")
{:row, [table_count]} = Exqlite.Sqlite3.step(conn, stmt)
Exqlite.Sqlite3.release(conn, stmt)
# Get page count for size estimate
{:ok, stmt} =
Exqlite.Sqlite3.prepare(
conn,
"SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()"
)
{:row, [size]} = Exqlite.Sqlite3.step(conn, stmt)
Exqlite.Sqlite3.release(conn, stmt)
# Get latest migration version
{:ok, stmt} =
Exqlite.Sqlite3.prepare(
conn,
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
)
latest_migration =
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, [version]} -> version
_ -> nil
end
Exqlite.Sqlite3.release(conn, stmt)
# Get key entity counts
key_counts = get_backup_key_counts(conn)
%{
table_count: table_count,
size: size,
latest_migration: latest_migration,
key_counts: key_counts
}
end
defp get_backup_key_counts(conn) do
@key_tables
|> Enum.map(fn table ->
count =
case Exqlite.Sqlite3.prepare(conn, "SELECT COUNT(*) FROM \"#{table}\"") do
{:ok, stmt} ->
result =
case Exqlite.Sqlite3.step(conn, stmt) do
{:row, [count]} -> count
_ -> 0
end
Exqlite.Sqlite3.release(conn, stmt)
result
_ ->
0
end
{table, count}
end)
|> Enum.into(%{})
end
@doc """
Restores a database from a backup file.
This performs a full file-based restore by:
1. Validating the backup (including schema version match)
2. Stopping Oban completely
3. Checkpointing WAL and draining all database connections
4. Stopping the Repo
5. Replacing the database file
6. Restarting the Repo
7. Restarting Oban
8. Clearing and warming caches
Returns `:ok` on success, `{:error, reason}` on failure.
"""
def restore_backup(backup_path) do
config = Application.get_env(:berrypod, Berrypod.Repo)
db_path = Keyword.fetch!(config, :database)
Logger.info("[Backup] Starting database restore from #{backup_path}")
with :ok <- validate_backup_before_restore(backup_path),
{:ok, pre_restore_path} <- create_pre_restore_backup(),
:ok <- broadcast_maintenance_mode(:entering),
:ok <- stop_oban(),
:ok <- drain_and_stop_repo(),
:ok <- swap_database_file(backup_path, db_path),
:ok <- start_repo(),
:ok <- start_oban(),
:ok <- clear_ets_caches(),
:ok <- warm_caches() do
broadcast_maintenance_mode(:exited)
Logger.info("[Backup] Database restore completed successfully")
Logger.info("[Backup] Pre-restore backup saved: #{pre_restore_path}")
:ok
else
{:error, reason} ->
Logger.error("[Backup] Restore failed: #{inspect(reason)}")
# Try to recover
start_repo()
start_oban()
broadcast_maintenance_mode(:exited)
{:error, reason}
end
end
defp stop_oban do
# Terminate Oban child from the application supervisor
# This stops all Oban processes including plugins and reporters
try do
Supervisor.terminate_child(Berrypod.Supervisor, Oban)
catch
_, _ -> :ok
end
# Give processes time to fully terminate
Process.sleep(500)
:ok
end
defp start_oban do
# Restart Oban child in the application supervisor
try do
Supervisor.restart_child(Berrypod.Supervisor, Oban)
catch
_, _ -> :ok
end
# Wait for Oban to be ready
Process.sleep(500)
:ok
end
defp drain_and_stop_repo do
# First checkpoint WAL while we still have connections
try do
Repo.query!("PRAGMA wal_checkpoint(TRUNCATE)")
rescue
_ -> :ok
end
# Disconnect all connections - this forces them to close gracefully
Ecto.Adapters.SQL.disconnect_all(Repo, 0)
# Give connections time to release their file handles
Process.sleep(500)
# Use GenServer.stop which properly shuts down the pool
repo_pid = Process.whereis(Repo)
if repo_pid do
ref = Process.monitor(repo_pid)
# GenServer.stop sends a :stop call which is handled gracefully
try do
GenServer.stop(repo_pid, :normal, 10_000)
catch
:exit, _ -> :ok
end
# Wait for the process to actually terminate
receive do
{:DOWN, ^ref, :process, ^repo_pid, _reason} -> :ok
after
5000 -> :ok
end
end
# Wait for file handles to be fully released by the OS
Process.sleep(500)
:ok
end
defp start_repo do
# The supervisor will restart the Repo automatically since we stopped it
# Wait for it to come back up
wait_for_repo(100)
end
defp wait_for_repo(0), do: {:error, :repo_start_timeout}
defp wait_for_repo(attempts) do
case Process.whereis(Repo) do
nil ->
Process.sleep(100)
wait_for_repo(attempts - 1)
_pid ->
# Give it a moment to fully initialize the connection pool
Process.sleep(200)
# Verify we can actually query
try do
case Repo.query("SELECT 1") do
{:ok, _} -> :ok
{:error, _} ->
Process.sleep(100)
wait_for_repo(attempts - 1)
end
catch
_, _ ->
Process.sleep(100)
wait_for_repo(attempts - 1)
end
end
end
defp swap_database_file(backup_path, db_path) do
# Remove WAL and SHM files if they exist (they're part of the old database state)
File.rm("#{db_path}-wal")
File.rm("#{db_path}-shm")
# Replace the database file
case File.cp(backup_path, db_path) do
:ok ->
File.rm(backup_path)
:ok
{:error, reason} ->
{:error, {:file_copy_failed, reason}}
end
end
defp validate_backup_before_restore(path) do
case validate_backup(path) do
{:ok, backup_stats} ->
# Check schema versions match
current_version = get_current_schema_version()
if backup_stats.latest_migration == current_version do
:ok
else
{:error, {:schema_mismatch, backup_stats.latest_migration, current_version}}
end
{:error, reason} ->
{:error, {:validation_failed, reason}}
end
end
defp get_current_schema_version do
case Repo.query("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1") do
{:ok, %{rows: [[version]]}} -> version
_ -> nil
end
end
defp broadcast_maintenance_mode(status) do
# Broadcast to all connected LiveViews that maintenance is happening
Phoenix.PubSub.broadcast(Berrypod.PubSub, "maintenance", {:maintenance, status})
:ok
end
defp clear_ets_caches do
# Clear known ETS caches to ensure they get rebuilt from the new database
caches = [
Berrypod.Theme.CSSCache,
Berrypod.Pages.PageCache,
Berrypod.Redirects.Cache
]
for cache <- caches do
try do
:ets.delete_all_objects(cache)
rescue
ArgumentError -> :ok
end
end
:ok
end
defp warm_caches do
# Warm up caches after restore
try do
Berrypod.Pages.PageCache.warm()
rescue
_ -> :ok
end
try do
Berrypod.Redirects.warm_cache()
rescue
_ -> :ok
end
try do
Berrypod.Theme.CSSCache.warm()
rescue
_ -> :ok
end
:ok
end
@doc """
Formats a byte size into a human-readable string.
"""
def format_size(bytes) when is_integer(bytes) do
cond do
bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
bytes >= 1024 -> "#{Float.round(bytes / 1024, 1)} KB"
true -> "#{bytes} B"
end
end
def format_size(_), do: "0 B"
end

View File

@@ -177,6 +177,14 @@
<.icon name="hero-arrow-uturn-right" class="size-5" /> Redirects
</.link>
</li>
<li>
<.link
navigate={~p"/admin/backup"}
class={admin_nav_active?(@current_path, "/admin/backup")}
>
<.icon name="hero-circle-stack" class="size-5" /> Backup
</.link>
</li>
</ul>
</div>
</nav>

View File

@@ -0,0 +1,592 @@
defmodule BerrypodWeb.Admin.Backup do
use BerrypodWeb, :live_view
alias Berrypod.Backup
@impl true
def mount(_params, _session, socket) do
stats = Backup.get_stats()
backups = Backup.list_backups()
{:ok,
socket
|> assign(:page_title, "Backup")
|> assign(:stats, stats)
|> assign(:backups, backups)
|> assign(:create_backup_status, :idle)
|> assign(:uploaded_backup, nil)
|> assign(:upload_error, nil)
|> assign(:confirming_restore, false)
|> assign(:restoring, false)
|> assign(:confirming_history_restore, nil)
|> assign(:confirming_delete, nil)
|> assign(:show_tables, false)
|> allow_upload(:backup,
accept: :any,
max_entries: 1,
max_file_size: 500_000_000
)}
end
@impl true
def handle_event("refresh_stats", _params, socket) do
stats = Backup.get_stats()
backups = Backup.list_backups()
{:noreply, socket |> assign(:stats, stats) |> assign(:backups, backups)}
end
def handle_event("toggle_tables", _params, socket) do
{:noreply, assign(socket, :show_tables, !socket.assigns.show_tables)}
end
def handle_event("create_backup", _params, socket) do
case Backup.create_backup() do
{:ok, _path} ->
{:noreply,
socket
|> assign(:backups, Backup.list_backups())
|> assign(:create_backup_status, :saved)}
{:error, error} ->
{:noreply,
socket
|> assign(:create_backup_status, :error)
|> put_flash(:error, "Failed to create backup: #{inspect(error)}")}
end
end
def handle_event("download_history_backup", %{"filename" => filename}, socket) do
path = Path.join(Backup.backup_dir(), filename)
if File.exists?(path) do
data = File.read!(path)
{:noreply,
socket
|> push_event("download", %{
filename: filename,
content: Base.encode64(data),
content_type: "application/octet-stream"
})}
else
{:noreply, put_flash(socket, :error, "Backup file not found")}
end
end
def handle_event("validate_upload", _params, socket) do
{:noreply, socket}
end
def handle_event("upload_backup", _params, socket) do
[result] =
consume_uploaded_entries(socket, :backup, fn %{path: path}, _entry ->
# Copy to temp location since consume deletes the original
temp_path = Path.join(System.tmp_dir!(), "berrypod-restore-#{System.unique_integer()}.db")
File.cp!(path, temp_path)
case Backup.validate_backup(temp_path) do
{:ok, backup_stats} ->
# Use actual file size instead of internal page calculation
file_size = File.stat!(temp_path).size
{:ok, {:ok, temp_path, Map.put(backup_stats, :file_size, file_size)}}
{:error, reason} ->
File.rm(temp_path)
{:ok, {:error, reason}}
end
end)
case result do
{:ok, path, backup_stats} ->
{:noreply,
socket
|> assign(:uploaded_backup, %{path: path, stats: backup_stats})
|> assign(:upload_error, nil)}
{:error, :invalid_key} ->
{:noreply,
assign(
socket,
:upload_error,
"Wrong encryption key — this backup was created with a different key"
)}
{:error, reason} ->
{:noreply, assign(socket, :upload_error, "Invalid backup file: #{inspect(reason)}")}
end
end
def handle_event("cancel_restore", _params, socket) do
# Clean up temp file
if socket.assigns.uploaded_backup do
File.rm(socket.assigns.uploaded_backup.path)
end
{:noreply,
socket
|> assign(:uploaded_backup, nil)
|> assign(:confirming_restore, false)}
end
def handle_event("confirm_restore", _params, socket) do
{:noreply, assign(socket, :confirming_restore, true)}
end
def handle_event("execute_restore", _params, socket) do
# Show loading state immediately, then do the restore async
send(self(), :do_restore)
{:noreply, assign(socket, :restoring, true)}
end
# Backup history actions
def handle_event("confirm_history_restore", %{"filename" => filename}, socket) do
{:noreply, assign(socket, :confirming_history_restore, filename)}
end
def handle_event("cancel_history_restore", _params, socket) do
{:noreply, assign(socket, :confirming_history_restore, nil)}
end
def handle_event("execute_history_restore", %{"filename" => filename}, socket) do
send(self(), {:do_history_restore, filename})
{:noreply, socket |> assign(:restoring, true) |> assign(:confirming_history_restore, nil)}
end
def handle_event("confirm_delete", %{"filename" => filename}, socket) do
{:noreply, assign(socket, :confirming_delete, filename)}
end
def handle_event("cancel_delete", _params, socket) do
{:noreply, assign(socket, :confirming_delete, nil)}
end
def handle_event("execute_delete", %{"filename" => filename}, socket) do
case Backup.delete_backup(filename) do
:ok ->
{:noreply,
socket
|> assign(:confirming_delete, nil)
|> assign(:backups, Backup.list_backups())
|> put_flash(:info, "Backup deleted")}
{:error, _} ->
{:noreply,
socket
|> assign(:confirming_delete, nil)
|> put_flash(:error, "Failed to delete backup")}
end
end
@impl true
def handle_info(:do_restore, socket) do
backup_path = socket.assigns.uploaded_backup.path
case Backup.restore_backup(backup_path) do
:ok ->
{:noreply,
socket
|> assign(:uploaded_backup, nil)
|> assign(:confirming_restore, false)
|> assign(:restoring, false)
|> assign(:stats, Backup.get_stats())
|> assign(:backups, Backup.list_backups())
|> put_flash(:info, "Database restored successfully")}
{:error, {:schema_mismatch, backup_version, current_version}} ->
{:noreply,
socket
|> assign(:confirming_restore, false)
|> assign(:restoring, false)
|> put_flash(
:error,
"Schema version mismatch: backup is #{backup_version}, current is #{current_version}. " <>
"Backups can only be restored to a database with the same schema version."
)}
{:error, reason} ->
{:noreply,
socket
|> assign(:confirming_restore, false)
|> assign(:restoring, false)
|> put_flash(:error, "Restore failed: #{inspect(reason)}")}
end
end
def handle_info({:do_history_restore, filename}, socket) do
path = Path.join(Backup.backup_dir(), filename)
case Backup.restore_backup(path) do
:ok ->
{:noreply,
socket
|> assign(:restoring, false)
|> assign(:stats, Backup.get_stats())
|> assign(:backups, Backup.list_backups())
|> put_flash(:info, "Database restored from #{filename}")}
{:error, {:schema_mismatch, backup_version, current_version}} ->
{:noreply,
socket
|> assign(:restoring, false)
|> put_flash(
:error,
"Schema version mismatch: backup is #{backup_version}, current is #{current_version}."
)}
{:error, reason} ->
{:noreply,
socket
|> assign(:restoring, false)
|> put_flash(:error, "Restore failed: #{inspect(reason)}")}
end
end
@impl true
def render(assigns) do
~H"""
<div class="admin-backup" phx-hook="Download" id="backup-page">
<.header>
Backup
</.header>
<%!-- Database status --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Database</h2>
<%= if @stats.encryption_status do %>
<.status_pill color="green">
<.icon name="hero-lock-closed-mini" class="size-3" /> Encrypted
</.status_pill>
<% else %>
<.status_pill color="amber">
<.icon name="hero-lock-open-mini" class="size-3" /> Not encrypted
</.status_pill>
<% end %>
</div>
<p class="admin-section-desc">
{Backup.format_size(@stats.total_size)} total ·
{length(@stats.tables)} tables ·
{@stats.key_counts["products"] || 0} products ·
{@stats.key_counts["orders"] || 0} orders ·
{@stats.key_counts["images"] || 0} images
</p>
<div class="admin-section-body">
<button
type="button"
phx-click="toggle_tables"
class="admin-link"
>
<%= if @show_tables do %>
<.icon name="hero-chevron-up-mini" class="size-4" /> Hide table details
<% else %>
<.icon name="hero-chevron-down-mini" class="size-4" /> Show table details
<% end %>
</button>
</div>
<%= if @show_tables do %>
<div class="backup-tables">
<table class="admin-table admin-table-compact">
<thead>
<tr>
<th>Table</th>
<th class="text-right">Rows</th>
<th class="text-right">Size</th>
</tr>
</thead>
<tbody>
<tr :for={table <- @stats.tables}>
<td>{table.name}</td>
<td class="text-right tabular-nums">{table.rows}</td>
<td class="text-right tabular-nums">{Backup.format_size(table.size)}</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</section>
<%!-- Create backup --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Create backup</h2>
<.status_pill color="zinc">{length(@backups)} saved</.status_pill>
</div>
<p class="admin-section-desc">
Creates an encrypted snapshot of your database. Backups are stored locally and the last 5 are kept automatically.
</p>
<div class="admin-section-body">
<div class="backup-actions">
<button
type="button"
phx-click="create_backup"
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-plus-mini" class="size-4" /> Create backup
</button>
<.inline_feedback status={@create_backup_status} />
</div>
</div>
</section>
<%!-- Backup history --%>
<%= if @backups != [] do %>
<section class="admin-section">
<h2 class="admin-section-title">Saved backups</h2>
<%= if @restoring do %>
<div class="backup-progress">
<.icon name="hero-arrow-path" class="size-5 animate-spin" />
<div>
<p class="backup-progress-text">Restoring database...</p>
<p class="backup-progress-hint">This may take a few seconds.</p>
</div>
</div>
<% else %>
<div class="backup-list">
<%= for backup <- @backups do %>
<div class="backup-item">
<div class="backup-item-info">
<span class="backup-item-date">{format_backup_date(backup.created_at)}</span>
<span class="backup-item-meta">
{Backup.format_size(backup.size)}
<%= if backup.type == :pre_restore do %>
· auto-saved before restore
<% end %>
</span>
</div>
<div class="backup-item-actions">
<%= if @confirming_history_restore == backup.filename do %>
<span class="backup-item-confirm">Replace current database?</span>
<button
type="button"
class="admin-btn admin-btn-danger admin-btn-sm"
phx-click="execute_history_restore"
phx-value-filename={backup.filename}
>
Restore
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_history_restore"
>
Cancel
</button>
<% else %>
<%= if @confirming_delete == backup.filename do %>
<span class="backup-item-confirm">Delete this backup?</span>
<button
type="button"
class="admin-btn admin-btn-danger admin-btn-sm"
phx-click="execute_delete"
phx-value-filename={backup.filename}
>
Delete
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="cancel_delete"
>
Cancel
</button>
<% else %>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="download_history_backup"
phx-value-filename={backup.filename}
title="Download"
>
<.icon name="hero-arrow-down-tray-mini" class="size-4" />
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="confirm_history_restore"
phx-value-filename={backup.filename}
>
Restore
</button>
<button
type="button"
class="admin-btn admin-btn-outline admin-btn-sm"
phx-click="confirm_delete"
phx-value-filename={backup.filename}
title="Delete"
>
<.icon name="hero-trash-mini" class="size-4" />
</button>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</section>
<% end %>
<%!-- Restore from file --%>
<section class="admin-section">
<h2 class="admin-section-title">Restore from file</h2>
<p class="admin-section-desc">
Upload a backup file to restore. Must be encrypted with the same key as this database.
</p>
<%= if @upload_error do %>
<p class="admin-error">{@upload_error}</p>
<% end %>
<%= if @uploaded_backup do %>
<div class="backup-comparison">
<div class="backup-comparison-grid">
<div class="backup-comparison-col">
<h4 class="backup-comparison-label">Current</h4>
<dl class="backup-comparison-stats">
<div><dt>Size</dt><dd>{Backup.format_size(@stats.total_size)}</dd></div>
<div><dt>Products</dt><dd>{@stats.key_counts["products"] || 0}</dd></div>
<div><dt>Orders</dt><dd>{@stats.key_counts["orders"] || 0}</dd></div>
<div><dt>Images</dt><dd>{@stats.key_counts["images"] || 0}</dd></div>
</dl>
</div>
<div class="backup-comparison-arrow">
<.icon name="hero-arrow-right" class="size-5" />
</div>
<div class="backup-comparison-col">
<h4 class="backup-comparison-label">Uploaded</h4>
<dl class="backup-comparison-stats">
<div><dt>Size</dt><dd>{Backup.format_size(@uploaded_backup.stats.file_size)}</dd></div>
<div><dt>Products</dt><dd>{@uploaded_backup.stats.key_counts["products"] || 0}</dd></div>
<div><dt>Orders</dt><dd>{@uploaded_backup.stats.key_counts["orders"] || 0}</dd></div>
<div><dt>Images</dt><dd>{@uploaded_backup.stats.key_counts["images"] || 0}</dd></div>
</dl>
</div>
</div>
<%= if @uploaded_backup.stats.latest_migration == @stats.schema_version do %>
<div class="backup-validation backup-validation-ok">
<.icon name="hero-check-circle-mini" class="size-4" />
<span>Backup validated · Schema version {@uploaded_backup.stats.latest_migration}</span>
</div>
<%= if @restoring do %>
<div class="backup-progress">
<.icon name="hero-arrow-path" class="size-5 animate-spin" />
<div>
<p class="backup-progress-text">Restoring database...</p>
<p class="backup-progress-hint">This may take a few seconds.</p>
</div>
</div>
<% else %>
<%= if @confirming_restore do %>
<div class="backup-warning">
<p>This will replace your current database. A backup will be saved automatically.</p>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-danger admin-btn-sm" phx-click="execute_restore">
Replace database
</button>
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
Cancel
</button>
</div>
</div>
<% else %>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-primary admin-btn-sm" phx-click="confirm_restore">
Restore this backup
</button>
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
Cancel
</button>
</div>
<% end %>
<% end %>
<% else %>
<div class="backup-validation backup-validation-error">
<.icon name="hero-x-circle-mini" class="size-4" />
<span>
Schema mismatch: backup is v{@uploaded_backup.stats.latest_migration},
current is v{@stats.schema_version}
</span>
</div>
<div class="backup-actions">
<button type="button" class="admin-btn admin-btn-outline admin-btn-sm" phx-click="cancel_restore">
Cancel
</button>
</div>
<% end %>
</div>
<% else %>
<form phx-submit="upload_backup" phx-change="validate_upload">
<div class="backup-dropzone" phx-drop-target={@uploads.backup.ref}>
<.live_file_input upload={@uploads.backup} class="sr-only" />
<div class="backup-dropzone-content">
<.icon name="hero-arrow-up-tray" class="size-6" />
<p>
Drop a backup file here or
<label for={@uploads.backup.ref} class="backup-dropzone-link">browse</label>
</p>
</div>
</div>
<%= for entry <- @uploads.backup.entries do %>
<div class="backup-upload-entry">
<span>{entry.client_name}</span>
<span class="tabular-nums">{Backup.format_size(entry.client_size)}</span>
<progress value={entry.progress} max="100">{entry.progress}%</progress>
</div>
<%= for err <- upload_errors(@uploads.backup, entry) do %>
<p class="admin-error">{upload_error_to_string(err)}</p>
<% end %>
<% end %>
<%= if length(@uploads.backup.entries) > 0 do %>
<div class="backup-actions">
<button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">
<.icon name="hero-arrow-up-tray-mini" class="size-4" /> Upload and validate
</button>
</div>
<% end %>
</form>
<% end %>
</section>
</div>
"""
end
defp format_backup_date(nil), do: "unknown date"
defp format_backup_date(datetime) do
Calendar.strftime(datetime, "%d %b %Y, %H:%M")
end
defp upload_error_to_string(:too_large), do: "File is too large (max 500 MB)"
defp upload_error_to_string(:too_many_files), do: "Only one file allowed"
defp upload_error_to_string(err), do: "Upload error: #{inspect(err)}"
attr :color, :string, default: "zinc"
slot :inner_block, required: true
defp status_pill(assigns) do
modifier =
case assigns.color do
"green" -> "admin-status-pill-green"
"amber" -> "admin-status-pill-amber"
_ -> "admin-status-pill-zinc"
end
assigns = assign(assigns, :modifier, modifier)
~H"""
<span class={["admin-status-pill", @modifier]}>
{render_slot(@inner_block)}
</span>
"""
end
end

View File

@@ -170,6 +170,7 @@ defmodule BerrypodWeb.Router do
live "/providers/:id/edit", Admin.Providers.Form, :edit
live "/settings", Admin.Settings, :index
live "/settings/email", Admin.EmailSettings, :index
live "/backup", Admin.Backup, :index
live "/account", Admin.Account, :index
live "/pages", Admin.Pages.Index, :index
live "/pages/new", Admin.Pages.CustomForm, :new